@vue-skuilder/db 0.2.15 → 0.2.17

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.2.15",
7
+ "version": "0.2.17",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.2.15",
51
+ "@vue-skuilder/common": "0.2.17",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -62,5 +62,5 @@
62
62
  "vite": "^8.0.0",
63
63
  "vitest": "^4.1.0"
64
64
  },
65
- "stableVersion": "0.2.15"
65
+ "stableVersion": "0.2.17"
66
66
  }
@@ -0,0 +1,76 @@
1
+ // ============================================================================
2
+ // STRATEGY PRESSURE DEBUGGER
3
+ // ============================================================================
4
+ //
5
+ // A generic, semi-structured capture channel for the backpressures that
6
+ // navigation strategies exert on the pipeline — the general-purpose sibling
7
+ // of SrsDebugger's bespoke review-backlog capture.
8
+ //
9
+ // Any strategy (built-in or consumer-registered via `registerNavigator`) may
10
+ // push a snapshot of its current pressure state once per run. The live
11
+ // session overlay renders every captured source in a "strategy backpressure"
12
+ // section without knowing producer semantics: each snapshot is a list of
13
+ // *gauges* — named multipliers, optionally capped, with human-readable detail
14
+ // and expandable item lists (blocked targets, per-tag debt ages, ...).
15
+ //
16
+ // Mirrors SrsDebugger's lifecycle: latest snapshot per (source, course) wins;
17
+ // the controller reads via getDebugSnapshot() with no DB load; cleared on
18
+ // session start alongside the pipeline run history.
19
+ //
20
+ // ============================================================================
21
+
22
+ /**
23
+ * A single named pressure reading. The common shape across strategies: a
24
+ * multiplier that climbs under some accumulated condition (staleness, debt,
25
+ * blockage), optionally clamped at a cap.
26
+ */
27
+ export interface PressureGaugeDebug {
28
+ /** Stable identifier within the source (e.g. 'group:intro-core:target'). */
29
+ id: string;
30
+ /** Short display label (e.g. 'intro-core targets'). */
31
+ label: string;
32
+ /** Current pressure multiplier (×1.0 = no pressure). */
33
+ multiplier: number;
34
+ /** Clamp ceiling, if the multiplier is capped. Omit for unbounded. */
35
+ max?: number;
36
+ /** One-line context for the reading (counts, mode, staleness). */
37
+ detail?: string;
38
+ /** Expandable rows (blocked target ids, per-tag debt ages, ...). */
39
+ items?: Array<{ label: string; value?: string }>;
40
+ }
41
+
42
+ /** One strategy's pressure snapshot for one course, captured once per run. */
43
+ export interface StrategyPressureDebug {
44
+ /** Producer identity — implementingClass name (e.g. 'prescribed'). */
45
+ source: string;
46
+ courseId: string;
47
+ gauges: PressureGaugeDebug[];
48
+ /**
49
+ * Highest score this strategy emitted into the candidate pool this run;
50
+ * null if it emitted nothing. Compare against the supplyQ head score to
51
+ * read how the pressure is competing for slots (same crossover read as the
52
+ * SRS panel's `top review`).
53
+ */
54
+ topScore?: number | null;
55
+ /** Label of any one-shot hints emitted alongside the cards this run. */
56
+ hintsLabel?: string;
57
+ /** Epoch ms of capture. */
58
+ timestamp: number;
59
+ }
60
+
61
+ const snapshots = new Map<string, StrategyPressureDebug>();
62
+
63
+ /** Called by a strategy once per run. Latest snapshot per (source, course) wins. */
64
+ export function captureStrategyPressure(snapshot: StrategyPressureDebug): void {
65
+ snapshots.set(`${snapshot.source}:${snapshot.courseId}`, snapshot);
66
+ }
67
+
68
+ /** Current pressure snapshot for every source seen, newest-first. */
69
+ export function getStrategyPressureDebug(): StrategyPressureDebug[] {
70
+ return [...snapshots.values()].sort((a, b) => b.timestamp - a.timestamp);
71
+ }
72
+
73
+ /** Drop all captured snapshots (called on session start, alongside pipeline history). */
74
+ export function clearStrategyPressureDebug(): void {
75
+ snapshots.clear();
76
+ }
@@ -4,6 +4,10 @@ import { ContentNavigator } from '../index';
4
4
  import type { WeightedCard } from '../index';
5
5
  import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
