@vue-skuilder/db 0.2.8 → 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 (38) 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 +29 -4
  4. package/dist/core/index.d.ts +29 -4
  5. package/dist/core/index.js +132 -39
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +130 -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 +128 -39
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +128 -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 +128 -39
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +128 -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 +371 -251
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +369 -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/SrsDebugger.ts +53 -0
  33. package/src/core/navigators/generators/prescribed.ts +76 -9
  34. package/src/core/navigators/generators/srs.ts +81 -37
  35. package/src/core/navigators/index.ts +5 -0
  36. package/src/study/SessionController.ts +260 -249
  37. package/src/study/SessionDebugger.ts +15 -25
  38. 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.8",
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.8",
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.8"
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
 
@@ -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}`,
@@ -3,6 +3,7 @@ import type { ScheduledCard } from '../../types/user';
3
3
  import type { CourseDBInterface } from '../../interfaces/courseDB';
4
4
  import type { UserDBInterface } from '../../interfaces/userDB';
5
5
  import { ContentNavigator } from '../index';
6
+ import { captureSrsBacklog } from '../SrsDebugger';
6
7
  import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
7
8
  import type { CardGenerator, GeneratorContext, GeneratorResult } from './types';
8
9
  import { logger } from '@db/util/logger';
@@ -41,10 +42,19 @@ import { logger } from '@db/util/logger';
41
42
  const DEFAULT_HEALTHY_BACKLOG = 20;
42
43
 
43
44
  /**
44
- * Maximum backlog pressure contribution to score.
45
- * At 3x healthy backlog, pressure maxes out.
45
+ * Maximum backlog pressure as a *multiplier* on review urgency.
46
+ *
47
+ * Backlog pressure is multiplicative (×1.0 at/below healthy, scaling up as the
48
+ * due pile grows, maxing here at 3× healthy backlog). It replaces an older
49
+ * additive +0..+0.5 term that was a [0,1]-era modifier — once review scores
50
+ * stopped being clamped to 1.0 and new cards could be boosted well past it
51
+ * (e.g. an intro ×5 → 7+), a flat +0.5 was both too small to compete and mostly
52
+ * eaten by the old 1.0 clamp. A multiplier scales review priority onto the same
53
+ * open scale the boosted new cards live on, so a heavy backlog can genuinely
54
+ * lift reviews into competition. Tunable — verify review vs new ordering in the
55
+ * dbg overlay's "review backpressure" panel.
46
56
  */
47
- const MAX_BACKLOG_PRESSURE = 0.5;
57
+ const MAX_BACKLOG_MULTIPLIER = 2.0;
48
58
 
49
59
  /**
50
60
  * Configuration for the SRS strategy.
@@ -158,14 +168,31 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
158
168
  }
159
169
  }
160
170
 
161
- // Compute backlog pressure - applies globally to all reviews
162
- const backlogPressure = this.computeBacklogPressure(dueReviews.length);
171
+ // Compute backlog pressure (multiplicative) - applies globally to all reviews
172
+ const backlogMultiplier = this.computeBacklogMultiplier(dueReviews.length);
173
+
174
+ // Time until the next not-yet-due review (for the debug overlay): shows
175
+ // reviews are *coming* even when none are due right now.
176
+ const notDue = reviews.filter((r) => !now.isAfter(moment.utc(r.reviewTime)));
177
+ let nextDueIn: string | null = null;
178
+ if (notDue.length > 0) {
179
+ const next = notDue.reduce((a, b) =>
180
+ moment.utc(a.reviewTime).isBefore(moment.utc(b.reviewTime)) ? a : b
181
+ );
182
+ const until = moment.duration(moment.utc(next.reviewTime).diff(now));
183
+ nextDueIn =
184
+ until.asHours() < 1
185
+ ? `${Math.round(until.asMinutes())}m`
186
+ : until.asHours() < 24
187
+ ? `${Math.round(until.asHours())}h`
188
+ : `${Math.round(until.asDays())}d`;
189
+ }
163
190
 
164
191
  // Log review status for transparency
165
192
  if (dueReviews.length > 0) {
166
193
  const pressureNote =
167
- backlogPressure > 0
168
- ? ` [backlog pressure: +${backlogPressure.toFixed(2)}]`
194
+ backlogMultiplier > 1
195
+ ? ` [backlog pressure: ×${backlogMultiplier.toFixed(2)}]`
169
196
  : ` [healthy backlog]`;
170
197
  logger.info(
171
198
  `[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
@@ -192,7 +219,7 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
192
219
  }
