@vue-skuilder/db 0.1.31-b → 0.1.31

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 (49) hide show
  1. package/dist/{contentSource-ygoFw9oV.d.ts → contentSource-Bdwkvqa8.d.ts} +16 -0
  2. package/dist/{contentSource-B7nXusjk.d.cts → contentSource-DF1nUbPQ.d.cts} +16 -0
  3. package/dist/core/index.d.cts +34 -3
  4. package/dist/core/index.d.ts +34 -3
  5. package/dist/core/index.js +510 -50
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +510 -50
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BW7HvkMt.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
  10. package/dist/{dataLayerProvider-BfXUVDuG.d.ts → dataLayerProvider-BQdfJuBN.d.cts} +20 -1
  11. package/dist/impl/couch/index.d.cts +156 -4
  12. package/dist/impl/couch/index.d.ts +156 -4
  13. package/dist/impl/couch/index.js +730 -41
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +729 -41
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +3 -2
  18. package/dist/impl/static/index.d.ts +3 -2
  19. package/dist/impl/static/index.js +467 -31
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +467 -31
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index.d.cts +64 -3
  24. package/dist/index.d.ts +64 -3
  25. package/dist/index.js +948 -72
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +948 -72
  28. package/dist/index.mjs.map +1 -1
  29. package/package.json +3 -3
  30. package/src/core/interfaces/contentSource.ts +6 -0
  31. package/src/core/interfaces/courseDB.ts +6 -0
  32. package/src/core/interfaces/dataLayerProvider.ts +20 -0
  33. package/src/core/navigators/Pipeline.ts +414 -9
  34. package/src/core/navigators/PipelineAssembler.ts +23 -18
  35. package/src/core/navigators/PipelineDebugger.ts +35 -1
  36. package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
  37. package/src/core/navigators/generators/prescribed.ts +95 -0
  38. package/src/core/navigators/index.ts +12 -0
  39. package/src/impl/common/BaseUserDB.ts +4 -1
  40. package/src/impl/couch/CourseSyncService.ts +356 -0
  41. package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
  42. package/src/impl/couch/courseDB.ts +60 -13
  43. package/src/impl/couch/index.ts +1 -0
  44. package/src/impl/static/courseDB.ts +5 -0
  45. package/src/study/ItemQueue.ts +42 -0
  46. package/src/study/SessionController.ts +195 -22
  47. package/src/study/SpacedRepetition.ts +3 -1
  48. package/tests/core/navigators/Pipeline.test.ts +1 -1
  49. package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.1.31-b",
7
+ "version": "0.1.31",
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.1.31-b",
51
+ "@vue-skuilder/common": "0.1.31",
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": "^7.0.0",
63
63
  "vitest": "^4.0.15"
64
64
  },
65
- "stableVersion": "0.1.30"
65
+ "stableVersion": "0.1.31"
66
66
  }
@@ -79,6 +79,12 @@ export interface StudyContentSource {
79
79
  * Used for recording learning outcomes.
80
80
  */
81
81
  getOrchestrationContext?(): Promise<OrchestrationContext>;
82
+
83
+ /**
84
+ * Set ephemeral hints for the next pipeline run.
85
+ * No-op for sources that don't support hints.
86
+ */
87
+ setEphemeralHints?(hints: Record<string, unknown>): void;
82
88
  }
83
89
  // #endregion docs_StudyContentSource
84
90
 
@@ -100,6 +100,12 @@ export interface CourseDBInterface extends NavigationStrategyManager, StudyConte
100
100
  */
101
101
  getAppliedTagsBatch(cardIds: string[]): Promise<Map<string, string[]>>;
102
102
 
103
+ /**
104
+ * Get all card IDs in the course.
105
+ * Used by diagnostics to scan the full card space.
106
+ */
107
+ getAllCardIds(): Promise<string[]>;
108
+
103
109
  /**
104
110
  * Add a tag to a card
105
111
  */