6
6
  import type { CardGenerator, GeneratorContext, GeneratorResult, ReplanHints } from './types';
7
+ import {
8
+ captureStrategyPressure,
9
+ type PressureGaugeDebug,
10
+ } from '../StrategyPressureDebugger';
7
11
  import { logger } from '@db/util/logger';
8
12
 
9
13
  // ============================================================================
@@ -115,9 +119,19 @@ interface GroupRuntimeState {
115
119
  supportTags: string[];
116
120
  pressureMultiplier: number;
117
121
  supportMultiplier: number;
122
+ /** Carried-forward staleness count driving the pressure multipliers. */
123
+ sessionsSinceSurfaced: number;
118
124
  debugVersion: string;
119
125
  }
120
126
 
127
+ /** One open practice debt, surfaced for the strategy-pressure debug channel. */
128
+ interface PracticeDebtDebug {
129
+ tag: string;
130
+ multiplier: number;
131
+ /** ISO timestamp when the skill first appeared unlocked-but-under-practiced. */
132
+ firstOwedAt: string;
133
+ }
134
+
121
135
  interface HintEmissionSummary {
122
136
  boostTags: Record<string, number>;
123
137
  blockedTargetIds: string[];
@@ -199,6 +213,15 @@ function shuffleInPlace<T>(arr: T[]): void {
199
213
  }
200
214
  }
201
215
 
216
+ /** Compact age string for debt-open durations: 42m / 7h / 3d. */
217
+ function formatAge(ms: number): string {
218
+ const minutes = Math.max(0, Math.round(ms / 60000));
219
+ if (minutes < 60) return `${minutes}m`;
220
+ const hours = Math.round(minutes / 60);
221
+ if (hours < 24) return `${hours}h`;
222
+ return `${Math.round(hours / 24)}d`;
223
+ }
224
+
202
225
  function pickTopByScore(cards: WeightedCard[], limit: number): WeightedCard[] {
203
226
  return [...cards]
204
227
  .sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId))
@@ -293,6 +316,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
293
316
  // under-practiced, dropped once it's discharged (see buildPracticeCards).
294
317
  const priorPracticeDebt = progress.practiceDebt ?? {};
295
318
  const nextPracticeDebt: Record<string, string> = {};
319
+ const practiceDebtsByGroup = new Map<string, PracticeDebtDebug[]>();
296
320
 
