@vue-skuilder/db 0.2.7 → 0.2.9

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 (40) hide show
  1. package/dist/{contentSource-Cplhv3bJ.d.ts → contentSource-C-0t0y0V.d.ts} +7 -0
  2. package/dist/{contentSource-kI9_jwTu.d.cts → contentSource-jSkcOt2s.d.cts} +7 -0
  3. package/dist/core/index.d.cts +67 -4
  4. package/dist/core/index.d.ts +67 -4
  5. package/dist/core/index.js +201 -39
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +198 -39
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-DrBqOUa3.d.ts → dataLayerProvider-BB0oi9T0.d.ts} +1 -1
  10. package/dist/{dataLayerProvider-CiA2Rr0v.d.cts → dataLayerProvider-BDClIrFC.d.cts} +1 -1
  11. package/dist/impl/couch/index.d.cts +2 -2
  12. package/dist/impl/couch/index.d.ts +2 -2
  13. package/dist/impl/couch/index.js +195 -39
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +195 -39
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +2 -2
  18. package/dist/impl/static/index.d.ts +2 -2
  19. package/dist/impl/static/index.js +195 -39
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +195 -39
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index.d.cts +115 -81
  24. package/dist/index.d.ts +115 -81
  25. package/dist/index.js +440 -251
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +437 -251
  28. package/dist/index.mjs.map +1 -1
  29. package/docs/navigators-architecture.md +29 -13
  30. package/package.json +3 -3
  31. package/src/core/interfaces/contentSource.ts +7 -0
  32. package/src/core/navigators/Pipeline.ts +93 -1
  33. package/src/core/navigators/PipelineDebugger.ts +11 -1
  34. package/src/core/navigators/SrsDebugger.ts +53 -0
  35. package/src/core/navigators/generators/prescribed.ts +76 -9
  36. package/src/core/navigators/generators/srs.ts +81 -37
  37. package/src/core/navigators/index.ts +9 -0
  38. package/src/study/SessionController.ts +260 -249
  39. package/src/study/SessionDebugger.ts +15 -25
  40. package/src/study/SessionOverlay.ts +108 -13
@@ -18,7 +18,7 @@ A card with a suitability score, audit trail, and pre-fetched metadata:
18
18
  interface WeightedCard {
19
19
  cardId: string;
20
20
  courseId: string;
21
- score: number; // 0-1 suitability score
21
+ score: number; // suitability; anchored at [0,1] but unbounded above (see Score Semantics)
22
22
  provenance: StrategyContribution[]; // Audit trail
23
23
  tags?: string[]; // Pre-fetched tags (hydrated by Pipeline)
24
24
  }