@@ -56,4 +56,24 @@ export interface DataLayerProvider {
56
56
  * Check if this data layer is read-only
57
57
  */
58
58
  isReadOnly(): boolean;
59
+
60
+ /**
61
+ * Trigger local replication of a course database.
62
+ *
63
+ * When a course opts in via `CourseConfig.localSync.enabled`, this method
64
+ * replicates the remote course DB to a local PouchDB instance. Subsequent
65
+ * `getCourseDB()` calls for that course will return a CourseDB that reads
66
+ * from the local replica (fast, no network) and writes to the remote
67
+ * (ELO updates, admin ops).
68
+ *
69
+ * Safe to call multiple times — concurrent calls coalesce. Returns when
70
+ * sync is complete (or immediately if already synced / disabled).
71
+ *
72
+ * Implementations that don't support local sync may no-op.
73
+ *
74
+ * @param courseId - The course to sync locally
75
+ * @param forceEnabled - Skip CourseConfig check and sync regardless.
76
+ * Use when the caller already knows local sync is desired.
77
+ */
78
+ ensureCourseSynced?(courseId: string, forceEnabled?: boolean): Promise<void>;
59
79
  }
@@ -7,7 +7,60 @@ import type { CardFilter, FilterContext } from './filters/types';
7
7
  import type { CardGenerator, GeneratorContext } from './generators/types';
8
8
  import { logger } from '../../util/logger';
9
9
  import { createOrchestrationContext, OrchestrationContext } from '../orchestration';
10
- import { captureRun, buildRunReport, type GeneratorSummary, type FilterImpact } from './PipelineDebugger';
10
+ import { captureRun, buildRunReport, registerPipelineForDebug, type GeneratorSummary, type FilterImpact } from './PipelineDebugger';
11
+
12
+ // ============================================================================
13
+ // REPLAN HINTS
14
+ // ============================================================================
15
+ //
16
+ // Ephemeral, one-shot scoring hints passed at replan time.
17
+ // Applied after the filter chain, consumed after one pipeline run.
18
+ //
19
+ // Tag patterns support glob-style matching:
20
+ // 'gpc:exercise:t-T' — exact match
21
+ // 'gpc:intro:*' — all intro tags
22
+ // 'gpc:exercise:t-*' — all t-variant exercises
23
+ //
24
+
25
+ /**
26
+ * Ephemeral pipeline hints for a single run.
27
+ * All fields are optional. Tag/card patterns support `*` wildcards.
28
+ */
29
+ export interface ReplanHints {
30
+ /** Multiply scores for cards matching these tag patterns. */
31
+ boostTags?: Record<string, number>;
32
+ /** Multiply scores for these specific card IDs (glob patterns). */
33
+ boostCards?: Record<string, number>;
34
+ /** Cards matching these tag patterns MUST appear in results. */
35
+ requireTags?: string[];
36
+ /** These specific card IDs MUST appear in results. */
37
+ requireCards?: string[];
38
+ /** Remove cards matching these tag patterns from results. */
39
+ excludeTags?: string[];
40
+ /** Remove these specific card IDs from results. */
41
+ excludeCards?: string[];
42
+ }
43
+
44
+ /**
45
+ * Convert a glob pattern (with `*` wildcards) to a RegExp.
46
+ * Only `*` is supported as a wildcard (matches any characters).
47
+ */
48
+ function globToRegex(pattern: string): RegExp {
49
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
50
+ const withWildcards = escaped.replace(/\*/g, '.*');
51
+ return new RegExp(`^${withWildcards}$`);
52
+ }
53
+
54
+ /** Test whether a string matches a glob pattern. */
55
+ function globMatch(value: string, pattern: string): boolean {
56
+ if (!pattern.includes('*')) return value === pattern;
57
+ return globToRegex(pattern).test(value);
58
+ }
59
+
60
+ /** Test whether any of a card's tags match a glob pattern. */
61
+ function cardMatchesTagPattern(card: WeightedCard, pattern: string): boolean {
62
+ return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
63
+ }
11
64
 
12
65
  // ============================================================================
13
66
  // PIPELINE LOGGING HELPERS
@@ -79,6 +132,32 @@ function logExecutionSummary(
79
132
  );
80
133
  }
81
134
 