297
321
  for (const group of this.config.groups) {
298
322
  const runtime = this.buildGroupRuntimeState({
@@ -358,7 +382,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
358
382
  courseId,
359
383
  emittedIds
360
384
  );
361
- const practiceCards = this.buildPracticeCards({
385
+ const practice = this.buildPracticeCards({
362
386
  group,
363
387
  courseId,
364
388
  emittedIds,
@@ -371,8 +395,9 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
371
395
  priorPracticeDebt,
372
396
  nextPracticeDebt,
373
397
  });
398
+ practiceDebtsByGroup.set(group.id, practice.debts);
374
399
 
375
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
400
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practice.cards);
376
401
  }
377
402
 
378
403
  // Persist the carried-forward practice debt (self-pruned: discharged skills
@@ -401,6 +426,11 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
401
426
  logger.info('[Prescribed] No hints to emit (no blocked targets or no support tags)');
402
427
  }
403
428
 
429
+ // Push this run's pressure state to the live-overlay debug channel. Done
430
+ // before the empty-emission return — an all-blocked run is exactly the
431
+ // state the overlay most needs to show.
432
+ this.capturePressureSnapshot(courseId, groupRuntimes, practiceDebtsByGroup, emitted, hints);
433
+
404
434
  if (emitted.length === 0) {
405
435
  logger.info(
406
436
  '[Prescribed] 0 cards emitted (all targets blocked, authored/discovered support candidates exhausted)' +
@@ -481,6 +511,80 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
481
511
  };
482
512
  }
483
513
 
514
+ /**
515
+ * Translate this run's per-group runtimes and practice debts into gauges on
516
+ * the generic strategy-pressure debug channel (rendered by the live session
517
+ * overlay's "strategy backpressure" section).
518
+ */
519
+ private capturePressureSnapshot(
520
+ courseId: string,
521
+ groupRuntimes: GroupRuntimeState[],
522
+ practiceDebtsByGroup: Map<string, PracticeDebtDebug[]>,
523
+ emitted: WeightedCard[],
524
+ hints: ReplanHints | undefined
525
+ ): void {
526
+ const now = Date.now();
527
+ const gauges: PressureGaugeDebug[] = [];
528
+
529
+ for (const runtime of groupRuntimes) {
530
+ const group = runtime.group;
531
+ const window = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
532
+
533
+ gauges.push({
534
+ id: `group:${group.id}:target`,
535
+ label: `${group.id} targets`,
536
+ multiplier: runtime.pressureMultiplier,
537
+ max: MAX_TARGET_MULTIPLIER,
538
+ detail:
539
+ `${runtime.pendingTargets.length} pending ` +
540
+ `(${runtime.surfaceableTargets.length} surfaceable, ${runtime.blockedTargets.length} blocked) · ` +
541
+ `sinceSurfaced ${runtime.sessionsSinceSurfaced}/${window}`,
542
+ items: runtime.blockedTargets.map((cardId) => ({ label: cardId, value: 'blocked' })),
543
+ });
544
+
545
+ if (runtime.blockedTargets.length > 0) {
546
+ const mode =
547
+ runtime.supportCandidates.length > 0
548
+ ? 'direct-support'
549
+ : runtime.discoveredSupportCandidates.length > 0
550
+ ? 'inserted-support'
551
+ : 'boost-only';
552
+ gauges.push({
553
+ id: `group:${group.id}:support`,
554
+ label: `${group.id} support`,
555
+ multiplier: runtime.supportMultiplier,
556
+ max: MAX_SUPPORT_MULTIPLIER,
557
+ detail: `mode=${mode} · ${runtime.supportTags.length} support tag(s)`,
558
+ items: runtime.supportTags.map((tag) => ({ label: tag })),
559
+ });
560
+ }
561
+
562
+ const debts = practiceDebtsByGroup.get(group.id) ?? [];
563
+ if (debts.length > 0) {
564
+ gauges.push({
565
+ id: `group:${group.id}:practice-debt`,
566
+ label: `${group.id} practice debt`,
567
+ multiplier: Math.max(...debts.map((d) => d.multiplier)),
568
+ max: MAX_PRACTICE_MULTIPLIER,
569
+ detail: `${debts.length} under-practiced skill(s)`,
570
+ items: debts.map((d) => ({
571
+ label: d.tag,
572
+ value: `×${d.multiplier.toFixed(2)} · open ${formatAge(now - new Date(d.firstOwedAt).getTime())}`,
573
+ })),
574
+ });
575
+ }
576
+ }
577
+
578
+ captureStrategyPressure({
579
+ source: 'prescribed',
580
+ courseId,
581
+ gauges,
582
+ topScore: emitted.length > 0 ? Math.max(...emitted.map((c) => c.score)) : null,
583
+ hintsLabel: hints?._label,
584
+ timestamp: now,
585
+ });
586
+ }
587
+
484
588
  private parseConfig(serializedData: string): PrescribedConfig {
485
589
  try {
486
590
  const parsed = JSON.parse(serializedData) as { groups?: unknown[] };
@@ -693,6 +797,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
693
797
  supportTags: [...supportTags],
694
798
  pressureMultiplier,
695
799
  supportMultiplier,
800
+ sessionsSinceSurfaced,
696
801
  debugVersion: PRESCRIBED_DEBUG_VERSION,
697
802
  };
698
803
  }
@@ -875,7 +980,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
875
980
  seenIds: Set<string>;
876
981
  priorPracticeDebt: Record<string, string>;
877
982
  nextPracticeDebt: Record<string, string>;
878
- }): WeightedCard[] {
983
+ }): { cards: WeightedCard[]; debts: PracticeDebtDebug[] } {
879
984
  const {
880
985
  group,
881
986
  courseId,
@@ -891,7 +996,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
891
996
  } = args;
892
997
 
893
998
  const patterns = group.practiceTagPatterns ?? [];
894
- if (patterns.length === 0) return [];
999
+ if (patterns.length === 0) return { cards: [], debts: [] };
895
1000
 
896
1001
  const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
897
1002
  const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
@@ -903,7 +1008,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
903
1008
  (userTagElo[tag]?.count ?? 0) < practiceMinCount
904
1009
  );
905
1010
 
906
- if (practiceTags.length === 0) return [];
1011
+ if (practiceTags.length === 0) return { cards: [], debts: [] };
907
1012
 
