@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.
Files changed (40) hide show
  1. package/dist/{contentSource-Cplhv3bJ.d.ts → contentSource-C-0t0y0V.d.ts} +7 -0
  2. package/dist/{contentSource-kI9_jwTu.d.cts → contentSource-jSkcOt2s.d.cts} +7 -0
  3. package/dist/core/index.d.cts +67 -4
  4. package/dist/core/index.d.ts +67 -4
  5. package/dist/core/index.js +201 -39
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +198 -39
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-DrBqOUa3.d.ts → dataLayerProvider-BB0oi9T0.d.ts} +1 -1
  10. package/dist/{dataLayerProvider-CiA2Rr0v.d.cts → dataLayerProvider-BDClIrFC.d.cts} +1 -1
  11. package/dist/impl/couch/index.d.cts +2 -2
  12. package/dist/impl/couch/index.d.ts +2 -2
  13. package/dist/impl/couch/index.js +195 -39
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +195 -39
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +2 -2
  18. package/dist/impl/static/index.d.ts +2 -2
  19. package/dist/impl/static/index.js +195 -39
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +195 -39
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index.d.cts +115 -81
  24. package/dist/index.d.ts +115 -81
  25. package/dist/index.js +440 -251
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +437 -251
  28. package/dist/index.mjs.map +1 -1
  29. package/docs/navigators-architecture.md +29 -13
  30. package/package.json +3 -3
  31. package/src/core/interfaces/contentSource.ts +7 -0
  32. package/src/core/navigators/Pipeline.ts +93 -1
  33. package/src/core/navigators/PipelineDebugger.ts +11 -1
  34. package/src/core/navigators/SrsDebugger.ts +53 -0
  35. package/src/core/navigators/generators/prescribed.ts +76 -9
  36. package/src/core/navigators/generators/srs.ts +81 -37
  37. package/src/core/navigators/index.ts +9 -0
  38. package/src/study/SessionController.ts +260 -249
  39. package/src/study/SessionDebugger.ts +15 -25
  40. 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 newQ.
89
- * - `'replace'` (default): atomically swap the entire newQ.
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
- private reviewQ: ItemQueue<StudySessionReviewItem> = new ItemQueue<StudySessionReviewItem>();
250
- private newQ: ItemQueue<StudySessionNewItem> = new ItemQueue<StudySessionNewItem>();
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 new cards remaining before the queue
270
- * degrades to poorly-indicated content. Decremented on each newQ
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 (newQ exhausted).
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 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.
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 reviewCount = this.reviewQ.dequeueCount;
347
- const newCount = this.newQ.dequeueCount;
348
- const reviewWord = reviewCount === 1 ? 'review' : 'reviews';
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.newQ.toString + '\n' + this.reviewQ.toString + '\n' + this.failedQ.toString;
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 pipeline batch size (default: 20).
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.reviewQ.length, this.newQ.length, this.failedQ.length);
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 newQ contents. Safe to call at any time during
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
- * Does NOT affect reviewQ or failedQ.
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
- * newQ.peek(0) is the imminent draw we need to exclude.
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 newQ via replaceAll and cause duplicates.
619
+ // they can re-enter supplyQ via replaceAll and cause duplicates.
626
620
  //
627
- // Also exclude newQ.peek(0): the imminent draw. When a replan fires
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 newQ and shown twice in a row — most visible in early
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 newQ entries are still fair game
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.newQ.length > 0) {
648
- excludeSet.add(this.newQ.peek(0).cardID);
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 { cardID: string }>(q: ItemQueue<T>): SessionQueueDebug => {
734
- const cards: string[] = [];
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
- cards.push(q.peek(i).cardID);
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
- reviewQ: describe(this.reviewQ),
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
- * newQ length at or below which the opportunistic depletion-prefetch
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, builds a new newQ,
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 (newQ empty) takes over instead.
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}: newQ now has ${this.newQ.length} cards (mode=${mode})`);
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.reviewQ.length, this.newQ.length, this.failedQ.length);
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.reviewQ.length} Reviews, ${this.newQ.length} New, ${this.failedQ.length} failed`;
974
+ return `Session: ${this.supplyQ.length} supply, ${this.failedQ.length} failed`;
975
975
  }