@@ -47,17 +47,21 @@ interface CardGenerator {
47
47
 
48
48
  **Implementations:**
49
49
  - `ELONavigator` — New cards scored by ELO proximity to user skill (scores 0.0-1.0)
50
- - `SRSNavigator` — Review cards scored by overdueness, interval recency, and **backlog pressure** (scores 0.5-1.0)
51
- - `HardcodedOrderNavigator` — Fixed sequence defined by course author // NB this no longer exists but /home/colin/pn/vue-skuilder/master/packages/db/src/core/navigators/generators/prescribed.ts does - please update the doc in place
50
+ - `SRSNavigator` — Review cards scored by overdueness, interval recency, and a multiplicative **backlog pressure** (base urgency ~0.5–0.95, scaled up by the backlog multiplier — can exceed 1.0)
51
+ - `PrescribedCardsGenerator` — Stateful generator for authored, course-prescribed content (e.g. intro cards that must eventually surface). Tracks whether prescribed targets have been encountered, applies progressive pressure to stale/pending target groups, can emit upstream support cards to satisfy prereqs of still-blocked targets, and drills unlocked-but-under-practiced skills under a **practice-debt pressure** (base ×2 escalating with debt age, capped — a persistent, self-discharging analog of SRS backlog pressure that guarantees a minimum number of reps land after intro). Registered as `prescribed`. See `generators/prescribed.ts`.
52
52
  - `CompositeGenerator` — Merges multiple generators with frequency boost
53
53
 
54
54
  #### SRS Backlog Pressure
55
55
 
56
- The SRS generator implements a self-regulating **backlog pressure** mechanism that prevents review pile-up while maintaining healthy new/review balance:
56
+ The SRS generator implements a self-regulating **backlog pressure** mechanism that prevents review pile-up while maintaining healthy new/review balance.
57
57
 
58
- - **Healthy backlog** (≤20 due reviews): No pressure boost, scores 0.5-0.95. New content (ELO) naturally dominates.
59
- - **Elevated backlog** (40 due): +0.25 boost, scores 0.75-1.0. Reviews compete with new cards.
60
- - **High backlog** (60+ due): +0.50 boost (max), scores 0.95-1.0. Reviews take priority.
58
+ Each review's per-card urgency is `base 0.5 + (overdueness/recency factors)·0.45` ~0.50.95. Backlog pressure then **multiplies** that urgency by a global factor that grows as the due pile exceeds the healthy threshold (default `MAX_BACKLOG_MULTIPLIER = 2.0`, reached at 3× healthy):
59
+
60
+ - **Healthy backlog** (≤20 due reviews): ×1.0 — no boost, scores ~0.5–0.95. New content (ELO) naturally dominates.
61
+ - **Elevated backlog** (40 due): ×1.5 — scores ~0.85–1.4. Reviews compete with new cards.
62
+ - **High backlog** (60+ due): ×2.0 (max) — scores ~1.1–1.9. Reviews take priority.
63
+
64
+ **Why multiplicative, and not clamped to 1.0:** review scores are *not* capped at 1.0. They are designed to be cross-comparable with new-card scores, which are themselves no longer [0,1]-bounded — session hints multiply scores (an intro can boost its exercise tag ×5+), and `SessionController` draws one rank-ordered supply queue where reviews and new compete on a single open scale (see `decision-single-supply-queue.md`). A flat additive `+0.5` (the previous design) was both too small to contend with such boosts and largely swallowed by the old 1.0 clamp. A bounded multiplier scales review priority onto the same open scale, so a heavy backlog can genuinely lift reviews into contention; the cap (not a score ceiling) is what keeps them from running away.
61
65
 
62
66
  This treats SRS scheduling times as **eligibility dates** rather than hard due dates—reviewing slightly later may be optimal. The system maintains a healthy backlog rather than always clearing to zero (avoiding "Anki death spiral").
63
67
 
@@ -66,7 +70,7 @@ Configuration via strategy `serializedData`:
66
70
  { "healthyBacklog": 20 }
67
71
  ```
68
72
 
69
- See `todo-review-adaptation.md` for planned per-user adaptation extensions.
73
+ `MAX_BACKLOG_MULTIPLIER` is currently a module constant in `generators/srs.ts` (tune against the dbg overlay's "review backpressure" panel). See `todo-review-adaptation.md` for planned per-user adaptation extensions.
70
74
 
71
75
  ### CardFilter
72
76
 
@@ -200,10 +204,14 @@ Pipeline(
200
204
 
201
205
  | Score | Meaning |
202
206
  |-------|---------|
203
- | 1.0 | Fully suitable |
207
+ | 1.0 | Fully suitable (the generator anchor, **not** a ceiling) |
204
208
  | 0.5 | Neutral |
205
209
  | 0.0 | Exclude (hard filter) |
206
210
  | 0.x | Proportional suitability |
211
+ | >1.0 | Boosted / elevated-urgency — above the nominal "fully suitable" anchor |
212
+ | +INF | Mandatory — a required card injected by a `requireCards`/`requireTags` hint |
213
+
214
+ **1.0 is an anchor, not a cap.** Generators emit in ~[0,1], but the final score is an *open* scale: multiplicative filters and session hints can push scores above 1.0 (an intro boosting its exercise tag ×5), and the SRS backlog multiplier lifts urgent reviews past 1.0 under heavy backlog. This is deliberate — it lets reviews and new cards compete on one cross-comparable scale, which is what `SessionController`'s single supply queue draws against (see `decision-single-supply-queue.md`). `+INF` is the require-injection sentinel; such cards float to the head of the supply.
207
215
 
208
216
  **All filters are multipliers.** This means:
209
217
  - Filter order doesn't affect final scores (multiplication is commutative)
@@ -531,8 +539,15 @@ class MyFilter extends ContentNavigator implements CardFilter {
531
539
  ```
532
540
 
533
541
  This enables **single source of truth** patterns: the consumer app writes state
534
- via `UsrCrsDataInterface.putStrategyState()`, and the consumer filter reads it
535
- via the same key. No framework changes needed.
542
+ through the course-scoped interface, which is reached via the **async**
543
+ `getCourseInterface(courseId)` (await it first it returns a `Promise`):
544
+
545
+ ```typescript
546
+ const courseDb = await userDB.getCourseInterface(courseId);
547
+ await courseDb.putStrategyState<MyStateType>('MySharedStateKey', data);
548
+ ```
549
+
550
+ The consumer filter then reads it via the same key. No framework changes needed.
536
551
 
537
552
  ---
538
553
 
@@ -633,8 +648,9 @@ return { ...card, score: card.score * multiplier };
633
648
  | `core/navigators/PipelineAssembler.ts` | Builds Pipeline from strategy docs |
634
649
  | `core/navigators/CompositeGenerator.ts` | Merges multiple generators |
635
650
  | `core/navigators/generators/elo.ts` | ELO generator |
636
- | `core/navigators/generators/srs.ts` | SRS generator (with backlog pressure) |
637
- | `core/navigators/hardcodedOrder.ts` | Fixed-order generator |
651
+ | `core/navigators/generators/srs.ts` | SRS generator (multiplicative backlog pressure) |
652
+ | `core/navigators/generators/prescribed.ts` | Prescribed-content generator (authored targets, support cards, practice drilling) |
653
+ | `core/navigators/SrsDebugger.ts` | Per-run SRS backlog capture for the session overlay |
638
654
  | `core/navigators/hierarchyDefinition.ts` | Prerequisite filter |
639
655
  | `core/navigators/interferenceMitigator.ts` | Interference filter |
640
656
  | `core/navigators/relativePriority.ts` | Priority filter |
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.7",
7
+ "version": "0.2.9",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.2.7",
51
+ "@vue-skuilder/common": "0.2.9",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -62,5 +62,5 @@
62
62
  "vite": "^8.0.0",
63
63
  "vitest": "^4.1.0"
64
64
  },
