@vue-skuilder/db 0.1.31 → 0.1.32-b

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.1.31",
7
+ "version": "0.1.32-b",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.1.31",
51
+ "@vue-skuilder/common": "0.1.32-b",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -39,6 +39,13 @@ export interface ReplanHints {
39
39
  excludeTags?: string[];
40
40
  /** Remove these specific card IDs from results. */
41
41
  excludeCards?: string[];
42
+ /**
43
+ * Debugging label threaded from the replan requester.
44
+ * Attached to provenance entries so card scoring history
45
+ * can be traced back to the originating event.
46
+ * Prefixed with `_` to signal it's metadata, not a scoring hint.
47
+ */
48
+ _label?: string;
42
49
  }
43
50
 
44
51
  /**
@@ -544,7 +551,7 @@ export class Pipeline extends ContentNavigator {
544
551
  card.provenance.push({
545
552
  strategy: 'ephemeralHint',
546
553
  strategyId: 'ephemeral-hint',
547
- strategyName: 'Replan Hint',
554
+ strategyName: hints._label ? `Replan Hint (${hints._label})` : 'Replan Hint',
548
555
  action: 'boosted',
549
556
  score: card.score,
550
557
  reason: `boostTag ${pattern} ×${factor}`,
@@ -561,7 +568,7 @@ export class Pipeline extends ContentNavigator {
561
568
  card.provenance.push({
562
569
  strategy: 'ephemeralHint',
563
570
  strategyId: 'ephemeral-hint',
564
- strategyName: 'Replan Hint',
571
+ strategyName: hints._label ? `Replan Hint (${hints._label})` : 'Replan Hint',
565
572
  action: 'boosted',
566
573
  score: card.score,
567
574
  reason: `boostCard ${pattern} ×${factor}`,
@@ -573,6 +580,7 @@ export class Pipeline extends ContentNavigator {
573
580
 
574
581
  // 3. Require — inject from the full pool if not already present
575
582
  const cardIds = new Set(cards.map((c) => c.cardId));
583
+ const hintLabel = hints._label ? `Replan Hint (${hints._label})` : 'Replan Hint';
576
584
  const inject = (card: WeightedCard, reason: string) => {
577
585
  if (!cardIds.has(card.cardId)) {
578
586
  // Give required cards a floor score so they sort above zero-score filler
@@ -585,7 +593,7 @@ export class Pipeline extends ContentNavigator {
585
593
  {
586
594
  strategy: 'ephemeralHint',
587
595
  strategyId: 'ephemeral-hint',
588
- strategyName: 'Replan Hint',
596
+ strategyName: hintLabel,
589
597
  action: 'boosted',
590
598
  score: floorScore,
591
599
  reason,
@@ -5,6 +5,7 @@ import type { WeightedCard } from '../index';
5
5
  import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
6
6
  import type { CardFilter, FilterContext } from './types';
7
7
  import { toCourseElo } from '@vue-skuilder/common';
8
+ import { logger } from '../../../util/logger';
8
9
 
9
10
  /**
10
11
  * A single prerequisite requirement for a tag.
@@ -286,6 +287,9 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
286
287
  finalScore *= maxBoost;
287
288
  action = 'boosted';
288
289
  finalReason = `${reason} | preReqBoost ×${maxBoost.toFixed(2)} for ${boostedPrereqs.join(', ')}`;
290
+ logger.info(
291
+ `[HierarchyDefinition] preReqBoost ×${maxBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedPrereqs.join(', ')}] (score: ${card.score.toFixed(3)} → ${finalScore.toFixed(3)})`
292
+ );
289
293
  }
290
294
  }
291
295
 
@@ -198,7 +198,13 @@ export default class RelativePriorityNavigator extends ContentNavigator implemen
198
198
  const cardTags = card.tags ?? [];
199
199
  const priority = this.computeCardPriority(cardTags);
200
200
  const boostFactor = this.computeBoostFactor(priority);
201
- const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
201
+ // No upper clamp — scores may exceed 1.0 intentionally.
202
+ // Scores are only used for relative ordering within a pipeline run,
203
+ // so absolute magnitude doesn't matter. Clamping to 1.0 here collapsed
204
+ // differentiation when GPC preReqBoosts and priority boosts compounded
205
+ // (e.g. 0.96 × 2.5 × 1.24 → 2.98, previously crushed back to 1.0).
206
+ // Floor of 0 is kept: negative scores have no meaning.
207
+ const finalScore = Math.max(0, card.score * boostFactor);
202
208
 
203
209
  // Determine action based on boost factor
204
210
  const action = boostFactor > 1.0 ? 'boosted' : boostFactor < 1.0 ? 'penalized' : 'passed';
@@ -650,11 +650,21 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
650
650
  * @param limit - Maximum number of cards to return
651
651
  * @returns Cards sorted by score descending
652
652
  */
