@vue-skuilder/db 0.1.23 → 0.1.25

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 (80) hide show
  1. package/dist/{contentSource-BP9hznNV.d.ts → contentSource-BmnmvH8C.d.ts} +268 -3
  2. package/dist/{contentSource-DsJadoBU.d.cts → contentSource-DfBbaLA-.d.cts} +268 -3
  3. package/dist/core/index.d.cts +310 -6
  4. package/dist/core/index.d.ts +310 -6
  5. package/dist/core/index.js +2606 -666
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +2564 -639
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-CHYrQ5pB.d.cts → dataLayerProvider-BeRXVMs5.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-MDTxXq2l.d.ts → dataLayerProvider-CG9GfaAY.d.ts} +1 -1
  11. package/dist/impl/couch/index.d.cts +11 -3
  12. package/dist/impl/couch/index.d.ts +11 -3
  13. package/dist/impl/couch/index.js +2336 -656
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +2316 -631
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +4 -4
  18. package/dist/impl/static/index.d.ts +4 -4
  19. package/dist/impl/static/index.js +2312 -632
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +2315 -630
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-Dj0SEgk3.d.ts → index-BWvO-_rJ.d.ts} +1 -1
  24. package/dist/{index-B_j6u5E4.d.cts → index-Ba7hYbHj.d.cts} +1 -1
  25. package/dist/index.d.cts +278 -20
  26. package/dist/index.d.ts +278 -20
  27. package/dist/index.js +3603 -720
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +3529 -674
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/{types-DQaXnuoc.d.ts → types-CJrLM1Ew.d.ts} +1 -1
  32. package/dist/{types-Bn0itutr.d.cts → types-W8n-B6HG.d.cts} +1 -1
  33. package/dist/{types-legacy-DDY4N-Uq.d.cts → types-legacy-JXDxinpU.d.cts} +5 -1
  34. package/dist/{types-legacy-DDY4N-Uq.d.ts → types-legacy-JXDxinpU.d.ts} +5 -1
  35. package/dist/util/packer/index.d.cts +3 -3
  36. package/dist/util/packer/index.d.ts +3 -3
  37. package/docs/brainstorm-navigation-paradigm.md +40 -34
  38. package/docs/future-orchestration-vision.md +216 -0
  39. package/docs/navigators-architecture.md +210 -9
  40. package/docs/todo-review-urgency-adaptation.md +205 -0
  41. package/docs/todo-strategy-authoring.md +8 -6
  42. package/package.json +3 -3
  43. package/src/core/index.ts +2 -0
  44. package/src/core/interfaces/contentSource.ts +7 -0
  45. package/src/core/interfaces/userDB.ts +50 -0
  46. package/src/core/navigators/Pipeline.ts +132 -5
  47. package/src/core/navigators/PipelineAssembler.ts +21 -22
  48. package/src/core/navigators/PipelineDebugger.ts +426 -0
  49. package/src/core/navigators/filters/WeightedFilter.ts +141 -0
  50. package/src/core/navigators/filters/types.ts +4 -0
  51. package/src/core/navigators/generators/CompositeGenerator.ts +82 -19
  52. package/src/core/navigators/generators/elo.ts +14 -1
  53. package/src/core/navigators/generators/srs.ts +146 -18
  54. package/src/core/navigators/generators/types.ts +4 -0
  55. package/src/core/navigators/index.ts +203 -13
  56. package/src/core/orchestration/gradient.ts +133 -0
  57. package/src/core/orchestration/index.ts +210 -0
  58. package/src/core/orchestration/learning.ts +250 -0
  59. package/src/core/orchestration/recording.ts +92 -0
  60. package/src/core/orchestration/signal.ts +67 -0
  61. package/src/core/types/contentNavigationStrategy.ts +38 -0
  62. package/src/core/types/learningState.ts +77 -0
  63. package/src/core/types/types-legacy.ts +4 -0
  64. package/src/core/types/userOutcome.ts +51 -0
  65. package/src/courseConfigRegistration.ts +107 -0
  66. package/src/factory.ts +6 -0
  67. package/src/impl/common/BaseUserDB.ts +16 -0
  68. package/src/impl/couch/user-course-relDB.ts +12 -0
  69. package/src/study/MixerDebugger.ts +555 -0
  70. package/src/study/SessionController.ts +159 -20
  71. package/src/study/SessionDebugger.ts +442 -0
  72. package/src/study/SourceMixer.ts +36 -17
  73. package/src/study/TODO-session-scheduling.md +133 -0
  74. package/src/study/index.ts +2 -0
  75. package/src/study/services/EloService.ts +79 -4
  76. package/src/study/services/ResponseProcessor.ts +130 -72
  77. package/src/study/services/SrsService.ts +9 -0
  78. package/tests/core/navigators/Pipeline.test.ts +2 -0
  79. package/tests/core/navigators/PipelineAssembler.test.ts +4 -4
  80. package/docs/todo-evolutionary-orchestration.md +0 -310
