@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.
- 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 +67 -4
- package/dist/core/index.d.ts +67 -4
- package/dist/core/index.js +201 -39
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +198 -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 +195 -39
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +195 -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 +195 -39
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +195 -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 +440 -251
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +437 -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/Pipeline.ts +93 -1
- package/src/core/navigators/PipelineDebugger.ts +11 -1
- 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 +9 -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
|
|
|
@@ -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
|
-
|
|
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
|
|
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}`,
|