@vue-skuilder/db 0.2.3 → 0.2.5
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/core/index.js +5 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +5 -4
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.js +5 -4
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +5 -4
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.js +5 -4
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +5 -4
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +103 -0
- package/dist/index.d.ts +103 -0
- package/dist/index.js +413 -14
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +413 -14
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/navigators/generators/elo.ts +32 -11
- package/src/study/SessionController.ts +187 -10
- package/src/study/SessionDebugger.ts +10 -0
- package/src/study/SessionOverlay.ts +500 -0
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.5",
|
|
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.5",
|
|
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.5"
|
|
66
66
|
}
|
|
@@ -7,6 +7,13 @@ import type { QualifiedCardID } from '../..';
|
|
|
7
7
|
import type { CardGenerator, GeneratorContext, GeneratorResult } from './types';
|
|
8
8
|
import { logger } from '@db/util/logger';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Std-dev (in ELO points) of the Gaussian that converts card↔user ELO distance
|
|
12
|
+
* into a relevance weight. 300 reproduces the legacy linear ramp's half-weight
|
|
13
|
+
* point (distance 250 → ~0.5) while removing its hard zero beyond distance 500.
|
|
14
|
+
*/
|
|
15
|
+
const ELO_RELEVANCE_SIGMA = 300;
|
|
16
|
+
|
|
10
17
|
// ============================================================================
|
|
11
18
|
// ELO NAVIGATOR
|
|
12
19
|
// ============================================================================
|
|
@@ -96,17 +103,31 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
|
|
|
96
103
|
// `[active=${activeCards.length} candidates=${newCards.length}]`
|
|
97
104
|
// );
|
|
98
105
|
|
|
99
|
-
// Score new cards by ELO
|
|
100
|
-
//
|
|
106
|
+
// Score new cards by ELO proximity, then apply bounded multiplicative
|
|
107
|
+
// jitter for session-to-session variety.
|
|
101
108
|
//
|
|
102
|
-
//
|
|
109
|
+
// relevance = exp(-(distance / SIGMA)^2) // Gaussian: smooth, always > 0
|
|
110
|
+
// score = relevance * (0.5 + 0.5 * U) // U ~ Uniform(0, 1)
|
|
103
111
|
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
112
|
+
// This replaces the legacy `rawScore = max(0, 1 - distance/500)` ramp +
|
|
113
|
+
// Efraimidis-Spirakis key `U^(1/rawScore)`, which introduced two
|
|
114
|
+
// discontinuities that defeated downstream replan boosts:
|
|
115
|
+
// 1. The ramp's clamp made every card ≥500 ELO from the user a HARD zero.
|
|
116
|
+
// The pipeline DELETES zero-score cards (filter score>0) *before* boosts
|
|
117
|
+
// are applied, so no boost could resurface an under-ELO'd target — e.g.
|
|
118
|
+
// a freshly-introduced grapheme sitting ~475 below an inflated global
|
|
119
|
+
// ELO. (See packages/db/docs/todo-intro-concept-emphasis-and-retrieval.md.)
|
|
120
|
+
// 2. The A-Res key `U^(1/rawScore)` ALSO manufactured effective zeros: as
|
|
121
|
+
// rawScore→0 the exponent explodes and `U^huge` underflows to 0, with
|
|
122
|
+
// wild variance just above it — so a downstream boost multiplied a
|
|
123
|
+
// lottery ticket rather than a stable relevance.
|
|
108
124
|
//
|
|
109
|
-
//
|
|
125
|
+
// Gaussian relevance never hits zero (no cliff, survives the score>0 filter,
|
|
126
|
+
// so a boost can always lift a low-ELO target), and the [0.5, 1] jitter keeps
|
|
127
|
+
// ELO ordering up to a 2× factor while still shuffling near-equal cards so the
|
|
128
|
+
// same cards don't loop every session. SIGMA=300 reproduces the old ramp's
|
|
129
|
+
// half-weight point (distance 250 → ~0.5), leaving center-of-range difficulty
|
|
130
|
+
// matching unchanged.
|
|
110
131
|
//
|
|
111
132
|
// Card ELO is read from the pooled `.elo` carried on each candidate by
|
|
112
133
|
// getCardsCenteredAtELO — verified equal to a separate getCardEloData()
|
|
@@ -115,8 +136,8 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
|
|
|
115
136
|
const cardElo = c.elo ?? 1000;
|
|
116
137
|
|
|
117
138
|
const distance = Math.abs(cardElo - userGlobalElo);
|
|
118
|
-
const
|
|
119
|
-
const samplingKey =
|
|
139
|
+
const relevance = Math.exp(-((distance / ELO_RELEVANCE_SIGMA) ** 2));
|
|
140
|
+
const samplingKey = relevance * (0.5 + 0.5 * Math.random());
|
|
120
141
|
|
|
121
142
|
return {
|
|
122
143
|
cardId: c.cardID,
|
|
@@ -129,7 +150,7 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
|
|
|
129
150
|
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-ELO-default',
|
|
130
151
|
action: 'generated',
|
|
131
152
|
score: samplingKey,
|
|
132
|
-
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}),
|
|
153
|
+
reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), relevance ${relevance.toFixed(3)}, key ${samplingKey.toFixed(3)}`,
|
|
133
154
|
},
|
|
134
155
|
],
|
|
135
156
|
};
|
|
@@ -21,6 +21,12 @@ import { mergeHints } from '@db/core/navigators/Pipeline';
|
|
|
21
21
|
import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
|
|
22
22
|
import { captureMixerRun } from './MixerDebugger';
|
|
23
23
|
import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
|
|
24
|
+
import {
|
|
25
|
+
registerActiveController,
|
|
26
|
+
type SessionDebugSnapshot,
|
|
27
|
+
type SessionDrawnCardDebug,
|
|
28
|
+
type SessionQueueDebug,
|
|
29
|
+
} from './SessionOverlay';
|
|
24
30
|
|
|
25
31
|
// ReplanHints is defined in generators/types to avoid circular dependencies.
|
|
26
32
|
// Re-exported here for backward compatibility.
|
|
@@ -58,6 +64,21 @@ export interface ReplanOptions {
|
|
|
58
64
|
* multiply, require/exclude lists concatenate).
|
|
59
65
|
*/
|
|
60
66
|
sessionHints?: ReplanHints;
|
|
67
|
+
/**
|
|
68
|
+
* Like `sessionHints`, but *merged* into the existing session-durable hints
|
|
69
|
+
* (via `mergeHints`) instead of replacing them. Use when emphasis should
|
|
70
|
+
* *accumulate* across replans rather than clobber — e.g. introducing a second
|
|
71
|
+
* concept mid-session must not wipe the first concept's boost, nor any
|
|
72
|
+
* `difficultyBooster`/`conceptBackoff` state on other concepts.
|
|
73
|
+
*
|
|
74
|
+
* Merge semantics (see `mergeHints`): boosts MULTIPLY, require/exclude lists
|
|
75
|
+
* concat-dedup. Re-emphasising the *same* tag therefore compounds — callers
|
|
76
|
+
* boosting a tag they may have already boosted should clamp at the call site.
|
|
77
|
+
*
|
|
78
|
+
* If both `sessionHints` and `mergeSessionHints` are supplied, the replace is
|
|
79
|
+
* applied first, then the merge — but they are normally mutually exclusive.
|
|
80
|
+
*/
|
|
81
|
+
mergeSessionHints?: ReplanHints;
|
|
61
82
|
/**
|
|
62
83
|
* Maximum number of new cards to return from the pipeline.
|
|
63
84
|
* Default: 20 (the standard session batch size).
|
|
@@ -236,6 +257,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
236
257
|
*/
|
|
237
258
|
private _replanPromise: Promise<void> | null = null;
|
|
238
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Reason for the replan currently executing in `_runReplan`, surfaced by the
|
|
262
|
+
* debug overlay's spinner. The caller's `opts.label` when present, else
|
|
263
|
+
* `'(auto)'`. Only meaningful while `_replanPromise` is non-null; cleared
|
|
264
|
+
* when the in-flight chain settles.
|
|
265
|
+
*/
|
|
266
|
+
private _activeReplanLabel: string | null = null;
|
|
267
|
+
|
|
239
268
|
/**
|
|
240
269
|
* Number of well-indicated new cards remaining before the queue
|
|
241
270
|
* degrades to poorly-indicated content. Decremented on each newQ
|
|
@@ -274,6 +303,22 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
274
303
|
*/
|
|
275
304
|
private _sessionHints: ReplanHints | null = null;
|
|
276
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Card IDs that have been *served* (drawn/consumed) this session. Populated
|
|
308
|
+
* at the single consumption choke-point (removeItemFromQueue), so it reflects
|
|
309
|
+
* a draw the instant it happens — earlier than `_sessionRecord`, which only
|
|
310
|
+
* lands once the card is *responded to*.
|
|
311
|
+
*
|
|
312
|
+
* Used to keep already-served cards out of newQ on every (re)plan: a `new`
|
|
313
|
+
* card shown once must never re-enter newQ this session. This is the general
|
|
314
|
+
* guard against re-presentation — including the case where a replan in flight
|
|
315
|
+
* captured a now-drawn card (e.g. a +INF require-injected follow-up the
|
|
316
|
+
* depletion prefetch grabbed just before it was drawn). Reviews/failed cards
|
|
317
|
+
* legitimately recur and are tracked by their own queues, so this only gates
|
|
318
|
+
* `new`-origin candidates.
|
|
319
|
+
*/
|
|
320
|
+
private _servedCardIds: Set<string> = new Set();
|
|
321
|
+
|
|
277
322
|
/**
|
|
278
323
|
* Consumer-supplied hooks invoked after each question response is processed.
|
|
279
324
|
* Seeded from constructor options (threaded from
|
|
@@ -372,6 +417,11 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
372
417
|
endTime: ${this.endTime}
|
|
373
418
|
defaultBatchLimit: ${this._defaultBatchLimit}
|
|
374
419
|
initialReviewCap: ${this._initialReviewCap}`);
|
|
420
|
+
|
|
421
|
+
// Expose this (now the most-recently-constructed) controller to the debug
|
|
422
|
+
// overlay (window.skuilder.session.dbgOverlay()). A new session overwrites
|
|
423
|
+
// the prior handle; no-op overhead when the overlay is never opened.
|
|
424
|
+
registerActiveController(this);
|
|
375
425
|
}
|
|
376
426
|
|
|
377
427
|
private tick() {
|
|
@@ -500,17 +550,33 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
500
550
|
.catch(() => undefined)
|
|
501
551
|
.then(() => this._runReplan(opts));
|
|
502
552
|
|
|
503
|
-
|
|
504
|
-
|
|
553
|
+
// Compare against the promise we actually store. `.finally()` returns a
|
|
554
|
+
// NEW promise, so guarding on `=== queued` (the pre-finally promise) never
|
|
555
|
+
// matches and would leak _replanPromise. `tracked` is read only inside the
|
|
556
|
+
// async callback (after init), so the self-reference is safe.
|
|
557
|
+
const tracked: Promise<void> = queued.finally(() => {
|
|
558
|
+
if (this._replanPromise === tracked) {
|
|
559
|
+
this._replanPromise = null;
|
|
560
|
+
this._activeReplanLabel = null;
|
|
561
|
+
}
|
|
505
562
|
});
|
|
563
|
+
this._replanPromise = tracked;
|
|
506
564
|
|
|
507
565
|
return queued;
|
|
508
566
|
}
|
|
509
567
|
|
|
510
568
|
const run = this._runReplan(opts);
|
|
511
|
-
|
|
512
|
-
|
|
569
|
+
// Compare against the wrapped promise we store, not `run` — `.finally()`
|
|
570
|
+
// returns a new promise, so `=== run` never matches and _replanPromise
|
|
571
|
+
// would never clear (perpetual "replan in progress"). Safe self-reference:
|
|
572
|
+
// `tracked` is read only in the async callback, after initialization.
|
|
573
|
+
const tracked: Promise<void> = run.finally(() => {
|
|
574
|
+
if (this._replanPromise === tracked) {
|
|
575
|
+
this._replanPromise = null;
|
|
576
|
+
this._activeReplanLabel = null;
|
|
577
|
+
}
|
|
513
578
|
});
|
|
579
|
+
this._replanPromise = tracked;
|
|
514
580
|
|
|
515
581
|
await run;
|
|
516
582
|
}
|
|
@@ -521,12 +587,18 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
521
587
|
* triggers in nextCard) return false and may coalesce.
|
|
522
588
|
*/
|
|
523
589
|
private _replanHasIntent(opts: ReplanOptions): boolean {
|
|
524
|
-
|
|
590
|
+
// NOTE: `label` is intentionally NOT an intent signal. It is observability-
|
|
591
|
+
// only metadata (debug overlay spinner, log tags, Pipeline strategy names),
|
|
592
|
+
// so labelling a replan must never change scheduling. Intent is strictly
|
|
593
|
+
// "does this replan carry scheduling-relevant options". This lets the
|
|
594
|
+
// unlabeled-but-named auto-replans (auto:depletion / auto:quality) keep
|
|
595
|
+
// coalescing while still showing a reason in the overlay.
|
|
525
596
|
if (opts.limit !== undefined) return true;
|
|
526
597
|
if (opts.minFollowUpCards !== undefined) return true;
|
|
527
598
|
if (opts.mode && opts.mode !== 'replace') return true;
|
|
528
599
|
if (opts.hints && Object.keys(opts.hints).length > 0) return true;
|
|
529
600
|
if (opts.sessionHints !== undefined) return true;
|
|
601
|
+
if (opts.mergeSessionHints !== undefined) return true;
|
|
530
602
|
return false;
|
|
531
603
|
}
|
|
532
604
|
|
|
@@ -543,6 +615,11 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
543
615
|
* newQ.peek(0) is the imminent draw we need to exclude.
|
|
544
616
|
*/
|
|
545
617
|
private async _runReplan(opts: ReplanOptions): Promise<void> {
|
|
618
|
+
// Surface the executing replan's reason to the debug overlay spinner.
|
|
619
|
+
// `label` is observability-only (see _replanHasIntent); '(auto)' covers any
|
|
620
|
+
// unlabeled path.
|
|
621
|
+
this._activeReplanLabel = opts.label ?? '(auto)';
|
|
622
|
+
|
|
546
623
|
// Exclude all cards already presented this session. The pipeline may
|
|
547
624
|
// not yet see their encounter records (async writes), so without this
|
|
548
625
|
// they can re-enter newQ via replaceAll and cause duplicates.
|
|
@@ -583,6 +660,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
583
660
|
);
|
|
584
661
|
}
|
|
585
662
|
|
|
663
|
+
// Additive emphasis: merge (don't clobber) into durable hints. Lets a new
|
|
664
|
+
// concept's boost accumulate on top of prior concepts' boosts and any
|
|
665
|
+
// observer-managed boost/decay state. Boosts multiply, lists concat-dedup.
|
|
666
|
+
if (opts.mergeSessionHints !== undefined) {
|
|
667
|
+
this._sessionHints = mergeHints([this._sessionHints, opts.mergeSessionHints]) ?? null;
|
|
668
|
+
this.log(`[Replan] Session hints merged: ${JSON.stringify(this._sessionHints)}`);
|
|
669
|
+
}
|
|
670
|
+
|
|
586
671
|
// Forward hints to all sources (CourseDB stashes them, Pipeline consumes
|
|
587
672
|
// them). The one-shot `opts.hints` are merged with the durable
|
|
588
673
|
// `_sessionHints` so session emphasis survives this and every later run.
|
|
@@ -638,6 +723,46 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
638
723
|
return this._sessionHints;
|
|
639
724
|
}
|
|
640
725
|
|
|
726
|
+
/**
|
|
727
|
+
* Live state snapshot for the debug overlay (window.skuilder.session
|
|
728
|
+
* .dbgOverlay()). Reads directly from the private queues and hints, so it
|
|
729
|
+
* always reflects the current moment — unlike the passive SessionDebugger
|
|
730
|
+
* snapshots, which only capture what was explicitly pushed to them.
|
|
731
|
+
*/
|
|
732
|
+
public getDebugSnapshot(): SessionDebugSnapshot {
|
|
733
|
+
const describe = <T extends { cardID: string }>(q: ItemQueue<T>): SessionQueueDebug => {
|
|
734
|
+
const cards: string[] = [];
|
|
735
|
+
for (let i = 0; i < q.length; i++) {
|
|
736
|
+
cards.push(q.peek(i).cardID);
|
|
737
|
+
}
|
|
738
|
+
return { length: q.length, dequeueCount: q.dequeueCount, cards };
|
|
739
|
+
};
|
|
740
|
+
const drawnCards: SessionDrawnCardDebug[] = this._sessionRecord.map((r) => {
|
|
741
|
+
const last = r.records[r.records.length - 1];
|
|
742
|
+
return {
|
|
743
|
+
cardID: r.item.cardID,
|
|
744
|
+
status: r.item.status,
|
|
745
|
+
attempts: r.records.length,
|
|
746
|
+
correct: last && isQuestionRecord(last) ? last.isCorrect : null,
|
|
747
|
+
timeSpentMs: r.records.reduce((sum, rec) => sum + rec.timeSpent, 0),
|
|
748
|
+
};
|
|
749
|
+
});
|
|
750
|
+
return {
|
|
751
|
+
secondsRemaining: this.secondsRemaining,
|
|
752
|
+
hasCardGuarantee: this.hasCardGuarantee,
|
|
753
|
+
minCardsGuarantee: this._minCardsGuarantee,
|
|
754
|
+
wellIndicatedRemaining: this._wellIndicatedRemaining,
|
|
755
|
+
currentCard: this._currentCard?.item.cardID ?? null,
|
|
756
|
+
sessionHints: this._sessionHints,
|
|
757
|
+
replanActive: this._replanPromise !== null,
|
|
758
|
+
replanLabel: this._activeReplanLabel,
|
|
759
|
+
reviewQ: describe(this.reviewQ),
|
|
760
|
+
newQ: describe(this.newQ),
|
|
761
|
+
failedQ: describe(this.failedQ),
|
|
762
|
+
drawnCards,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
641
766
|
/**
|
|
642
767
|
* Merge `hints` into the durable session hints via the pipeline's
|
|
643
768
|
* `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
|
|
@@ -734,9 +859,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
734
859
|
*/
|
|
735
860
|
private async _replanUncoalesced(opts: ReplanOptions): Promise<void> {
|
|
736
861
|
const run = this._runReplan(opts);
|
|
737
|
-
|
|
738
|
-
|
|
862
|
+
// See requestReplan: guard against the wrapped promise we store, not `run`.
|
|
863
|
+
const tracked: Promise<void> = run.finally(() => {
|
|
864
|
+
if (this._replanPromise === tracked) {
|
|
865
|
+
this._replanPromise = null;
|
|
866
|
+
this._activeReplanLabel = null;
|
|
867
|
+
}
|
|
739
868
|
});
|
|
869
|
+
this._replanPromise = tracked;
|
|
740
870
|
await run;
|
|
741
871
|
}
|
|
742
872
|
|
|
@@ -1026,8 +1156,13 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1026
1156
|
const reviewWeighted = mixedWeighted
|
|
1027
1157
|
.filter((w) => getCardOrigin(w) === 'review')
|
|
1028
1158
|
.slice(0, this._initialReviewCap);
|
|
1159
|
+
// Proactive de-dup: a `new` card served earlier this session must never
|
|
1160
|
+
// re-enter newQ — whether it slipped back via a +INF require-injection, an
|
|
1161
|
+
// additive merge, or a stale generator candidate. This is the general guard
|
|
1162
|
+
// (see _servedCardIds); it makes re-presentation structurally impossible
|
|
1163
|
+
// rather than relying on each upstream path to exclude correctly.
|
|
1029
1164
|
const newWeighted = mixedWeighted
|
|
1030
|
-
.filter((w) => getCardOrigin(w) === 'new')
|
|
1165
|
+
.filter((w) => getCardOrigin(w) === 'new' && !this._servedCardIds.has(w.cardId))
|
|
1031
1166
|
.slice(0, newLimit);
|
|
1032
1167
|
|
|
1033
1168
|
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
@@ -1272,7 +1407,11 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1272
1407
|
`(${otherContent} in other queues) with ${this._secondsRemaining}s remaining. ` +
|
|
1273
1408
|
`Triggering background replan.`
|
|
1274
1409
|
);
|
|
1275
|
-
|
|
1410
|
+
// label is observability-only (overlay/logs); does not affect coalescing.
|
|
1411
|
+
// mode:'merge' preserves any already-queued cards (e.g. an undrawn
|
|
1412
|
+
// prescribed WST whose one-shot requireCards won't be re-asserted by this
|
|
1413
|
+
// bare replan) instead of replaceAll() wiping them. See mergeToFront.
|
|
1414
|
+
void this.requestReplan({ label: 'auto:depletion', mode: 'merge' });
|
|
1276
1415
|
}
|
|
1277
1416
|
|
|
1278
1417
|
// Opportunistic quality: few well-indicated cards remain.
|
|
@@ -1288,7 +1427,8 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1288
1427
|
`[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining ` +
|
|
1289
1428
|
`(newQ: ${this.newQ.length}). Triggering background replan.`
|
|
1290
1429
|
);
|
|
1291
|
-
|
|
1430
|
+
// label is observability-only (overlay/logs); does not affect coalescing.
|
|
1431
|
+
void this.requestReplan({ label: 'auto:quality' });
|
|
1292
1432
|
}
|
|
1293
1433
|
|
|
1294
1434
|
if (this._secondsRemaining <= 0 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
|
|
@@ -1510,6 +1650,21 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1510
1650
|
* Remove an item from its source queue after consumption by nextCard().
|
|
1511
1651
|
*/
|
|
1512
1652
|
private removeItemFromQueue(item: StudySessionItem): void {
|
|
1653
|
+
// Durable-until-drawn requirements: a caller may place a card ID in the
|
|
1654
|
+
// session-durable `requireCards` (via mergeSessionHints) so every later
|
|
1655
|
+
// replan re-asserts it at +INF until it actually surfaces — e.g. a
|
|
1656
|
+
// prescribed intro follow-up that must survive the replace-mode burst/auto
|
|
1657
|
+
// replans that would otherwise clobber a one-shot requirement. The
|
|
1658
|
+
// requirement is satisfied the instant the card is drawn, so clear it here
|
|
1659
|
+
// (the single consumption choke-point) to stop it being re-injected forever
|
|
1660
|
+
// and to bound accumulation. Generic: card-ID only, no domain vocabulary.
|
|
1661
|
+
this._clearDurableRequirement(item.cardID);
|
|
1662
|
+
|
|
1663
|
+
// Record the draw immediately (earlier than _sessionRecord, which waits for
|
|
1664
|
+
// a response) so getWeightedContent can keep this card out of newQ on any
|
|
1665
|
+
// replan that lands after this draw.
|
|
1666
|
+
this._servedCardIds.add(item.cardID);
|
|
1667
|
+
|
|
1513
1668
|
// Check each queue - item should be at the front of one of them
|
|
1514
1669
|
if (this.reviewQ.peek(0)?.cardID === item.cardID) {
|
|
1515
1670
|
this.reviewQ.dequeue((queueItem) => queueItem.cardID);
|
|
@@ -1523,6 +1678,28 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1523
1678
|
}
|
|
1524
1679
|
}
|
|
1525
1680
|
|
|
1681
|
+
/**
|
|
1682
|
+
* Remove a satisfied card ID from the durable session-hint `requireCards`
|
|
1683
|
+
* list. Called when a card is consumed (see removeItemFromQueue). No-op if
|
|
1684
|
+
* the card was not a durable requirement.
|
|
1685
|
+
*
|
|
1686
|
+
* Matches literal IDs only: a glob/pattern requirement (which may stand for
|
|
1687
|
+
* several cards) is NOT considered satisfied by a single draw and is left in
|
|
1688
|
+
* place — durable patterns are the caller's responsibility, one-shot `hints`
|
|
1689
|
+
* remain the right tool for them.
|
|
1690
|
+
*/
|
|
1691
|
+
private _clearDurableRequirement(cardID: string): void {
|
|
1692
|
+
const req = this._sessionHints?.requireCards;
|
|
1693
|
+
if (!req || req.length === 0) return;
|
|
1694
|
+
const next = req.filter((id) => id !== cardID);
|
|
1695
|
+
if (next.length === req.length) return; // not a durable requirement
|
|
1696
|
+
this._sessionHints = {
|
|
1697
|
+
...this._sessionHints!,
|
|
1698
|
+
requireCards: next.length > 0 ? next : undefined,
|
|
1699
|
+
};
|
|
1700
|
+
this.log(`[Replan] Durable requirement satisfied & cleared on draw: ${cardID}`);
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1526
1703
|
/**
|
|
1527
1704
|
* End the session and record learning outcomes.
|
|
1528
1705
|
*
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { logger } from '../util/logger';
|
|
2
2
|
import { clearRunHistory as clearPipelineRunHistory } from '../core/navigators/PipelineDebugger';
|
|
3
|
+
import { toggleSessionOverlay } from './SessionOverlay';
|
|
3
4
|
|
|
4
5
|
// ============================================================================
|
|
5
6
|
// SESSION DEBUGGER
|
|
@@ -344,6 +345,14 @@ export const sessionDebugAPI = {
|
|
|
344
345
|
showCurrentQueue();
|
|
345
346
|
},
|
|
346
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Toggle the pinned, live-updating DOM overlay for the active controller
|
|
350
|
+
* (queues, session hints, timer). No-ops in non-browser hosts.
|
|
351
|
+
*/
|
|
352
|
+
dbgOverlay(): void {
|
|
353
|
+
toggleSessionOverlay();
|
|
354
|
+
},
|
|
355
|
+
|
|
347
356
|
/**
|
|
348
357
|
* Show presentation history for current or past session.
|
|
349
358
|
*/
|
|
@@ -413,6 +422,7 @@ export const sessionDebugAPI = {
|
|
|
413
422
|
🎯 Session Debug API
|
|
414
423
|
|
|
415
424
|
Commands:
|
|
425
|
+
.dbgOverlay() Toggle the pinned live overlay (queues, hints, timer)
|
|
416
426
|
.showQueue() Show current queue state (active session only)
|
|
417
427
|
.showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
|
|
418
428
|
.showInterleaving(index?) Analyze course interleaving pattern
|