@vue-skuilder/db 0.1.24 → 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 (50) hide show
  1. package/dist/{contentSource-BotbOOfX.d.ts → contentSource-BmnmvH8C.d.ts} +41 -0
  2. package/dist/{contentSource-C90LH-OH.d.cts → contentSource-DfBbaLA-.d.cts} +41 -0
  3. package/dist/core/index.d.cts +94 -4
  4. package/dist/core/index.d.ts +94 -4
  5. package/dist/core/index.js +530 -83
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +528 -83
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-DGKp4zFB.d.cts → dataLayerProvider-BeRXVMs5.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-SBpz9jQf.d.ts → dataLayerProvider-CG9GfaAY.d.ts} +1 -1
  11. package/dist/impl/couch/index.d.cts +2 -2
  12. package/dist/impl/couch/index.d.ts +2 -2
  13. package/dist/impl/couch/index.js +526 -83
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +526 -83
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +2 -2
  18. package/dist/impl/static/index.d.ts +2 -2
  19. package/dist/impl/static/index.js +526 -83
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +526 -83
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index.d.cts +247 -14
  24. package/dist/index.d.ts +247 -14
  25. package/dist/index.js +1419 -140
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +1409 -137
  28. package/dist/index.mjs.map +1 -1
  29. package/docs/navigators-architecture.md +22 -4
  30. package/docs/todo-review-urgency-adaptation.md +205 -0
  31. package/package.json +3 -3
  32. package/src/core/interfaces/userDB.ts +44 -0
  33. package/src/core/navigators/Pipeline.ts +86 -5
  34. package/src/core/navigators/PipelineAssembler.ts +7 -21
  35. package/src/core/navigators/PipelineDebugger.ts +426 -0
  36. package/src/core/navigators/generators/CompositeGenerator.ts +21 -0
  37. package/src/core/navigators/generators/elo.ts +14 -1
  38. package/src/core/navigators/generators/srs.ts +146 -18
  39. package/src/core/navigators/index.ts +9 -0
  40. package/src/impl/couch/user-course-relDB.ts +12 -0
  41. package/src/study/MixerDebugger.ts +555 -0
  42. package/src/study/SessionController.ts +95 -19
  43. package/src/study/SessionDebugger.ts +442 -0
  44. package/src/study/SourceMixer.ts +36 -17
  45. package/src/study/TODO-session-scheduling.md +133 -0
  46. package/src/study/index.ts +2 -0
  47. package/src/study/services/EloService.ts +79 -4
  48. package/src/study/services/ResponseProcessor.ts +130 -72
  49. package/src/study/services/SrsService.ts +9 -0
  50. package/tests/core/navigators/PipelineAssembler.test.ts +4 -4
@@ -17,6 +17,8 @@ import { recordUserOutcome } from '@db/core/orchestration/recording';
17
17
  import { Loggable } from '@db/util';
18
18
  import { getCardOrigin } from '@db/core/navigators';
19
19
  import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
20
+ import { captureMixerRun } from './MixerDebugger';
21
+ import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
20
22
 
