@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
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
import { CardRecord, CardHistory, CourseRegistrationDoc, QuestionRecord, isQuestionRecord } from '@db/core';
|
|
16
16
|
import { recordUserOutcome } from '@db/core/orchestration/recording';
|
|
17
17
|
import { Loggable } from '@db/util';
|
|
18
|
-
import { getCardOrigin } from '@db/core/navigators';
|
|
18
|
+
import { getCardOrigin, WeightedCard, getSrsBacklogDebug } from '@db/core/navigators';
|
|
19
19
|
import { ReplanHints } from '@db/core/navigators/generators/types';
|
|
20
20
|
import { mergeHints } from '@db/core/navigators/Pipeline';
|
|
21
21
|
import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type SessionDebugSnapshot,
|
|
27
27
|
type SessionDrawnCardDebug,
|
|
28
28
|
type SessionQueueDebug,
|
|
29
|
+
type SessionQueueItemDebug,
|
|
29
30
|
} from './SessionOverlay';
|
|
30
31
|
|
|
31
32
|
// ReplanHints is defined in generators/types to avoid circular dependencies.
|
|
@@ -85,8 +86,8 @@ export interface ReplanOptions {
|
|
|
85
86
|
*/
|
|
86
87
|
limit?: number;
|
|
87
88
|
/**
|
|
88
|
-
* How to integrate the new cards into the existing
|
|
89
|
-
* - `'replace'` (default): atomically swap the entire
|
|
89
|
+
* How to integrate the new cards into the existing supplyQ.
|
|
90
|
+
* - `'replace'` (default): atomically swap the entire supplyQ.
|
|
90
91
|
* - `'merge'`: insert new cards at the front, keeping existing cards.
|
|
91
92
|
*/
|
|
92
93
|
mode?: 'replace' | 'merge';
|
|
@@ -226,16 +227,6 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
226
227
|
*/
|
|
227
228
|
private _defaultBatchLimit: number = 20;
|
|
228
229
|
|
|
229
|
-
/**
|
|
230
|
-
* Maximum number of reviews enqueued at session start. Reviews live
|
|
231
|
-
* outside the replan flow — the queue drains via consumption and is
|
|
232
|
-
* not refilled mid-session. The session timer caps total review
|
|
233
|
-
* exposure, so overfilling here is intentional. Default is generous
|
|
234
|
-
* to accommodate Anki-style power users with hundreds of due reviews;
|
|
235
|
-
* apps targeting nimbler sessions should override via constructor.
|
|
236
|
-
*/
|
|
237
|
-
private _initialReviewCap: number = 200;
|
|
238
|
-
|
|
239
230
|
private sources: StudyContentSource[];
|
|
240
231
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
241
232
|
private _sessionRecord: StudySessionRecord[] = [];
|
|
@@ -246,11 +237,30 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
246
237
|
// Session card stores
|
|
247
238
|
private _currentCard: HydratedCard<TView> | null = null;
|
|
248
239
|
|
|
249
|
-
|
|
250
|
-
|
|
240
|
+
/**
|
|
241
|
+
* The single supply queue: `new` + `review` items interleaved in pipeline
|
|
242
|
+
* rank order (the mixer's score-ordered, source-interleaved output, with
|
|
243
|
+
* `+INF` required cards floated to the front). Drawn front-to-back; reviews
|
|
244
|
+
* and new compete on one cross-comparable scale rather than being re-mixed
|
|
245
|
+
* by a probability gate. Replaced/re-ranked wholesale on replan. See
|
|
246
|
+
* `docs/decision-single-supply-queue.md`.
|
|
247
|
+
*/
|
|
248
|
+
private supplyQ: ItemQueue<StudySessionItem> = new ItemQueue<StudySessionItem>();
|
|
251
249
|
private failedQ: ItemQueue<StudySessionFailedItem> = new ItemQueue<StudySessionFailedItem>();
|
|
252
250
|
// END Session card stores
|
|
253
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Supply draws since the last failed-queue *event* (a failed draw, or a card
|
|
254
|
+
* entering failedQ on failure). Drives the light steady failed-interleave
|
|
255
|
+
* (§7): after this many consecutive supply draws, a pending failed card is
|
|
256
|
+
* drawn so remediation doesn't starve mid-session. Incremented on each supply
|
|
257
|
+
* draw; reset to 0 both when a failed card is drawn AND when one is added to
|
|
258
|
+
* failedQ — the latter gives a just-failed card spacing instead of an instant
|
|
259
|
+
* retry (the counter would otherwise already be ≥ threshold from the preceding
|
|
260
|
+
* supply run).
|
|
261
|
+
*/
|
|
262
|
+
private _supplyDrawsSinceFailed: number = 0;
|
|
263
|
+
|
|
254
264
|
/**
|
|
255
265
|
* Promise tracking a currently in-progress replan, or null if idle.
|
|
256
266
|
* Used by nextCard() to await completion before drawing from queues.
|
|
@@ -266,8 +276,8 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
266
276
|
private _activeReplanLabel: string | null = null;
|
|
267
277
|
|
|
268
278
|
/**
|
|
269
|
-
* Number of well-indicated
|
|
270
|
-
* degrades to poorly-indicated content. Decremented on each
|
|
279
|
+
* Number of well-indicated supply cards remaining before the queue
|
|
280
|
+
* degrades to poorly-indicated content. Decremented on each supplyQ
|
|
271
281
|
* draw; when it hits 0, a replan is triggered automatically
|
|
272
282
|
* (user state has changed from completing good cards).
|
|
273
283
|
*/
|
|
@@ -277,7 +287,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
277
287
|
* When true, suppresses the quality-based auto-replan trigger in
|
|
278
288
|
* nextCard(). Set after a burst replan (small limit) to prevent the
|
|
279
289
|
* auto-replan from clobbering the burst cards before they're consumed.
|
|
280
|
-
* Cleared when the depletion-triggered replan fires (
|
|
290
|
+
* Cleared when the depletion-triggered replan fires (supplyQ exhausted).
|
|
281
291
|
*/
|
|
282
292
|
private _suppressQualityReplan: boolean = false;
|
|
283
293
|
|
|
@@ -309,13 +319,15 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
309
319
|
* a draw the instant it happens — earlier than `_sessionRecord`, which only
|
|
310
320
|
* lands once the card is *responded to*.
|
|
311
321
|
*
|
|
312
|
-
* Used to keep already-served cards out of
|
|
313
|
-
* card shown once must never re-enter
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
322
|
+
* Used to keep already-served cards out of supplyQ on every (re)plan, across
|
|
323
|
+
* ALL origins: a `new` card shown once must never re-enter, and once replans
|
|
324
|
+
* re-pull reviews, an answered/in-flight review must not re-enter the supply
|
|
325
|
+
* before its SRS reschedule clears the due-window (the review-loop guard,
|
|
326
|
+
* decision doc §4). This is the general guard against re-presentation —
|
|
327
|
+
* including the case where a replan in flight captured a now-drawn card (e.g.
|
|
328
|
+
* a +INF require-injected follow-up the depletion prefetch grabbed just before
|
|
329
|
+
* it was drawn). failedQ is separate and controller-owned, so failed cards
|
|
330
|
+
* legitimately recur there without being gated here.
|
|
319
331
|
*/
|
|
320
332
|
private _servedCardIds: Set<string> = new Set();
|
|
321
333
|
|
|
@@ -343,14 +355,12 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
343
355
|
return this._minCardsGuarantee > 0;
|
|
344
356
|
}
|
|
345
357
|
public get report(): string {
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
const newCardWord = newCount === 1 ? 'new card' : 'new cards';
|
|
350
|
-
return `${reviewCount} ${reviewWord}, ${newCount} ${newCardWord}`;
|
|
358
|
+
const supplyCount = this.supplyQ.dequeueCount;
|
|
359
|
+
const supplyWord = supplyCount === 1 ? 'card' : 'cards';
|
|
360
|
+
return `${supplyCount} supply ${supplyWord} drawn`;
|
|
351
361
|
}
|
|
352
362
|
public get detailedReport(): string {
|
|
353
|
-
return this.
|
|
363
|
+
return this.supplyQ.toString + '\n' + this.failedQ.toString;
|
|
354
364
|
}
|
|
355
365
|
// @ts-expect-error NodeJS.Timeout type not available in browser context
|
|
356
366
|
private _intervalHandle: NodeJS.Timeout;
|
|
@@ -362,11 +372,9 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
362
372
|
* @param getViewComponent - Function to resolve view components
|
|
363
373
|
* @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
|
|
364
374
|
* @param options - Optional session-level configuration
|
|
365
|
-
* @param options.defaultBatchLimit - Default
|
|
375
|
+
* @param options.defaultBatchLimit - Default supply working-set size (default: 20).
|
|
366
376
|
* Smaller values for newer users cause more frequent replans, keeping plans
|
|
367
377
|
* aligned with rapidly-changing user state.
|
|
368
|
-
* @param options.initialReviewCap - Max reviews loaded at session start (default: 200).
|
|
369
|
-
* Applied only on initial planning; replans do not refill the review queue.
|
|
370
378
|
*/
|
|
371
379
|
constructor(
|
|
372
380
|
sources: StudyContentSource[],
|
|
@@ -376,7 +384,6 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
376
384
|
mixer?: SourceMixer,
|
|
377
385
|
options?: {
|
|
378
386
|
defaultBatchLimit?: number;
|
|
379
|
-
initialReviewCap?: number;
|
|
380
387
|
outcomeObservers?: OutcomeObserver[];
|
|
381
388
|
}
|
|
382
389
|
) {
|
|
@@ -405,9 +412,6 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
405
412
|
if (options?.defaultBatchLimit !== undefined) {
|
|
406
413
|
this._defaultBatchLimit = options.defaultBatchLimit;
|
|
407
414
|
}
|
|
408
|
-
if (options?.initialReviewCap !== undefined) {
|
|
409
|
-
this._initialReviewCap = options.initialReviewCap;
|
|
410
|
-
}
|
|
411
415
|
if (options?.outcomeObservers?.length) {
|
|
412
416
|
this._outcomeObservers = [...options.outcomeObservers];
|
|
413
417
|
}
|
|
@@ -415,8 +419,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
415
419
|
this.log(`Session constructed:
|
|
416
420
|
startTime: ${this.startTime}
|
|
417
421
|
endTime: ${this.endTime}
|
|
418
|
-
defaultBatchLimit: ${this._defaultBatchLimit}
|
|
419
|
-
initialReviewCap: ${this._initialReviewCap}`);
|
|
422
|
+
defaultBatchLimit: ${this._defaultBatchLimit}`);
|
|
420
423
|
|
|
421
424
|
// Expose this (now the most-recently-constructed) controller to the debug
|
|
422
425
|
// overlay (window.skuilder.session.dbgOverlay()). A new session overwrites
|
|
@@ -464,16 +467,6 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
464
467
|
return ret;
|
|
465
468
|
}
|
|
466
469
|
|
|
467
|
-
/**
|
|
468
|
-
* Extremely rough, conservative, estimate of amound of time to complete
|
|
469
|
-
* all scheduled reviews
|
|
470
|
-
*/
|
|
471
|
-
private estimateReviewTime(): number {
|
|
472
|
-
const ret = 5 * this.reviewQ.length;
|
|
473
|
-
this.log(`Review card time estimate: ${ret}`);
|
|
474
|
-
return ret;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
470
|
public async prepareSession() {
|
|
478
471
|
// All content sources must implement getWeightedCards()
|
|
479
472
|
if (this.sources.some((s) => typeof s.getWeightedCards !== 'function')) {
|
|
@@ -492,7 +485,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
492
485
|
await this.hydrationService.ensureHydratedCards();
|
|
493
486
|
|
|
494
487
|
// Start session tracking for debugging
|
|
495
|
-
startSessionTracking(this.
|
|
488
|
+
startSessionTracking(this.supplyQ.length, this.failedQ.length);
|
|
496
489
|
|
|
497
490
|
this._intervalHandle = setInterval(() => {
|
|
498
491
|
this.tick();
|
|
@@ -501,8 +494,8 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
501
494
|
|
|
502
495
|
/**
|
|
503
496
|
* Request a mid-session replan. Re-runs the pipeline with current user state
|
|
504
|
-
* and atomically replaces the
|
|
505
|
-
* a session.
|
|
497
|
+
* and atomically replaces (or merges into) the supplyQ contents. Safe to call
|
|
498
|
+
* at any time during a session.
|
|
506
499
|
*
|
|
507
500
|
* Concurrency policy:
|
|
508
501
|
* - Two unhinted auto-replans never run in parallel; the second coalesces
|
|
@@ -516,7 +509,8 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
516
509
|
* results (e.g. surfacing another gpc-intro card right after one
|
|
517
510
|
* completed, skipping the prescribed `c-wst-*` follow-up).
|
|
518
511
|
*
|
|
519
|
-
*
|
|
512
|
+
* Re-pulls and re-ranks the whole supply (including reviews); does NOT affect
|
|
513
|
+
* failedQ (controller-owned remediation).
|
|
520
514
|
*
|
|
521
515
|
* If nextCard() is called while a replan is in flight, it will automatically
|
|
522
516
|
* await the replan before drawing from queues, ensuring the user always sees
|
|
@@ -612,7 +606,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
612
606
|
* excludeCards happen at *invocation* time, not at *queue* time. For a
|
|
613
607
|
* queued replan that means excludes reflect the state after the prior
|
|
614
608
|
* replan landed — which is what we want, since the prior replan's
|
|
615
|
-
*
|
|
609
|
+
* supplyQ.peek(0) is the imminent draw we need to exclude.
|
|
616
610
|
*/
|
|
617
611
|
private async _runReplan(opts: ReplanOptions): Promise<void> {
|
|
618
612
|
// Surface the executing replan's reason to the debug overlay spinner.
|
|
@@ -622,16 +616,16 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
622
616
|
|
|
623
617
|
// Exclude all cards already presented this session. The pipeline may
|
|
624
618
|
// not yet see their encounter records (async writes), so without this
|
|
625
|
-
// they can re-enter
|
|
619
|
+
// they can re-enter supplyQ via replaceAll and cause duplicates.
|
|
626
620
|
//
|
|
627
|
-
// Also exclude
|
|
621
|
+
// Also exclude supplyQ.peek(0): the imminent draw. When a replan fires
|
|
628
622
|
// from inside nextCard() (auto depletion/quality trigger) or as a
|
|
629
623
|
// deferred post-submit replan, the next-up card is about to become
|
|
630
624
|
// _currentCard but isn't yet, and hasn't yet landed in _sessionRecord.
|
|
631
625
|
// Without this, the just-drawn card can be re-seated at the head of
|
|
632
|
-
// the replaced
|
|
626
|
+
// the replaced supplyQ and shown twice in a row — most visible in early
|
|
633
627
|
// sessions where state is sparse and triggers fire aggressively.
|
|
634
|
-
// Only the head is excluded; deeper
|
|
628
|
+
// Only the head is excluded; deeper supplyQ entries are still fair game
|
|
635
629
|
// for the new plan (they aren't at risk of double-display since the
|
|
636
630
|
// old queue is replaced atomically and only its head gets drawn).
|
|
637
631
|
if (!opts.hints) opts.hints = {};
|
|
@@ -644,8 +638,8 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
644
638
|
for (const rec of this._sessionRecord) {
|
|
645
639
|
excludeSet.add(rec.card.card_id);
|
|
646
640
|
}
|
|
647
|
-
if (this.
|
|
648
|
-
excludeSet.add(this.
|
|
641
|
+
if (this.supplyQ.length > 0) {
|
|
642
|
+
excludeSet.add(this.supplyQ.peek(0).cardID);
|
|
649
643
|
}
|
|
650
644
|
|
|
651
645
|
hints.excludeCards = [...excludeSet];
|
|
@@ -730,10 +724,16 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
730
724
|
* snapshots, which only capture what was explicitly pushed to them.
|
|
731
725
|
*/
|
|
732
726
|
public getDebugSnapshot(): SessionDebugSnapshot {
|
|
733
|
-
const describe = <T extends
|
|
734
|
-
const cards:
|
|
727
|
+
const describe = <T extends StudySessionItem>(q: ItemQueue<T>): SessionQueueDebug => {
|
|
728
|
+
const cards: SessionQueueItemDebug[] = [];
|
|
735
729
|
for (let i = 0; i < q.length; i++) {
|
|
736
|
-
|
|
730
|
+
const item = q.peek(i);
|
|
731
|
+
cards.push({
|
|
732
|
+
cardID: item.cardID,
|
|
733
|
+
status: item.status,
|
|
734
|
+
origin: isReview(item) ? 'review' : 'new',
|
|
735
|
+
score: item.score,
|
|
736
|
+
});
|
|
737
737
|
}
|
|
738
738
|
return { length: q.length, dequeueCount: q.dequeueCount, cards };
|
|
739
739
|
};
|
|
@@ -756,9 +756,9 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
756
756
|
sessionHints: this._sessionHints,
|
|
757
757
|
replanActive: this._replanPromise !== null,
|
|
758
758
|
replanLabel: this._activeReplanLabel,
|
|
759
|
-
|
|
760
|
-
newQ: describe(this.newQ),
|
|
759
|
+
supplyQ: describe(this.supplyQ),
|
|
761
760
|
failedQ: describe(this.failedQ),
|
|
761
|
+
reviewBacklog: getSrsBacklogDebug(),
|
|
762
762
|
drawnCards,
|
|
763
763
|
};
|
|
764
764
|
}
|
|
@@ -905,7 +905,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
905
905
|
private static readonly WELL_INDICATED_SCORE = 0.10;
|
|
906
906
|
|
|
907
907
|
/**
|
|
908
|
-
*
|
|
908
|
+
* supplyQ length at or below which the opportunistic depletion-prefetch
|
|
909
909
|
* fires. Sets the lead time available for the background replan to land
|
|
910
910
|
* before the user actually empties the queue and falls into the
|
|
911
911
|
* (synchronous) wedge-breaker path.
|
|
@@ -919,7 +919,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
919
919
|
private static readonly DEPLETION_PREFETCH_THRESHOLD = 3;
|
|
920
920
|
|
|
921
921
|
/**
|
|
922
|
-
* Internal replan execution. Runs the pipeline,
|
|
922
|
+
* Internal replan execution. Runs the pipeline, rebuilds the supplyQ,
|
|
923
923
|
* atomically swaps it in, and triggers hydration for the new contents.
|
|
924
924
|
*
|
|
925
925
|
* If the initial replan produces fewer than MIN_WELL_INDICATED cards that
|
|
@@ -939,7 +939,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
939
939
|
|
|
940
940
|
// Burst replan: suppress quality-based auto-replan so the background
|
|
941
941
|
// replan doesn't clobber the small hinted queue before it's consumed.
|
|
942
|
-
// The depletion trigger (
|
|
942
|
+
// The depletion trigger (supplyQ empty) takes over instead.
|
|
943
943
|
if (limit !== undefined && limit < this._defaultBatchLimit) {
|
|
944
944
|
this._suppressQualityReplan = true;
|
|
945
945
|
this.log(`[Replan] Burst mode (limit=${limit}): suppressing quality-based auto-replan`);
|
|
@@ -956,10 +956,10 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
956
956
|
|
|
957
957
|
await this.hydrationService.ensureHydratedCards();
|
|
958
958
|
const labelTag = opts.label ? ` [${opts.label}]` : '';
|
|
959
|
-
this.log(`Replan complete${labelTag}:
|
|
959
|
+
this.log(`Replan complete${labelTag}: supplyQ now has ${this.supplyQ.length} cards (mode=${mode})`);
|
|
960
960
|
|
|
961
961
|
// Snapshot queue state for debugging
|
|
962
|
-
snapshotQueues(this.
|
|
962
|
+
snapshotQueues(this.supplyQ.length, this.failedQ.length);
|
|
963
963
|
}
|
|
964
964
|
|
|
965
965
|
public addTime(seconds: number) {
|
|
@@ -971,10 +971,10 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
971
971
|
}
|
|
972
972
|
|
|
973
973
|
public toString() {
|
|
974
|
-
return `Session: ${this.
|
|
974
|
+
return `Session: ${this.supplyQ.length} supply, ${this.failedQ.length} failed`;
|
|
975
975
|
}
|
|
976
976
|
public reportString() {
|
|
977
|
-
return `${this.
|
|
977
|
+
return `${this.supplyQ.dequeueCount} supply, ${this.failedQ.dequeueCount} failed`;
|
|
978
978
|
}
|
|
979
979
|
|
|
980
980
|
/**
|
|
@@ -995,6 +995,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
995
995
|
courseID: item.courseID || 'unknown',
|
|
996
996
|
cardID: item.cardID || 'unknown',
|
|
997
997
|
status: item.status || 'unknown',
|
|
998
|
+
score: item.score,
|
|
998
999
|
});
|
|
999
1000
|
}
|
|
1000
1001
|
return items;
|
|
@@ -1007,15 +1008,10 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1007
1008
|
? 'Using getWeightedCards() API with scored candidates'
|
|
1008
1009
|
: 'ERROR: getWeightedCards() not a function.',
|
|
1009
1010
|
},
|
|
1010
|
-
|
|
1011
|
-
length: this.
|
|
1012
|
-
dequeueCount: this.
|
|
1013
|
-
items: extractQueueItems(this.
|
|
1014
|
-
},
|
|
1015
|
-
newQueue: {
|
|
1016
|
-
length: this.newQ.length,
|
|
1017
|
-
dequeueCount: this.newQ.dequeueCount,
|
|
1018
|
-
items: extractQueueItems(this.newQ),
|
|
1011
|
+
supplyQueue: {
|
|
1012
|
+
length: this.supplyQ.length,
|
|
1013
|
+
dequeueCount: this.supplyQ.dequeueCount,
|
|
1014
|
+
items: extractQueueItems(this.supplyQ),
|
|
1019
1015
|
},
|
|
1020
1016
|
failedQueue: {
|
|
1021
1017
|
length: this.failedQ.length,
|
|
@@ -1036,22 +1032,21 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1036
1032
|
}
|
|
1037
1033
|
|
|
1038
1034
|
/**
|
|
1039
|
-
* Fetch content
|
|
1035
|
+
* Fetch weighted content from all sources, mix across sources, and populate
|
|
1036
|
+
* the single supply queue in pipeline rank order.
|
|
1040
1037
|
*
|
|
1041
|
-
*
|
|
1042
|
-
* 1.
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
|
|
1047
|
-
/**
|
|
1048
|
-
* Fetch weighted content from all sources and populate session queues.
|
|
1038
|
+
* Reviews and new cards compete on one cross-comparable scale (SRS 0.5–1.0
|
|
1039
|
+
* w/ backlog pressure vs ELO 0.0–1.0) — there is no origin split and no
|
|
1040
|
+
* second mixer. The working set is `supplyLimit` cards (the top of the mixed
|
|
1041
|
+
* ranking, plus any `+INF` required cards floated to the front); replans
|
|
1042
|
+
* re-pull and re-rank the whole supply, so a heavy review backlog surfaces as
|
|
1043
|
+
* a refreshed top-ranked working set rather than a frozen 200-card snapshot.
|
|
1049
1044
|
*
|
|
1050
1045
|
* @param options.replan - If true, this is a mid-session replan rather than
|
|
1051
|
-
* initial session setup.
|
|
1052
|
-
*
|
|
1053
|
-
* @param options.additive - If true (replan only), merge
|
|
1054
|
-
* candidates into the front of the existing
|
|
1046
|
+
* initial session setup. Atomically replaces supplyQ contents and treats
|
|
1047
|
+
* empty results as non-fatal.
|
|
1048
|
+
* @param options.additive - If true (replan only), merge high-quality
|
|
1049
|
+
* candidates into the front of the existing supplyQ instead of replacing it.
|
|
1055
1050
|
* @returns Number of "well-indicated" cards (passed all hierarchy filters)
|
|
1056
1051
|
* in the new content. Returns -1 if no content was loaded.
|
|
1057
1052
|
*/
|
|
@@ -1063,11 +1058,12 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1063
1058
|
// const tGwc0 = performance.now(); // [perf] parked
|
|
1064
1059
|
const replan = options?.replan ?? false;
|
|
1065
1060
|
const additive = options?.additive ?? false;
|
|
1066
|
-
const
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
|
|
1061
|
+
const supplyLimit = options?.limit ?? this._defaultBatchLimit;
|
|
1062
|
+
// Single working set: fetch the top `supplyLimit` from each source and let
|
|
1063
|
+
// the mixer interleave them. Reviews and new are ranked together, so there
|
|
1064
|
+
// is no separate review budget to inflate for (cf. the old dual-budget
|
|
1065
|
+
// fetch that over-fetched to fill a frozen review cap).
|
|
1066
|
+
const fetchLimit = supplyLimit;
|
|
1071
1067
|
|
|
1072
1068
|
// Initial plan: push session-durable hints to sources so the very first
|
|
1073
1069
|
// pipeline run reflects them (e.g. a post-lesson boost). Replans push
|
|
@@ -1105,7 +1101,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1105
1101
|
if (batches.length === 0) {
|
|
1106
1102
|
if (replan) {
|
|
1107
1103
|
// Replan finding no content is non-fatal — old queue remains
|
|
1108
|
-
this.log('Replan: no content from any source, keeping existing
|
|
1104
|
+
this.log('Replan: no content from any source, keeping existing supplyQ');
|
|
1109
1105
|
return -1;
|
|
1110
1106
|
}
|
|
1111
1107
|
throw new Error(
|
|
@@ -1149,88 +1145,61 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1149
1145
|
mixedWeighted
|
|
1150
1146
|
);
|
|
1151
1147
|
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
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.
|
|
1164
|
-
const newCandidates = mixedWeighted.filter(
|
|
1165
|
-
(w) => getCardOrigin(w) === 'new' && !this._servedCardIds.has(w.cardId)
|
|
1166
|
-
);
|
|
1148
|
+
// Single supply working set — no origin split. Reviews and new compete on
|
|
1149
|
+
// one rank. Drop already-served cards across ALL origins: a `new` card
|
|
1150
|
+
// shown once must never recur, and once replans re-pull reviews, an
|
|
1151
|
+
// answered/in-flight review must not re-enter the supply before its SRS
|
|
1152
|
+
// reschedule clears the due-window (the review-loop guard, decision doc §4).
|
|
1153
|
+
// failedQ is separate, so this never blocks legitimate failed re-presentation.
|
|
1154
|
+
const candidates = mixedWeighted.filter((w) => !this._servedCardIds.has(w.cardId));
|
|
1155
|
+
|
|
1167
1156
|
// `+INF` is the hard "include at all costs" sentinel applied by require*
|
|
1168
|
-
// injection (see Pipeline.applyRequirement).
|
|
1169
|
-
//
|
|
1157
|
+
// injection (see Pipeline.applyRequirement). Float these mandatory cards to
|
|
1158
|
+
// the front and exempt them from the supplyLimit slice, so neither the
|
|
1170
1159
|
// mixer's source-shuffle/round-robin nor the cap can bury or drop a required
|
|
1171
|
-
// card
|
|
1160
|
+
// card. This preserves rank-respect through the mixer — it is NOT a per-item
|
|
1161
|
+
// "mandatory" flag (the supplyQ is drawn front-to-back, and the timer guards
|
|
1162
|
+
// honour `_minCardsGuarantee`, so a required follow-up is served on its own
|
|
1163
|
+
// merits; see decision doc §3). The set is also handed to mergeToFront so an
|
|
1172
1164
|
// already-queued required card gets re-fronted rather than leapfrogged.
|
|
1173
|
-
const mandatoryWeighted =
|
|
1174
|
-
const optionalWeighted =
|
|
1175
|
-
const
|
|
1165
|
+
const mandatoryWeighted = candidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
|
|
1166
|
+
const optionalWeighted = candidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
|
|
1167
|
+
const supplyWeighted = [
|
|
1176
1168
|
...mandatoryWeighted,
|
|
1177
|
-
...optionalWeighted.slice(0, Math.max(0,
|
|
1169
|
+
...optionalWeighted.slice(0, Math.max(0, supplyLimit - mandatoryWeighted.length)),
|
|
1178
1170
|
];
|
|
1179
1171
|
const mandatoryIds = new Set(mandatoryWeighted.map((w) => w.cardId));
|
|
1180
1172
|
|
|
1181
|
-
logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
|
|
1182
|
-
|
|
1183
|
-
// Populate review queue from mixed results (skip during replan to avoid duplicates)
|
|
1184
|
-
let report = replan ? 'Replan content:\n' : 'Mixed content session created with:\n';
|
|
1185
|
-
if (!replan) {
|
|
1186
|
-
for (const w of reviewWeighted) {
|
|
1187
|
-
const reviewItem: StudySessionReviewItem = {
|
|
1188
|
-
cardID: w.cardId,
|
|
1189
|
-
courseID: w.courseId,
|
|
1190
|
-
contentSourceType: 'course',
|
|
1191
|
-
contentSourceID: w.courseId,
|
|
1192
|
-
reviewID: w.reviewID!,
|
|
1193
|
-
status: 'review',
|
|
1194
|
-
};
|
|
1195
|
-
this.reviewQ.add(reviewItem, reviewItem.cardID);
|
|
1196
|
-
report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})\n`;
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
1173
|
// Count well-indicated cards by final score. Cards above the threshold
|
|
1201
1174
|
// are genuinely appropriate content; cards below are fallback filler
|
|
1202
1175
|
// that survived only because no strategy hard-removed them.
|
|
1203
|
-
const wellIndicated =
|
|
1176
|
+
const wellIndicated = supplyWeighted.filter(
|
|
1204
1177
|
(w) => w.score >= SessionController.WELL_INDICATED_SCORE
|
|
1205
1178
|
).length;
|
|
1206
1179
|
|
|
1207
|
-
// Build
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
};
|
|
1217
|
-
newItems.push(newItem);
|
|
1218
|
-
report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})\n`;
|
|
1219
|
-
}
|
|
1180
|
+
// Build heterogeneous supply items in rank order (review items carry
|
|
1181
|
+
// reviewID; score is carried for the debug overlay).
|
|
1182
|
+
let report = replan ? 'Replan content:\n' : 'Mixed content session created with:\n';
|
|
1183
|
+
const supplyItems: StudySessionItem[] = supplyWeighted.map((w) => {
|
|
1184
|
+
const origin = getCardOrigin(w);
|
|
1185
|
+
const scoreStr = Number.isFinite(w.score) ? w.score.toFixed(2) : '+INF';
|
|
1186
|
+
report += `${origin === 'review' ? 'Review' : 'New'}: ${w.courseId}::${w.cardId} (score: ${scoreStr})\n`;
|
|
1187
|
+
return this._buildSupplyItem(w, origin);
|
|
1188
|
+
});
|
|
1220
1189
|
|
|
1221
1190
|
if (additive) {
|
|
1222
|
-
// Additive replan: merge
|
|
1191
|
+
// Additive replan: merge candidates into front of existing queue.
|
|
1223
1192
|
// Pass mandatory (+INF) ids so an already-queued required card is pulled
|
|
1224
1193
|
// back to the front instead of being buried by fresh non-required cards.
|
|
1225
|
-
const added = this.
|
|
1226
|
-
report += `Additive merge: ${added}
|
|
1194
|
+
const added = this.supplyQ.mergeToFront(supplyItems, (item) => item.cardID, mandatoryIds);
|
|
1195
|
+
report += `Additive merge: ${added} cards added to front of supplyQ\n`;
|
|
1227
1196
|
} else if (replan) {
|
|
1228
|
-
// Atomic swap: replace entire
|
|
1229
|
-
this.
|
|
1197
|
+
// Atomic swap: replace entire supplyQ contents at once (no empty-queue window)
|
|
1198
|
+
this.supplyQ.replaceAll(supplyItems, (item) => item.cardID);
|
|
1230
1199
|
} else {
|
|
1231
1200
|
// Initial session setup: add items normally
|
|
1232
|
-
for (const item of
|
|
1233
|
-
this.
|
|
1201
|
+
for (const item of supplyItems) {
|
|
1202
|
+
this.supplyQ.add(item, item.cardID);
|
|
1234
1203
|
}
|
|
1235
1204
|
}
|
|
1236
1205
|
|
|
@@ -1244,12 +1213,33 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1244
1213
|
// `mix=${(tMixed - tSources).toFixed(0)}ms ` +
|
|
1245
1214
|
// `post=${(tEnd - tMixed).toFixed(0)}ms ` +
|
|
1246
1215
|
// `total=${(tEnd - tGwc0).toFixed(0)}ms ` +
|
|
1247
|
-
// `[sources=${this.sources.length} fetchLimit=${fetchLimit}
|
|
1216
|
+
// `[sources=${this.sources.length} fetchLimit=${fetchLimit} supplyLimit=${supplyLimit}]`
|
|
1248
1217
|
// );
|
|
1249
1218
|
|
|
1250
1219
|
return wellIndicated;
|
|
1251
1220
|
}
|
|
1252
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* Build a supply item from a weighted candidate. Review-origin cards carry
|
|
1224
|
+
* their `reviewID` so SRS outcome tracking and re-presentation work; new
|
|
1225
|
+
* cards do not. `score` is carried on both for the debug overlay.
|
|
1226
|
+
*/
|
|
1227
|
+
private _buildSupplyItem(w: WeightedCard, origin = getCardOrigin(w)): StudySessionItem {
|
|
1228
|
+
const base = {
|
|
1229
|
+
cardID: w.cardId,
|
|
1230
|
+
courseID: w.courseId,
|
|
1231
|
+
contentSourceType: 'course' as const,
|
|
1232
|
+
contentSourceID: w.courseId,
|
|
1233
|
+
score: w.score,
|
|
1234
|
+
};
|
|
1235
|
+
if (origin === 'review') {
|
|
1236
|
+
const reviewItem: StudySessionReviewItem = { ...base, status: 'review', reviewID: w.reviewID! };
|
|
1237
|
+
return reviewItem;
|
|
1238
|
+
}
|
|
1239
|
+
const newItem: StudySessionNewItem = { ...base, status: 'new' };
|
|
1240
|
+
return newItem;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1253
1243
|
/**
|
|
1254
1244
|
* Returns items that should be pre-hydrated.
|
|
1255
1245
|
* Deterministic: top N items from each queue to ensure coverage.
|
|
@@ -1257,15 +1247,15 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1257
1247
|
*/
|
|
1258
1248
|
private _getItemsToHydrate(): StudySessionItem[] {
|
|
1259
1249
|
const items: StudySessionItem[] = [];
|
|
1260
|
-
|
|
1250
|
+
// Prefetch a little deeper into supplyQ (the primary draw source) than the
|
|
1251
|
+
// intermittently-drawn failedQ.
|
|
1252
|
+
const SUPPLY_PREFETCH = 3;
|
|
1253
|
+
const FAILED_PREFETCH = 2;
|
|
1261
1254
|
|
|
1262
|
-
for (let i = 0; i < Math.min(
|
|
1263
|
-
items.push(this.
|
|
1255
|
+
for (let i = 0; i < Math.min(SUPPLY_PREFETCH, this.supplyQ.length); i++) {
|
|
1256
|
+
items.push(this.supplyQ.peek(i));
|
|
1264
1257
|
}
|
|
1265
|
-
for (let i = 0; i < Math.min(
|
|
1266
|
-
items.push(this.newQ.peek(i));
|
|
1267
|
-
}
|
|
1268
|
-
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.failedQ.length); i++) {
|
|
1258
|
+
for (let i = 0; i < Math.min(FAILED_PREFETCH, this.failedQ.length); i++) {
|
|
1269
1259
|
items.push(this.failedQ.peek(i));
|
|
1270
1260
|
}
|
|
1271
1261
|
|
|
@@ -1274,78 +1264,94 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1274
1264
|
|
|
1275
1265
|
/**
|
|
1276
1266
|
* Selects the next item to present to the user.
|
|
1277
|
-
*
|
|
1267
|
+
*
|
|
1268
|
+
* The supplyQ is already rank-ordered (the pipeline + mixer did the mixing,
|
|
1269
|
+
* with `+INF` required cards floated to the front), so the primary path is a
|
|
1270
|
+
* deterministic front-to-back draw — no second new-vs-review mixer. The only
|
|
1271
|
+
* remaining decisions are (a) when the session ends and (b) when to interleave
|
|
1272
|
+
* a remediation card from failedQ. See decision doc §2/§3/§7.
|
|
1278
1273
|
*/
|
|
1279
1274
|
private _selectNextItemToHydrate(): StudySessionItem | null {
|
|
1280
|
-
|
|
1281
|
-
let newBound: number = 0.1;
|
|
1282
|
-
let reviewBound: number = 0.75;
|
|
1283
|
-
|
|
1284
|
-
if (this.reviewQ.length === 0 && this.failedQ.length === 0 && this.newQ.length === 0) {
|
|
1275
|
+
if (this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
1285
1276
|
// all queues empty - session is over (and course is complete?)
|
|
1286
1277
|
return null;
|
|
1287
1278
|
}
|
|
1288
1279
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1280
|
+
// Near/at session end with no remediation pending and no guarantee active:
|
|
1281
|
+
// don't start a card we can't finish. A `_minCardsGuarantee` (set by a
|
|
1282
|
+
// replan's minFollowUpCards) holds the session open so a required follow-up
|
|
1283
|
+
// and its practice can still be drawn past the timer — see decision doc §3.
|
|
1284
|
+
if (
|
|
1285
|
+
this._secondsRemaining < 2 &&
|
|
1286
|
+
this.failedQ.length === 0 &&
|
|
1287
|
+
this._minCardsGuarantee <= 0
|
|
1288
|
+
) {
|
|
1291
1289
|
return null;
|
|
1292
1290
|
}
|
|
1293
1291
|
|
|
1294
|
-
//
|
|
1292
|
+
// Timer expired, no guarantee: drain remediation only, then end.
|
|
1295
1293
|
if (this._secondsRemaining <= 0 && this._minCardsGuarantee <= 0) {
|
|
1296
|
-
|
|
1297
|
-
return this.failedQ.peek(0);
|
|
1298
|
-
} else {
|
|
1299
|
-
return null; // No more failed cards, session over
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// supply new cards at start of session
|
|
1304
|
-
if (this.newQ.dequeueCount < this.sources.length && this.newQ.length) {
|
|
1305
|
-
return this.newQ.peek(0);
|
|
1294
|
+
return this.failedQ.length > 0 ? this.failedQ.peek(0) : null;
|
|
1306
1295
|
}
|
|
1307
1296
|
|
|
1308
|
-
const
|
|
1309
|
-
const reviewTime = this.estimateReviewTime();
|
|
1310
|
-
const availableTime = this._secondsRemaining - (cleanupTime + reviewTime);
|
|
1297
|
+
const supplyTop = this.supplyQ.length > 0 ? this.supplyQ.peek(0) : null;
|
|
1311
1298
|
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
}
|
|
1318
|
-
// else if time-remaining vs failureQ looks good,
|
|
1319
|
-
// lean toward reviewQ
|
|
1320
|
-
else if (this._secondsRemaining - cleanupTime > 20) {
|
|
1321
|
-
newBound = 0.05;
|
|
1322
|
-
reviewBound = 0.9;
|
|
1323
|
-
}
|
|
1324
|
-
// else (time-remaining vs failureQ looks bad!)
|
|
1325
|
-
// lean heavily toward failureQ
|
|
1326
|
-
else {
|
|
1327
|
-
newBound = 0.01;
|
|
1328
|
-
reviewBound = 0.1;
|
|
1299
|
+
// A card guarantee exists to surface supplyQ follow-ups past the timer
|
|
1300
|
+
// (e.g. an intro's required WST + practice). Don't let the failed-interleave
|
|
1301
|
+
// consume those guaranteed draws.
|
|
1302
|
+
if (this._minCardsGuarantee > 0 && supplyTop) {
|
|
1303
|
+
return supplyTop;
|
|
1329
1304
|
}
|
|
1330
1305
|
|
|
1331
|
-
//
|
|
1332
|
-
|
|
1333
|
-
|
|
1306
|
+
// Failed-interleave (§7): pressure-based endgame + a light steady cadence so
|
|
1307
|
+
// remediation neither starves mid-session nor overruns the clock.
|
|
1308
|
+
if (this.failedQ.length > 0 && this._shouldInterleaveFailed(supplyTop !== null)) {
|
|
1309
|
+
return this.failedQ.peek(0);
|
|
1334
1310
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1311
|
+
|
|
1312
|
+
if (supplyTop) {
|
|
1313
|
+
return supplyTop;
|
|
1337
1314
|
}
|
|
1338
1315
|
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
} else if (choice < reviewBound && this.reviewQ.length) {
|
|
1342
|
-
return this.reviewQ.peek(0);
|
|
1343
|
-
} else if (this.failedQ.length) {
|
|
1316
|
+
// Supply exhausted; remediation remains.
|
|
1317
|
+
if (this.failedQ.length > 0) {
|
|
1344
1318
|
return this.failedQ.peek(0);
|
|
1345
|
-
} else {
|
|
1346
|
-
this.log(`No more cards available for the session!`);
|
|
1347
|
-
return null;
|
|
1348
1319
|
}
|
|
1320
|
+
|
|
1321
|
+
this.log(`No more cards available for the session!`);
|
|
1322
|
+
return null;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/** Supply draws between forced failed-queue interleaves (light steady cadence). */
|
|
1326
|
+
private static readonly FAILED_INTERLEAVE_EVERY = 4;
|
|
1327
|
+
/**
|
|
1328
|
+
* Slack (seconds) below which the endgame failed-pressure kicks in: when the
|
|
1329
|
+
* time left after clearing remediation drops under this, bias hard to failed
|
|
1330
|
+
* so the session doesn't end with un-cleared remediation. Mirrors the old
|
|
1331
|
+
* `availableTime > 20` ladder thresholds.
|
|
1332
|
+
*/
|
|
1333
|
+
private static readonly FAILED_ENDGAME_SLACK_SECONDS = 20;
|
|
1334
|
+
|
|
1335
|
+
/**
|
|
1336
|
+
* Whether to interleave a failed (remediation) card now instead of drawing
|
|
1337
|
+
* the supply head. Replaces the old `newBound`/`reviewBound` probability
|
|
1338
|
+
* ladder's failed path (decision doc §7).
|
|
1339
|
+
*
|
|
1340
|
+
* @param supplyAvailable - whether supplyQ has a card to draw instead.
|
|
1341
|
+
*/
|
|
1342
|
+
private _shouldInterleaveFailed(supplyAvailable: boolean): boolean {
|
|
1343
|
+
if (this.failedQ.length === 0) return false;
|
|
1344
|
+
// Nothing else to draw — failed is all that's left.
|
|
1345
|
+
if (!supplyAvailable) return true;
|
|
1346
|
+
|
|
1347
|
+
// Endgame pressure: as estimateCleanupTime → secondsRemaining, the time
|
|
1348
|
+
// left after clearing remediation shrinks; once it's under the slack,
|
|
1349
|
+
// prioritise failed so remediation isn't stranded by session end.
|
|
1350
|
+
const availableTime = this._secondsRemaining - this.estimateCleanupTime();
|
|
1351
|
+
if (availableTime <= SessionController.FAILED_ENDGAME_SLACK_SECONDS) return true;
|
|
1352
|
+
|
|
1353
|
+
// Light steady interleave so failed doesn't starve while supply is healthy.
|
|
1354
|
+
return this._supplyDrawsSinceFailed >= SessionController.FAILED_INTERLEAVE_EVERY;
|
|
1349
1355
|
}
|
|
1350
1356
|
|
|
1351
1357
|
public async nextCard(
|
|
@@ -1371,8 +1377,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1371
1377
|
// wedge-breaker below handles the genuinely-empty case.
|
|
1372
1378
|
if (
|
|
1373
1379
|
this._replanPromise &&
|
|
1374
|
-
this.
|
|
1375
|
-
this.reviewQ.length === 0 &&
|
|
1380
|
+
this.supplyQ.length === 0 &&
|
|
1376
1381
|
this.failedQ.length === 0
|
|
1377
1382
|
) {
|
|
1378
1383
|
this.log('nextCard: queues empty, awaiting in-flight replan before drawing');
|
|
@@ -1399,7 +1404,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1399
1404
|
// Rule of thumb: a redundant pipeline run is a perf bug, a missing
|
|
1400
1405
|
// pipeline run is a correctness bug. Bias toward the cheaper failure.
|
|
1401
1406
|
|
|
1402
|
-
// Opportunistic depletion:
|
|
1407
|
+
// Opportunistic depletion: supplyQ running dry → background prefetch.
|
|
1403
1408
|
// No latch — if this fires repeatedly when the pipeline keeps coming
|
|
1404
1409
|
// back empty, the wedge-breaker's local backoff handles spin protection.
|
|
1405
1410
|
//
|
|
@@ -1411,15 +1416,14 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1411
1416
|
// slightly earlier prefetch (which is anyway desirable for content
|
|
1412
1417
|
// freshness) for a much more reliable lead time.
|
|
1413
1418
|
if (
|
|
1414
|
-
this.
|
|
1419
|
+
this.supplyQ.length <= SessionController.DEPLETION_PREFETCH_THRESHOLD &&
|
|
1415
1420
|
this._secondsRemaining > 0 &&
|
|
1416
1421
|
!this._replanPromise
|
|
1417
1422
|
) {
|
|
1418
1423
|
this._suppressQualityReplan = false; // burst is (nearly) consumed
|
|
1419
|
-
const otherContent = this.reviewQ.length + this.failedQ.length;
|
|
1420
1424
|
this.log(
|
|
1421
|
-
`[AutoReplan:depletion]
|
|
1422
|
-
`(${
|
|
1425
|
+
`[AutoReplan:depletion] supplyQ has ${this.supplyQ.length} card(s) ` +
|
|
1426
|
+
`(${this.failedQ.length} failed pending) with ${this._secondsRemaining}s remaining. ` +
|
|
1423
1427
|
`Triggering background replan.`
|
|
1424
1428
|
);
|
|
1425
1429
|
// label is observability-only (overlay/logs); does not affect coalescing.
|
|
@@ -1435,12 +1439,12 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1435
1439
|
if (
|
|
1436
1440
|
!this._suppressQualityReplan &&
|
|
1437
1441
|
this._wellIndicatedRemaining <= REPLAN_BUFFER &&
|
|
1438
|
-
this.
|
|
1442
|
+
this.supplyQ.length > 0 &&
|
|
1439
1443
|
!this._replanPromise
|
|
1440
1444
|
) {
|
|
1441
1445
|
this.log(
|
|
1442
1446
|
`[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining ` +
|
|
1443
|
-
`(
|
|
1447
|
+
`(supplyQ: ${this.supplyQ.length}). Triggering background replan.`
|
|
1444
1448
|
);
|
|
1445
1449
|
// label is observability-only (overlay/logs); does not affect coalescing.
|
|
1446
1450
|
void this.requestReplan({ label: 'auto:quality' });
|
|
@@ -1467,8 +1471,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1467
1471
|
let wedgeEmptyStreak = 0;
|
|
1468
1472
|
while (
|
|
1469
1473
|
this._secondsRemaining > 0 &&
|
|
1470
|
-
this.
|
|
1471
|
-
this.reviewQ.length === 0 &&
|
|
1474
|
+
this.supplyQ.length === 0 &&
|
|
1472
1475
|
this.failedQ.length === 0
|
|
1473
1476
|
) {
|
|
1474
1477
|
this.log(
|
|
@@ -1478,8 +1481,7 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1478
1481
|
await this._replanUncoalesced({ label: 'wedge-breaker' });
|
|
1479
1482
|
// wedgeRuns++; // [perf] parked
|
|
1480
1483
|
if (
|
|
1481
|
-
this.
|
|
1482
|
-
this.reviewQ.length === 0 &&
|
|
1484
|
+
this.supplyQ.length === 0 &&
|
|
1483
1485
|
this.failedQ.length === 0
|
|
1484
1486
|
) {
|
|
1485
1487
|
wedgeEmptyStreak++;
|
|
@@ -1522,22 +1524,24 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1522
1524
|
await this.hydrationService.ensureHydratedCards();
|
|
1523
1525
|
this._currentCard = card;
|
|
1524
1526
|
|
|
1525
|
-
// Record presentation for debugging
|
|
1527
|
+
// Record presentation for debugging. `origin` is the item's nature
|
|
1528
|
+
// (new vs review); `queueSource` is which controller queue it was drawn
|
|
1529
|
+
// from (supplyQ vs the remediation failedQ).
|
|
1526
1530
|
const origin = nextItem.status === 'review' || nextItem.status === 'failed-review' ? 'review' :
|
|
1527
1531
|
nextItem.status === 'new' || nextItem.status === 'failed-new' ? 'new' : 'failed';
|
|
1528
|
-
const queueSource = nextItem.status.startsWith('failed') ? 'failedQ' :
|
|
1529
|
-
(nextItem.status === 'review' ? 'reviewQ' : 'newQ');
|
|
1532
|
+
const queueSource = nextItem.status.startsWith('failed') ? 'failedQ' : 'supplyQ';
|
|
1530
1533
|
|
|
1531
1534
|
recordCardPresentation(
|
|
1532
1535
|
nextItem.cardID,
|
|
1533
1536
|
nextItem.courseID,
|
|
1534
1537
|
this.courseNameCache.get(nextItem.courseID),
|
|
1535
1538
|
origin,
|
|
1536
|
-
queueSource
|
|
1539
|
+
queueSource,
|
|
1540
|
+
nextItem.score
|
|
1537
1541
|
);
|
|
1538
1542
|
|
|
1539
1543
|
// Snapshot queue state
|
|
1540
|
-
snapshotQueues(this.
|
|
1544
|
+
snapshotQueues(this.supplyQ.length, this.failedQ.length);
|
|
1541
1545
|
|
|
1542
1546
|
// [perf] parked: per-draw nextCard timing
|
|
1543
1547
|
// logger.info(
|
|
@@ -1651,6 +1655,12 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1651
1655
|
}
|
|
1652
1656
|
|
|
1653
1657
|
this.failedQ.add(failedItem, failedItem.cardID);
|
|
1658
|
+
// Reset the failed-interleave cadence so a just-failed card gets spacing:
|
|
1659
|
+
// it now waits FAILED_INTERLEAVE_EVERY supply draws before being
|
|
1660
|
+
// re-presented, rather than being instantly "due" because the counter
|
|
1661
|
+
// had already climbed during a healthy supply run. (Endgame pressure in
|
|
1662
|
+
// _shouldInterleaveFailed still overrides this near session end.)
|
|
1663
|
+
this._supplyDrawsSinceFailed = 0;
|
|
1654
1664
|
} else if (action === 'dismiss-error') {
|
|
1655
1665
|
// Remove from cache on error as well
|
|
1656
1666
|
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
@@ -1676,20 +1686,21 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
1676
1686
|
this._clearDurableRequirement(item.cardID);
|
|
1677
1687
|
|
|
1678
1688
|
// Record the draw immediately (earlier than _sessionRecord, which waits for
|
|
1679
|
-
// a response) so getWeightedContent can keep this card out of
|
|
1689
|
+
// a response) so getWeightedContent can keep this card out of supplyQ on any
|
|
1680
1690
|
// replan that lands after this draw.
|
|
1681
1691
|
this._servedCardIds.add(item.cardID);
|
|
1682
1692
|
|
|
1683
|
-
//
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
this.
|
|
1693
|
+
// Item should be at the front of one of the two queues. Track the
|
|
1694
|
+
// failed-interleave cadence: reset on a failed draw, advance on a supply draw.
|
|
1695
|
+
if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
1696
|
+
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
1697
|
+
this._supplyDrawsSinceFailed = 0;
|
|
1698
|
+
} else if (this.supplyQ.peek(0)?.cardID === item.cardID) {
|
|
1699
|
+
this.supplyQ.dequeue((queueItem) => queueItem.cardID);
|
|
1700
|
+
this._supplyDrawsSinceFailed++;
|
|
1688
1701
|
if (this._wellIndicatedRemaining > 0) {
|
|
1689
1702
|
this._wellIndicatedRemaining--;
|
|
1690
1703
|
}
|
|
1691
|
-
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
1692
|
-
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
1693
1704
|
}
|
|
1694
1705
|
}
|
|
1695
1706
|
|