653
+ private _pendingHints: Record<string, unknown> | null = null;
654
+
655
+ public setEphemeralHints(hints: Record<string, unknown>): void {
656
+ this._pendingHints = hints;
657
+ }
658
+
653
659
  public async getWeightedCards(limit: number): Promise<WeightedCard[]> {
654
660
  const u = await this._getCurrentUser();
655
661
 
656
662
  try {
657
663
  const navigator = await this.createNavigator(u);
664
+ if (this._pendingHints) {
665
+ navigator.setEphemeralHints(this._pendingHints);
666
+ this._pendingHints = null;
667
+ }
658
668
  return navigator.getWeightedCards(limit);
659
669
  } catch (e) {
660
670
  logger.error(`[courseDB] Error getting weighted cards: ${e}`);
@@ -442,9 +442,21 @@ export class StaticCourseDB implements CourseDBInterface {
442
442
  }
443
443
 
444
444
  // Study Content Source implementation
445
+
446
+ private _pendingHints: Record<string, unknown> | null = null;
447
+
448
+ setEphemeralHints(hints: Record<string, unknown>): void {
449
+ this._pendingHints = hints;
450
+ }
451
+
445
452
  async getWeightedCards(limit: number): Promise<WeightedCard[]> {
446
453
  try {
447
454
  const navigator = await this.createNavigator(this.userDB);
455
+ // Forward any pending hints to the Pipeline
456
+ if (this._pendingHints) {
457
+ navigator.setEphemeralHints(this._pendingHints);
458
+ this._pendingHints = null;
459
+ }
448
460
  return navigator.getWeightedCards(limit);
449
461
  } catch (e) {
450
462
  logger.error(`[static/courseDB] Error getting weighted cards: ${e}`);
@@ -20,6 +20,35 @@ import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
20
20
  import { captureMixerRun } from './MixerDebugger';
21
21
  import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
22
22
 
23
+ /**
24
+ * Options for requesting a mid-session replan.
25
+ *
26
+ * All fields are optional — callers can pass just the fields they need.
27
+ * When omitted, defaults match the existing behaviour (full 20-card
28
+ * replace with no hints).
29
+ */
30
+ export interface ReplanOptions {
31
+ /** Scoring hints forwarded to the pipeline (boost/exclude/require). */
32
+ hints?: Record<string, unknown>;
33
+ /**
34
+ * Maximum number of new cards to return from the pipeline.
35
+ * Default: 20 (the standard session batch size).
36
+ */
37
+ limit?: number;
38
+ /**
39
+ * How to integrate the new cards into the existing newQ.
40
+ * - `'replace'` (default): atomically swap the entire newQ.
41
+ * - `'merge'`: insert new cards at the front, keeping existing cards.
42
+ */
43
+ mode?: 'replace' | 'merge';
44
+ /**
45
+ * Human-readable label for debugging / provenance.
46
+ * Appears in console logs and in card provenance entries created
47
+ * by ephemeral hint application.
48
+ */
49
+ label?: string;
50
+ }
51
+
23
52
  export interface StudySessionRecord {
24
53
  card: {
25
54
  course_id: string;
@@ -44,6 +73,14 @@ export interface ResponseResult {
44
73
  nextCardAction: Exclude<SessionAction, 'dismiss-error'> | 'none';
45
74
  shouldLoadNextCard: boolean;
46
75
 
76
+ /**
77
+ * When true, the card requested deferred advancement via `deferAdvance`.
78
+ * The record was logged and ELO updated, but navigation was suppressed.
79
+ * StudySession should stash `nextCardAction` and wait for a
80
+ * `ready-to-advance` event from the card before calling `nextCard()`.
81
+ */
82
+ deferred?: boolean;
83
+
47
84
  // UI Data (let view decide how to render)
48
85
  isCorrect: boolean;
49
86
  performanceScore?: number; // for shadow color calculation
@@ -67,6 +104,13 @@ export class SessionController<TView = unknown> extends Loggable {
67
104
  private dataLayer: DataLayerProvider;
68
105
  private courseNameCache: Map<string, string> = new Map();
69
106
 
107
+ /**
108
+ * Default pipeline batch size for new-card planning.
109
+ * Set via constructor options; falls back to 20 when not specified.
110
+ * Individual replans can override via `ReplanOptions.limit`.
111
+ */
112
+ private _defaultBatchLimit: number = 20;
113
+
70
114
  private sources: StudyContentSource[];
71
115
  // dataLayer and getViewComponent now injected into CardHydrationService
72
116
  private _sessionRecord: StudySessionRecord[] = [];
@@ -96,6 +140,22 @@ export class SessionController<TView = unknown> extends Loggable {
96
140
  */
97
141
  private _wellIndicatedRemaining: number = 0;
98
142
 
143
+ /**
144
+ * When true, suppresses the quality-based auto-replan trigger in
145
+ * nextCard(). Set after a burst replan (small limit) to prevent the
146
+ * auto-replan from clobbering the burst cards before they're consumed.
147
+ * Cleared when the depletion-triggered replan fires (newQ exhausted).
148
+ */
149
+ private _suppressQualityReplan: boolean = false;
150
+
151
+ /**
152
+ * Guards against infinite depletion-triggered replans. Set to true
153
+ * when a depletion replan fires; cleared when a replan produces
154
+ * content (newQ.length > 0 after replan) or when an explicit
155
+ * (non-auto) replan is requested.
156
+ */
157
+ private _depletionReplanAttempted: boolean = false;
158
+
99
159
  private startTime: Date;
100
160
  private endTime: Date;
101
161
  private _secondsRemaining: number;
@@ -121,13 +181,18 @@ export class SessionController<TView = unknown> extends Loggable {
121
181
  * @param dataLayer - Data layer provider
122
182
  * @param getViewComponent - Function to resolve view components
123
183
  * @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
184
+ * @param options - Optional session-level configuration
185
+ * @param options.defaultBatchLimit - Default pipeline batch size (default: 20).
186
+ * Smaller values for newer users cause more frequent replans, keeping plans
187
+ * aligned with rapidly-changing user state.
124
188
  */
125
189
  constructor(
126
190
  sources: StudyContentSource[],
127
191
  time: number,
128
192
  dataLayer: DataLayerProvider,
129
193
  getViewComponent: (viewId: string) => TView,
130
- mixer?: SourceMixer
194
+ mixer?: SourceMixer,
195
+ options?: { defaultBatchLimit?: number }
131
196
  ) {
132
197
  super();
133
198
 
@@ -151,9 +216,14 @@ export class SessionController<TView = unknown> extends Loggable {
151
216
  this._secondsRemaining = time;
152
217
  this.endTime = new Date(this.startTime.valueOf() + 1000 * this._secondsRemaining);
153
218
 
219
+ if (options?.defaultBatchLimit !== undefined) {
220
+ this._defaultBatchLimit = options.defaultBatchLimit;
221
+ }
222
+
154
223
  this.log(`Session constructed:
155
224
  startTime: ${this.startTime}
156
- endTime: ${this.endTime}`);
225
+ endTime: ${this.endTime}
226
+ defaultBatchLimit: ${this._defaultBatchLimit}`);
157
227
  }
158
228
 
159
229
  private tick() {
@@ -246,22 +316,39 @@ export class SessionController<TView = unknown> extends Loggable {
246
316
  * Typical trigger: application-level code (e.g. after a GPC intro completion)
247
317
  * calls this to ensure newly-unlocked content appears in the session.
248
318
  */
249
- public async requestReplan(hints?: Record<string, unknown>): Promise<void> {
319
+ public async requestReplan(options?: ReplanOptions | Record<string, unknown>): Promise<void> {
320
+ // Normalise: bare hints object (legacy callers) → ReplanOptions wrapper
321
+ const opts = this.normalizeReplanOptions(options);
322
+
323
+ // Explicit (non-auto) replans clear the depletion guard — the caller
324
+ // is providing fresh intent that may change pipeline results.
325
+ if (opts.hints || opts.label || opts.limit) {
326
+ this._depletionReplanAttempted = false;
327
+ }
328
+
250
329
  if (this._replanPromise) {
251
330
  this.log('Replan already in progress, awaiting existing replan');
252
331
  return this._replanPromise;
253
332
  }
254
333
 
255
- // Forward hints to all sources that support them (Pipeline)
256
- if (hints) {
334
+ // Forward hints to all sources (CourseDB stashes them, Pipeline consumes them)
335
+ if (opts.hints) {
336
+ // Thread label into hints so Pipeline can attach it to provenance
337
+ const hintsWithLabel = opts.label
338
+ ? { ...opts.hints, _label: opts.label }
339
+ : opts.hints;
257
340
  for (const source of this.sources) {
258
- this.log(`[Hints] source type=${source.constructor.name}, hasMethod=${typeof source.setEphemeralHints}`);
259
- source.setEphemeralHints?.(hints);
341
+ source.setEphemeralHints?.(hintsWithLabel);
260
342
  }
261
343
  }
262
344
 
263
- this.log(`Mid-session replan requested${hints ? ` (hints: ${JSON.stringify(hints)})` : ''}`);
264
- this._replanPromise = this._executeReplan();
345
+ const labelTag = opts.label ? ` [${opts.label}]` : '';
346
+ this.log(
347
+ `Mid-session replan requested${labelTag}` +
348
+ ` (limit: ${opts.limit ?? 'default'}, mode: ${opts.mode ?? 'replace'}` +
349
+ `${opts.hints ? ', with hints' : ''})`
350
+ );
351
+ this._replanPromise = this._executeReplan(opts);
265
352
 
266
353
  try {
267
354
  await this._replanPromise;
@@ -270,6 +357,28 @@ export class SessionController<TView = unknown> extends Loggable {
270
357
  }
271
358
  }
272
359
 
360
+ /**
361
+ * Normalise the requestReplan argument. Accepts either a ReplanOptions
362
+ * object (new API) or a plain Record<string, unknown> (legacy callers
363
+ * that passed hints directly). Distinguishes the two by checking for
364
+ * the presence of ReplanOptions-specific keys.
365
+ */
366
+ private normalizeReplanOptions(
367
+ input?: ReplanOptions | Record<string, unknown>
368
+ ): ReplanOptions {
369
+ if (!input) return {};
370
+
371
+ // If the input has any ReplanOptions-specific key, treat it as ReplanOptions
372
+ const replanKeys = ['hints', 'limit', 'mode', 'label'];
373
+ const inputKeys = Object.keys(input);
374
+ if (inputKeys.some((k) => replanKeys.includes(k))) {
375
+ return input as ReplanOptions;
376
+ }
377
+
378
+ // Otherwise treat as legacy bare-hints object
379
+ return { hints: input as Record<string, unknown> };
380
+ }
381
+
273
382
  /** Minimum well-indicated cards before an additive retry is attempted */
274
383
  private static readonly MIN_WELL_INDICATED = 5;
275
384
 
@@ -290,18 +399,43 @@ export class SessionController<TView = unknown> extends Loggable {
290
399
  * pass all hierarchy filters, one additive retry is attempted — merging
291
400
  * any new high-quality candidates into the front of the queue.
292
401
  */
293
- private async _executeReplan(): Promise<void> {
294
- const wellIndicated = await this.getWeightedContent({ replan: true });
402
+ private async _executeReplan(opts: ReplanOptions = {}): Promise<void> {
403
+ const limit = opts.limit;
404
+ const mode = opts.mode ?? 'replace';
405
+
406
+ const wellIndicated = await this.getWeightedContent({
407
+ replan: true,
408
+ additive: mode === 'merge',
409
+ limit,
410
+ });
295
411
  this._wellIndicatedRemaining = wellIndicated;
296
412
 
413
+ // Burst replan: suppress quality-based auto-replan so the background
414
+ // replan doesn't clobber the small hinted queue before it's consumed.
415
+ // The depletion trigger (newQ empty) takes over instead.
416
+ if (limit !== undefined && limit < this._defaultBatchLimit) {
417
+ this._suppressQualityReplan = true;
418
+ this.log(`[Replan] Burst mode (limit=${limit}): suppressing quality-based auto-replan`);
419
+ } else {
420
+ // Normal or auto-replan — clear the burst suppression flag
421
+ this._suppressQualityReplan = false;
422
+ }
423
+
297
424
  if (wellIndicated >= 0 && wellIndicated < SessionController.MIN_WELL_INDICATED) {
298
425
  this.log(
299
426
  `[Replan] Only ${wellIndicated}/${SessionController.MIN_WELL_INDICATED} well-indicated cards after replan`
300
427
  );
301
428
  }
302
429
 
430
+ // If the replan produced content, clear the depletion guard so future
431
+ // depletions can trigger fresh replans.
432
+ if (this.newQ.length > 0) {
433
+ this._depletionReplanAttempted = false;
434
+ }
435
+
303
436
  await this.hydrationService.ensureHydratedCards();
304
- this.log(`Replan complete: newQ now has ${this.newQ.length} cards`);
437
+ const labelTag = opts.label ? ` [${opts.label}]` : '';
438
+ this.log(`Replan complete${labelTag}: newQ now has ${this.newQ.length} cards (mode=${mode})`);
305
439
 
306
440
  // Snapshot queue state for debugging
307
441
  snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
@@ -373,6 +507,8 @@ export class SessionController<TView = unknown> extends Loggable {
373
507
  },
374
508
  replan: {
375
509
  inProgress: this._replanPromise !== null,
510
+ suppressQualityReplan: this._suppressQualityReplan,
511
+ defaultBatchLimit: this._defaultBatchLimit,
376
512
  },
377
513
  };
378
514
  }
@@ -397,10 +533,14 @@ export class SessionController<TView = unknown> extends Loggable {
397
533
  * @returns Number of "well-indicated" cards (passed all hierarchy filters)
398
534
  * in the new content. Returns -1 if no content was loaded.
399
535
  */
400
- private async getWeightedContent(options?: { replan?: boolean; additive?: boolean }): Promise<number> {
536
+ private async getWeightedContent(options?: {
537
+ replan?: boolean;
538
+ additive?: boolean;
539
+ limit?: number;
540
+ }): Promise<number> {
401
541
  const replan = options?.replan ?? false;
402
542
  const additive = options?.additive ?? false;
403
- const limit = 20; // Initial batch size per source
543
+ const limit = options?.limit ?? this._defaultBatchLimit;
404
544
 
405
545
  // Collect batches from each source
406
546
  const batches: SourceBatch[] = [];
@@ -645,19 +785,69 @@ export class SessionController<TView = unknown> extends Loggable {
645
785
  await this._replanPromise;
646
786
  }
647
787
 
648
- // Quality-based auto-replan: when few well-indicated cards remain,
649
- // trigger a background replan. The buffer of remaining good cards
650
- // covers the replan latency — by the time they're consumed, the
651
- // refreshed queue is ready. Strategy-agnostic: relies only on the
652
- // score-based well-indicated count, not any specific filter's output.
788
+ // --- Auto-replan triggers ---
789
+ // Two automatic replan triggers maintain queue freshness:
790
+ //
791
+ // 1. Depletion: newQ is running dry fire a replan so fresh content
792
+ // is ready by the time the last card is consumed.
793
+ // - newQ === 1: background replan — overlaps pipeline latency with
794
+ // the user's interaction on the last card. On the *next*
795
+ // nextCard(), the _replanPromise await at the top ensures the
796
+ // replan has landed before we try to draw.
797
+ // - newQ === 0 && all queues empty: blocking await — nothing else
798
+ // to serve, so we must wait for the pipeline before proceeding.
799
+ // - newQ === 0 && other queues have content: background — the user
800
+ // draws from reviewQ/failedQ while the pipeline runs.
801
+ //
802
+ // 2. Quality: few well-indicated cards remain → background replan so
803
+ // the refreshed queue is ready by the time the buffer is consumed.
804
+ // Suppressed after a burst replan to avoid clobbering burst cards.
805
+
806
+ // 1. Depletion trigger
807
+ // Guarded by _depletionReplanAttempted to avoid infinite loops when
808
+ // the pipeline consistently returns no new content.
809
+ if (
810
+ this.newQ.length <= 1 &&
811
+ this._secondsRemaining > 0 &&
812
+ !this._replanPromise &&
813
+ !this._depletionReplanAttempted
814
+ ) {
815
+ this._suppressQualityReplan = false; // burst is (nearly) consumed, clear suppression
816
+ this._depletionReplanAttempted = true;
817
+
818
+ const otherContent = this.reviewQ.length + this.failedQ.length;
819
+
820
+ if (this.newQ.length === 0 && otherContent === 0) {
821
+ // Truly empty — nothing to serve. Must block until the pipeline delivers.
822
+ this.log(
823
+ `[AutoReplan:depletion] All queues empty with ${this._secondsRemaining}s remaining. ` +
824
+ `Awaiting replan.`
825
+ );
826
+ await this.requestReplan();
827
+ } else {
828
+ // Either 1 card remains (look-ahead) or other queues can cover.
829
+ // Fire in background — pipeline runs while the user works.
830
+ this.log(
831
+ `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) ` +
832
+ `(${otherContent} in other queues) with ${this._secondsRemaining}s remaining. ` +
833
+ `Triggering background replan.`
834
+ );
835
+ void this.requestReplan();
836
+ }
837
+ }
838
+
839
+ // 2. Quality trigger: few well-indicated cards remain. The buffer of
840
+ // remaining good cards covers replan latency.
841
+ // Suppressed after a burst replan to avoid clobbering burst cards.
653
842
  const REPLAN_BUFFER = 3;
654
843
  if (
844
+ !this._suppressQualityReplan &&
655
845
  this._wellIndicatedRemaining <= REPLAN_BUFFER &&
656
846
  this.newQ.length > 0 &&
657
847
  !this._replanPromise
658
848
  ) {
659
849
  this.log(
660
- `[AutoReplan] ${this._wellIndicatedRemaining} well-indicated cards remaining ` +
850
+ `[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining ` +
661
851
  `(newQ: ${this.newQ.length}). Triggering background replan.`
662
852
  );
663
853
  void this.requestReplan();
@@ -107,9 +107,11 @@ export class ResponseProcessor {
107
107
  try {
108
108
  const history = await cardHistory;
109
109
 
110
+ let result: ResponseResult;
111
+
110
112
  // Handle correct responses
111
113
  if (cardRecord.isCorrect) {
112
- return this.processCorrectResponse(
114
+ result = this.processCorrectResponse(
113
115
  cardRecord,
114
116
  history,
115
117
  studySessionItem,
@@ -120,7 +122,7 @@ export class ResponseProcessor {
120
122
  );
121
123
  } else {
122
124
  // Handle incorrect responses
123
- return this.processIncorrectResponse(
125
+ result = this.processIncorrectResponse(
124
126
  cardRecord,
125
127
  history,
126
128
  courseRegistrationDoc,
@@ -132,6 +134,24 @@ export class ResponseProcessor {
132
134
  sessionViews
133
135
  );
134
136
  }
137
+
138
+ // Apply deferred advancement: record is logged and ELO updated above,
139
+ // but we suppress navigation so the view retains control of the UI
140
+ // timeline. StudySession will stash the nextCardAction and wait for
141
+ // a `ready-to-advance` event from the view.
142
+ if (cardRecord.deferAdvance && result.shouldLoadNextCard) {
143
+ logger.info(
144
+ '[ResponseProcessor] deferAdvance requested — suppressing navigation, action stashed:',
145
+ { nextCardAction: result.nextCardAction }
146
+ );
147
+ result = {
148
+ ...result,
149
+ shouldLoadNextCard: false,
150
+ deferred: true,
151
+ };
152
+ }
153
+
154
+ return result;
135
155
  } catch (e: unknown) {
136
156
  logger.error('[ResponseProcessor] Failed to load card history', { e, cardId });
137
157
  throw e;