@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.
- package/LICENSE +190 -0
- package/README.md +151 -0
- package/dist/catalog/bundled/claude-code/.gitkeep +0 -0
- package/dist/catalog/bundled/claude-code/context-management.yaml +302 -0
- package/dist/catalog/bundled/claude-code/planning.yaml +313 -0
- package/dist/catalog/bundled/claude-code/subagents.yaml +357 -0
- package/dist/catalog/bundled/general/.gitkeep +0 -0
- package/dist/catalog/bundled/general/_placeholder.yaml +39 -0
- package/dist/catalog/bundled/general/task-decomposition.yaml +390 -0
- package/dist/catalog/bundled/index.d.ts +39 -0
- package/dist/catalog/bundled/index.d.ts.map +1 -0
- package/dist/catalog/bundled/index.js +41 -0
- package/dist/catalog/bundled/index.js.map +1 -0
- package/dist/catalog/fetcher.d.ts +201 -0
- package/dist/catalog/fetcher.d.ts.map +1 -0
- package/dist/catalog/fetcher.js +452 -0
- package/dist/catalog/fetcher.js.map +1 -0
- package/dist/catalog/loader.d.ts +165 -0
- package/dist/catalog/loader.d.ts.map +1 -0
- package/dist/catalog/loader.js +241 -0
- package/dist/catalog/loader.js.map +1 -0
- package/dist/catalog/resolve.d.ts +85 -0
- package/dist/catalog/resolve.d.ts.map +1 -0
- package/dist/catalog/resolve.js +103 -0
- package/dist/catalog/resolve.js.map +1 -0
- package/dist/cli/getOffer.d.ts +38 -0
- package/dist/cli/getOffer.d.ts.map +1 -0
- package/dist/cli/getOffer.js +150 -0
- package/dist/cli/getOffer.js.map +1 -0
- package/dist/cli/index.d.ts +46 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +88 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +63 -0
- package/dist/config.js.map +1 -0
- package/dist/engine/elo.d.ts +76 -0
- package/dist/engine/elo.d.ts.map +1 -0
- package/dist/engine/elo.js +79 -0
- package/dist/engine/elo.js.map +1 -0
- package/dist/engine/graduation.d.ts +108 -0
- package/dist/engine/graduation.d.ts.map +1 -0
- package/dist/engine/graduation.js +161 -0
- package/dist/engine/graduation.js.map +1 -0
- package/dist/engine/lapse.d.ts +80 -0
- package/dist/engine/lapse.d.ts.map +1 -0
- package/dist/engine/lapse.js +125 -0
- package/dist/engine/lapse.js.map +1 -0
- package/dist/engine/selection.d.ts +84 -0
- package/dist/engine/selection.d.ts.map +1 -0
- package/dist/engine/selection.js +119 -0
- package/dist/engine/selection.js.map +1 -0
- package/dist/grading/deterministic.d.ts +102 -0
- package/dist/grading/deterministic.d.ts.map +1 -0
- package/dist/grading/deterministic.js +118 -0
- package/dist/grading/deterministic.js.map +1 -0
- package/dist/grading/freeform.d.ts +64 -0
- package/dist/grading/freeform.d.ts.map +1 -0
- package/dist/grading/freeform.js +85 -0
- package/dist/grading/freeform.js.map +1 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/observation/hookEvents.d.ts +113 -0
- package/dist/observation/hookEvents.d.ts.map +1 -0
- package/dist/observation/hookEvents.js +170 -0
- package/dist/observation/hookEvents.js.map +1 -0
- package/dist/observation/offers.d.ts +215 -0
- package/dist/observation/offers.d.ts.map +1 -0
- package/dist/observation/offers.js +327 -0
- package/dist/observation/offers.js.map +1 -0
- package/dist/observation/source.d.ts +133 -0
- package/dist/observation/source.d.ts.map +1 -0
- package/dist/observation/source.js +105 -0
- package/dist/observation/source.js.map +1 -0
- package/dist/profile/migrate.d.ts +122 -0
- package/dist/profile/migrate.d.ts.map +1 -0
- package/dist/profile/migrate.js +147 -0
- package/dist/profile/migrate.js.map +1 -0
- package/dist/profile/store.d.ts +84 -0
- package/dist/profile/store.d.ts.map +1 -0
- package/dist/profile/store.js +267 -0
- package/dist/profile/store.js.map +1 -0
- package/dist/schemas/common.d.ts +95 -0
- package/dist/schemas/common.d.ts.map +1 -0
- package/dist/schemas/common.js +106 -0
- package/dist/schemas/common.js.map +1 -0
- package/dist/schemas/content.d.ts +828 -0
- package/dist/schemas/content.d.ts.map +1 -0
- package/dist/schemas/content.js +219 -0
- package/dist/schemas/content.js.map +1 -0
- package/dist/schemas/profile.d.ts +599 -0
- package/dist/schemas/profile.d.ts.map +1 -0
- package/dist/schemas/profile.js +177 -0
- package/dist/schemas/profile.js.map +1 -0
- package/dist/schemas/tools.d.ts +1581 -0
- package/dist/schemas/tools.d.ts.map +1 -0
- package/dist/schemas/tools.js +286 -0
- package/dist/schemas/tools.js.map +1 -0
- package/dist/tools/config.d.ts +51 -0
- package/dist/tools/config.d.ts.map +1 -0
- package/dist/tools/config.js +104 -0
- package/dist/tools/config.js.map +1 -0
- package/dist/tools/gate.d.ts +50 -0
- package/dist/tools/gate.d.ts.map +1 -0
- package/dist/tools/gate.js +67 -0
- package/dist/tools/gate.js.map +1 -0
- package/dist/tools/guidance.d.ts +36 -0
- package/dist/tools/guidance.d.ts.map +1 -0
- package/dist/tools/guidance.js +117 -0
- package/dist/tools/guidance.js.map +1 -0
- package/dist/tools/listTopics.d.ts +55 -0
- package/dist/tools/listTopics.d.ts.map +1 -0
- package/dist/tools/listTopics.js +78 -0
- package/dist/tools/listTopics.js.map +1 -0
- package/dist/tools/offers.d.ts +60 -0
- package/dist/tools/offers.d.ts.map +1 -0
- package/dist/tools/offers.js +152 -0
- package/dist/tools/offers.js.map +1 -0
- package/dist/tools/placeholders.d.ts +27 -0
- package/dist/tools/placeholders.d.ts.map +1 -0
- package/dist/tools/placeholders.js +49 -0
- package/dist/tools/placeholders.js.map +1 -0
- package/dist/tools/recordObservation.d.ts +52 -0
- package/dist/tools/recordObservation.d.ts.map +1 -0
- package/dist/tools/recordObservation.js +87 -0
- package/dist/tools/recordObservation.js.map +1 -0
- package/dist/tools/startQuiz.d.ts +82 -0
- package/dist/tools/startQuiz.d.ts.map +1 -0
- package/dist/tools/startQuiz.js +180 -0
- package/dist/tools/startQuiz.js.map +1 -0
- package/dist/tools/status.d.ts +59 -0
- package/dist/tools/status.d.ts.map +1 -0
- package/dist/tools/status.js +133 -0
- package/dist/tools/status.js.map +1 -0
- package/dist/tools/submitAnswer.d.ts +156 -0
- package/dist/tools/submitAnswer.d.ts.map +1 -0
- package/dist/tools/submitAnswer.js +402 -0
- package/dist/tools/submitAnswer.js.map +1 -0
- package/dist/tools/types.d.ts +82 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +48 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/tools/us2/standing.d.ts +111 -0
- package/dist/tools/us2/standing.d.ts.map +1 -0
- package/dist/tools/us2/standing.js +143 -0
- package/dist/tools/us2/standing.js.map +1 -0
- 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":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AAwDjD,2EAA2E;AAC3E,MAAM,KAAK,GAAoB,iBAAiB,CAAC,WAA8B,CAAC;AAEhF;;;;;;;;;GASG;AACH,MAAM,aAAa,GAAG,CAAC,WAAuB,EAAsB,EAAE;IACpE,MAAM,EAAE,cAAc,EAAE,GAAG,iBAAiB,CAAC;IAC7C,IAAI,WAAW,KAAK,CAAC;QAAE,OAAO,cAAc,CAAC,CAAC,CAAC,CAAC;IAChD,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACvC,6EAA6E;IAC7E,mFAAmF;IACnF,OAAO,cAAc,CAAC,GAAG,CAAC,CAAC;AAC7B,CAAC,CAAC;AAEF;;;;;;;;GAQG;AACH,MAAM,aAAa,GAAG,CAAC,WAAuB,EAAsB,EAAE;IACpE,MAAM,EAAE,cAAc,EAAE,GAAG,iBAAiB,CAAC;IAC7C,IAAI,WAAW,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IACxC,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACvC,kDAAkD;IAClD,OAAO,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,cAAc,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AACvD,CAAC,CAAC;AAEF,8EAA8E;AAC9E,MAAM,QAAQ,GAAG,CAAC,WAAuB,EAAoB,EAAE;IAC7D,IAAI,WAAW,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC;IACvC,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACvC,OAAO,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;AACxB,CAAC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAChC,KAAsB,EACF,EAAE;IACtB,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,GAAG,KAAK,CAAC;IAC9C,MAAM,EAAE,gBAAgB,EAAE,KAAK,EAAE,WAAW,EAAE,GAAG,iBAAiB,CAAC;IAEnE,2EAA2E;IAC3E,MAAM,UAAU,GAAG,aAAa,CAAC,WAAW,CAAC,CAAC;IAC9C,MAAM,MAAM,GAAG,QAAQ,CAAC,WAAW,CAAC,CAAC;IACrC,IACE,UAAU,KAAK,SAAS;QACxB,MAAM,KAAK,SAAS;QACpB,OAAO,IAAI,UAAU,GAAG,gBAAgB,EACxC,CAAC;QACD,MAAM,SAAS,GAAG,KAAK,GAAG,CAAC,CAAC;QAC5B,IAAI,SAAS,IAAI,WAAW,EAAE,CAAC;YAC7B,6DAA6D;YAC7D,OAAO;gBACL,OAAO,EAAE,IAAI;gBACb,IAAI,EAAE,MAAM;gBACZ,MAAM,EAAE,WAAW;gBACnB,KAAK,EAAE,CAAC;gBACR,YAAY,EAAE,KAAK;aACpB,CAAC;QACJ,CAAC;QACD,uEAAuE;QACvE,OAAO;YACL,OAAO,EAAE,KAAK;YACd,IAAI,EAAE,WAAW;YACjB,MAAM,EAAE,IAAI;YACZ,KAAK,EAAE,SAAS;YAChB,YAAY,EAAE,KAAK;SACpB,CAAC;IACJ,CAAC;IAED,2EAA2E;IAC3E,MAAM,WAAW,GAAG,aAAa,CAAC,WAAW,CAAC,CAAC;IAC/C,IACE,WAAW,KAAK,CAAC;QACjB,WAAW,KAAK,SAAS;QACzB,OAAO,IAAI,WAAW,GAAG,gBAAgB,EACzC,CAAC;QACD,MAAM,GAAG,GAAG,KAAK,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;QACvC,MAAM,KAAK,GAAe,GAAG,GAAG,CAAC,CAAC,CAAC,CAAE,KAAK,CAAC,GAAG,GAAG,CAAC,CAAU,CAAC,CAAC,CAAC,CAAC,CAAC;QACjE,OAAO;YACL,OAAO,EAAE,IAAI;YACb,IAAI,EAAE,KAAK;YACX,MAAM,EAAE,SAAS;YACjB,KAAK,EAAE,CAAC;YACR,YAAY,EAAE,IAAI;SACnB,CAAC;IACJ,CAAC;IAED,2EAA2E;IAC3E,yEAAyE;IACzE,OAAO;QACL,OAAO,EAAE,KAAK;QACd,IAAI,EAAE,WAAW;QACjB,MAAM,EAAE,IAAI;QACZ,KAAK,EAAE,CAAC;QACR,YAAY,EAAE,KAAK;KACpB,CAAC;AACJ,CAAC,CAAC"}
|
|
@@ -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"}
|