135
+ /**
136
+ * Log all result cards with score, cardId, and key provenance.
137
+ * Toggle: set VERBOSE_RESULTS = true to enable.
138
+ */
139
+ const VERBOSE_RESULTS = true;
140
+
141
+ function logResultCards(cards: WeightedCard[]): void {
142
+ if (!VERBOSE_RESULTS || cards.length === 0) return;
143
+
144
+ logger.info(`[Pipeline] Results (${cards.length} cards):`);
145
+ for (let i = 0; i < cards.length; i++) {
146
+ const c = cards[i];
147
+ const tags = c.tags?.slice(0, 3).join(', ') || '';
148
+ const filters = c.provenance
149
+ .filter((p) => p.strategy === 'hierarchyDefinition' || p.strategy === 'priorityDefinition' || p.strategy === 'interferenceFilter' || p.strategy === 'letterGating' || p.strategy === 'ephemeralHint')
150
+ .map((p) => {
151
+ const arrow = p.action === 'boosted' ? '↑' : p.action === 'penalized' ? '↓' : '=';
152
+ return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
153
+ })
154
+ .join(' | ');
155
+ logger.info(
156
+ `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ''}`
157
+ );
158
+ }
159
+ }
160
+
82
161
  /**
83
162
  * Log provenance trails for cards.
84
163
  * Shows the complete scoring history for each card through the pipeline.
@@ -145,6 +224,30 @@ export class Pipeline extends ContentNavigator {
145
224
  private generator: CardGenerator;
146
225
  private filters: CardFilter[];
147
226
 
227
+ /**
228
+ * Cached orchestration context. Course config and salt don't change within
229
+ * a page load, so we build the orchestration context once and reuse it on
230
+ * subsequent getWeightedCards() calls (e.g. mid-session replans).
231
+ *
232
+ * This eliminates a remote getCourseConfig() round trip per pipeline run.
233
+ */
234
+ private _cachedOrchestration: OrchestrationContext | null = null;
235
+
236
+ /**
237
+ * Persistent tag cache. Maps cardId → tag names.
238
+ *
239
+ * Tags are static within a session (they're set at card generation time),
240
+ * so we cache them across pipeline runs. On replans, many of the same cards
241
+ * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
242
+ */
243
+ private _tagCache: Map<string, string[]> = new Map();
244
+
245
+ /**
246
+ * One-shot replan hints. Applied after the filter chain on the next
247
+ * getWeightedCards() call, then cleared.
248
+ */
249
+ private _ephemeralHints: ReplanHints | null = null;
250
+
148
251
  /**
149
252
  * Create a new pipeline.
150
253
  *
@@ -175,6 +278,20 @@ export class Pipeline extends ContentNavigator {
175
278
  });
176
279
  // Toggle pipeline configuration logging:
177
280
  logPipelineConfig(generator, filters);
281
+
282
+ // Register for debug API access
283
+ registerPipelineForDebug(this);
284
+ }
285
+
286
+ /**
287
+ * Set one-shot hints for the next pipeline run.
288
+ * Consumed after one getWeightedCards() call, then cleared.
289
+ *
290
+ * Overrides ContentNavigator.setEphemeralHints() no-op.
291
+ */
292
+ override setEphemeralHints(hints: Record<string, unknown>): void {
293
+ this._ephemeralHints = hints as ReplanHints;
294
+ logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
178
295
  }
179
296
 
180
297
  /**
@@ -192,12 +309,17 @@ export class Pipeline extends ContentNavigator {
192
309
  * @returns Cards sorted by score descending
193
310
  */