65
- "stableVersion": "0.2.7"
65
+ "stableVersion": "0.2.9"
66
66
  }
@@ -40,6 +40,13 @@ export interface StudySessionItem {
40
40
  cardID: string;
41
41
  courseID: string;
42
42
  elo?: number;
43
+ /**
44
+ * Pipeline suitability score at queue-build time, carried for observability
45
+ * (the debug overlay renders the now-load-bearing supply ranking). `+INF`
46
+ * marks a mandatory required card. Not used for any draw decision — the
47
+ * supplyQ is already ordered, so the controller draws front-to-back.
48
+ */
49
+ score?: number;
43
50
  // reviewID?: string;
44
51
  }
45
52
 
@@ -255,7 +255,22 @@ function logCardProvenance(cards: WeightedCard[], maxCards: number = 3): void {
255
255
  * const cards = await pipeline.getWeightedCards(20);
256
256
  * ```
257
257
  */
258
- export class Pipeline extends ContentNavigator {
258
+
259
+ /**
260
+ * Narrow capability surface for out-of-band, commit-free reads against a live
261
+ * pipeline (see {@link getActivePipeline}). Kept minimal on purpose — consumers
262
+ * get the forecast capability, not the whole `Pipeline` class.
263
+ */
264
+ export interface PipelineForecaster {
265
+ forecast(opts?: {
266
+ hints?: ReplanHints;
267
+ unseenOnly?: boolean;
268
+ threshold?: number;
269
+ limit?: number;
270
+ }): Promise<WeightedCard[]>;
271
+ }
272
+
273
+ export class Pipeline extends ContentNavigator implements PipelineForecaster {
259
274
  private generator: CardGenerator;
260
275
  private filters: CardFilter[];
261
276
 
@@ -890,6 +905,83 @@ export class Pipeline extends ContentNavigator {
890
905
  // Card-space diagnostic
891
906
  // ---------------------------------------------------------------------------
892
907
 
908
+ /**
909
+ * Commit-free forecast: score the user's full card space through the filter
910
+ * chain and return the cards that are currently *reachable* (score >=
911
+ * threshold), optionally nudged by caller-supplied hints and/or restricted
912
+ * to cards the user hasn't seen yet.
913
+ *
914
+ * This is a GENERIC primitive — it returns scored, tag-hydrated cards and
915
+ * stops there. It has no knowledge of any particular tag convention; callers
916
+ * decide what the surviving cards mean (e.g. filter to their own "intro"
917
+ * tag family). Nothing is written and no session is started.
918
+ *
919
+ * The optional `hints` are the "out-of-band kick": they run through the same
920
+ * {@link applyHints} path a live replan uses, so the two semantics carry over —
921
+ * - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
922
+ * stays out), and
923
+ * - `requireTags`/`requireCards` inject from the full pre-filter pool,
924
+ * *bypassing* gating (use when you want a card regardless of reachability).
925
+ * Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
926
+ * user has already seen — pass `unseenOnly: false` if that matters.
927
+ *
928
+ * Cost note: like {@link diagnoseCardSpace}, this scans every card through the
929
+ * filters, so it's heavier than a normal replan. Intended for one-shot
930
+ * out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
931
+ *
932
+ * @param opts.hints Optional ephemeral hints to apply after the filter chain.
933
+ * @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
934
+ * @param opts.threshold Min score to count as reachable (default 0.10).
935
+ * @param opts.limit Optional cap on results (already sorted desc).
936
+ */
937
+ async forecast(opts?: {
938
+ hints?: ReplanHints;
939
+ unseenOnly?: boolean;
940
+ threshold?: number;
941
+ limit?: number;
942
+ }): Promise<WeightedCard[]> {
943
+ const threshold = opts?.threshold ?? 0.10;
944
+ const unseenOnly = opts?.unseenOnly ?? true;
945
+
946
+ const courseId = this.course!.getCourseID();
947
+ const allCardIds = await this.course!.getAllCardIds();
948
+
949
+ let cards: WeightedCard[] = allCardIds.map((cardId) => ({
950
+ cardId,
951
+ courseId,
952
+ score: 1.0,
953
+ provenance: [],
954
+ }));
955
+
956
+ cards = await this.hydrateTags(cards);
957
+ // Snapshot the full pool before filtering, for require-injection in applyHints.
958
+ const fullPool = cards.slice();
959
+
960
+ const context = await this.buildContext();
961
+ for (const filter of this.filters) {
962
+ cards = await filter.transform(cards, context);
963
+ }
964
+
965
+ if (opts?.hints) {
966
+ cards = this.applyHints(cards, opts.hints, fullPool);
967
+ }
968
+
969
+ cards = cards.filter((c) => c.score >= threshold);
970
+
971
+ if (unseenOnly) {
972
+ let encountered: Set<string>;
973
+ try {
974
+ encountered = new Set(await this.user!.getSeenCards(courseId));
975
+ } catch {
976
+ encountered = new Set();
977
+ }
978
+ cards = cards.filter((c) => !encountered.has(c.cardId));
979
+ }
980
+
981
+ cards.sort((a, b) => b.score - a.score);
982
+ return opts?.limit ? cards.slice(0, opts.limit) : cards;
983
+ }
984
+
893
985
  /**
894
986
  * Scan every card in the course through the filter chain and report
895
987
  * how many are "well indicated" (score >= threshold) for the current user.
@@ -8,7 +8,7 @@ import {
8
8
  isFilter,
9
9
  } from './index';
10
10
  import { logger } from '../../util/logger';
11
- import type { Pipeline, CardSpaceDiagnosis } from './Pipeline';
11
+ import type { Pipeline, CardSpaceDiagnosis, PipelineForecaster } from './Pipeline';
12
12
  import type { ReplanHints } from './generators/types';
13
13
 
14
14
  /**
@@ -25,6 +25,16 @@ export function registerPipelineForDebug(pipeline: Pipeline): void {
25
25
  _activePipeline = pipeline;
26
26
  }
27
27
 
28
+ /**
29
+ * The most recently constructed pipeline for the current session, or null if
30
+ * none has been built yet. This is the supported, non-debug accessor for
31
+ * out-of-band reads against the live pipeline (e.g. a commit-free
32
+ * `forecast()`), replacing reach-ins through `window.skuilder.pipeline`.
33
+ */
34
+ export function getActivePipeline(): PipelineForecaster | null {
35
+ return _activePipeline;
36
+ }
37
+
28
38
  // ============================================================================
29
39
  // PIPELINE DEBUGGER
30
40
  // ============================================================================
@@ -0,0 +1,53 @@
1
+ // ============================================================================
2
+ // SRS BACKLOG DEBUGGER
3
+ // ============================================================================
4
+ //
5
+ // A tiny module-level capture of the SRS generator's per-run backlog state,
6
+ // so the live session overlay can show whether reviews being out-competed by
7
+ // (boosted) new cards is *temporary* (the backlog multiplier still has headroom
8
+ // to climb and lift reviews) or *boost-driven* (the multiplier is maxed and the
9
+ // new-card boosts simply sit higher — only a hint relaxation reorders them).
10
+ //
11
+ // Mirrors MixerDebugger/PipelineDebugger: the generator pushes a snapshot each
12
+ // run (keyed by course, latest wins); the overlay reads it via the controller's
13
+ // getDebugSnapshot(). No DB load on the read path.
14
+ //
15
+ // ============================================================================
16
+
17
+ /** Per-course snapshot of SRS backlog state, captured on each SRS generator run. */
18
+ export interface SrsBacklogDebug {
19
+ courseId: string;
20
+ /** Total reviews scheduled for this course (due + not-yet-due). */
21
+ scheduledTotal: number;
22
+ /** Reviews eligible (due) right now. */
23
+ dueNow: number;
24
+ /** Healthy backlog threshold; multiplier is ×1.0 at or below this. */
25
+ healthyBacklog: number;
26
+ /** Global multiplier applied to every due review's urgency this run (1.0 → max). */
27
+ backlogMultiplier: number;
28
+ /** Max achievable backlog multiplier (the cap), for headroom context. */
29
+ maxBacklogMultiplier: number;
30
+ /** Highest review score produced this run (post-multiplier; can exceed 1.0); null if none due. */
31
+ topReviewScore: number | null;
32
+ /** Human-readable time until the next review comes due, or null if some are due now. */
33
+ nextDueIn: string | null;
34
+ /** Epoch ms of capture. */
35
+ timestamp: number;
36
+ }
37
+
38
+ const snapshots = new Map<string, SrsBacklogDebug>();
39
+
40
+ /** Called by the SRS generator once per run. Latest snapshot per course wins. */
41
+ export function captureSrsBacklog(snapshot: SrsBacklogDebug): void {
42
+ snapshots.set(snapshot.courseId, snapshot);
43
+ }
44
+
45
+ /** Current backlog snapshot for every course seen, newest-first. */
46
+ export function getSrsBacklogDebug(): SrsBacklogDebug[] {
47
+ return [...snapshots.values()].sort((a, b) => b.timestamp - a.timestamp);
48
+ }
49
+
50
+ /** Drop all captured snapshots (called on session start, alongside pipeline history). */
51
+ export function clearSrsBacklogDebug(): void {
52
+ snapshots.clear();
53
+ }
@@ -79,6 +79,14 @@ interface GroupCardState {
79
79
  interface PrescribedProgressState {
80
80
  updatedAt: string;
81
81
  groups: Record<string, GroupCardState>;
82
+ /**
83
+ * Per-practice-tag debt age: tag → ISO timestamp when the skill first appeared
84
+ * unlocked-but-under-practiced. Drives the practice-debt staleness escalation
85
+ * (older unpaid debt → higher pressure). An entry is carried while the debt is
86
+ * open and dropped the moment the skill reaches `practiceMinCount` — so the
87
+ * map self-prunes and "staleness" is measured from first-owed, not last-seen.
88
+ */
89
+ practiceDebt?: Record<string, string>;
82
90
  }
83
91
 
84
92
  interface TagPrerequisite {
@@ -126,10 +134,20 @@ const DEFAULT_MAX_PRACTICE_PER_RUN = 4;
126
134
  const BASE_TARGET_SCORE = 1.0;
127
135
  const BASE_SUPPORT_SCORE = 0.8;
128
136
  const DISCOVERED_SUPPORT_SCORE = 12.0;
129
- // Practice drill cards enter the pool at parity with a well-matched target so
130
- // they survive into the candidate set; per-skill *emphasis* (recency, decay) is
131
- // the durable boost's job, not this base score's.
137
+ // Practice drill cards: a *practice-debt pressure*, parallel to the SRS backlog
138
+ // multiplier. An unlocked-but-under-practiced skill owes reps; that debt is
139
+ // durable (keyed off per-tag attempt count) and discharges by practice, not
140
+ // time. The score is base × a debt multiplier that starts at PRACTICE_BASE_MULT
141
+ // (so a few reps land promptly after intro, competing with pressured reviews)
142
+ // and escalates by how long the debt has stayed open (capped), so a chronically
143
+ // out-competed skill eventually forces exposure rather than competing at flat
144
+ // parity forever. Replaces the old flat 1.0, which punted emphasis to the
145
+ // session-scoped intro boost that evaporates at session end.
132
146
  const BASE_PRACTICE_SCORE = 1.0;
147
+ const PRACTICE_BASE_MULT = 2.0;
148
+ const MAX_PRACTICE_MULTIPLIER = 4.0;
149
+ // Added to the multiplier per day the debt stays open (linear, then clamped).
150
+ const PRACTICE_STALENESS_BUMP_PER_DAY = 0.5;
133
151
  const MAX_TARGET_MULTIPLIER = 8.0;
134
152
  const MAX_SUPPORT_MULTIPLIER = 4.0;
135
153
 
@@ -271,6 +289,10 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
271
289
  const emitted: WeightedCard[] = [];
272
290
  const emittedIds = new Set<string>();
273
291
  const groupRuntimes: GroupRuntimeState[] = [];
292
+ // Practice-debt ages carried forward: stamped when a skill first appears
293
+ // under-practiced, dropped once it's discharged (see buildPracticeCards).
294
+ const priorPracticeDebt = progress.practiceDebt ?? {};
295
+ const nextPracticeDebt: Record<string, string> = {};
274
296
 
275
297
  for (const group of this.config.groups) {
276
298
  const runtime = this.buildGroupRuntimeState({
@@ -346,11 +368,17 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
346
368
  userGlobalElo,
347
369
  activeIds,
348
370
  seenIds,
371
+ priorPracticeDebt,
372
+ nextPracticeDebt,
349
373
  });
350
374
 
351
375
  emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
352
376
  }
353
377
 
378
+ // Persist the carried-forward practice debt (self-pruned: discharged skills
379
+ // simply aren't re-stamped above, so they drop out of the next state).
380
+ nextState.practiceDebt = nextPracticeDebt;
381
+
354
382
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
355
383
  const hints: ReplanHints | undefined =
356
384
  Object.keys(hintSummary.boostTags).length > 0
@@ -821,9 +849,16 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
821
849
  * `practiceMinCount`), this resolves cards carrying that tag and emits them
822
850
  * into the candidate pool. It exists because global-ELO retrieval
823
851
  * systematically fails to fetch the (low-ELO) drill cards for a
824
- * freshly-introduced skill — putting them in the pool here lets the pipeline's
825
- * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
826
- * this method's job; it only guarantees presence.
852
+ * freshly-introduced skill — putting them in the pool here guarantees presence.
853
+ *
854
+ * Emphasis is a **practice-debt pressure** (parallel to SRS backlog pressure):
855
+ * cards score `base × multiplier`, where the multiplier starts at
856
+ * PRACTICE_BASE_MULT (so a few reps land promptly post-intro, competing with
857
+ * pressured reviews) and escalates by how long the debt has stayed open
858
+ * (per-tag, time-based via `priorPracticeDebt`/`nextPracticeDebt`), clamped at
859
+ * MAX_PRACTICE_MULTIPLIER. The debt is durable and self-discharges the instant
860
+ * the skill reaches `practiceMinCount` — so this no longer relies on the
861
+ * session-scoped intro boost to actually surface.
827
862
  *
828
863
  * Fully data-driven: the unlock relation comes from the hierarchy config and
829
864
  * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
@@ -838,6 +873,8 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
838
873
  userGlobalElo: number;
839
874
  activeIds: Set<string>;
840
875
  seenIds: Set<string>;
876
+ priorPracticeDebt: Record<string, string>;
877
+ nextPracticeDebt: Record<string, string>;
841
878
  }): WeightedCard[] {
842
879
  const {
843
880
  group,
@@ -849,6 +886,8 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
849
886
  userGlobalElo,
850
887
  activeIds,
851
888
  seenIds,
889
+ priorPracticeDebt,
890
+ nextPracticeDebt,
852
891
  } = args;
853
892
 
854
893
  const patterns = group.practiceTagPatterns ?? [];
@@ -866,6 +905,25 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
866
905
 
867
906
  if (practiceTags.length === 0) return [];
868
907
 
908
+ // Carry forward (or open) each under-practiced skill's debt age, and derive
909
+ // its practice multiplier: base + staleness-since-first-owed, clamped. Done
910
+ // for every under-practiced tag (not just emitted ones) so the debt clock
911
+ // keeps running even on runs where the cap or de-dup emits nothing.
912
+ const now = Date.now();
913
+ const DAY_MS = 24 * 60 * 60 * 1000;
914
+ const tagMultiplier = new Map<string, number>();
915
+ for (const tag of practiceTags) {
916
+ const firstOwedAt = priorPracticeDebt[tag] ?? isoNow();
917
+ nextPracticeDebt[tag] = firstOwedAt;
918
+ const staleDays = Math.max(0, (now - new Date(firstOwedAt).getTime()) / DAY_MS);
919
+ const mult = clamp(
920
+ PRACTICE_BASE_MULT + staleDays * PRACTICE_STALENESS_BUMP_PER_DAY,
921
+ PRACTICE_BASE_MULT,
922
+ MAX_PRACTICE_MULTIPLIER
923
+ );
924
+ tagMultiplier.set(tag, mult);
925
+ }
926
+
869
927
  // Reuse the diversity-aware tag→cards collector (stem-dedup + shuffle).
870
928
  const practiceCardIds = this.findDiscoveredSupportCards({
871
929
  supportTags: practiceTags,
@@ -886,19 +944,28 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
886
944
  const cards: WeightedCard[] = [];
887
945
  for (const cardId of practiceCardIds) {
888
946
  emittedIds.add(cardId);
947
+ // Most-stale wins: a card may carry several practice tags; take the
948
+ // highest debt multiplier among the ones it serves.
949
+ let mult = PRACTICE_BASE_MULT;
950
+ for (const tag of practiceTags) {
951
+ if ((cardsByTag.get(tag)?.includes(cardId) ?? false)) {
952
+ mult = Math.max(mult, tagMultiplier.get(tag) ?? PRACTICE_BASE_MULT);
953
+ }
954
+ }
955
+ const score = BASE_PRACTICE_SCORE * mult;
889
956
  cards.push({
890
957
  cardId,
891
958
  courseId,
892
- score: BASE_PRACTICE_SCORE,
959
+ score,
893
960
  provenance: [
894
961
  {
895
962
  strategy: 'prescribed',
896
963
  strategyName: this.strategyName || this.name,
897
964
  strategyId: this.strategyId || 'NAVIGATION_STRATEGY-prescribed',
898
965
  action: 'generated' as const,
899
- score: BASE_PRACTICE_SCORE,
966
+ score,
900
967
  reason:
901
- `mode=practice;group=${group.id};` +
968
+ `mode=practice;group=${group.id};debtMult=×${mult.toFixed(2)};` +
902
969
  `underPracticedSkills=${practiceTags.length};` +
903
970
  `practiceTags=${practiceTags.slice(0, 8).join('|')}${practiceTags.length > 8 ? '|…' : ''};` +
904
971
  `testversion=${PRESCRIBED_DEBUG_VERSION}`,