976
976
  public reportString() {
977
- return `${this.reviewQ.dequeueCount} Reviews, ${this.newQ.dequeueCount} New, ${this.failedQ.dequeueCount} failed`;
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
- reviewQueue: {
1011
- length: this.reviewQ.length,
1012
- dequeueCount: this.reviewQ.dequeueCount,
1013
- items: extractQueueItems(this.reviewQ),
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 using the getWeightedCards API and mix across sources.
1035
+ * Fetch weighted content from all sources, mix across sources, and populate
1036
+ * the single supply queue in pipeline rank order.
1040
1037
  *
1041
- * This method:
1042
- * 1. Fetches weighted cards from each source
1043
- * 2. Fetches full review data (we need ScheduledCard fields for queue)
1044
- * 3. Uses SourceMixer to balance content across sources
1045
- * 4. Populates review and new card queues with mixed results
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. Skips review queue population (avoiding duplicates),
1052
- * atomically replaces newQ contents, and treats empty results as non-fatal.
1053
- * @param options.additive - If true (replan only), merge new high-quality
1054
- * candidates into the front of the existing newQ instead of replacing it.
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 newLimit = options?.limit ?? this._defaultBatchLimit;
1067
- // Initial planning inflates the per-source budget so reviews can fill up
1068
- // to _initialReviewCap independently of the new-card budget. Replans
1069
- // never touch reviewQ, so the inflation is unnecessary there.
1070
- const fetchLimit = replan ? newLimit : newLimit + this._initialReviewCap;
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 newQ');
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
- // Split mixed results by card origin, then apply per-origin caps. The
1153
- // pre-mixer fetch is inflated to fit both budgets; trimming here keeps
1154
- // newQ at the nimble batch size while letting reviewQ overfill up to
1155
- // _initialReviewCap (replan path discards reviewWeighted entirely).
1156
- const reviewWeighted = mixedWeighted
1157
- .filter((w) => getCardOrigin(w) === 'review')
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.
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). Partition these mandatory cards
1169
- // to the front and exempt them from the newLimit slice, so neither the
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 before it reaches newQ. The set is also handed to mergeToFront so an
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 = newCandidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
1174
- const optionalWeighted = newCandidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
1175
- const newWeighted = [
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, newLimit - mandatoryWeighted.length)),
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 = newWeighted.filter(
1176
+ const wellIndicated = supplyWeighted.filter(
1204
1177
  (w) => w.score >= SessionController.WELL_INDICATED_SCORE
1205
1178
  ).length;
1206
1179
 
1207
- // Build new card items
1208
- const newItems: StudySessionNewItem[] = [];
1209
- for (const w of newWeighted) {
1210
- const newItem: StudySessionNewItem = {
1211
- cardID: w.cardId,
1212
- courseID: w.courseId,
1213
- contentSourceType: 'course',
1214
- contentSourceID: w.courseId,
1215
- status: 'new',
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 new candidates into front of existing queue.
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.newQ.mergeToFront(newItems, (item) => item.cardID, mandatoryIds);
1226
- report += `Additive merge: ${added} new cards added to front of newQ\n`;
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 newQ contents at once (no empty-queue window)
1229
- this.newQ.replaceAll(newItems, (item) => item.cardID);
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 newItems) {
1233
- this.newQ.add(item, item.cardID);
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} newLimit=${newLimit}]`
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
- const ITEMS_PER_QUEUE = 2;
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(ITEMS_PER_QUEUE, this.reviewQ.length); i++) {
1263
- items.push(this.reviewQ.peek(i));
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(ITEMS_PER_QUEUE, this.newQ.length); i++) {
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
- * Nondeterministic: uses probability to balance between queues based on session state.
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
- const choice = Math.random();
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
- if (this._secondsRemaining < 2 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
1290
- // session is over!
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
- // If timer expired, only return failed cards (unless card guarantee active)
1292
+ // Timer expired, no guarantee: drain remediation only, then end.
1295
1293
  if (this._secondsRemaining <= 0 && this._minCardsGuarantee <= 0) {
1296
- if (this.failedQ.length > 0) {
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 cleanupTime = this.estimateCleanupTime();
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
- // if time-remaing vs (reviewQ + failureQ) looks good,
1313
- // lean toward newQ
1314
- if (availableTime > 20) {
1315
- newBound = 0.5;
1316
- reviewBound = 0.9;
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
- // exclude possibility of drawing from empty queues
1332
- if (this.failedQ.length === 0) {
1333
- reviewBound = 1;
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
- if (this.reviewQ.length === 0) {
1336
- newBound = reviewBound;
1311
+
1312
+ if (supplyTop) {
1313
+ return supplyTop;
1337
1314
  }
1338
1315
 
1339
- if (choice < newBound && this.newQ.length) {
1340
- return this.newQ.peek(0);
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.newQ.length === 0 &&
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: newQ running dry → background prefetch.
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.newQ.length <= SessionController.DEPLETION_PREFETCH_THRESHOLD &&
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] newQ has ${this.newQ.length} card(s) ` +
1422
- `(${otherContent} in other queues) with ${this._secondsRemaining}s remaining. ` +
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.newQ.length > 0 &&
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
- `(newQ: ${this.newQ.length}). Triggering background replan.`
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.newQ.length === 0 &&
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.newQ.length === 0 &&
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 as 'reviewQ' | 'newQ' | 'failedQ'
1539
+ queueSource,
1540
+ nextItem.score
1537
1541
  );
1538
1542
 
1539
1543
  // Snapshot queue state
1540
- snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
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 newQ on any
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
- // Check each queue - item should be at the front of one of them
1684
- if (this.reviewQ.peek(0)?.cardID === item.cardID) {
1685
- this.reviewQ.dequeue((queueItem) => queueItem.cardID);
1686
- } else if (this.newQ.peek(0)?.cardID === item.cardID) {
1687
- this.newQ.dequeue((queueItem) => queueItem.cardID);
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