193
220
 
194
221
  const scored = dueReviews.map((review) => {
195
- const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
222
+ const { score, reason } = this.computeUrgencyScore(review, now, backlogMultiplier);
196
223
 
197
224
  return {
198
225
  cardId: review.cardId,
@@ -213,41 +240,56 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
213
240
  });
214
241
 
215
242
  // Sort by score descending and limit
243
+ const sorted = scored.sort((a, b) => b.score - a.score);
244
+
245
+ // Capture backlog state for the live session overlay (see SrsDebugger).
246
+ captureSrsBacklog({
247
+ courseId,
248
+ scheduledTotal: reviews.length,
249
+ dueNow: dueReviews.length,
250
+ healthyBacklog: this.healthyBacklog,
251
+ backlogMultiplier,
252
+ maxBacklogMultiplier: MAX_BACKLOG_MULTIPLIER,
253
+ topReviewScore: sorted.length > 0 ? sorted[0].score : null,
254
+ nextDueIn,
255
+ timestamp: Date.now(),
256
+ });
257
+
216
258
  // [perf] parked: SRSgen / getPendingReviews timing
217
- // const srsResult = { cards: scored.sort((a, b) => b.score - a.score).slice(0, limit) };
259
+ // const srsResult = { cards: sorted.slice(0, limit) };
218
260
  // logger.info(
219
261
  // `[perf][SRSgen] total=${(performance.now() - tSrs0).toFixed(0)}ms ` +
220
262
  // `(pendingReviews=${(tReviews - tSrs0).toFixed(0)}) ` +
221
263
  // `[scheduled=${reviews.length} due=${dueReviews.length}]`
222
264
  // );
223
- return { cards: scored.sort((a, b) => b.score - a.score).slice(0, limit) };
265
+ return { cards: sorted.slice(0, limit) };
224
266
  }
225
267
 
226
268
  /**
227
- * Compute backlog pressure based on number of due reviews.
269
+ * Compute the multiplicative backlog pressure based on number of due reviews.
228
270
  *
229
- * Backlog pressure is 0 when at or below healthy threshold,
230
- * and increases linearly above it, maxing out at MAX_BACKLOG_PRESSURE.
271
+ * ×1.0 at or below the healthy threshold (no boost), increasing linearly above
272
+ * it and maxing out at MAX_BACKLOG_MULTIPLIER at the healthy backlog.
231
273
  *
232
- * Examples (with default healthyBacklog=20):
233
- * - 10 due reviews → 0.00 (healthy)
234
- * - 20 due reviews → 0.00 (at threshold)
235
- * - 40 due reviews → 0.25 (2x threshold)
236
- * - 60 due reviews → 0.50 (3x threshold, maxed)
274
+ * Examples (with default healthyBacklog=20, MAX_BACKLOG_MULTIPLIER=2.0):
275
+ * - 10 due reviews → ×1.00 (healthy)
276
+ * - 20 due reviews → ×1.00 (at threshold)
277
+ * - 40 due reviews → ×1.50 (2x threshold)
278
+ * - 60 due reviews → ×2.00 (3x threshold, maxed)
237
279
  *
238
280
  * @param dueCount - Number of reviews currently due
239
- * @returns Backlog pressure score to add to urgency (0 to MAX_BACKLOG_PRESSURE)
281
+ * @returns Multiplier applied to review urgency (1.0 to MAX_BACKLOG_MULTIPLIER)
240
282
  */
