@vibe-hero/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +151 -0
  3. package/dist/catalog/bundled/claude-code/.gitkeep +0 -0
  4. package/dist/catalog/bundled/claude-code/context-management.yaml +302 -0
  5. package/dist/catalog/bundled/claude-code/planning.yaml +313 -0
  6. package/dist/catalog/bundled/claude-code/subagents.yaml +357 -0
  7. package/dist/catalog/bundled/general/.gitkeep +0 -0
  8. package/dist/catalog/bundled/general/_placeholder.yaml +39 -0
  9. package/dist/catalog/bundled/general/task-decomposition.yaml +390 -0
  10. package/dist/catalog/bundled/index.d.ts +39 -0
  11. package/dist/catalog/bundled/index.d.ts.map +1 -0
  12. package/dist/catalog/bundled/index.js +41 -0
  13. package/dist/catalog/bundled/index.js.map +1 -0
  14. package/dist/catalog/fetcher.d.ts +201 -0
  15. package/dist/catalog/fetcher.d.ts.map +1 -0
  16. package/dist/catalog/fetcher.js +452 -0
  17. package/dist/catalog/fetcher.js.map +1 -0
  18. package/dist/catalog/loader.d.ts +165 -0
  19. package/dist/catalog/loader.d.ts.map +1 -0
  20. package/dist/catalog/loader.js +241 -0
  21. package/dist/catalog/loader.js.map +1 -0
  22. package/dist/catalog/resolve.d.ts +85 -0
  23. package/dist/catalog/resolve.d.ts.map +1 -0
  24. package/dist/catalog/resolve.js +103 -0
  25. package/dist/catalog/resolve.js.map +1 -0
  26. package/dist/cli/getOffer.d.ts +38 -0
  27. package/dist/cli/getOffer.d.ts.map +1 -0
  28. package/dist/cli/getOffer.js +150 -0
  29. package/dist/cli/getOffer.js.map +1 -0
  30. package/dist/cli/index.d.ts +46 -0
  31. package/dist/cli/index.d.ts.map +1 -0
  32. package/dist/cli/index.js +88 -0
  33. package/dist/cli/index.js.map +1 -0
  34. package/dist/config.d.ts +34 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +63 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/engine/elo.d.ts +76 -0
  39. package/dist/engine/elo.d.ts.map +1 -0
  40. package/dist/engine/elo.js +79 -0
  41. package/dist/engine/elo.js.map +1 -0
  42. package/dist/engine/graduation.d.ts +108 -0
  43. package/dist/engine/graduation.d.ts.map +1 -0
  44. package/dist/engine/graduation.js +161 -0
  45. package/dist/engine/graduation.js.map +1 -0
  46. package/dist/engine/lapse.d.ts +80 -0
  47. package/dist/engine/lapse.d.ts.map +1 -0
  48. package/dist/engine/lapse.js +125 -0
  49. package/dist/engine/lapse.js.map +1 -0
  50. package/dist/engine/selection.d.ts +84 -0
  51. package/dist/engine/selection.d.ts.map +1 -0
  52. package/dist/engine/selection.js +119 -0
  53. package/dist/engine/selection.js.map +1 -0
  54. package/dist/grading/deterministic.d.ts +102 -0
  55. package/dist/grading/deterministic.d.ts.map +1 -0
  56. package/dist/grading/deterministic.js +118 -0
  57. package/dist/grading/deterministic.js.map +1 -0
  58. package/dist/grading/freeform.d.ts +64 -0
  59. package/dist/grading/freeform.d.ts.map +1 -0
  60. package/dist/grading/freeform.js +85 -0
  61. package/dist/grading/freeform.js.map +1 -0
  62. package/dist/index.d.ts +52 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +91 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/observation/hookEvents.d.ts +113 -0
  67. package/dist/observation/hookEvents.d.ts.map +1 -0
  68. package/dist/observation/hookEvents.js +170 -0
  69. package/dist/observation/hookEvents.js.map +1 -0
  70. package/dist/observation/offers.d.ts +215 -0
  71. package/dist/observation/offers.d.ts.map +1 -0
  72. package/dist/observation/offers.js +327 -0
  73. package/dist/observation/offers.js.map +1 -0
  74. package/dist/observation/source.d.ts +133 -0
  75. package/dist/observation/source.d.ts.map +1 -0
  76. package/dist/observation/source.js +105 -0
  77. package/dist/observation/source.js.map +1 -0
  78. package/dist/profile/migrate.d.ts +122 -0
  79. package/dist/profile/migrate.d.ts.map +1 -0
  80. package/dist/profile/migrate.js +147 -0
  81. package/dist/profile/migrate.js.map +1 -0
  82. package/dist/profile/store.d.ts +84 -0
  83. package/dist/profile/store.d.ts.map +1 -0
  84. package/dist/profile/store.js +267 -0
  85. package/dist/profile/store.js.map +1 -0
  86. package/dist/schemas/common.d.ts +95 -0
  87. package/dist/schemas/common.d.ts.map +1 -0
  88. package/dist/schemas/common.js +106 -0
  89. package/dist/schemas/common.js.map +1 -0
  90. package/dist/schemas/content.d.ts +828 -0
  91. package/dist/schemas/content.d.ts.map +1 -0
  92. package/dist/schemas/content.js +219 -0
  93. package/dist/schemas/content.js.map +1 -0
  94. package/dist/schemas/profile.d.ts +599 -0
  95. package/dist/schemas/profile.d.ts.map +1 -0
  96. package/dist/schemas/profile.js +177 -0
  97. package/dist/schemas/profile.js.map +1 -0
  98. package/dist/schemas/tools.d.ts +1581 -0
  99. package/dist/schemas/tools.d.ts.map +1 -0
  100. package/dist/schemas/tools.js +286 -0
  101. package/dist/schemas/tools.js.map +1 -0
  102. package/dist/tools/config.d.ts +51 -0
  103. package/dist/tools/config.d.ts.map +1 -0
  104. package/dist/tools/config.js +104 -0
  105. package/dist/tools/config.js.map +1 -0
  106. package/dist/tools/gate.d.ts +50 -0
  107. package/dist/tools/gate.d.ts.map +1 -0
  108. package/dist/tools/gate.js +67 -0
  109. package/dist/tools/gate.js.map +1 -0
  110. package/dist/tools/guidance.d.ts +36 -0
  111. package/dist/tools/guidance.d.ts.map +1 -0
  112. package/dist/tools/guidance.js +117 -0
  113. package/dist/tools/guidance.js.map +1 -0
  114. package/dist/tools/listTopics.d.ts +55 -0
  115. package/dist/tools/listTopics.d.ts.map +1 -0
  116. package/dist/tools/listTopics.js +78 -0
  117. package/dist/tools/listTopics.js.map +1 -0
  118. package/dist/tools/offers.d.ts +60 -0
  119. package/dist/tools/offers.d.ts.map +1 -0
  120. package/dist/tools/offers.js +152 -0
  121. package/dist/tools/offers.js.map +1 -0
  122. package/dist/tools/placeholders.d.ts +27 -0
  123. package/dist/tools/placeholders.d.ts.map +1 -0
  124. package/dist/tools/placeholders.js +49 -0
  125. package/dist/tools/placeholders.js.map +1 -0
  126. package/dist/tools/recordObservation.d.ts +52 -0
  127. package/dist/tools/recordObservation.d.ts.map +1 -0
  128. package/dist/tools/recordObservation.js +87 -0
  129. package/dist/tools/recordObservation.js.map +1 -0
  130. package/dist/tools/startQuiz.d.ts +82 -0
  131. package/dist/tools/startQuiz.d.ts.map +1 -0
  132. package/dist/tools/startQuiz.js +180 -0
  133. package/dist/tools/startQuiz.js.map +1 -0
  134. package/dist/tools/status.d.ts +59 -0
  135. package/dist/tools/status.d.ts.map +1 -0
  136. package/dist/tools/status.js +133 -0
  137. package/dist/tools/status.js.map +1 -0
  138. package/dist/tools/submitAnswer.d.ts +156 -0
  139. package/dist/tools/submitAnswer.d.ts.map +1 -0
  140. package/dist/tools/submitAnswer.js +402 -0
  141. package/dist/tools/submitAnswer.js.map +1 -0
  142. package/dist/tools/types.d.ts +82 -0
  143. package/dist/tools/types.d.ts.map +1 -0
  144. package/dist/tools/types.js +48 -0
  145. package/dist/tools/types.js.map +1 -0
  146. package/dist/tools/us2/standing.d.ts +111 -0
  147. package/dist/tools/us2/standing.d.ts.map +1 -0
  148. package/dist/tools/us2/standing.js +143 -0
  149. package/dist/tools/us2/standing.js.map +1 -0
  150. package/package.json +62 -0
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @file PURE tier-graduation engine with hysteresis + dwell (T043, US-3).
3
+ *
4
+ * Decides whether a learner graduates to a higher tier, is demoted/flagged for
5
+ * review, or stays put, given their current ability θ, their current graduated
6
+ * tier, and a DWELL counter of how many *consecutive* recent graded items have
7
+ * satisfied the same crossing condition. The hysteresis band (FR-008 / SC-014)
8
+ * plus the dwell requirement together prevent a single fluke item — or ability
9
+ * oscillating within ±margin of a boundary — from toggling the tier.
10
+ *
11
+ * This module is IO-FREE and time-free (invariant E5): it reads no clock, no
12
+ * filesystem, no network, and never calls `Math.random`. All inputs are passed
13
+ * explicitly; the same inputs always yield the same decision. Time-dependent
14
+ * lapse/staleness lives in `./lapse.ts`; tools read the clock and pass it in.
15
+ *
16
+ * Rules (OD-005 / research.md):
17
+ * - PROMOTE to the next tier when θ ≥ (next boundary) + hysteresisMargin AND
18
+ * this promotion-crossing condition has held for `dwell` consecutive graded
19
+ * items (a single qualifying item is never enough — SC-014).
20
+ * - DEMOTE / flag for review when θ ≤ (boundary below the current tier) −
21
+ * hysteresisMargin. Demotion is immediate (no dwell): a confirmed drop below
22
+ * the lower band should surface the topic promptly rather than hide a lapse.
23
+ * - Otherwise NO change — in particular, ability anywhere inside the band
24
+ * `[boundaryBelow − margin, nextBoundary + margin]` leaves the tier alone.
25
+ *
26
+ * Dwell tracking (see {@link evaluateGraduation}): the caller persists a small
27
+ * `dwell` counter on the AbilityEstimate. On each graded item the engine returns
28
+ * the *next* dwell value: it increments while the promotion-crossing condition
29
+ * holds and resets to 0 the moment an item fails to satisfy it. Promotion fires
30
+ * only when the (incremented) counter reaches `ASSESSMENT_CONFIG.dwell`.
31
+ *
32
+ * Source of truth: specs/001-vibe-hero-mvp/spec.md (FR-007/008/008a/009,
33
+ * SC-010/SC-014), research.md (OD-005), data-model.md (TierGraduation).
34
+ */
35
+ import type { Tier } from "../schemas/common.js";
36
+ /** A graduated tier, or `0` for "not yet graduated". */
37
+ export type TierOrZero = Tier | 0;
38
+ /**
39
+ * Why the tier changed (or why review is due). Mirrors
40
+ * `TierGraduation.lastChangeReason`; `null` when nothing changed this item.
41
+ */
42
+ export type GraduationChangeReason = "graduated" | "demoted" | "review_due" | null;
43
+ /** The inputs the pure graduation evaluation needs. */
44
+ export interface GraduationState {
45
+ /** The learner's current ability estimate (θ) AFTER the latest Elo update. */
46
+ readonly ability: number;
47
+ /**
48
+ * The tier the learner is currently graduated at (`0` = none yet). Drives
49
+ * which boundaries bound the hysteresis band.
50
+ */
51
+ readonly currentTier: TierOrZero;
52
+ /**
53
+ * Consecutive graded items (BEFORE this one) for which the promotion-crossing
54
+ * condition held. The engine increments this when the current item also
55
+ * satisfies promotion, and resets it to 0 otherwise. Persisted on the
56
+ * AbilityEstimate so dwell carries across `submit_answer` calls.
57
+ */
58
+ readonly dwell: number;
59
+ }
60
+ /** The decision returned by {@link evaluateGraduation} (pure, total). */
61
+ export interface GraduationDecision {
62
+ /** Whether the tier (or review status) changed as a result of this item. */
63
+ readonly changed: boolean;
64
+ /** The new tier after applying the decision (unchanged when `changed` is false). */
65
+ readonly tier: TierOrZero;
66
+ /** The reason for the change, or `null` when nothing changed. */
67
+ readonly reason: GraduationChangeReason;
68
+ /**
69
+ * The dwell counter to persist for the NEXT evaluation. Incremented while the
70
+ * promotion-crossing condition holds; reset to 0 the moment it does not (and
71
+ * after a promotion fires, so the next tier starts fresh).
72
+ */
73
+ readonly dwell: number;
74
+ /**
75
+ * When `reason === "demoted"`, whether the drop should be surfaced as
76
+ * `due_for_review` (the spec demotes *to review* rather than silently
77
+ * un-graduating — FR-009). Always `true` for a demotion; `false` otherwise.
78
+ */
79
+ readonly dueForReview: boolean;
80
+ }
81
+ /**
82
+ * Decide the graduation outcome for one graded item (PURE, total).
83
+ *
84
+ * Evaluation order, given ability θ, `currentTier`, and the prior `dwell`:
85
+ *
86
+ * 1. **Promotion check** — if there is a tier above and
87
+ * `θ ≥ boundaryAbove + hysteresisMargin`, the promotion-crossing condition
88
+ * holds: the dwell counter is incremented. When it reaches
89
+ * `ASSESSMENT_CONFIG.dwell`, promote (reason `"graduated"`, dwell reset to
90
+ * 0). If it has not yet reached `dwell`, NO change is reported but the
91
+ * incremented counter is returned so the streak is remembered.
92
+ * 2. **Demotion check** — else, if the learner is graduated and
93
+ * `θ ≤ boundaryBelow − hysteresisMargin`, demote/flag for review (reason
94
+ * `"demoted"`, `dueForReview: true`, tier steps down one). Demotion does not
95
+ * require dwell and resets the counter to 0.
96
+ * 3. **No change** — otherwise the tier holds (ability is inside the band or
97
+ * promotion has insufficient dwell). The dwell counter resets to 0 because
98
+ * this item did NOT satisfy the promotion-crossing condition.
99
+ *
100
+ * Because promotion needs `dwell` consecutive qualifying items and demotion
101
+ * needs a clear `margin` below the lower boundary, ability oscillating inside
102
+ * the band never toggles the tier (SC-014).
103
+ *
104
+ * @param state - {@link GraduationState}: current ability, tier, and prior dwell.
105
+ * @returns A {@link GraduationDecision}; `changed: false` leaves tier untouched.
106
+ */
107
+ export declare const evaluateGraduation: (state: GraduationState) => GraduationDecision;
108
+ //# sourceMappingURL=graduation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graduation.d.ts","sourceRoot":"","sources":["../../src/engine/graduation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAGH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAC;AAEjD,wDAAwD;AACxD,MAAM,MAAM,UAAU,GAAG,IAAI,GAAG,CAAC,CAAC;AAElC;;;GAGG;AACH,MAAM,MAAM,sBAAsB,GAC9B,WAAW,GACX,SAAS,GACT,YAAY,GACZ,IAAI,CAAC;AAET,uDAAuD;AACvD,MAAM,WAAW,eAAe;IAC9B,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,WAAW,EAAE,UAAU,CAAC;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,yEAAyE;AACzE,MAAM,WAAW,kBAAkB;IACjC,4EAA4E;IAC5E,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,oFAAoF;IACpF,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,iEAAiE;IACjE,QAAQ,CAAC,MAAM,EAAE,sBAAsB,CAAC;IACxC;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;CAChC;AAgDD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,kBAAkB,GAC7B,OAAO,eAAe,KACrB,kBA4DF,CAAC"}
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @file PURE tier-graduation engine with hysteresis + dwell (T043, US-3).
3
+ *
4
+ * Decides whether a learner graduates to a higher tier, is demoted/flagged for
5
+ * review, or stays put, given their current ability θ, their current graduated
6
+ * tier, and a DWELL counter of how many *consecutive* recent graded items have
7
+ * satisfied the same crossing condition. The hysteresis band (FR-008 / SC-014)
8
+ * plus the dwell requirement together prevent a single fluke item — or ability
9
+ * oscillating within ±margin of a boundary — from toggling the tier.
10
+ *
11
+ * This module is IO-FREE and time-free (invariant E5): it reads no clock, no
12
+ * filesystem, no network, and never calls `Math.random`. All inputs are passed
13
+ * explicitly; the same inputs always yield the same decision. Time-dependent
14
+ * lapse/staleness lives in `./lapse.ts`; tools read the clock and pass it in.
15
+ *
16
+ * Rules (OD-005 / research.md):
17
+ * - PROMOTE to the next tier when θ ≥ (next boundary) + hysteresisMargin AND
18
+ * this promotion-crossing condition has held for `dwell` consecutive graded
19
+ * items (a single qualifying item is never enough — SC-014).
20
+ * - DEMOTE / flag for review when θ ≤ (boundary below the current tier) −
21
+ * hysteresisMargin. Demotion is immediate (no dwell): a confirmed drop below
22
+ * the lower band should surface the topic promptly rather than hide a lapse.
23
+ * - Otherwise NO change — in particular, ability anywhere inside the band
24
+ * `[boundaryBelow − margin, nextBoundary + margin]` leaves the tier alone.
25
+ *
26
+ * Dwell tracking (see {@link evaluateGraduation}): the caller persists a small
27
+ * `dwell` counter on the AbilityEstimate. On each graded item the engine returns
28
+ * the *next* dwell value: it increments while the promotion-crossing condition
29
+ * holds and resets to 0 the moment an item fails to satisfy it. Promotion fires
30
+ * only when the (incremented) counter reaches `ASSESSMENT_CONFIG.dwell`.
31
+ *
32
+ * Source of truth: specs/001-vibe-hero-mvp/spec.md (FR-007/008/008a/009,
33
+ * SC-010/SC-014), research.md (OD-005), data-model.md (TierGraduation).
34
+ */
35
+ import { ASSESSMENT_CONFIG } from "../config.js";
36
+ /** Sorted tier ladder (`[100,200,300,400,500]`), typed as {@link Tier}. */
37
+ const TIERS = ASSESSMENT_CONFIG.tierCenters;
38
+ /**
39
+ * The boundary the learner must clear (plus margin) to graduate FROM
40
+ * `currentTier` to the next one, or `undefined` if already at the top tier
41
+ * (500) — there is nothing higher to promote into.
42
+ *
43
+ * Boundaries are `[150,250,350,450]`; the boundary above tier `T` is the one
44
+ * sitting between `T` and the next center (e.g. above tier 300 ⇒ 350, the bar
45
+ * into tier 400). For `currentTier === 0` (not graduated) the relevant boundary
46
+ * is the first one (150), the bar into tier 100.
47
+ */
48
+ const boundaryAbove = (currentTier) => {
49
+ const { tierBoundaries } = ASSESSMENT_CONFIG;
50
+ if (currentTier === 0)
51
+ return tierBoundaries[0];
52
+ const idx = TIERS.indexOf(currentTier);
53
+ // idx is the index of the current center; the boundary into the NEXT tier is
54
+ // at the same index in tierBoundaries (centers[i] → boundaries[i] → centers[i+1]).
55
+ return tierBoundaries[idx];
56
+ };
57
+ /**
58
+ * The boundary BELOW the current tier — the floor of the hysteresis band; a
59
+ * drop of `margin` below it triggers demotion/review. `undefined` when the
60
+ * learner is not graduated (`currentTier === 0`): there is no lower band, so a
61
+ * non-graduate can never be demoted.
62
+ *
63
+ * The boundary below tier `T` sits between the previous center and `T` (e.g.
64
+ * below tier 300 ⇒ 250, the bar that was crossed to enter tier 300).
65
+ */
66
+ const boundaryBelow = (currentTier) => {
67
+ const { tierBoundaries } = ASSESSMENT_CONFIG;
68
+ if (currentTier === 0)
69
+ return undefined;
70
+ const idx = TIERS.indexOf(currentTier);
71
+ // centers[i] is bounded below by boundaries[i-1].
72
+ return idx > 0 ? tierBoundaries[idx - 1] : undefined;
73
+ };
74
+ /** The tier one step above `currentTier`, or `undefined` at the top (500). */
75
+ const nextTier = (currentTier) => {
76
+ if (currentTier === 0)
77
+ return TIERS[0];
78
+ const idx = TIERS.indexOf(currentTier);
79
+ return TIERS[idx + 1];
80
+ };
81
+ /**
82
+ * Decide the graduation outcome for one graded item (PURE, total).
83
+ *
84
+ * Evaluation order, given ability θ, `currentTier`, and the prior `dwell`:
85
+ *
86
+ * 1. **Promotion check** — if there is a tier above and
87
+ * `θ ≥ boundaryAbove + hysteresisMargin`, the promotion-crossing condition
88
+ * holds: the dwell counter is incremented. When it reaches
89
+ * `ASSESSMENT_CONFIG.dwell`, promote (reason `"graduated"`, dwell reset to
90
+ * 0). If it has not yet reached `dwell`, NO change is reported but the
91
+ * incremented counter is returned so the streak is remembered.
92
+ * 2. **Demotion check** — else, if the learner is graduated and
93
+ * `θ ≤ boundaryBelow − hysteresisMargin`, demote/flag for review (reason
94
+ * `"demoted"`, `dueForReview: true`, tier steps down one). Demotion does not
95
+ * require dwell and resets the counter to 0.
96
+ * 3. **No change** — otherwise the tier holds (ability is inside the band or
97
+ * promotion has insufficient dwell). The dwell counter resets to 0 because
98
+ * this item did NOT satisfy the promotion-crossing condition.
99
+ *
100
+ * Because promotion needs `dwell` consecutive qualifying items and demotion
101
+ * needs a clear `margin` below the lower boundary, ability oscillating inside
102
+ * the band never toggles the tier (SC-014).
103
+ *
104
+ * @param state - {@link GraduationState}: current ability, tier, and prior dwell.
105
+ * @returns A {@link GraduationDecision}; `changed: false` leaves tier untouched.
106
+ */
107
+ export const evaluateGraduation = (state) => {
108
+ const { ability, currentTier, dwell } = state;
109
+ const { hysteresisMargin, dwell: dwellTarget } = ASSESSMENT_CONFIG;
110
+ // --- 1. Promotion -------------------------------------------------------
111
+ const promoteBar = boundaryAbove(currentTier);
112
+ const target = nextTier(currentTier);
113
+ if (promoteBar !== undefined &&
114
+ target !== undefined &&
115
+ ability >= promoteBar + hysteresisMargin) {
116
+ const nextDwell = dwell + 1;
117
+ if (nextDwell >= dwellTarget) {
118
+ // Streak satisfied → graduate, reset dwell for the new tier.
119
+ return {
120
+ changed: true,
121
+ tier: target,
122
+ reason: "graduated",
123
+ dwell: 0,
124
+ dueForReview: false,
125
+ };
126
+ }
127
+ // Crossing held but dwell not yet met: remember the streak, no change.
128
+ return {
129
+ changed: false,
130
+ tier: currentTier,
131
+ reason: null,
132
+ dwell: nextDwell,
133
+ dueForReview: false,
134
+ };
135
+ }
136
+ // --- 2. Demotion / review (only when graduated) -------------------------
137
+ const demoteFloor = boundaryBelow(currentTier);
138
+ if (currentTier !== 0 &&
139
+ demoteFloor !== undefined &&
140
+ ability <= demoteFloor - hysteresisMargin) {
141
+ const idx = TIERS.indexOf(currentTier);
142
+ const lower = idx > 0 ? TIERS[idx - 1] : 0;
143
+ return {
144
+ changed: true,
145
+ tier: lower,
146
+ reason: "demoted",
147
+ dwell: 0,
148
+ dueForReview: true,
149
+ };
150
+ }
151
+ // --- 3. No change (inside the band, or promotion lacked dwell) ----------
152
+ // This item did not satisfy promotion, so the consecutive streak resets.
153
+ return {
154
+ changed: false,
155
+ tier: currentTier,
156
+ reason: null,
157
+ dwell: 0,
158
+ dueForReview: false,
159
+ };
160
+ };
161
+ //# sourceMappingURL=graduation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graduation.js","sourceRoot":"","sources":["../../src/engine/graduation.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @file PURE knowledge-lapse / staleness engine (T044, US-3, OD-003).
3
+ *
4
+ * Implements the "staleness threshold + exponential ability decay" review model
5
+ * (research.md OD-003) without any separate per-item scheduler: it reuses the
6
+ * Elo ability already stored on the profile and decays it toward the tier center
7
+ * over calendar time, then asks whether a previously-graduated topic has gone
8
+ * stale enough to surface for review (FR-009 / FR-010).
9
+ *
10
+ * The decay is PURE math; the only "time" input is `daysSinceLast` /
11
+ * explicit timestamps, which the CALLER computes from an injected `now` (E5 —
12
+ * the engine itself never reads the clock). Tools read `new Date()` and pass it
13
+ * in; the engine stays deterministic and unit-testable.
14
+ *
15
+ * Model (research.md OD-003):
16
+ * θ_effective(t) = tier_center + (θ_last − tier_center) · exp(−daysSinceLast / H)
17
+ * H = decayHalfLifeDays (60d; tier-tunable)
18
+ * due_for_review ⟺ daysSinceLast ≥ stalenessWindowDays
19
+ * AND θ_effective < (tier_boundary_below + hysteresisMargin)
20
+ *
21
+ * A correct review resets the clock (lastAssessedAt) and restores ability; a
22
+ * wrong review lets normal Elo demote — both handled by the submit path, not
23
+ * here. This module only *detects* the due-for-review condition.
24
+ *
25
+ * Source of truth: specs/001-vibe-hero-mvp/research.md (OD-003), spec.md
26
+ * FR-009/FR-010, config.ts (decayHalfLifeDays, stalenessWindowDays).
27
+ */
28
+ import type { AbilityEstimate, TierGraduation } from "../schemas/profile.js";
29
+ /**
30
+ * Whole/fractional days between two ISO timestamps (`now − then`), clamped at 0
31
+ * so a clock skew or future `lastAssessedAt` never yields a negative age. PURE:
32
+ * both instants are passed in; the engine reads no clock itself.
33
+ *
34
+ * @param then - The earlier ISO datetime (e.g. `lastAssessedAt`).
35
+ * @param now - The reference ISO datetime (injected by the caller).
36
+ * @returns Days elapsed, ≥ 0.
37
+ */
38
+ export declare const daysBetween: (then: string, now: string) => number;
39
+ /**
40
+ * Exponentially-decayed effective ability (PURE).
41
+ *
42
+ * `θ_effective = tierCenter + (θ_last − tierCenter) · exp(−daysSinceLast / H)`.
43
+ *
44
+ * Ability relaxes toward the tier center as time passes: a learner who graduated
45
+ * *well above* center decays downward, and one who was *just* above center barely
46
+ * moves. At `daysSinceLast = 0` the result equals `θ_last`; as days → ∞ it tends
47
+ * to `tierCenter`. Negative `daysSinceLast` is treated as 0 (no decay).
48
+ *
49
+ * @param lastAbility - The stored ability at last assessment (θ_last).
50
+ * @param center - The tier center to decay toward (see {@link tierCenter}).
51
+ * @param daysSinceLast - Days since the last assessment (≥ 0).
52
+ * @param halfLifeDays - Decay constant `H` in days
53
+ * (default {@link ASSESSMENT_CONFIG.decayHalfLifeDays}).
54
+ * @returns The decayed effective ability.
55
+ */
56
+ export declare const effectiveAbility: (lastAbility: number, center: number, daysSinceLast: number, halfLifeDays?: number) => number;
57
+ /**
58
+ * Whether a graduated topic is now due for review (PURE; clock injected as
59
+ * `now`). Implements OD-003:
60
+ *
61
+ * `daysSinceLast ≥ stalenessWindowDays`
62
+ * AND `effectiveAbility < (boundaryBelow(currentTier) + hysteresisMargin)`
63
+ *
64
+ * Returns `false` for an ungraduated topic (`currentTier === 0`) — there is
65
+ * nothing to lapse from — and for a topic already flagged `due_for_review`
66
+ * (idempotent: it is already surfaced, so re-flagging is a no-op decision).
67
+ *
68
+ * The two-part test means a topic only surfaces when it is BOTH stale (enough
69
+ * time has passed) AND its decayed ability has fallen near/under the tier's
70
+ * lower band. A frequently-practiced or comfortably-above-center topic never
71
+ * goes due.
72
+ *
73
+ * @param graduation - The topic's graduation state (tier + status).
74
+ * @param ability - The stored ability estimate (its `value` and
75
+ * `lastAssessedAt` drive decay + staleness).
76
+ * @param now - The reference ISO datetime, injected by the caller (E5).
77
+ * @returns `true` iff the topic should be surfaced for review.
78
+ */
79
+ export declare const isDueForReview: (graduation: TierGraduation, ability: AbilityEstimate, now: string) => boolean;
80
+ //# sourceMappingURL=lapse.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lapse.d.ts","sourceRoot":"","sources":["../../src/engine/lapse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAQ7E;;;;;;;;GAQG;AACH,eAAO,MAAM,WAAW,GAAI,MAAM,MAAM,EAAE,KAAK,MAAM,KAAG,MAKvD,CAAC;AAwBF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,gBAAgB,GAC3B,aAAa,MAAM,EACnB,QAAQ,MAAM,EACd,eAAe,MAAM,EACrB,eAAc,MAA4C,KACzD,MAIF,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,cAAc,GACzB,YAAY,cAAc,EAC1B,SAAS,eAAe,EACxB,KAAK,MAAM,KACV,OAYF,CAAC"}
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @file PURE knowledge-lapse / staleness engine (T044, US-3, OD-003).
3
+ *
4
+ * Implements the "staleness threshold + exponential ability decay" review model
5
+ * (research.md OD-003) without any separate per-item scheduler: it reuses the
6
+ * Elo ability already stored on the profile and decays it toward the tier center
7
+ * over calendar time, then asks whether a previously-graduated topic has gone
8
+ * stale enough to surface for review (FR-009 / FR-010).
9
+ *
10
+ * The decay is PURE math; the only "time" input is `daysSinceLast` /
11
+ * explicit timestamps, which the CALLER computes from an injected `now` (E5 —
12
+ * the engine itself never reads the clock). Tools read `new Date()` and pass it
13
+ * in; the engine stays deterministic and unit-testable.
14
+ *
15
+ * Model (research.md OD-003):
16
+ * θ_effective(t) = tier_center + (θ_last − tier_center) · exp(−daysSinceLast / H)
17
+ * H = decayHalfLifeDays (60d; tier-tunable)
18
+ * due_for_review ⟺ daysSinceLast ≥ stalenessWindowDays
19
+ * AND θ_effective < (tier_boundary_below + hysteresisMargin)
20
+ *
21
+ * A correct review resets the clock (lastAssessedAt) and restores ability; a
22
+ * wrong review lets normal Elo demote — both handled by the submit path, not
23
+ * here. This module only *detects* the due-for-review condition.
24
+ *
25
+ * Source of truth: specs/001-vibe-hero-mvp/research.md (OD-003), spec.md
26
+ * FR-009/FR-010, config.ts (decayHalfLifeDays, stalenessWindowDays).
27
+ */
28
+ import { ASSESSMENT_CONFIG } from "../config.js";
29
+ /** Milliseconds in one day (UTC), for date-difference math. */
30
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
31
+ /** Sorted tier ladder (`[100,200,300,400,500]`), typed as {@link Tier}. */
32
+ const TIERS = ASSESSMENT_CONFIG.tierCenters;
33
+ /**
34
+ * Whole/fractional days between two ISO timestamps (`now − then`), clamped at 0
35
+ * so a clock skew or future `lastAssessedAt` never yields a negative age. PURE:
36
+ * both instants are passed in; the engine reads no clock itself.
37
+ *
38
+ * @param then - The earlier ISO datetime (e.g. `lastAssessedAt`).
39
+ * @param now - The reference ISO datetime (injected by the caller).
40
+ * @returns Days elapsed, ≥ 0.
41
+ */
42
+ export const daysBetween = (then, now) => {
43
+ const thenMs = Date.parse(then);
44
+ const nowMs = Date.parse(now);
45
+ if (Number.isNaN(thenMs) || Number.isNaN(nowMs))
46
+ return 0;
47
+ return Math.max(0, (nowMs - thenMs) / MS_PER_DAY);
48
+ };
49
+ /**
50
+ * The center ability of a tier (e.g. tier 300 → 300). For `currentTier === 0`
51
+ * (not graduated) decay has no meaningful center, so we fall back to the
52
+ * cold-start ability — but lapse only ever runs on graduated topics, so this
53
+ * branch is defensive.
54
+ */
55
+ const tierCenter = (currentTier) => currentTier === 0 ? ASSESSMENT_CONFIG.startingAbility : currentTier;
56
+ /**
57
+ * The boundary BELOW a graduated tier — the floor used in the due-for-review
58
+ * test (`θ_effective < boundaryBelow + margin`). Below tier `T` sits the
59
+ * boundary between the previous center and `T` (e.g. below 300 ⇒ 250). For tier
60
+ * 100 (the lowest) there is no lower boundary; we use `0` so the only way a
61
+ * tier-100 topic goes due is by decaying below the margin near the floor.
62
+ */
63
+ const boundaryBelow = (currentTier) => {
64
+ const idx = TIERS.indexOf(currentTier);
65
+ const { tierBoundaries } = ASSESSMENT_CONFIG;
66
+ return idx > 0 ? (tierBoundaries[idx - 1] ?? 0) : 0;
67
+ };
68
+ /**
69
+ * Exponentially-decayed effective ability (PURE).
70
+ *
71
+ * `θ_effective = tierCenter + (θ_last − tierCenter) · exp(−daysSinceLast / H)`.
72
+ *
73
+ * Ability relaxes toward the tier center as time passes: a learner who graduated
74
+ * *well above* center decays downward, and one who was *just* above center barely
75
+ * moves. At `daysSinceLast = 0` the result equals `θ_last`; as days → ∞ it tends
76
+ * to `tierCenter`. Negative `daysSinceLast` is treated as 0 (no decay).
77
+ *
78
+ * @param lastAbility - The stored ability at last assessment (θ_last).
79
+ * @param center - The tier center to decay toward (see {@link tierCenter}).
80
+ * @param daysSinceLast - Days since the last assessment (≥ 0).
81
+ * @param halfLifeDays - Decay constant `H` in days
82
+ * (default {@link ASSESSMENT_CONFIG.decayHalfLifeDays}).
83
+ * @returns The decayed effective ability.
84
+ */
85
+ export const effectiveAbility = (lastAbility, center, daysSinceLast, halfLifeDays = ASSESSMENT_CONFIG.decayHalfLifeDays) => {
86
+ const days = Math.max(0, daysSinceLast);
87
+ const decay = Math.exp(-days / halfLifeDays);
88
+ return center + (lastAbility - center) * decay;
89
+ };
90
+ /**
91
+ * Whether a graduated topic is now due for review (PURE; clock injected as
92
+ * `now`). Implements OD-003:
93
+ *
94
+ * `daysSinceLast ≥ stalenessWindowDays`
95
+ * AND `effectiveAbility < (boundaryBelow(currentTier) + hysteresisMargin)`
96
+ *
97
+ * Returns `false` for an ungraduated topic (`currentTier === 0`) — there is
98
+ * nothing to lapse from — and for a topic already flagged `due_for_review`
99
+ * (idempotent: it is already surfaced, so re-flagging is a no-op decision).
100
+ *
101
+ * The two-part test means a topic only surfaces when it is BOTH stale (enough
102
+ * time has passed) AND its decayed ability has fallen near/under the tier's
103
+ * lower band. A frequently-practiced or comfortably-above-center topic never
104
+ * goes due.
105
+ *
106
+ * @param graduation - The topic's graduation state (tier + status).
107
+ * @param ability - The stored ability estimate (its `value` and
108
+ * `lastAssessedAt` drive decay + staleness).
109
+ * @param now - The reference ISO datetime, injected by the caller (E5).
110
+ * @returns `true` iff the topic should be surfaced for review.
111
+ */
112
+ export const isDueForReview = (graduation, ability, now) => {
113
+ if (graduation.currentTier === 0)
114
+ return false;
115
+ if (graduation.status === "due_for_review")
116
+ return false;
117
+ const daysSinceLast = daysBetween(ability.lastAssessedAt, now);
118
+ if (daysSinceLast < ASSESSMENT_CONFIG.stalenessWindowDays)
119
+ return false;
120
+ const center = tierCenter(graduation.currentTier);
121
+ const effective = effectiveAbility(ability.value, center, daysSinceLast);
122
+ const floor = boundaryBelow(graduation.currentTier) + ASSESSMENT_CONFIG.hysteresisMargin;
123
+ return effective < floor;
124
+ };
125
+ //# sourceMappingURL=lapse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lapse.js","sourceRoot":"","sources":["../../src/engine/lapse.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAIjD,+DAA+D;AAC/D,MAAM,UAAU,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAEvC,2EAA2E;AAC3E,MAAM,KAAK,GAAoB,iBAAiB,CAAC,WAA8B,CAAC;AAEhF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,IAAY,EAAE,GAAW,EAAU,EAAE;IAC/D,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IAChC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC9B,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IAC1D,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,KAAK,GAAG,MAAM,CAAC,GAAG,UAAU,CAAC,CAAC;AACpD,CAAC,CAAC;AAEF;;;;;GAKG;AACH,MAAM,UAAU,GAAG,CAAC,WAAqB,EAAU,EAAE,CACnD,WAAW,KAAK,CAAC,CAAC,CAAC,CAAC,iBAAiB,CAAC,eAAe,CAAC,CAAC,CAAC,WAAW,CAAC;AAEtE;;;;;;GAMG;AACH,MAAM,aAAa,GAAG,CAAC,WAAiB,EAAU,EAAE;IAClD,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACvC,MAAM,EAAE,cAAc,EAAE,GAAG,iBAAiB,CAAC;IAC7C,OAAO,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,GAAG,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AACtD,CAAC,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAC9B,WAAmB,EACnB,MAAc,EACd,aAAqB,EACrB,eAAuB,iBAAiB,CAAC,iBAAiB,EAClD,EAAE;IACV,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,aAAa,CAAC,CAAC;IACxC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,GAAG,YAAY,CAAC,CAAC;IAC7C,OAAO,MAAM,GAAG,CAAC,WAAW,GAAG,MAAM,CAAC,GAAG,KAAK,CAAC;AACjD,CAAC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAC5B,UAA0B,EAC1B,OAAwB,EACxB,GAAW,EACF,EAAE;IACX,IAAI,UAAU,CAAC,WAAW,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAC/C,IAAI,UAAU,CAAC,MAAM,KAAK,gBAAgB;QAAE,OAAO,KAAK,CAAC;IAEzD,MAAM,aAAa,GAAG,WAAW,CAAC,OAAO,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;IAC/D,IAAI,aAAa,GAAG,iBAAiB,CAAC,mBAAmB;QAAE,OAAO,KAAK,CAAC;IAExE,MAAM,MAAM,GAAG,UAAU,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;IAClD,MAAM,SAAS,GAAG,gBAAgB,CAAC,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,aAAa,CAAC,CAAC;IACzE,MAAM,KAAK,GACT,aAAa,CAAC,UAAU,CAAC,WAAW,CAAC,GAAG,iBAAiB,CAAC,gBAAgB,CAAC;IAC7E,OAAO,SAAS,GAAG,KAAK,CAAC;AAC3B,CAAC,CAAC"}
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @file PURE item-selection engine (T011, OD-005 / research.md).
3
+ *
4
+ * Chooses the items for one quiz session for a single (topic × class) given the
5
+ * learner's current ability θ, the topic's candidate items (with FIXED authored
6
+ * difficulties), the target tier's next boundary, and a recent-item exclusion
7
+ * set. Difficulty-targets at the promotion bar so "passing a set" ≈ "ready to
8
+ * graduate", while an information-weighted window avoids always serving the
9
+ * hardest item.
10
+ *
11
+ * This module is IO-FREE and time-free (E5) and never mutates item difficulty
12
+ * (E3): items are read-only inputs.
13
+ *
14
+ * Selection rule (OD-005):
15
+ * target = min(θ + targetOffset, nextBoundary + hysteresisMargin)
16
+ * pool = items with |difficulty − target| ≤ selectWindow, excluding recents
17
+ * weight = p · (1 − p), p = expectedScore(θ, difficulty) // Fisher info ∝ p(1−p)
18
+ * pick `length` items by weight; ALWAYS include one "anchor" item within
19
+ * ±anchorWindow of θ if one exists in the pool.
20
+ *
21
+ * Determinism: this function NEVER calls `Math.random`. Sampling is deterministic
22
+ * given the inputs. By default it picks the top-weighted items (ties broken by
23
+ * item id for total order). Callers that want stochastic-but-reproducible
24
+ * sampling may inject a seeded {@link Rng}; the same seed ⇒ the same output.
25
+ *
26
+ * Source of truth: specs/001-vibe-hero-mvp/research.md (OD-005);
27
+ * constants in ../config.ts (ASSESSMENT_CONFIG).
28
+ */
29
+ import type { ContentItem } from "../schemas/content.js";
30
+ /**
31
+ * A deterministic, reproducible random source returning values in `[0, 1)`.
32
+ * Injecting one makes weighted sampling stochastic yet repeatable. Omit it for
33
+ * the default deterministic top-weighted strategy.
34
+ */
35
+ export type Rng = () => number;
36
+ /** Parameters for {@link selectItems}. */
37
+ export interface SelectItemsParams {
38
+ /** The learner's current ability estimate (θ). */
39
+ readonly ability: number;
40
+ /**
41
+ * Candidate items for the topic (any tier). FIXED authored difficulties;
42
+ * read-only — never mutated by selection.
43
+ */
44
+ readonly candidates: readonly ContentItem[];
45
+ /**
46
+ * Difficulty of the next tier boundary above the learner (the promotion bar
47
+ * the target is clamped to). E.g. for tier 300 this is the 350 boundary.
48
+ */
49
+ readonly nextBoundary: number;
50
+ /** Item ids to exclude (recently served), to avoid immediate repeats. */
51
+ readonly recentItemIds?: readonly string[];
52
+ /**
53
+ * Number of items to return (3–5). Defaults to
54
+ * {@link ASSESSMENT_CONFIG.defaultQuizLength}. If fewer eligible candidates
55
+ * exist, returns what is available.
56
+ */
57
+ readonly length?: number;
58
+ /**
59
+ * Optional injected RNG for reproducible weighted sampling. When omitted,
60
+ * selection is deterministic top-weighted (no `Math.random`).
61
+ */
62
+ readonly rng?: Rng;
63
+ }
64
+ /**
65
+ * Select 3–5 items for a quiz session (PURE; see file header for the rule).
66
+ *
67
+ * Items are read-only inputs; their difficulty is never mutated (E3). No clock,
68
+ * file, or network access (E5). Output is deterministic for identical inputs
69
+ * (and identical injected {@link Rng} sequence, if any).
70
+ *
71
+ * Behaviour summary:
72
+ * - Builds `target = min(θ + targetOffset, nextBoundary + hysteresisMargin)`.
73
+ * - Pool = candidates within ±`selectWindow` of `target`, excluding
74
+ * `recentItemIds`.
75
+ * - Weights each by `p·(1−p)` (`p = expectedScore(θ, difficulty)`).
76
+ * - Guarantees one anchor item within ±`anchorWindow` of θ (the most
77
+ * informative such item) is included when one exists in the pool.
78
+ * - Returns at most `length` items; fewer if the pool is smaller.
79
+ *
80
+ * @returns The chosen items, length ≤ `length`, anchor-first when an anchor
81
+ * exists, then the remaining picks.
82
+ */
83
+ export declare const selectItems: (params: SelectItemsParams) => ContentItem[];
84
+ //# sourceMappingURL=selection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"selection.d.ts","sourceRoot":"","sources":["../../src/engine/selection.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAGzD;;;;GAIG;AACH,MAAM,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC;AAE/B,0CAA0C;AAC1C,MAAM,WAAW,iBAAiB;IAChC,kDAAkD;IAClD,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,UAAU,EAAE,SAAS,WAAW,EAAE,CAAC;IAC5C;;;OAGG;IACH,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC;CACpB;AAoDD;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,WAAW,GAAI,QAAQ,iBAAiB,KAAG,WAAW,EAkDlE,CAAC"}