@vue-skuilder/db 0.1.24 → 0.1.26

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
@@ -0,0 +1,555 @@
1
+ import type { WeightedCard } from '@db/core/navigators';
2
+ import type { SourceBatch } from './SourceMixer';
3
+ import { logger } from '../util/logger';
4
+ import { getCardOrigin } from '@db/core/navigators';
5
+
6
+ // ============================================================================
7
+ // MIXER DEBUGGER
8
+ // ============================================================================
9
+ //
10
+ // Console-accessible debug API for inspecting cross-source mixing decisions.
11
+ //
12
+ // Exposed as `window.skuilder.mixer` for interactive exploration.
13
+ //
14
+ // Usage:
15
+ // window.skuilder.mixer.showLastMix()
16
+ // window.skuilder.mixer.explainSourceBalance()
17
+ // window.skuilder.mixer.showCard('cardId123')
18
+ // window.skuilder.mixer.compareScores()
19
+ // window.skuilder.mixer.export()
20
+ //
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Summary of a single source's contribution to the mix.
25
+ */
26
+ export interface SourceSummary {
27
+ sourceIndex: number;
28
+ sourceId: string;
29
+ sourceName?: string;
30
+ totalCards: number;
31
+ reviewCount: number;
32
+ newCount: number;
33
+ topScore: number;
34
+ bottomScore: number;
35
+ scoreRange: [number, number];
36
+ avgScore: number;
37
+ }
38
+
39
+ /**
40
+ * Per-source selection breakdown.
41
+ */
42
+ export interface SourceSelectionBreakdown {
43
+ sourceId: string;
44
+ sourceName?: string;
45
+ reviewsProvided: number;
46
+ newProvided: number;
47
+ reviewsSelected: number;
48
+ newSelected: number;
49
+ totalSelected: number;
50
+ selectionRate: number; // percentage
51
+ }
52
+
53
+ /**
54
+ * Detailed card information in the mixer context.
55
+ */
56
+ export interface MixerCardInfo {
57
+ cardId: string;
58
+ courseId: string;
59
+ origin: 'review' | 'new' | 'failed' | 'unknown';
60
+ score: number;
61
+ sourceIndex: number;
62
+ selected: boolean;
63
+ rankInSource?: number; // 1-indexed position within its source batch
64
+ rankInMix?: number; // 1-indexed position in final mixed results
65
+ }
66
+
67
+ /**
68
+ * Complete record of a single mixer execution.
69
+ */
70
+ export interface MixerRunReport {
71
+ runId: string;
72
+ timestamp: Date;
73
+
74
+ // Mixer configuration
75
+ mixerType: string;
76
+ requestedLimit: number;
77
+ quotaPerSource?: number;
78
+
79
+ // Input batches
80
+ sourceSummaries: SourceSummary[];
81
+
82
+ // Selection results
83
+ cards: MixerCardInfo[];
84
+ finalCount: number;
85
+ reviewsSelected: number;
86
+ newSelected: number;
87
+
88
+ // Per-source breakdown
89
+ sourceBreakdowns: SourceSelectionBreakdown[];
90
+ }
91
+
92
+ /**
93
+ * Ring buffer for storing recent mixer runs.
94
+ */
95
+ const MAX_RUNS = 10;
96
+ const runHistory: MixerRunReport[] = [];
97
+
98
+ /**
99
+ * Build source summary from a batch.
100
+ */
101
+ function buildSourceSummary(batch: SourceBatch, sourceId: string, sourceName?: string): SourceSummary {
102
+ const scores = batch.weighted.map((c) => c.score);
103
+ const reviewCount = batch.weighted.filter((c) => getCardOrigin(c) === 'review').length;
104
+ const newCount = batch.weighted.filter((c) => getCardOrigin(c) === 'new').length;
105
+
106
+ return {
107
+ sourceIndex: batch.sourceIndex,
108
+ sourceId,
109
+ sourceName,
110
+ totalCards: batch.weighted.length,
111
+ reviewCount,
112
+ newCount,
113
+ topScore: scores.length > 0 ? Math.max(...scores) : 0,
114
+ bottomScore: scores.length > 0 ? Math.min(...scores) : 0,
115
+ scoreRange: scores.length > 0 ? [Math.min(...scores), Math.max(...scores)] : [0, 0],
116
+ avgScore: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Build source selection breakdown.
122
+ */
123
+ function buildSourceBreakdown(
124
+ sourceId: string,
125
+ sourceName: string | undefined,
126
+ allCards: MixerCardInfo[]
127
+ ): SourceSelectionBreakdown {
128
+ const sourceCards = allCards.filter((c) => c.courseId === sourceId);
129
+ const selectedCards = sourceCards.filter((c) => c.selected);
130
+
131
+ const reviewsProvided = sourceCards.filter((c) => c.origin === 'review').length;
132
+ const newProvided = sourceCards.filter((c) => c.origin === 'new').length;
133
+ const reviewsSelected = selectedCards.filter((c) => c.origin === 'review').length;
134
+ const newSelected = selectedCards.filter((c) => c.origin === 'new').length;
135
+
136
+ return {
137
+ sourceId,
138
+ sourceName,
139
+ reviewsProvided,
140
+ newProvided,
141
+ reviewsSelected,
142
+ newSelected,
143
+ totalSelected: selectedCards.length,
144
+ selectionRate: sourceCards.length > 0 ? (selectedCards.length / sourceCards.length) * 100 : 0,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Capture a mixer run for later inspection.
150
+ */
151
+ export function captureMixerRun(
152
+ mixerType: string,
153
+ batches: SourceBatch[],
154
+ sourceIds: string[],
155
+ sourceNames: (string | undefined)[],
156
+ requestedLimit: number,
157
+ quotaPerSource: number | undefined,
158
+ mixedResult: WeightedCard[]
159
+ ): void {
160
+ // Build source summaries
161
+ const sourceSummaries = batches.map((batch, idx) =>
162
+ buildSourceSummary(batch, sourceIds[idx] || `source-${idx}`, sourceNames[idx])
163
+ );
164
+
165
+ // Build card info with rankings
166
+ const selectedIds = new Set(mixedResult.map((c) => c.cardId));
167
+
168
+ // Rank cards within their source batches
169
+ const sourceRankings = new Map<string, Map<string, number>>();
170
+ batches.forEach((batch) => {
171
+ const sorted = [...batch.weighted].sort((a, b) => b.score - a.score);
172
+ const rankings = new Map<string, number>();
173
+ sorted.forEach((card, idx) => {
174
+ rankings.set(card.cardId, idx + 1);
175
+ });
176
+ sourceRankings.set(sourceIds[batch.sourceIndex] || `source-${batch.sourceIndex}`, rankings);
177
+ });
178
+
179
+ // Rank cards in final mix
180
+ const mixRankings = new Map<string, number>();
181
+ mixedResult.forEach((card, idx) => {
182
+ mixRankings.set(card.cardId, idx + 1);
183
+ });
184
+
185
+ // Build all card info
186
+ const allCardsMap = new Map<string, WeightedCard>();
187
+ batches.forEach((batch) => {
188
+ batch.weighted.forEach((card) => {
189
+ allCardsMap.set(card.cardId, card);
190
+ });
191
+ });
192
+
193
+ const cards: MixerCardInfo[] = Array.from(allCardsMap.values()).map((card) => ({
194
+ cardId: card.cardId,
195
+ courseId: card.courseId,
196
+ origin: getCardOrigin(card),
197
+ score: card.score,
198
+ sourceIndex: batches.findIndex((b) => b.weighted.some((c) => c.cardId === card.cardId)),
199
+ selected: selectedIds.has(card.cardId),
200
+ rankInSource: sourceRankings.get(card.courseId)?.get(card.cardId),
201
+ rankInMix: mixRankings.get(card.cardId),
202
+ }));
203
+
204
+ // Build per-source breakdowns
205
+ const uniqueSourceIds = Array.from(new Set(sourceIds.filter((id) => id)));
206
+ const sourceBreakdowns = uniqueSourceIds.map((sourceId, idx) =>
207
+ buildSourceBreakdown(sourceId, sourceNames[idx], cards)
208
+ );
209
+
210
+ const report: MixerRunReport = {
211
+ runId: `mix-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
212
+ timestamp: new Date(),
213
+ mixerType,
214
+ requestedLimit,
215
+ quotaPerSource,
216
+ sourceSummaries,
217
+ cards,
218
+ finalCount: mixedResult.length,
219
+ reviewsSelected: mixedResult.filter((c) => getCardOrigin(c) === 'review').length,
220
+ newSelected: mixedResult.filter((c) => getCardOrigin(c) === 'new').length,
221
+ sourceBreakdowns,
222
+ };
223
+
224
+ runHistory.unshift(report);
225
+ if (runHistory.length > MAX_RUNS) {
226
+ runHistory.pop();
227
+ }
228
+ }
229
+
230
+ // ============================================================================
231
+ // CONSOLE API
232
+ // ============================================================================
233
+
234
+ /**
235
+ * Print summary of a single mixer run.
236
+ */
237
+ function printMixerSummary(run: MixerRunReport): void {
238
+ // eslint-disable-next-line no-console
239
+ console.group(`🎨 Mixer Run: ${run.mixerType}`);
240
+ logger.info(`Run ID: ${run.runId}`);
241
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
242
+ logger.info(
243
+ `Config: limit=${run.requestedLimit}${run.quotaPerSource ? `, quota/source=${run.quotaPerSource}` : ''}`
244
+ );
245
+
246
+ // eslint-disable-next-line no-console
247
+ console.group(`📥 Input: ${run.sourceSummaries.length} sources`);
248
+ for (const src of run.sourceSummaries) {
249
+ logger.info(
250
+ ` ${src.sourceName || src.sourceId}: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`
251
+ );
252
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}], avg: ${src.avgScore.toFixed(2)}`);
253
+ }
254
+ // eslint-disable-next-line no-console
255
+ console.groupEnd();
256
+
257
+ // eslint-disable-next-line no-console
258
+ console.group(`📤 Output: ${run.finalCount} cards selected (${run.reviewsSelected} reviews, ${run.newSelected} new)`);
259
+ for (const breakdown of run.sourceBreakdowns) {
260
+ const name = breakdown.sourceName || breakdown.sourceId;
261
+ logger.info(
262
+ ` ${name}: ${breakdown.totalSelected} selected (${breakdown.reviewsSelected} reviews, ${breakdown.newSelected} new) - ${breakdown.selectionRate.toFixed(1)}% selection rate`
263
+ );
264
+ }
265
+ // eslint-disable-next-line no-console
266
+ console.groupEnd();
267
+
268
+ // eslint-disable-next-line no-console
269
+ console.groupEnd();
270
+ }
271
+
272
+ /**
273
+ * Console API object exposed on window.skuilder.mixer
274
+ */
275
+ export const mixerDebugAPI = {
276
+ /**
277
+ * Get raw run history for programmatic access.
278
+ */
279
+ get runs(): MixerRunReport[] {
280
+ return [...runHistory];
281
+ },
282
+
283
+ /**
284
+ * Show summary of a specific mixer run.
285
+ */
286
+ showRun(idOrIndex: string | number = 0): void {
287
+ if (runHistory.length === 0) {
288
+ logger.info('[Mixer Debug] No runs captured yet.');
289
+ return;
290
+ }
291
+
292
+ let run: MixerRunReport | undefined;
293
+
294
+ if (typeof idOrIndex === 'number') {
295
+ run = runHistory[idOrIndex];
296
+ if (!run) {
297
+ logger.info(`[Mixer Debug] No run found at index ${idOrIndex}. History length: ${runHistory.length}`);
298
+ return;
299
+ }
300
+ } else {
301
+ run = runHistory.find((r) => r.runId.endsWith(idOrIndex));
302
+ if (!run) {
303
+ logger.info(`[Mixer Debug] No run found matching ID '${idOrIndex}'.`);
304
+ return;
305
+ }
306
+ }
307
+
308
+ printMixerSummary(run);
309
+ },
310
+
311
+ /**
312
+ * Show summary of the last mixer run.
313
+ */
314
+ showLastMix(): void {
315
+ this.showRun(0);
316
+ },
317
+
318
+ /**
319
+ * Explain source balance in the last run.
320
+ */
321
+ explainSourceBalance(): void {
322
+ if (runHistory.length === 0) {
323
+ logger.info('[Mixer Debug] No runs captured yet.');
324
+ return;
325
+ }
326
+
327
+ const run = runHistory[0];
328
+
329
+ // eslint-disable-next-line no-console
330
+ console.group('⚖️ Source Balance Analysis');
331
+
332
+ logger.info(`Mixer: ${run.mixerType}`);
333
+ logger.info(`Requested limit: ${run.requestedLimit}`);
334
+ if (run.quotaPerSource) {
335
+ logger.info(`Quota per source: ${run.quotaPerSource}`);
336
+ }
337
+
338
+ // eslint-disable-next-line no-console
339
+ console.group('Input Distribution:');
340
+ for (const src of run.sourceSummaries) {
341
+ const name = src.sourceName || src.sourceId;
342
+ logger.info(`${name}:`);
343
+ logger.info(` Provided: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`);
344
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}]`);
345
+ }
346
+ // eslint-disable-next-line no-console
347
+ console.groupEnd();
348
+
349
+ // eslint-disable-next-line no-console
350
+ console.group('Selection Results:');
351
+ for (const breakdown of run.sourceBreakdowns) {
352
+ const name = breakdown.sourceName || breakdown.sourceId;
353
+ logger.info(`${name}:`);
354
+ logger.info(
355
+ ` Selected: ${breakdown.totalSelected}/${breakdown.reviewsProvided + breakdown.newProvided} (${breakdown.selectionRate.toFixed(1)}%)`
356
+ );
357
+ logger.info(` Reviews: ${breakdown.reviewsSelected}/${breakdown.reviewsProvided}`);
358
+ logger.info(` New: ${breakdown.newSelected}/${breakdown.newProvided}`);
359
+
360
+ // Identify potential issues
361
+ if (breakdown.reviewsProvided > 0 && breakdown.reviewsSelected === 0) {
362
+ logger.info(` ⚠️ Had reviews but none selected!`);
363
+ }
364
+ if (breakdown.totalSelected === 0 && breakdown.reviewsProvided + breakdown.newProvided > 0) {
365
+ logger.info(` ⚠️ Had cards but none selected!`);
366
+ }
367
+ }
368
+ // eslint-disable-next-line no-console
369
+ console.groupEnd();
370
+
371
+ // Check for imbalances
372
+ const selectionRates = run.sourceBreakdowns.map((b) => b.selectionRate);
373
+ const avgRate = selectionRates.reduce((a, b) => a + b, 0) / selectionRates.length;
374
+ const maxDeviation = Math.max(...selectionRates.map((r) => Math.abs(r - avgRate)));
375
+
376
+ if (maxDeviation > 20) {
377
+ logger.info(`\n⚠️ Significant imbalance detected (max deviation: ${maxDeviation.toFixed(1)}%)`);
378
+ logger.info('Possible causes:');
379
+ logger.info(' - Score range differences between sources');
380
+ logger.info(' - One source has much better quality cards');
381
+ logger.info(' - Different card availability (reviews vs new)');
382
+ }
383
+
384
+ // eslint-disable-next-line no-console
385
+ console.groupEnd();
386
+ },
387
+
388
+ /**
389
+ * Compare score distributions across sources.
390
+ */
391
+ compareScores(): void {
392
+ if (runHistory.length === 0) {
393
+ logger.info('[Mixer Debug] No runs captured yet.');
394
+ return;
395
+ }
396
+
397
+ const run = runHistory[0];
398
+
399
+ // eslint-disable-next-line no-console
400
+ console.group('📊 Score Distribution Comparison');
401
+
402
+ // eslint-disable-next-line no-console
403
+ console.table(
404
+ run.sourceSummaries.map((src) => ({
405
+ source: src.sourceName || src.sourceId,
406
+ cards: src.totalCards,
407
+ min: src.bottomScore.toFixed(3),
408
+ max: src.topScore.toFixed(3),
409
+ avg: src.avgScore.toFixed(3),
410
+ range: (src.topScore - src.bottomScore).toFixed(3),
411
+ }))
412
+ );
413
+
414
+ // Check for score normalization issues
415
+ const ranges = run.sourceSummaries.map((s) => s.topScore - s.bottomScore);
416
+ const avgScores = run.sourceSummaries.map((s) => s.avgScore);
417
+
418
+ const rangeDiff = Math.max(...ranges) - Math.min(...ranges);
419
+ const avgDiff = Math.max(...avgScores) - Math.min(...avgScores);
420
+
421
+ if (rangeDiff > 0.3 || avgDiff > 0.2) {
422
+ logger.info('\n⚠️ Significant score distribution differences detected');
423
+ logger.info(
424
+ 'This may cause one source to dominate selection if using global sorting (not quota-based)'
425
+ );
426
+ }
427
+
428
+ // eslint-disable-next-line no-console
429
+ console.groupEnd();
430
+ },
431
+
432
+ /**
433
+ * Show detailed information for a specific card.
434
+ */
435
+ showCard(cardId: string): void {
436
+ for (const run of runHistory) {
437
+ const card = run.cards.find((c) => c.cardId === cardId);
438
+ if (card) {
439
+ const source = run.sourceSummaries.find((s) => s.sourceIndex === card.sourceIndex);
440
+
441
+ // eslint-disable-next-line no-console
442
+ console.group(`🎴 Card: ${cardId}`);
443
+ logger.info(`Course: ${card.courseId}`);
444
+ logger.info(`Source: ${source?.sourceName || source?.sourceId || 'unknown'}`);
445
+ logger.info(`Origin: ${card.origin}`);
446
+ logger.info(`Score: ${card.score.toFixed(3)}`);
447
+ if (card.rankInSource) {
448
+ logger.info(`Rank in source: #${card.rankInSource}`);
449
+ }
450
+ if (card.rankInMix) {
451
+ logger.info(`Rank in mixed results: #${card.rankInMix}`);
452
+ }
453
+ logger.info(`Selected: ${card.selected ? 'Yes ✅' : 'No ❌'}`);
454
+
455
+ if (!card.selected && card.rankInSource) {
456
+ logger.info('\nWhy not selected:');
457
+ if (run.quotaPerSource && card.rankInSource > run.quotaPerSource) {
458
+ logger.info(` - Ranked #${card.rankInSource} in source, but quota was ${run.quotaPerSource}`);
459
+ }
460
+ logger.info(' - Check score compared to selected cards using .showRun()');
461
+ }
462
+
463
+ // eslint-disable-next-line no-console
464
+ console.groupEnd();
465
+ return;
466
+ }
467
+ }
468
+ logger.info(`[Mixer Debug] Card '${cardId}' not found in recent runs.`);
469
+ },
470
+
471
+ /**
472
+ * Show all runs in compact format.
473
+ */
474
+ listRuns(): void {
475
+ if (runHistory.length === 0) {
476
+ logger.info('[Mixer Debug] No runs captured yet.');
477
+ return;
478
+ }
479
+
480
+ // eslint-disable-next-line no-console
481
+ console.table(
482
+ runHistory.map((r) => ({
483
+ id: r.runId.slice(-8),
484
+ time: r.timestamp.toLocaleTimeString(),
485
+ mixer: r.mixerType,
486
+ sources: r.sourceSummaries.length,
487
+ selected: r.finalCount,
488
+ reviews: r.reviewsSelected,
489
+ new: r.newSelected,
490
+ }))
491
+ );
492
+ },
493
+
494
+ /**
495
+ * Export run history as JSON for bug reports.
496
+ */
497
+ export(): string {
498
+ const json = JSON.stringify(runHistory, null, 2);
499
+ logger.info('[Mixer Debug] Run history exported. Copy the returned string or use:');
500
+ logger.info(' copy(window.skuilder.mixer.export())');
501
+ return json;
502
+ },
503
+
504
+ /**
505
+ * Clear run history.
506
+ */
507
+ clear(): void {
508
+ runHistory.length = 0;
509
+ logger.info('[Mixer Debug] Run history cleared.');
510
+ },
511
+
512
+ /**
513
+ * Show help.
514
+ */
515
+ help(): void {
516
+ logger.info(`
517
+ 🎨 Mixer Debug API
518
+
519
+ Commands:
520
+ .showLastMix() Show summary of most recent mixer run
521
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
522
+ .explainSourceBalance() Analyze source balance and selection patterns
523
+ .compareScores() Compare score distributions across sources
524
+ .showCard(cardId) Show mixer decisions for a specific card
525
+ .listRuns() List all captured runs in table format
526
+ .export() Export run history as JSON for bug reports
527
+ .clear() Clear run history
528
+ .runs Access raw run history array
529
+ .help() Show this help message
530
+
531
+ Example:
532
+ window.skuilder.mixer.showLastMix()
533
+ window.skuilder.mixer.explainSourceBalance()
534
+ window.skuilder.mixer.compareScores()
535
+ `);
536
+ },
537
+ };
538
+
539
+ // ============================================================================
540
+ // WINDOW MOUNT
541
+ // ============================================================================
542
+
543
+ /**
544
+ * Mount the debug API on window.skuilder.mixer
545
+ */
546
+ export function mountMixerDebugger(): void {
547
+ if (typeof window === 'undefined') return;
548
+
549
+ const win = window as any;
550
+ win.skuilder = win.skuilder || {};
551
+ win.skuilder.mixer = mixerDebugAPI;
552
+ }
553
+
554
+ // Auto-mount when module is loaded
555
+ mountMixerDebugger();