@@ -12,10 +12,13 @@ import {
12
12
  StudySessionReviewItem,
13
13
  } from '@db/impl/couch';
14
14
 
15
- import { CardRecord, CardHistory, CourseRegistrationDoc } from '@db/core';
15
+ import { CardRecord, CardHistory, CourseRegistrationDoc, QuestionRecord } from '@db/core';
16
+ import { recordUserOutcome } from '@db/core/orchestration/recording';
16
17
  import { Loggable } from '@db/util';
17
18
  import { getCardOrigin } from '@db/core/navigators';
18
19
  import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
20
+ import { captureMixerRun } from './MixerDebugger';
21
+ import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
19
22
 
20
23
  export interface StudySessionRecord {
21
24
  card: {
@@ -61,6 +64,8 @@ export class SessionController<TView = unknown> extends Loggable {
61
64
  private eloService: EloService;
62
65
  private hydrationService: CardHydrationService<TView>;
63
66
  private mixer: SourceMixer;
67
+ private dataLayer: DataLayerProvider;
68
+ private courseNameCache: Map<string, string> = new Map();
64
69
 
65
70
  private sources: StudyContentSource[];
66
71
  // dataLayer and getViewComponent now injected into CardHydrationService
@@ -84,7 +89,11 @@ export class SessionController<TView = unknown> extends Loggable {
84
89
  return this._secondsRemaining;
85
90
  }
86
91
  public get report(): string {
87
- return `${this.reviewQ.dequeueCount} reviews, ${this.newQ.dequeueCount} new cards`;
92
+ const reviewCount = this.reviewQ.dequeueCount;
93
+ const newCount = this.newQ.dequeueCount;
94
+ const reviewWord = reviewCount === 1 ? 'review' : 'reviews';
95
+ const newCardWord = newCount === 1 ? 'new card' : 'new cards';
96
+ return `${reviewCount} ${reviewWord}, ${newCount} ${newCardWord}`;
88
97
  }
89
98
  public get detailedReport(): string {
90
99
  return this.newQ.toString + '\n' + this.reviewQ.toString + '\n' + this.failedQ.toString;
@@ -108,6 +117,7 @@ export class SessionController<TView = unknown> extends Loggable {
108
117
  ) {
109
118
  super();
110
119
 
120
+ this.dataLayer = dataLayer;
111
121
  this.mixer = mixer || new QuotaRoundRobinMixer();
112
122
  this.srsService = new SrsService(dataLayer.getUserDB());
113
123
  this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
@@ -193,6 +203,9 @@ export class SessionController<TView = unknown> extends Loggable {
193
203
  await this.getWeightedContent();
194
204
  await this.hydrationService.ensureHydratedCards();
195
205
 
206
+ // Start session tracking for debugging
207
+ startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
208
+
196
209
  this._intervalHandle = setInterval(() => {
197
210
  this.tick();
198
211
  }, 1000);
@@ -310,6 +323,35 @@ export class SessionController<TView = unknown> extends Loggable {
310
323
  // Mix weighted cards across sources using configured strategy
311
324
  const mixedWeighted = this.mixer.mix(batches, limit * this.sources.length);
312
325
 
326
+ // Capture mixer run for debugging - fetch course names
327
+ const sourceIds = batches.map((b) => {
328
+ const firstCard = b.weighted[0];
329
+ return firstCard?.courseId || `source-${b.sourceIndex}`;
330
+ });
331
+ // Populate course name cache (one-time fetch, reused by SessionDebugger)
332
+ await Promise.all(
333
+ sourceIds.map(async (id) => {
334
+ try {
335
+ const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
336
+ this.courseNameCache.set(id, config.name);
337
+ } catch {
338
+ // leave unmapped
339
+ }
340
+ })
341
+ );
342
+ const sourceNames = sourceIds.map((id) => this.courseNameCache.get(id));
343
+ const quotaPerSource =
344
+ this.mixer instanceof QuotaRoundRobinMixer ? Math.ceil((limit * this.sources.length) / batches.length) : undefined;
345
+ captureMixerRun(
346
+ this.mixer.constructor.name,
347
+ batches,
348
+ sourceIds,
349
+ sourceNames,
350
+ limit * this.sources.length,
351
+ quotaPerSource,
352
+ mixedWeighted
353
+ );
354
+
313
355
  // Split mixed results by card origin
314
356
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === 'review');
315
357
  const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === 'new');
@@ -453,32 +495,67 @@ export class SessionController<TView = unknown> extends Loggable {
453
495
 
454
496
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
455
497
  this._currentCard = null;
498
+ endSessionTracking();
456
499
  return null;
457
500
  }
458
501
 
459
- // Get what SessionController thinks should be next
460
- const nextItem = this._selectNextItemToHydrate();
461
- if (!nextItem) {
462
- this._currentCard = null;
463
- return null;
464
- }
502
+ // Try multiple cards in case some fail hydration (e.g., deleted from DB)
503
+ const MAX_SKIP = 20;
504
+ for (let attempt = 0; attempt < MAX_SKIP; attempt++) {
505
+ const nextItem = this._selectNextItemToHydrate();
506
+ if (!nextItem) {
507
+ this._currentCard = null;
508
+ endSessionTracking();
509
+ return null;
510
+ }
465
511
 
466
- // Look up in hydration cache
467
- let card = this.hydrationService.getHydratedCard(nextItem.cardID);
512
+ // Look up in hydration cache
513
+ let card = this.hydrationService.getHydratedCard(nextItem.cardID);
468
514
 
469
- // If not ready, wait for it
470
- if (!card) {
471
- card = await this.hydrationService.waitForCard(nextItem.cardID);
472
- }
515
+ // If not ready, wait for it
516
+ if (!card) {
517
+ card = await this.hydrationService.waitForCard(nextItem.cardID);
518
+ }
473
519
 
474
- // Remove from source queue now that we're consuming it
475
- this.removeItemFromQueue(nextItem);
520
+ // Remove from source queue now that we're consuming it
521
+ this.removeItemFromQueue(nextItem);
476
522
 
477
- // Trigger background hydration to maintain cache (async, non-blocking)
478
- await this.hydrationService.ensureHydratedCards();
523
+ if (card) {
524
+ // Trigger background hydration to maintain cache (async, non-blocking)
525
+ await this.hydrationService.ensureHydratedCards();
526
+ this._currentCard = card;
527
+
528
+ // Record presentation for debugging
529
+ const origin = nextItem.status === 'review' || nextItem.status === 'failed-review' ? 'review' :
530
+ nextItem.status === 'new' || nextItem.status === 'failed-new' ? 'new' : 'failed';
531
+ const queueSource = nextItem.status.startsWith('failed') ? 'failedQ' :
532
+ (nextItem.status === 'review' ? 'reviewQ' : 'newQ');
533
+
534
+ recordCardPresentation(
535
+ nextItem.cardID,
536
+ nextItem.courseID,
537
+ this.courseNameCache.get(nextItem.courseID),
538
+ origin,
539
+ queueSource as 'reviewQ' | 'newQ' | 'failedQ'
540
+ );
541
+
542
+ // Snapshot queue state
543
+ snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
479
544
 
480
- this._currentCard = card;
481
- return card;
545
+ return card;
546
+ }
547
+
548
+ // Card failed hydration (deleted from DB?) — skip and clean up
549
+ this.log(`Skipping card ${nextItem.cardID}: hydration failed, trying next`);
550
+ if (isReview(nextItem)) {
551
+ this.srsService.removeReview(nextItem.reviewID);
552
+ }
553
+ }
554
+
555
+ this.log(`Exhausted ${MAX_SKIP} skip attempts finding a hydratable card`);
556
+ this._currentCard = null;
557
+ endSessionTracking();
558
+ return null;
482
559
  }
483
560
 
484
561
  /**
@@ -587,4 +664,66 @@ export class SessionController<TView = unknown> extends Loggable {
587
664
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
588
665
  }
589
666
  }
667
+
668
+ /**
669
+ * End the session and record learning outcomes.
670
+ *
671
+ * This method aggregates all responses from the session and records a
672
+ * UserOutcomeRecord if evolutionary orchestration is enabled.
673
+ */
674
+ public async endSession(): Promise<void> {
675
+ if (!this._sessionRecord || this._sessionRecord.length === 0) {
676
+ return;
677
+ }
678
+
679
+ const questionRecords = this._sessionRecord
680
+ .flatMap((r) => r.records)
681
+ .filter((r): r is QuestionRecord => (r as any).userAnswer !== undefined);
682
+
683
+ if (questionRecords.length === 0) {
684
+ return;
685
+ }
686
+
687
+ // We need to access the orchestration context.
688
+ // Ideally this would be passed in or available via services.
689
+ // For now, we'll try to get it from one of the content sources if possible,
690
+ // or skip if we can't access it.
691
+
692
+ // Try to find a source that supports orchestration
693
+ let orchestrationContext = null;
694
+ const strategies: string[] = [];
695
+
696
+ for (const source of this.sources) {
697
+ if (source.getOrchestrationContext) {
698
+ try {
699
+ orchestrationContext = await source.getOrchestrationContext();
700
+ // Also try to get strategy IDs if available on the source (e.g. Pipeline)
701
+ if ((source as any).getStrategyIds) {
702
+ strategies.push(...(source as any).getStrategyIds());
703
+ }
704
+ } catch (e) {
705
+ logger.warn(`[SessionController] Failed to get orchestration context: ${e}`);
706
+ }
707
+ if (orchestrationContext) break;
708
+ }
709
+ }
710
+
711
+ if (!orchestrationContext) {
712
+ logger.debug('[SessionController] No orchestration context available, skipping outcome recording');
713
+ return;
714
+ }
715
+
716
+ // Use current time as period end
717
+ const periodEnd = new Date().toISOString();
718
+ // Use session start time as period start
719
+ const periodStart = new Date(this.startTime).toISOString();
720
+
721
+ await recordUserOutcome(
722
+ orchestrationContext,
723
+ periodStart,
724
+ periodEnd,
725
+ questionRecords,
726
+ strategies
727
+ );
728
+ }
590
729
  }
@@ -0,0 +1,442 @@
1
+ import { logger } from '../util/logger';
2
+
3
+ // ============================================================================
4
+ // SESSION DEBUGGER
5
+ // ============================================================================
6
+ //
7
+ // Console-accessible debug API for inspecting session runtime behavior.
8
+ //
9
+ // Exposed as `window.skuilder.session` for interactive exploration.
10
+ //
11
+ // Usage:
12
+ // window.skuilder.session.showQueue()
13
+ // window.skuilder.session.showHistory()
14
+ // window.skuilder.session.showInterleaving()
15
+ // window.skuilder.session.export()
16
+ //
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Snapshot of queue state at a given moment.
21
+ */
22
+ export interface QueueSnapshot {
23
+ timestamp: Date;
24
+ reviewQLength: number;
25
+ newQLength: number;
26
+ failedQLength: number;
27
+ reviewQNext3?: string[]; // cardIds of next 3 in reviewQ
28
+ newQNext3?: string[]; // cardIds of next 3 in newQ
29
+ }
30
+
31
+ /**
32
+ * Record of a single card presentation.
33
+ */
34
+ export interface CardPresentation {
35
+ timestamp: Date;
36
+ sequenceNumber: number; // 1-indexed position in session
37
+ cardId: string;
38
+ courseId: string;
39
+ courseName?: string;
40
+ origin: 'review' | 'new' | 'failed';
41
+ queueSource: 'reviewQ' | 'newQ' | 'failedQ';
42
+ score?: number; // If available from weighted cards
43
+ }
44
+
45
+ /**
46
+ * Complete session execution record.
47
+ */
48
+ export interface SessionRunReport {
49
+ sessionId: string;
50
+ startTime: Date;
51
+ endTime?: Date;
52
+
53
+ // Initial state
54
+ initialQueues: QueueSnapshot;
55
+
56
+ // Card presentations in order
57
+ presentations: CardPresentation[];
58
+
59
+ // Queue snapshots at various points
60
+ queueSnapshots: QueueSnapshot[];
61
+ }
62
+
63
+ /**
64
+ * Active session state.
65
+ */
66
+ let activeSession: SessionRunReport | null = null;
67
+ const sessionHistory: SessionRunReport[] = [];
68
+ const MAX_HISTORY = 5;
69
+
70
+ /**
71
+ * Start tracking a new session.
72
+ */
73
+ export function startSessionTracking(
74
+ reviewQLength: number,
75
+ newQLength: number,
76
+ failedQLength: number
77
+ ): void {
78
+ const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
79
+
80
+ activeSession = {
81
+ sessionId,
82
+ startTime: new Date(),
83
+ initialQueues: {
84
+ timestamp: new Date(),
85
+ reviewQLength,
86
+ newQLength,
87
+ failedQLength,
88
+ },
89
+ presentations: [],
90
+ queueSnapshots: [],
91
+ };
92
+
93
+ logger.debug(`[SessionDebugger] Started tracking session: ${sessionId}`);
94
+ }
95
+
96
+ /**
97
+ * Record a card presentation.
98
+ */
99
+ export function recordCardPresentation(
100
+ cardId: string,
101
+ courseId: string,
102
+ courseName: string | undefined,
103
+ origin: 'review' | 'new' | 'failed',
104
+ queueSource: 'reviewQ' | 'newQ' | 'failedQ',
105
+ score?: number
106
+ ): void {
107
+ if (!activeSession) {
108
+ logger.warn('[SessionDebugger] No active session to record presentation');
109
+ return;
110
+ }
111
+
112
+ activeSession.presentations.push({
113
+ timestamp: new Date(),
114
+ sequenceNumber: activeSession.presentations.length + 1,
115
+ cardId,
116
+ courseId,
117
+ courseName,
118
+ origin,
119
+ queueSource,
120
+ score,
121
+ });
122
+ }
123
+
124
+ /**
125
+ * Take a snapshot of current queue state.
126
+ */
127
+ export function snapshotQueues(
128
+ reviewQLength: number,
129
+ newQLength: number,
130
+ failedQLength: number,
131
+ reviewQNext3?: string[],
132
+ newQNext3?: string[]
133
+ ): void {
134
+ if (!activeSession) {
135
+ return;
136
+ }
137
+
138
+ activeSession.queueSnapshots.push({
139
+ timestamp: new Date(),
140
+ reviewQLength,
141
+ newQLength,
142
+ failedQLength,
143
+ reviewQNext3,
144
+ newQNext3,
145
+ });
146
+ }
147
+
148
+ /**
149
+ * End the current session tracking.
150
+ */
151
+ export function endSessionTracking(): void {
152
+ if (!activeSession) {
153
+ return;
154
+ }
155
+
156
+ activeSession.endTime = new Date();
157
+
158
+ // Add to history
159
+ sessionHistory.unshift(activeSession);
160
+ if (sessionHistory.length > MAX_HISTORY) {
161
+ sessionHistory.pop();
162
+ }
163
+
164
+ logger.debug(`[SessionDebugger] Ended session: ${activeSession.sessionId}`);
165
+ activeSession = null;
166
+ }
167
+
168
+ // ============================================================================
169
+ // CONSOLE API
170
+ // ============================================================================
171
+
172
+ /**
173
+ * Show current queue state (if session active).
174
+ */
175
+ function showCurrentQueue(): void {
176
+ if (!activeSession) {
177
+ logger.info('[Session Debug] No active session.');
178
+ return;
179
+ }
180
+
181
+ const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
182
+
183
+ // eslint-disable-next-line no-console
184
+ console.group('📊 Current Queue State');
185
+ logger.info(`Review Queue: ${latest.reviewQLength} cards`);
186
+ if (latest.reviewQNext3 && latest.reviewQNext3.length > 0) {
187
+ logger.info(` Next: ${latest.reviewQNext3.join(', ')}`);
188
+ }
189
+ logger.info(`New Queue: ${latest.newQLength} cards`);
190
+ if (latest.newQNext3 && latest.newQNext3.length > 0) {
191
+ logger.info(` Next: ${latest.newQNext3.join(', ')}`);
192
+ }
193
+ logger.info(`Failed Queue: ${latest.failedQLength} cards`);
194
+ // eslint-disable-next-line no-console
195
+ console.groupEnd();
196
+ }
197
+
198
+ /**
199
+ * Show presentation history for current or last session.
200
+ */
201
+ function showPresentationHistory(sessionIndex: number = 0): void {
202
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
203
+
204
+ if (!session) {
205
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
206
+ return;
207
+ }
208
+
209
+ // eslint-disable-next-line no-console
210
+ console.group(`📜 Session History: ${session.sessionId}`);
211
+ logger.info(`Started: ${session.startTime.toLocaleTimeString()}`);
212
+ if (session.endTime) {
213
+ logger.info(`Ended: ${session.endTime.toLocaleTimeString()}`);
214
+ }
215
+ logger.info(`Cards presented: ${session.presentations.length}`);
216
+
217
+ if (session.presentations.length > 0) {
218
+ // eslint-disable-next-line no-console
219
+ console.table(
220
+ session.presentations.map((p) => ({
221
+ '#': p.sequenceNumber,
222
+ course: p.courseName || p.courseId.slice(0, 8),
223
+ origin: p.origin,
224
+ queue: p.queueSource,
225
+ score: p.score?.toFixed(3) || '-',
226
+ time: p.timestamp.toLocaleTimeString(),
227
+ }))
228
+ );
229
+ }
230
+
231
+ // eslint-disable-next-line no-console
232
+ console.groupEnd();
233
+ }
234
+
235
+ /**
236
+ * Analyze course interleaving pattern.
237
+ */
238
+ function showInterleaving(sessionIndex: number = 0): void {
239
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
240
+
241
+ if (!session) {
242
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
243
+ return;
244
+ }
245
+
246
+ // eslint-disable-next-line no-console
247
+ console.group('🔀 Interleaving Analysis');
248
+
249
+ // Course distribution
250
+ const courseCounts = new Map<string, number>();
251
+ const courseOrigins = new Map<string, { review: number; new: number; failed: number }>();
252
+
253
+ session.presentations.forEach((p) => {
254
+ const name = p.courseName || p.courseId;
255
+ courseCounts.set(name, (courseCounts.get(name) || 0) + 1);
256
+
257
+ if (!courseOrigins.has(name)) {
258
+ courseOrigins.set(name, { review: 0, new: 0, failed: 0 });
259
+ }
260
+ const origins = courseOrigins.get(name)!;
261
+ origins[p.origin]++;
262
+ });
263
+
264
+ logger.info('Course distribution:');
265
+ // eslint-disable-next-line no-console
266
+ console.table(
267
+ Array.from(courseCounts.entries()).map(([course, count]) => {
268
+ const origins = courseOrigins.get(course)!;
269
+ return {
270
+ course,
271
+ total: count,
272
+ reviews: origins.review,
273
+ new: origins.new,
274
+ failed: origins.failed,
275
+ percentage: ((count / session.presentations.length) * 100).toFixed(1) + '%',
276
+ };
277
+ })
278
+ );
279
+
280
+ // Show interleaving pattern (first 20 cards)
281
+ if (session.presentations.length > 0) {
282
+ logger.info('\nPresentation sequence (first 20):');
283
+ const sequence = session.presentations
284
+ .slice(0, 20)
285
+ .map((p, idx) => `${idx + 1}. ${p.courseName || p.courseId.slice(0, 8)} (${p.origin})`)
286
+ .join('\n');
287
+ logger.info(sequence);
288
+ }
289
+
290
+ // Detect clustering (same course in a row)
291
+ let maxCluster = 0;
292
+ let currentCluster = 1;
293
+ let currentCourse = session.presentations[0]?.courseId;
294
+
295
+ for (let i = 1; i < session.presentations.length; i++) {
296
+ if (session.presentations[i].courseId === currentCourse) {
297
+ currentCluster++;
298
+ maxCluster = Math.max(maxCluster, currentCluster);
299
+ } else {
300
+ currentCourse = session.presentations[i].courseId;
301
+ currentCluster = 1;
302
+ }
303
+ }
304
+
305
+ if (maxCluster > 3) {
306
+ logger.info(`\n⚠️ Detected clustering: max ${maxCluster} cards from same course in a row`);
307
+ logger.info('This suggests cards are sorted by score rather than round-robin by course.');
308
+ }
309
+
310
+ // eslint-disable-next-line no-console
311
+ console.groupEnd();
312
+ }
313
+
314
+ /**
315
+ * Console API object exposed on window.skuilder.session
316
+ */
317
+ export const sessionDebugAPI = {
318
+ /**
319
+ * Get raw session history for programmatic access.
320
+ */
321
+ get sessions(): SessionRunReport[] {
322
+ return [...sessionHistory];
323
+ },
324
+
325
+ /**
326
+ * Get active session if any.
327
+ */
328
+ get active(): SessionRunReport | null {
329
+ return activeSession;
330
+ },
331
+
332
+ /**
333
+ * Show current queue state.
334
+ */
335
+ showQueue(): void {
336
+ showCurrentQueue();
337
+ },
338
+
339
+ /**
340
+ * Show presentation history for current or past session.
341
+ */
342
+ showHistory(sessionIndex: number = 0): void {
343
+ showPresentationHistory(sessionIndex);
344
+ },
345
+
346
+ /**
347
+ * Analyze course interleaving pattern.
348
+ */
349
+ showInterleaving(sessionIndex: number = 0): void {
350
+ showInterleaving(sessionIndex);
351
+ },
352
+
353
+ /**
354
+ * List all tracked sessions.
355
+ */
356
+ listSessions(): void {
357
+ if (activeSession) {
358
+ logger.info(`Active session: ${activeSession.sessionId} (${activeSession.presentations.length} cards presented)`);
359
+ }
360
+
361
+ if (sessionHistory.length === 0) {
362
+ logger.info('[Session Debug] No completed sessions in history.');
363
+ return;
364
+ }
365
+
366
+ // eslint-disable-next-line no-console
367
+ console.table(
368
+ sessionHistory.map((s, idx) => ({
369
+ index: idx,
370
+ id: s.sessionId.slice(-8),
371
+ started: s.startTime.toLocaleTimeString(),
372
+ ended: s.endTime?.toLocaleTimeString() || 'incomplete',
373
+ cards: s.presentations.length,
374
+ }))
375
+ );
376
+ },
377
+
378
+ /**
379
+ * Export session history as JSON for bug reports.
380
+ */
381
+ export(): string {
382
+ const data = {
383
+ active: activeSession,
384
+ history: sessionHistory,
385
+ };
386
+ const json = JSON.stringify(data, null, 2);
387
+ logger.info('[Session Debug] Session data exported. Copy the returned string or use:');
388
+ logger.info(' copy(window.skuilder.session.export())');
389
+ return json;
390
+ },
391
+
392
+ /**
393
+ * Clear session history.
394
+ */
395
+ clear(): void {
396
+ sessionHistory.length = 0;
397
+ logger.info('[Session Debug] Session history cleared.');
398
+ },
399
+
400
+ /**
401
+ * Show help.
402
+ */
403
+ help(): void {
404
+ logger.info(`
405
+ 🎯 Session Debug API
406
+
407
+ Commands:
408
+ .showQueue() Show current queue state (active session only)
409
+ .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
410
+ .showInterleaving(index?) Analyze course interleaving pattern
411
+ .listSessions() List all tracked sessions
412
+ .export() Export session data as JSON for bug reports
413
+ .clear() Clear session history
414
+ .sessions Access raw session history array
415
+ .active Access active session (if any)
416
+ .help() Show this help message
417
+
418
+ Example:
419
+ window.skuilder.session.showHistory()
420
+ window.skuilder.session.showInterleaving()
421
+ window.skuilder.session.showQueue()
422
+ `);
423
+ },
424
+ };
425
+
426
+ // ============================================================================
427
+ // WINDOW MOUNT
428
+ // ============================================================================
429
+
430
+ /**
431
+ * Mount the debug API on window.skuilder.session
432
+ */
433
+ export function mountSessionDebugger(): void {
434
+ if (typeof window === 'undefined') return;
435
+
436
+ const win = window as any;
437
+ win.skuilder = win.skuilder || {};
438
+ win.skuilder.session = sessionDebugAPI;
439
+ }
440
+
441
+ // Auto-mount when module is loaded
442
+ mountSessionDebugger();