908
1013
  // Carry forward (or open) each under-practiced skill's debt age, and derive
909
1014
  // its practice multiplier: base + staleness-since-first-owed, clamped. Done
@@ -924,6 +1029,16 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
924
1029
  tagMultiplier.set(tag, mult);
925
1030
  }
926
1031
 
1032
+ // Debug view of every open debt (built before the emission early-return so
1033
+ // the pressure channel sees debts even on runs that emit no drill cards).
1034
+ const debts: PracticeDebtDebug[] = practiceTags
1035
+ .map((tag) => ({
1036
+ tag,
1037
+ multiplier: tagMultiplier.get(tag) ?? PRACTICE_BASE_MULT,
1038
+ firstOwedAt: nextPracticeDebt[tag],
1039
+ }))
1040
+ .sort((a, b) => b.multiplier - a.multiplier || a.tag.localeCompare(b.tag));
1041
+
927
1042
  // Reuse the diversity-aware tag→cards collector (stem-dedup + shuffle).
928
1043
  const practiceCardIds = this.findDiscoveredSupportCards({
929
1044
  supportTags: practiceTags,
@@ -934,7 +1049,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
934
1049
  limit: maxPractice,
935
1050
  });
936
1051
 
937
- if (practiceCardIds.length === 0) return [];
1052
+ if (practiceCardIds.length === 0) return { cards: [], debts };
938
1053
 
939
1054
  logger.info(
940
1055
  `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced ` +
@@ -974,7 +1089,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
974
1089
  });
975
1090
  }
976
1091
 
977
- return cards;
1092
+ return { cards, debts };
978
1093
  }
979
1094
 
980
1095
  /**
@@ -33,6 +33,18 @@ export {
33
33
  type SrsBacklogDebug,
34
34
  } from './SrsDebugger';
35
35
 
36
+ // Re-export the generic strategy-pressure debug channel. `captureStrategyPressure`
37
+ // is part of the consumer surface: client-defined strategies (registered via
38
+ // registerNavigator) push their backpressure state through it for the live
39
+ // session overlay.
40
+ export {
41
+ captureStrategyPressure,
42
+ getStrategyPressureDebug,
43
+ clearStrategyPressureDebug,
44
+ type StrategyPressureDebug,
45
+ type PressureGaugeDebug,
46
+ } from './StrategyPressureDebugger';
47
+
36
48
  import { LearnableWeight } from '../types/contentNavigationStrategy';
37
49
  export type { ContentNavigationStrategyData, LearnableWeight } from '../types/contentNavigationStrategy';
38
50
  import type { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
@@ -15,7 +15,12 @@ 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, WeightedCard, getSrsBacklogDebug } from '@db/core/navigators';
18
+ import {
19
+ getCardOrigin,
20
+ WeightedCard,
21
+ getSrsBacklogDebug,
22
+ getStrategyPressureDebug,
23
+ } from '@db/core/navigators';
19
24
  import { ReplanHints } from '@db/core/navigators/generators/types';
20
25
  import { mergeHints } from '@db/core/navigators/Pipeline';
21
26
  import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
@@ -763,6 +768,7 @@ export class SessionController<TView = unknown> extends Loggable {
763
768
  supplyQ: describe(this.supplyQ),
764
769
  failedQ: describe(this.failedQ),
765
770
  reviewBacklog: getSrsBacklogDebug(),
771
+ strategyPressure: getStrategyPressureDebug(),
766
772
  drawnCards,
767
773
  };
768
774
  }
@@ -1,6 +1,7 @@
1
1
  import { logger } from '../util/logger';
2
2
  import { clearRunHistory as clearPipelineRunHistory } from '../core/navigators/PipelineDebugger';
3
3
  import { clearSrsBacklogDebug } from '../core/navigators/SrsDebugger';
4
+ import { clearStrategyPressureDebug } from '../core/navigators/StrategyPressureDebugger';
4
5
  import { toggleSessionOverlay } from './SessionOverlay';
5
6
 
6
7
  // ============================================================================
@@ -82,6 +83,7 @@ const MAX_HISTORY = 5;
82
83
  export function clearStaleSessionDebugState(): void {
83
84
  clearPipelineRunHistory();
84
85
  clearSrsBacklogDebug();
86
+ clearStrategyPressureDebug();
85
87
  }
86
88
 
87
89
  /**
@@ -1,6 +1,10 @@
1
1
  import { logger } from '../util/logger';
2
2
  import type { ReplanHints } from '@db/core/navigators/generators/types';
3
3
  import type { SrsBacklogDebug } from '@db/core/navigators/SrsDebugger';
4
+ import type {
5
+ StrategyPressureDebug,
6
+ PressureGaugeDebug,
7
+ } from '@db/core/navigators/StrategyPressureDebugger';
4
8
 
5
9
  // ============================================================================
6
10
  // SESSION OVERLAY
@@ -72,6 +76,8 @@ export interface SessionDebugSnapshot {
72
76
  failedQ: SessionQueueDebug;
73
77
  /** SRS backlog state per course (drives the "is review starvation permanent?" read). */