241
- private computeBacklogPressure(dueCount: number): number {
283
+ private computeBacklogMultiplier(dueCount: number): number {
242
284
  if (dueCount <= this.healthyBacklog) {
243
- return 0;
285
+ return 1.0;
244
286
  }
245
287
 
246
- // Linear increase: at 2x healthy, pressure = 0.25; at 3x, pressure = 0.50
288
+ // Linear in excess: ×1 at healthy, reaching MAX at healthy (excess = 2×healthy).
247
289
  const excess = dueCount - this.healthyBacklog;
248
- const pressure = (excess / this.healthyBacklog) * (MAX_BACKLOG_PRESSURE / 2);
290
+ const multiplier = 1 + (excess / this.healthyBacklog) * ((MAX_BACKLOG_MULTIPLIER - 1) / 2);
249
291
 
250
- return Math.min(MAX_BACKLOG_PRESSURE, pressure);
292
+ return Math.min(MAX_BACKLOG_MULTIPLIER, multiplier);
251
293
  }
252
294
 
253
295
  /**
@@ -263,22 +305,23 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
263
305
  * - 30 days (720h) → ~0.56
264
306
  * - 180 days → ~0.30
265
307
  *
266
- * 3. Backlog pressure = global boost when review backlog exceeds healthy threshold
267
- * - At healthy backlog: 0
268
- * - At 2x healthy: +0.25
269
- * - At 3x+ healthy: +0.50 (max)
308
+ * 3. Backlog pressure = global *multiplier* when review backlog exceeds the
309
+ * healthy threshold (×1.0 healthy up to MAX_BACKLOG_MULTIPLIER at 3×).
270
310
  *
271
- * Combined: base 0.5 + (urgency factors * 0.45) + backlog pressure
272
- * Result range: 0.5 to 1.0 (uncapped to allow high-urgency reviews to compete with new cards)
311
+ * Combined: (base 0.5 + urgency factors * 0.45) × backlog multiplier.
312
+ * Per-card range before pressure: ~0.57–0.95. NOT clamped to 1.0 under a
313
+ * heavy backlog reviews scale onto the open scale to compete with (and exceed)
314
+ * new cards; what keeps them from running away is the bounded multiplier, not
315
+ * a hard ceiling.
273
316
  *
274
317
  * @param review - The scheduled card to score
275
318
  * @param now - Current time
276
- * @param backlogPressure - Pre-computed backlog pressure (0 to 0.5)
319
+ * @param backlogMultiplier - Pre-computed backlog multiplier (1.0 to MAX_BACKLOG_MULTIPLIER)
277
320
  */
278
321
  private computeUrgencyScore(
279
322
  review: ScheduledCard,
280
323
  now: moment.Moment,
281
- backlogPressure: number
324
+ backlogMultiplier: number
282
325
  ): { score: number; reason: string } {
283
326
  const scheduledAt = moment.utc(review.scheduledAt);
284
327
  const due = moment.utc(review.reviewTime);
@@ -299,10 +342,11 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
299
342
  const overdueContribution = Math.min(1.0, Math.max(0, relativeOverdue));
300
343
  const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
301
344
 
302
- // Final score: base 0.5 + urgency contribution + backlog pressure
303
- // Uncapped at 1.0 (no 0.95 ceiling) - allows high-urgency reviews to compete with new cards
345
+ // Final score: per-card urgency (base 0.5 + contribution) scaled by the
346
+ // global backlog multiplier. No 1.0 clamp reviews compete on the open
347
+ // scale; the bounded multiplier (not a ceiling) caps the lift.
304
348
  const baseScore = 0.5 + urgency * 0.45;
305
- const score = Math.min(1.0, baseScore + backlogPressure);
349
+ const score = baseScore * backlogMultiplier;
306
350
 
307
351
  // Build reason string with all contributing factors
308
352
  const reasonParts = [
@@ -312,8 +356,8 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
312
356
  `recency: ${recencyFactor.toFixed(2)}`,
313
357
  ];
314
358
 
315
- if (backlogPressure > 0) {
316
- reasonParts.push(`backlog: +${backlogPressure.toFixed(2)}`);
359
+ if (backlogMultiplier > 1) {
360
+ reasonParts.push(`backlog: ×${backlogMultiplier.toFixed(2)}`);
317
361
  }
318
362
 
319
363
  reasonParts.push('review');
@@ -27,6 +27,11 @@ export {
27
27
 
28
28
  // Re-export the commit-free forecast capability surface.
29
29
  export type { PipelineForecaster } from './Pipeline';
30
+ export {
31
+ getSrsBacklogDebug,
32
+ clearSrsBacklogDebug,
33
+ type SrsBacklogDebug,
34
+ } from './SrsDebugger';
30
35
 
31
36
  import { LearnableWeight } from '../types/contentNavigationStrategy';
32
37
  export type { ContentNavigationStrategyData, LearnableWeight } from '../types/contentNavigationStrategy';