21
23
  export interface StudySessionRecord {
22
24
  card: {
@@ -62,6 +64,8 @@ export class SessionController<TView = unknown> extends Loggable {
62
64
  private eloService: EloService;
63
65
  private hydrationService: CardHydrationService<TView>;
64
66
  private mixer: SourceMixer;
67
+ private dataLayer: DataLayerProvider;
68
+ private courseNameCache: Map<string, string> = new Map();
65
69
 
66
70
  private sources: StudyContentSource[];
67
71
  // dataLayer and getViewComponent now injected into CardHydrationService
@@ -85,7 +89,11 @@ export class SessionController<TView = unknown> extends Loggable {
85
89
  return this._secondsRemaining;
86
90
  }
87
91
  public get report(): string {
88
- 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}`;
89
97
  }
90
98
  public get detailedReport(): string {
91
99
  return this.newQ.toString + '\n' + this.reviewQ.toString + '\n' + this.failedQ.toString;
@@ -109,6 +117,7 @@ export class SessionController<TView = unknown> extends Loggable {
109
117
  ) {
110
118
  super();
111
119
 
120
+ this.dataLayer = dataLayer;
112
121
  this.mixer = mixer || new QuotaRoundRobinMixer();
113
122
  this.srsService = new SrsService(dataLayer.getUserDB());
114
123
  this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
@@ -194,6 +203,9 @@ export class SessionController<TView = unknown> extends Loggable {
194
203
  await this.getWeightedContent();
195
204
  await this.hydrationService.ensureHydratedCards();
196
205
 
206
+ // Start session tracking for debugging
207
+ startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
208
+
197
209
  this._intervalHandle = setInterval(() => {
198
210
  this.tick();
199
211
  }, 1000);
@@ -311,6 +323,35 @@ export class SessionController<TView = unknown> extends Loggable {
311
323
  // Mix weighted cards across sources using configured strategy
312
324
  const mixedWeighted = this.mixer.mix(batches, limit * this.sources.length);
313
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
+
314
355
  // Split mixed results by card origin
315
356
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === 'review');
316
357
  const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === 'new');
@@ -454,32 +495,67 @@ export class SessionController<TView = unknown> extends Loggable {
454
495
 
455
496
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
456
497
  this._currentCard = null;
498
+ endSessionTracking();
457
499
  return null;
458
500
  }
459
501
 
460
- // Get what SessionController thinks should be next
461
- const nextItem = this._selectNextItemToHydrate();
462
- if (!nextItem) {
463
- this._currentCard = null;
464
- return null;
465
- }
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
+ }
466
511
 
467
- // Look up in hydration cache
468
- let card = this.hydrationService.getHydratedCard(nextItem.cardID);
512
+ // Look up in hydration cache
513
+ let card = this.hydrationService.getHydratedCard(nextItem.cardID);
469
514
 
470
- // If not ready, wait for it
471
- if (!card) {
472
- card = await this.hydrationService.waitForCard(nextItem.cardID);
473
- }
515
+ // If not ready, wait for it
516
+ if (!card) {
517
+ card = await this.hydrationService.waitForCard(nextItem.cardID);
518
+ }
474
519
 
475
- // Remove from source queue now that we're consuming it
476
- this.removeItemFromQueue(nextItem);
520
+ // Remove from source queue now that we're consuming it
521
+ this.removeItemFromQueue(nextItem);
477
522
 
478
- // Trigger background hydration to maintain cache (async, non-blocking)
479
- 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);
544
+
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
+ }
480
554
 
481
- this._currentCard = card;
482
- return card;
555
+ this.log(`Exhausted ${MAX_SKIP} skip attempts finding a hydratable card`);
556
+ this._currentCard = null;
557
+ endSessionTracking();
558
+ return null;
483
559
  }
484
560
 
485
561
  /**
@@ -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();
@@ -30,16 +30,16 @@ export interface SourceMixer {
30
30
  }
31
31
 
32
32
  /**
33
- * Simple quota-based mixer: allocates equal representation to each source,
34
- * taking the top-N cards by score from each.
33
+ * Quota-based mixer with interleaved output.
35
34
  *
36
- * Guarantees balanced representation across sources regardless of absolute
37
- * score differences. A low-scoring source gets the same quota as a high-scoring
38
- * source.
35
+ * Allocates equal representation to each source (top-N by score), then
36
+ * interleaves the results by dealing from a randomly-shuffled source order.
37
+ * Within each source, cards are dealt in score-descending order.
39
38
  *
40
- * This is the KISS approach - simple, predictable, and fair in terms of
41
- * source representation (though not necessarily optimal in terms of absolute
42
- * card quality).
39
+ * This ensures that cards from different courses are spread throughout the
40
+ * queue rather than clustered by score bands, which matters because
41
+ * SessionController consumes queues front-to-back and sessions often end
42
+ * before reaching the tail.
43
43
  */
44
44
  export class QuotaRoundRobinMixer implements SourceMixer {
45
45
  mix(batches: SourceBatch[], limit: number): WeightedCard[] {
@@ -48,18 +48,37 @@ export class QuotaRoundRobinMixer implements SourceMixer {
48
48
  }
49
49
 
50
50
  const quotaPerSource = Math.ceil(limit / batches.length);
51
- const mixed: WeightedCard[] = [];
52
51
 
53
- for (const batch of batches) {
54
- // Sort this source's cards by score descending
55
- const sortedBatch = [...batch.weighted].sort((a, b) => b.score - a.score);
52
+ // Build per-source stacks sorted by score descending, capped at quota
53
+ const sourceStacks: WeightedCard[][] = batches.map((batch) => {
54
+ return [...batch.weighted].sort((a, b) => b.score - a.score).slice(0, quotaPerSource);
55
+ });
56
56
 
57
- // Take top quotaPerSource from this source
58
- const topFromSource = sortedBatch.slice(0, quotaPerSource);
59
- mixed.push(...topFromSource);
57
+ // Shuffle the source ordering so no course is systematically first
58
+ for (let i = sourceStacks.length - 1; i > 0; i--) {
59
+ const j = Math.floor(Math.random() * (i + 1));
60
+ [sourceStacks[i], sourceStacks[j]] = [sourceStacks[j], sourceStacks[i]];
60
61
  }
61
62
 
62
- // Sort the mixed result by score descending and return up to limit
63
- return mixed.sort((a, b) => b.score - a.score).slice(0, limit);
63
+ // Interleave: deal one card from each source in rotation
64
+ const result: WeightedCard[] = [];
65
+ let exhausted = 0;
66
+ const cursors = new Array(sourceStacks.length).fill(0);
67
+
68
+ while (result.length < limit && exhausted < sourceStacks.length) {
69
+ exhausted = 0;
70
+ for (let s = 0; s < sourceStacks.length; s++) {
71
+ if (result.length >= limit) break;
72
+
73
+ if (cursors[s] < sourceStacks[s].length) {
74
+ result.push(sourceStacks[s][cursors[s]]);
75
+ cursors[s]++;
76
+ } else {
77
+ exhausted++;
78
+ }
79
+ }
80
+ }
81
+
82
+ return result;
64
83
  }
65
84
  }