74
78
  reviewBacklog: SrsBacklogDebug[];
79
+ /** Per-strategy backpressure gauges (prescribed pressure, practice debt, ...). */
80
+ strategyPressure: StrategyPressureDebug[];
75
81
  /** Every card the learner has interacted with this session, draw order. */
76
82
  drawnCards: SessionDrawnCardDebug[];
77
83
  }
@@ -241,6 +247,7 @@ function render(): void {
241
247
  metaHtml(s) +
242
248
  hintsHtml(s.sessionHints) +
243
249
  backlogHtml(s.reviewBacklog) +
250
+ strategyPressureHtml(s.strategyPressure) +
244
251
  queueHtml('supplyQ', 'supplyQ', s.supplyQ) +
245
252
  queueHtml('failedQ', 'failedQ', s.failedQ) +
246
253
  drawnHtml('drawn', s.drawnCards);
@@ -416,6 +423,93 @@ function backlogHtml(backlog: SrsBacklogDebug[]): string {
416
423
  );
417
424
  }
418
425
 
426
+ /**
427
+ * Colour for a pressure multiplier. Green at ×1.0 (no pressure); when the
428
+ * gauge is capped, amber → red by fraction of headroom used; when unbounded,
429
+ * fall back to the SRS panel's absolute threshold (×3 = hot).
430
+ */
431
+ function pressureColor(multiplier: number, max?: number): string {
432
+ if (multiplier <= 1.001) return '#86efac';
433
+ if (max !== undefined && max > 1) {
434
+ return (multiplier - 1) / (max - 1) >= 0.75 ? '#fca5a5' : '#fcd34d';
435
+ }
436
+ return multiplier >= 3 ? '#fca5a5' : '#fcd34d';
437
+ }
438
+
439
+ /** data-q keys land in HTML attributes; strip anything outside a safe set. */
440
+ function toggleKey(raw: string): string {
441
+ return raw.replace(/[^\w:.|-]/g, '_');
442
+ }
443
+
444
+ /**
445
+ * One gauge row: `label ×2.50/8 — detail`, with an item list underneath that
446
+ * mirrors the queues' collapse behaviour (INLINE_THRESHOLD shown, "+N more"
447
+ * expands).
448
+ */
449
+ function gaugeHtml(sectionKey: string, g: PressureGaugeDebug): string {
450
+ const cap = g.max !== undefined ? `<span style="opacity:.5">/${g.max}</span>` : '';
451
+ const detail = g.detail ? ` <span style="opacity:.6">${esc(g.detail)}</span>` : '';
452
+ let html =
453
+ `<div style="margin-left:6px">${esc(g.label)} ` +
454
+ `<span style="color:${pressureColor(g.multiplier, g.max)}">×${g.multiplier.toFixed(2)}</span>` +
455
+ `${cap}${detail}`;
456
+
457
+ const items = g.items ?? [];
458
+ if (items.length > 0) {
459
+ const key = toggleKey(`sp|${sectionKey}|${g.id}`);
460
+ const isOpen = !!expanded[key];
461
+ const shown = isOpen ? items : items.slice(0, INLINE_THRESHOLD);
462
+ html +=
463
+ `<div style="margin-left:6px">` +
464
+ shown
465
+ .map(
466
+ (it) =>
467
+ `<div style="white-space:nowrap;opacity:.75">${esc(it.label)}` +
468
+ `${it.value ? ` <span style="opacity:.6">${esc(it.value)}</span>` : ''}</div>`
469
+ )
470
+ .join('');
471
+ if (items.length > INLINE_THRESHOLD) {
472
+ const footer = isOpen ? '▾ show less' : `… +${items.length - shown.length} more`;
473
+ html += `<div data-q="${key}" style="cursor:pointer;opacity:.6">${footer}</div>`;
474
+ }
475
+ html += `</div>`;
476
+ }
477
+
478
+ return html + `</div>`;
479
+ }
480
+
481
+ /**
482
+ * Strategy backpressure panel — the generic counterpart of the SRS panel
483
+ * above, fed by `captureStrategyPressure` pushes from navigation strategies
484
+ * (built-in prescribed, or consumer-registered). Per source: gauge rows with
485
+ * cap-aware colour, then a `top score` line that reads against the supplyQ
486
+ * head exactly like the SRS panel's `top review`.
487
+ */
488
+ function strategyPressureHtml(list: StrategyPressureDebug[]): string {
489
+ if (!list.length) return '';
490
+ const sections = list
491
+ .map((s) => {
492
+ const sectionKey = `${s.source}:${s.courseId.slice(0, 8)}`;
493
+ const top = s.topScore !== null && s.topScore !== undefined ? s.topScore.toFixed(2) : '—';
494
+ const hints = s.hintsLabel
495
+ ? `<div style="margin-left:6px;opacity:.6">hints: ${esc(s.hintsLabel)}</div>`
496
+ : '';
497
+ return (
498
+ `<div style="margin-left:6px">` +
499
+ `<span style="opacity:.7">${esc(s.source)} · ${esc(s.courseId.slice(0, 8))}</span>` +
500
+ ` <span style="opacity:.6">top score ${top}</span>` +
501
+ s.gauges.map((g) => gaugeHtml(sectionKey, g)).join('') +
502
+ hints +
503
+ `</div>`
504
+ );
505
+ })
506
+ .join('');
507
+ return (
508
+ `<div style="margin-bottom:6px">` +
509
+ `<div style="color:#fdba74">strategy backpressure</div>${sections}</div>`
510
+ );
511
+ }
512
+
419
513
  /** Format a rank score for display: finite → 2dp, `+INF` → REQ (required). */
