@vue-skuilder/db 0.2.16 → 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/dist/core/index.d.cts +48 -1
- package/dist/core/index.d.ts +48 -1
- package/dist/core/index.js +112 -6
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +109 -6
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.js +106 -6
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +106 -6
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.js +106 -6
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +106 -6
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +5 -3
- package/dist/index.d.ts +5 -3
- package/dist/index.js +174 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +171 -7
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/navigators/StrategyPressureDebugger.ts +76 -0
- package/src/core/navigators/generators/prescribed.ts +122 -7
- package/src/core/navigators/index.ts +12 -0
- package/src/study/SessionController.ts +7 -1
- package/src/study/SessionDebugger.ts +2 -0
- package/src/study/SessionOverlay.ts +113 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.2.
|
|
7
|
+
"version": "0.2.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.
|
|
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.
|
|
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
|
|
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, ...
|
|
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 {
|
|
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})`);
|