194
311
  async getWeightedCards(limit: number): Promise<WeightedCard[]> {
312
+ const t0 = performance.now();
313
+
195
314
  // Build shared context once
196
315
  const context = await this.buildContext();
316
+ const tContext = performance.now();
197
317
 
198
- // Over-fetch from generator to account for filtering
199
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
200
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
318
+ // Over-fetch from generator to give filters a wide candidate pool.
319
+ // With local course DB the cost is negligible (~20ms for 500 cards).
320
+ // Filters (hierarchy, letter gating, etc.) can be aggressive — a wide
321
+ // pool ensures enough well-indicated candidates survive.
322
+ const fetchLimit = 500;
201
323
 
202
324
  logger.debug(
203
325
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
@@ -205,6 +327,7 @@ export class Pipeline extends ContentNavigator {
205
327
 
206
328
  // Get candidates from generator, passing context
207
329
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
330
+ const tGenerate = performance.now();
208
331
  const generatedCount = cards.length;
209
332
 
210
333
  // Capture generator breakdown for debugging (if CompositeGenerator)
@@ -239,6 +362,7 @@ export class Pipeline extends ContentNavigator {
239
362
 
240
363
  // Batch hydrate tags before filters run
241
364
  cards = await this.hydrateTags(cards);
365
+ const tHydrate = performance.now();
242
366
 
243
367
  // Keep a copy of all cards for debug capture (before filtering removes any)
244
368
  const allCardsBeforeFiltering = [...cards];
@@ -268,12 +392,26 @@ export class Pipeline extends ContentNavigator {
268
392
  // Remove zero-score cards (hard filtered)
269
393
  cards = cards.filter((c) => c.score > 0);
270
394
 
395
+ // Apply ephemeral hints (one-shot, post-filter)
396
+ const hints = this._ephemeralHints;
397
+ if (hints) {
398
+ this._ephemeralHints = null; // consume
399
+ cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
400
+ }
401
+
271
402
  // Sort by score descending
272
403
  cards.sort((a, b) => b.score - a.score);
273
404
 
274
405
  // Return top N
406
+ const tFilter = performance.now();
275
407
  const result = cards.slice(0, limit);
276
408
 
409
+ logger.info(
410
+ `[Pipeline:timing] total=${(tFilter - t0).toFixed(0)}ms ` +
411
+ `(context=${(tContext - t0).toFixed(0)} generate=${(tGenerate - tContext).toFixed(0)} ` +
412
+ `hydrate=${(tHydrate - tGenerate).toFixed(0)} filter=${(tFilter - tHydrate).toFixed(0)})`
413
+ );
414
+
277
415
  // Toggle execution summary logging:
278
416
  const topScores = result.slice(0, 3).map((c) => c.score);
279
417
  logExecutionSummary(
@@ -285,6 +423,9 @@ export class Pipeline extends ContentNavigator {
285
423
  filterImpacts
286
424
  );
287
425
 
426
+ // Toggle verbose result listing:
427
+ logResultCards(result);
428
+
288
429
  // Toggle provenance logging (shows scoring history for top cards):
289
430
  logCardProvenance(result, 3);
290
431
 
@@ -316,6 +457,10 @@ export class Pipeline extends ContentNavigator {
316
457
  * to the WeightedCard objects. Filters can then use card.tags instead of
317
458
  * making individual getAppliedTags() calls.
318
459
  *
460
+ * Uses a persistent tag cache across pipeline runs — tags are static within
461
+ * a session, so cards seen in a prior run (e.g. before a replan) don't
462
+ * require a second DB query.
463
+ *
319
464
  * @param cards - Cards to hydrate
320
465
  * @returns Cards with tags populated
321
466
  */
@@ -324,18 +469,153 @@ export class Pipeline extends ContentNavigator {
324
469
  return cards;
325
470
  }
326
471
 
327
- const cardIds = cards.map((c) => c.cardId);
328
- const tagsByCard = await this.course!.getAppliedTagsBatch(cardIds);
472
+ // Separate cards with cached tags from those needing a DB query
473
+ const uncachedIds: string[] = [];
474
+ for (const card of cards) {
475
+ if (!this._tagCache.has(card.cardId)) {
476
+ uncachedIds.push(card.cardId);
477
+ }
478
+ }
479
+
480
+ // Only query the DB for cards not already in cache
481
+ if (uncachedIds.length > 0) {
482
+ const freshTags = await this.course!.getAppliedTagsBatch(uncachedIds);
483
+ for (const [cardId, tags] of freshTags) {
484
+ this._tagCache.set(cardId, tags);
485
+ }
486
+ }
487
+
488
+ // Build the tagsByCard map from cache (for logging compatibility)
489
+ const tagsByCard = new Map<string, string[]>();
490
+ for (const card of cards) {
491
+ tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
492
+ }
329
493
 
330
494
  // Toggle tag hydration logging:
331
495
  logTagHydration(cards, tagsByCard);
332
496
 
333
497
  return cards.map((card) => ({
334
498
  ...card,
335
- tags: tagsByCard.get(card.cardId) ?? [],
499
+ tags: this._tagCache.get(card.cardId) ?? [],
336
500
  }));
337
501
  }
338
502
 
503
+ // ---------------------------------------------------------------------------
504
+ // Ephemeral hints application
505
+ // ---------------------------------------------------------------------------
506
+
507
+ /**
508
+ * Apply one-shot replan hints to the post-filter card set.
509
+ *
510
+ * Order of operations:
511
+ * 1. Exclude (remove unwanted cards)
512
+ * 2. Boost (multiply scores)
513
+ * 3. Require (inject must-have cards from the full pre-filter pool)
514
+ *
515
+ * @param cards - Post-filter cards (score > 0)
516
+ * @param hints - The ephemeral hints to apply
517
+ * @param allCards - Full pre-filter card pool (for require injection)
518
+ */
519
+ private applyHints(
520
+ cards: WeightedCard[],
521
+ hints: ReplanHints,
522
+ allCards: WeightedCard[]
523
+ ): WeightedCard[] {
524
+ const beforeCount = cards.length;
525
+
526
+ // 1. Exclude
527
+ if (hints.excludeCards?.length) {
528
+ cards = cards.filter(
529
+ (c) => !hints.excludeCards!.some((pat) => globMatch(c.cardId, pat))
530
+ );
531
+ }
532
+ if (hints.excludeTags?.length) {
533
+ cards = cards.filter(
534
+ (c) => !hints.excludeTags!.some((pat) => cardMatchesTagPattern(c, pat))
535
+ );
536
+ }
537
+
538
+ // 2. Boost
539
+ if (hints.boostTags) {
540
+ for (const [pattern, factor] of Object.entries(hints.boostTags)) {
541
+ for (const card of cards) {
542
+ if (cardMatchesTagPattern(card, pattern)) {
543
+ card.score *= factor;
544
+ card.provenance.push({
545
+ strategy: 'ephemeralHint',
546
+ strategyId: 'ephemeral-hint',
547
+ strategyName: 'Replan Hint',
548
+ action: 'boosted',
549
+ score: card.score,
550
+ reason: `boostTag ${pattern} ×${factor}`,
551
+ });
552
+ }
553
+ }
554
+ }
555
+ }
556
+ if (hints.boostCards) {
557
+ for (const [pattern, factor] of Object.entries(hints.boostCards)) {
558
+ for (const card of cards) {
559
+ if (globMatch(card.cardId, pattern)) {
560
+ card.score *= factor;
561
+ card.provenance.push({
562
+ strategy: 'ephemeralHint',
563
+ strategyId: 'ephemeral-hint',
564
+ strategyName: 'Replan Hint',
565
+ action: 'boosted',
566
+ score: card.score,
567
+ reason: `boostCard ${pattern} ×${factor}`,
568
+ });
569
+ }
570
+ }
571
+ }
572
+ }
573
+
574
+ // 3. Require — inject from the full pool if not already present
575
+ const cardIds = new Set(cards.map((c) => c.cardId));
576
+ const inject = (card: WeightedCard, reason: string) => {
577
+ if (!cardIds.has(card.cardId)) {
578
+ // Give required cards a floor score so they sort above zero-score filler
579
+ const floorScore = Math.max(card.score, 1.0);
580
+ cards.push({
581
+ ...card,
582
+ score: floorScore,
583
+ provenance: [
584
+ ...card.provenance,
585
+ {
586
+ strategy: 'ephemeralHint',
587
+ strategyId: 'ephemeral-hint',
588
+ strategyName: 'Replan Hint',
589
+ action: 'boosted',
590
+ score: floorScore,
591
+ reason,
592
+ },
593
+ ],
594
+ });
595
+ cardIds.add(card.cardId);
596
+ }
597
+ };
598
+
599
+ if (hints.requireCards?.length) {
600
+ for (const pattern of hints.requireCards) {
601
+ for (const card of allCards) {
602
+ if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
603
+ }
604
+ }
605
+ }
606
+ if (hints.requireTags?.length) {
607
+ for (const pattern of hints.requireTags) {
608
+ for (const card of allCards) {
609
+ if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
610
+ }
611
+ }
612
+ }
613
+
614
+ logger.info(`[Pipeline] Hints applied: ${beforeCount} → ${cards.length} cards`);
615
+
616
+ return cards;
617
+ }
618
+
339
619
  /**
340
620
  * Build shared context for generator and filters.
341
621
  *
@@ -355,8 +635,13 @@ export class Pipeline extends ContentNavigator {
355
635
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
356
636
  }
357
637
 
358
- // Initialize orchestration context (used for evolutionary weighting)
359
- const orchestration = await createOrchestrationContext(this.user!, this.course!);
638
+ // Reuse cached orchestration context if available (course config is stable
639
+ // within a page load). This avoids a remote getCourseConfig() call on
640
+ // subsequent pipeline runs (e.g. mid-session replans).
641
+ if (!this._cachedOrchestration) {
642
+ this._cachedOrchestration = await createOrchestrationContext(this.user!, this.course!);
643
+ }
644
+ const orchestration = this._cachedOrchestration;
360
645
 
361
646
  return {
362
647
  user: this.user!,
@@ -413,4 +698,124 @@ export class Pipeline extends ContentNavigator {
413
698
 
414
699
  return [...new Set(ids)];
415
700
  }
701
+
702
+ // ---------------------------------------------------------------------------
703
+ // Card-space diagnostic
704
+ // ---------------------------------------------------------------------------
705
+
706
+ /**
707
+ * Scan every card in the course through the filter chain and report
708
+ * how many are "well indicated" (score >= threshold) for the current user.
709
+ *
710
+ * Also reports how many well-indicated cards the user has NOT yet encountered.
711
+ *
712
+ * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
713
+ */
714
+ async diagnoseCardSpace(opts?: { threshold?: number }): Promise<CardSpaceDiagnosis> {
715
+ const THRESHOLD = opts?.threshold ?? 0.10;
716
+ const t0 = performance.now();
717
+
718
+ // 1. Get all card IDs
719
+ const allCardIds = await this.course!.getAllCardIds();
720
+
721
+ // 2. Build dummy WeightedCards (score=1.0, no provenance)
722
+ let cards: WeightedCard[] = allCardIds.map((cardId) => ({
723
+ cardId,
724
+ courseId: this.course!.getCourseID(),
725
+ score: 1.0,
726
+ provenance: [],
727
+ }));
728
+
729
+ // 3. Hydrate tags
730
+ cards = await this.hydrateTags(cards);
731
+
732
+ // 4. Run through filters
733
+ const context = await this.buildContext();
734
+ const filterBreakdown: Array<{ name: string; wellIndicated: number }> = [];
735
+
736
+ // Track cumulative filter effects
737
+ for (const filter of this.filters) {
738
+ cards = await filter.transform(cards, context);
739
+ const wi = cards.filter((c) => c.score >= THRESHOLD).length;
740
+ filterBreakdown.push({ name: filter.name, wellIndicated: wi });
741
+ }
742
+
743
+ // 5. Count well-indicated
744
+ const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
745
+ const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
746
+
747
+ // 6. Get encountered cards
748
+ let encounteredIds: Set<string>;
749
+ try {
750
+ const courseId = this.course!.getCourseID();
751
+ const seenCards = await this.user!.getSeenCards(courseId);
752
+ encounteredIds = new Set(seenCards);
753
+ } catch {
754
+ encounteredIds = new Set();
755
+ }
756
+
757
+ const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
758
+
759
+ // 7. Group by card type
760
+ const byType = new Map<string, { total: number; wellIndicated: number; new: number }>();
761
+ for (const card of cards) {
762
+ const type = card.cardId.split('-')[1] || 'unknown'; // c-ws-... → ws, c-intro-... → intro, etc.
763
+ if (!byType.has(type)) {
764
+ byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
765
+ }
766
+ const entry = byType.get(type)!;
767
+ entry.total++;
768
+ if (card.score >= THRESHOLD) {
769
+ entry.wellIndicated++;
770
+ if (!encounteredIds.has(card.cardId)) entry.new++;
771
+ }
772
+ }
773
+
774
+ const elapsed = performance.now() - t0;
775
+
776
+ const result: CardSpaceDiagnosis = {
777
+ totalCards: allCardIds.length,
778
+ threshold: THRESHOLD,
779
+ wellIndicated: wellIndicatedIds.size,
780
+ encountered: encounteredIds.size,
781
+ wellIndicatedNew: wellIndicatedNew.length,
782
+ byType: Object.fromEntries(byType),
783
+ filterBreakdown,
784
+ elapsedMs: Math.round(elapsed),
785
+ };
786
+
787
+ // Log to console
788
+ logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
789
+ logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
790
+ logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
791
+ logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
792
+ logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
793
+ logger.info(`[Pipeline:diagnose] By type:`);
794
+ for (const [type, counts] of byType) {
795
+ logger.info(
796
+ `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
797
+ );
798
+ }
799
+ logger.info(`[Pipeline:diagnose] After each filter:`);
800
+ for (const fb of filterBreakdown) {
801
+ logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
802
+ }
803
+
804
+ return result;
805
+ }
806
+
807
+ }
808
+
809
+ /**
810
+ * Diagnosis of the full card space for the current user.
811
+ */
812
+ export interface CardSpaceDiagnosis {
813
+ totalCards: number;
814
+ threshold: number;
815
+ wellIndicated: number;
816
+ encountered: number;
817
+ wellIndicatedNew: number;
818
+ byType: Record<string, { total: number; wellIndicated: number; new: number }>;
819
+ filterBreakdown: Array<{ name: string; wellIndicated: number }>;
820
+ elapsedMs: number;
416
821
  }
@@ -1,5 +1,5 @@
1
1
  import type { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
2
- import { ContentNavigator, isGenerator, isFilter } from './index';
2
+ import { ContentNavigator, isGenerator, isFilter, Navigators } from './index';
3
3
  import type { CardFilter } from './filters/types';
4
4
  import { WeightedFilter } from './filters/WeightedFilter';
5
5
  import type { CardGenerator } from './generators/types';
@@ -103,24 +103,29 @@ export class PipelineAssembler {
103
103
  }
104
104
  }
105
105
 
106
- // If no generator but filters exist, use default ELO and SRS generators
106
+ // Always ensure ELO and SRS generators are present.
107
+ // Custom generators (e.g., prescribed) supplement but don't replace them.
108
+ const courseId = course.getCourseID();
109
+ const hasElo = generatorStrategies.some((s) => s.implementingClass === Navigators.ELO);
110
+ const hasSrs = generatorStrategies.some((s) => s.implementingClass === Navigators.SRS);
111
+
112
+ if (!hasElo) {
113
+ logger.debug('[PipelineAssembler] No ELO generator configured, adding default');
114
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
115
+ }
116
+ if (!hasSrs) {
117
+ logger.debug('[PipelineAssembler] No SRS generator configured, adding default');
118
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
119
+ }
120
+
107
121
  if (generatorStrategies.length === 0) {
108
- if (filterStrategies.length > 0) {
109
- logger.debug(
110
- '[PipelineAssembler] No generator found, using default ELO and SRS with configured filters'
111
- );
112
- const courseId = course.getCourseID();
113
- generatorStrategies.push(createDefaultEloStrategy(courseId));
114
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
115
- } else {
116
- warnings.push('No generator strategy found');
117
- return {
118
- pipeline: null,
119
- generatorStrategies: [],
120
- filterStrategies: [],
121
- warnings,
122
- };
123
- }
122
+ warnings.push('No generator strategy found');
123
+ return {
124
+ pipeline: null,
125
+ generatorStrategies: [],
126
+ filterStrategies: [],
127
+ warnings,
128
+ };
124
129
  }
125
130
 
126
131
  // Instantiate generators