420
514
  function fmtScore(score?: number): string {
421
515
  if (score === undefined) return '';
@@ -564,6 +658,25 @@ function snapshotToText(s: SessionDebugSnapshot | null): string {
564
658
  }
565
659
  }
566
660
 
661
+ if (s.strategyPressure.length) {
662
+ lines.push('');
663
+ lines.push('strategy backpressure:');
664
+ for (const sp of s.strategyPressure) {
665
+ const top = sp.topScore !== null && sp.topScore !== undefined ? sp.topScore.toFixed(2) : '—';
666
+ lines.push(` ${sp.source} (${sp.courseId.slice(0, 8)}): top score ${top}`);
667
+ for (const g of sp.gauges) {
668
+ const cap = g.max !== undefined ? `/${g.max}` : '';
669
+ lines.push(
670
+ ` ${g.label} ×${g.multiplier.toFixed(2)}${cap}${g.detail ? ` — ${g.detail}` : ''}`
671
+ );
672
+ for (const it of g.items ?? []) {
673
+ lines.push(` ${it.label}${it.value ? ` (${it.value})` : ''}`);
674
+ }
675
+ }
676
+ if (sp.hintsLabel) lines.push(` hints: ${sp.hintsLabel}`);
677
+ }
678
+ }
679
+
567
680
  const queueText = (label: string, q: SessionQueueDebug) => {
568
681
  lines.push('');
569
682
  lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
@@ -250,8 +250,16 @@ export class CardHydrationService<TView = unknown> {
250
250
 
251
251
  const data = dataDocs.map(displayableDataToViewData).reverse();
252
252
 
253
+ // Attach the card doc's authored ELO to the item. Generators don't carry
254
+ // card ELO onto StudySessionItems (reviews couldn't know it without a
255
+ // lookup), so without this every downstream consumer of `item.elo` —
256
+ // StudySession's `card_elo`, CardViewer's modify-difficulty, session
257
+ // outcomes — fell back to the 1000 default for EVERY card. The doc is
258
+ // already fetched here, making this the one seam that covers all card
259
+ // origins for free. Copy rather than mutate: the queue holds the same
260
+ // item reference.
253
261
  this.hydratedCards.set(item.cardID, {
254
- item,
262
+ item: { ...item, elo: toCourseElo(cardData.elo).global.score },
255
263
  view,
256
264
  data,
257
265
  tags: tagsByCard.get(item.cardID) ?? [],