@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.
- package/dist/{contentSource-Cplhv3bJ.d.ts → contentSource-C-0t0y0V.d.ts} +7 -0
- package/dist/{contentSource-kI9_jwTu.d.cts → contentSource-jSkcOt2s.d.cts} +7 -0
- package/dist/core/index.d.cts +29 -4
- package/dist/core/index.d.ts +29 -4
- package/dist/core/index.js +132 -39
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +130 -39
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-DrBqOUa3.d.ts → dataLayerProvider-BB0oi9T0.d.ts} +1 -1
- package/dist/{dataLayerProvider-CiA2Rr0v.d.cts → dataLayerProvider-BDClIrFC.d.cts} +1 -1
- package/dist/impl/couch/index.d.cts +2 -2
- package/dist/impl/couch/index.d.ts +2 -2
- package/dist/impl/couch/index.js +128 -39
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +128 -39
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +2 -2
- package/dist/impl/static/index.d.ts +2 -2
- package/dist/impl/static/index.js +128 -39
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +128 -39
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +115 -81
- package/dist/index.d.ts +115 -81
- package/dist/index.js +371 -251
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +369 -251
- package/dist/index.mjs.map +1 -1
- package/docs/navigators-architecture.md +29 -13
- package/package.json +3 -3
- package/src/core/interfaces/contentSource.ts +7 -0
- package/src/core/navigators/SrsDebugger.ts +53 -0
- package/src/core/navigators/generators/prescribed.ts +76 -9
- package/src/core/navigators/generators/srs.ts +81 -37
- package/src/core/navigators/index.ts +5 -0
- package/src/study/SessionController.ts +260 -249
- package/src/study/SessionDebugger.ts +15 -25
- 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
|
|
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** (
|
|
51
|
-
- `
|
|
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
|
-
-
|
|
59
|
-
|
|
60
|
-
- **
|
|
58
|
+
Each review's per-card urgency is `base 0.5 + (overdueness/recency factors)·0.45` → ~0.5–0.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
|
-
|
|
535
|
-
|
|
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 (
|
|
637
|
-
| `core/navigators/
|
|
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
|
+
"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.
|
|
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.
|
|
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
|
|
130
|
-
//
|
|
131
|
-
//
|
|
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
|
|
825
|
-
*
|
|
826
|
-
*
|
|
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
|
|
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
|
|
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
|
|
45
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
168
|
-
? ` [backlog pressure:
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
230
|
-
* and
|
|
271
|
+
* ×1.0 at or below the healthy threshold (no boost), increasing linearly above
|
|
272
|
+
* it and maxing out at MAX_BACKLOG_MULTIPLIER at 3× the healthy backlog.
|
|
231
273
|
*
|
|
232
|
-
* Examples (with default healthyBacklog=20):
|
|
233
|
-
* - 10 due reviews →
|
|
234
|
-
* - 20 due reviews →
|
|
235
|
-
* - 40 due reviews →
|
|
236
|
-
* - 60 due reviews →
|
|
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
|
|
281
|
+
* @returns Multiplier applied to review urgency (1.0 to MAX_BACKLOG_MULTIPLIER)
|
|
240
282
|
*/
|
|
241
|
-
private
|
|
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
|
|
288
|
+
// Linear in excess: ×1 at healthy, reaching MAX at 3× healthy (excess = 2×healthy).
|
|
247
289
|
const excess = dueCount - this.healthyBacklog;
|
|
248
|
-
const
|
|
290
|
+
const multiplier = 1 + (excess / this.healthyBacklog) * ((MAX_BACKLOG_MULTIPLIER - 1) / 2);
|
|
249
291
|
|
|
250
|
-
return Math.min(
|
|
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
|
|
267
|
-
*
|
|
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 +
|
|
272
|
-
*
|
|
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
|
|
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
|
-
|
|
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 +
|
|
303
|
-
//
|
|
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 =
|
|
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 (
|
|
316
|
-
reasonParts.push(`backlog:
|
|
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';
|