@vue-skuilder/db 0.1.31-a → 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 (50) hide show
  1. package/dist/{contentSource-BmnmvH8C.d.ts → contentSource-Bdwkvqa8.d.ts} +35 -4
  2. package/dist/{contentSource-DfBbaLA-.d.cts → contentSource-DF1nUbPQ.d.cts} +35 -4
  3. package/dist/core/index.d.cts +48 -3
  4. package/dist/core/index.d.ts +48 -3
  5. package/dist/core/index.js +587 -56
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +586 -56
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BeRXVMs5.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
  10. package/dist/{dataLayerProvider-CG9GfaAY.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 +805 -47
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +804 -47
  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 +542 -37
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +542 -37
  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 +1040 -90
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +1030 -81
  28. package/dist/index.mjs.map +1 -1
  29. package/docs/navigators-architecture.md +64 -5
  30. package/package.json +3 -3
  31. package/src/core/interfaces/contentSource.ts +6 -0
  32. package/src/core/interfaces/courseDB.ts +6 -0
  33. package/src/core/interfaces/dataLayerProvider.ts +20 -0
  34. package/src/core/navigators/Pipeline.ts +414 -9
  35. package/src/core/navigators/PipelineAssembler.ts +23 -18
  36. package/src/core/navigators/PipelineDebugger.ts +115 -1
  37. package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
  38. package/src/core/navigators/generators/prescribed.ts +95 -0
  39. package/src/core/navigators/index.ts +55 -10
  40. package/src/impl/common/BaseUserDB.ts +4 -1
  41. package/src/impl/couch/CourseSyncService.ts +356 -0
  42. package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
  43. package/src/impl/couch/courseDB.ts +60 -13
  44. package/src/impl/couch/index.ts +1 -0
  45. package/src/impl/static/courseDB.ts +5 -0
  46. package/src/study/ItemQueue.ts +42 -0
  47. package/src/study/SessionController.ts +195 -22
  48. package/src/study/SpacedRepetition.ts +7 -2
  49. package/tests/core/navigators/Pipeline.test.ts +1 -1
  50. package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
@@ -1,5 +1,28 @@
1
1
  import type { WeightedCard, StrategyContribution } from './index';
2
+ import {
3
+ getRegisteredNavigatorNames,
4
+ getRegisteredNavigatorRole,
5
+ NavigatorRoles,
6
+ type Navigators,
7
+ isGenerator,
8
+ isFilter,
9
+ } from './index';
2
10
  import { logger } from '../../util/logger';
11
+ import type { Pipeline, CardSpaceDiagnosis } from './Pipeline';
12
+
13
+ /**
14
+ * Captured reference to the most recently created Pipeline instance.
15
+ * Used by the debug API to run diagnostics against the live pipeline.
16
+ */
17
+ let _activePipeline: Pipeline | null = null;
18
+
19
+ /**
20
+ * Register a pipeline instance for diagnostic access.
21
+ * Called by Pipeline constructor.
22
+ */
23
+ export function registerPipelineForDebug(pipeline: Pipeline): void {
24
+ _activePipeline = pipeline;
25
+ }
3
26
 
4
27
  // ============================================================================
5
28
  // PIPELINE DEBUGGER
@@ -68,6 +91,7 @@ export interface PipelineRunReport {
68
91
  origin: 'new' | 'review' | 'unknown';
69
92
  finalScore: number;
70
93
  provenance: StrategyContribution[];
94
+ tags?: string[];
71
95
  selected: boolean;
72
96
  }>;
73
97
  }
@@ -127,6 +151,7 @@ export function buildRunReport(
127
151
  origin: getOrigin(card),
128
152
  finalScore: card.score,
129
153
  provenance: card.provenance,
154
+ tags: card.tags,
130
155
  selected: selectedIds.has(card.cardId),
131
156
  }));
132
157
 
@@ -381,6 +406,92 @@ export const pipelineDebugAPI = {
381
406
  logger.info('[Pipeline Debug] Run history cleared.');
382
407
  },
383
408
 
409
+ /**
410
+ * Show the navigator registry: all registered classes and their roles.
411
+ *
412
+ * Useful for verifying that consumer-defined navigators were registered
413
+ * before pipeline assembly.
414
+ */
415
+ showRegistry(): void {
416
+ const names = getRegisteredNavigatorNames();
417
+ if (names.length === 0) {
418
+ logger.info('[Pipeline Debug] Navigator registry is empty.');
419
+ return;
420
+ }
421
+
422
+ // eslint-disable-next-line no-console
423
+ console.group('📦 Navigator Registry');
424
+ // eslint-disable-next-line no-console
425
+ console.table(
426
+ names.map((name) => {
427
+ const registryRole = getRegisteredNavigatorRole(name);
428
+ const builtinRole = NavigatorRoles[name as Navigators];
429
+ const effectiveRole = builtinRole || registryRole || '⚠️ NONE';
430
+ const source = builtinRole ? 'built-in' : registryRole ? 'consumer' : 'unclassified';
431
+ return {
432
+ name,
433
+ role: effectiveRole,
434
+ source,
435
+ isGenerator: isGenerator(name),
436
+ isFilter: isFilter(name),
437
+ };
438
+ })
439
+ );
440
+ // eslint-disable-next-line no-console
441
+ console.groupEnd();
442
+ },
443
+
444
+ /**
445
+ * Show strategy documents from the last pipeline run and how they mapped
446
+ * to the registry.
447
+ *
448
+ * If no runs are captured yet, falls back to showing just the registry.
449
+ */
450
+ showStrategies(): void {
451
+ this.showRegistry();
452
+
453
+ if (runHistory.length === 0) {
454
+ logger.info('[Pipeline Debug] No pipeline runs captured yet — cannot show strategy doc mapping.');
455
+ return;
456
+ }
457
+
458
+ const run = runHistory[0];
459
+ // eslint-disable-next-line no-console
460
+ console.group('🔌 Pipeline Strategy Mapping (last run)');
461
+ logger.info(`Generator: ${run.generatorName}`);
462
+ if (run.generators && run.generators.length > 0) {
463
+ for (const g of run.generators) {
464
+ logger.info(` 📥 ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
465
+ }
466
+ }
467
+ if (run.filters.length > 0) {
468
+ logger.info('Filters:');
469
+ for (const f of run.filters) {
470
+ logger.info(` 🔸 ${f.name}: ↑${f.boosted} ↓${f.penalized} =${f.passed} ✕${f.removed}`);
471
+ }
472
+ } else {
473
+ logger.info('Filters: (none)');
474
+ }
475
+ // eslint-disable-next-line no-console
476
+ console.groupEnd();
477
+ },
478
+
479
+ /**
480
+ * Scan the full card space through the filter chain for the current user.
481
+ *
482
+ * Reports how many cards are well-indicated and how many are new.
483
+ * Use this to understand how the search space grows during onboarding.
484
+ *
485
+ * @param threshold - Score threshold for "well indicated" (default 0.10)
486
+ */
487
+ async diagnoseCardSpace(threshold?: number): Promise<CardSpaceDiagnosis | null> {
488
+ if (!_activePipeline) {
489
+ logger.info('[Pipeline Debug] No active pipeline. Run a session first.');
490
+ return null;
491
+ }
492
+ return _activePipeline.diagnoseCardSpace({ threshold });
493
+ },
494
+
384
495
  /**
385
496
  * Show help.
386
497
  */
@@ -393,6 +504,9 @@ Commands:
393
504
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
394
505
  .showCard(cardId) Show provenance trail for a specific card
395
506
  .explainReviews() Analyze why reviews were/weren't selected
507
+ .diagnoseCardSpace() Scan full card space through filters (async)
508
+ .showRegistry() Show navigator registry (classes + roles)
509
+ .showStrategies() Show registry + strategy mapping from last run
396
510
  .listRuns() List all captured runs in table format
397
511
  .export() Export run history as JSON for bug reports
398
512
  .clear() Clear run history
@@ -402,7 +516,7 @@ Commands:
402
516
  Example:
403
517
  window.skuilder.pipeline.showLastRun()
404
518
  window.skuilder.pipeline.showRun(1)
405
- window.skuilder.pipeline.showCard('abc123')
519
+ await window.skuilder.pipeline.diagnoseCardSpace()
406
520
  `);
407
521
  },
408
522
  };
@@ -20,6 +20,15 @@ interface TagPrerequisite {
20
20
  /** Minimum interaction count (default: 3) */
21
21
  minCount?: number;
22
22
  };
23
+ /**
24
+ * Score multiplier applied to cards that carry this prereq tag while
25
+ * the gate is still closed. Steers the pipeline toward cards that help
26
+ * unlock the gated content. Falls away once the prereq is met.
27
+ *
28
+ * Example: `preReqBoost: 1.3` gives a 30% score increase to cards
29
+ * tagged `gpc:expose:t-T` while `gpc:intro:t-T` is still locked.
30
+ */
31
+ preReqBoost?: number;
23
32
  }
24
33
 
25
34
  /**
@@ -38,7 +47,7 @@ const DEFAULT_MIN_COUNT = 3;
38
47
  * A filter strategy that gates cards based on prerequisite mastery.
39
48
  *
40
49
  * Cards are locked until the user masters all prerequisite tags.
41
- * Locked cards receive score * 0.01 (strong penalty, not hard filter).
50
+ * Locked cards receive score * 0.05 (strong penalty, not hard filter).
42
51
  *
43
52
  * Mastery is determined by:
44
53
  * - User's ELO for the tag exceeds threshold (or avgElo if not specified)
@@ -94,8 +103,13 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
94
103
 
95
104
  if (prereq.masteryThreshold?.minElo !== undefined) {
96
105
  return userTagElo.score >= prereq.masteryThreshold.minElo;
106
+ } else if (prereq.masteryThreshold?.minCount !== undefined) {
107
+ // Explicit minCount without minElo: count alone is sufficient.
108
+ // The config author specified a concrete interaction threshold —
109
+ // don't additionally require above-average ELO.
110
+ return true;
97
111
  } else {
98
- // Default: user ELO for tag > global user ELO (proxy for "above average")
112
+ // No thresholds specified at all: fall back to above-average ELO
99
113
  return userTagElo.score >= userGlobalElo;
100
114
  }
101
115
  }
@@ -195,17 +209,51 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
195
209
  }
196
210
  }
197
211
 
212
+ /**
213
+ * Build a map of prereq tag → max configured boost for all *closed* gates.
214
+ *
215
+ * When a gate is closed (prereqs unmet), cards carrying that gate's prereq
216
+ * tags get boosted — steering the pipeline toward content that helps unlock
217
+ * the gated material. Once the gate opens, the boost disappears.
218
+ */
219
+ private getPreReqBoosts(
220
+ unlockedTags: Set<string>,
221
+ masteredTags: Set<string>
222
+ ): Map<string, number> {
223
+ const boosts = new Map<string, number>();
224
+
225
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
226
+ // Only boost prereqs of closed gates
227
+ if (unlockedTags.has(tagId)) continue;
228
+
229
+ for (const prereq of prereqs) {
230
+ if (!prereq.preReqBoost || prereq.preReqBoost <= 1.0) continue;
231
+ // Only boost prereqs that aren't already met
232
+ if (masteredTags.has(prereq.tag)) continue;
233
+
234
+ const existing = boosts.get(prereq.tag) ?? 1.0;
235
+ boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
236
+ }
237
+ }
238
+
239
+ return boosts;
240
+ }
241
+
198
242
  /**
199
243
  * CardFilter.transform implementation.
200
244
  *
201
- * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
245
+ * Two effects:
246
+ * 1. Cards with locked tags receive score * 0.05 (gating penalty)
247
+ * 2. Cards carrying prereq tags of closed gates receive a configured
248
+ * boost (preReqBoost), steering toward content that unlocks gates
202
249
  */
203
250
  async transform(cards: WeightedCard[], context: FilterContext): Promise<WeightedCard[]> {
204
251
  // Get mastery state
205
252
  const masteredTags = await this.getMasteredTags(context);
206
253
  const unlockedTags = this.getUnlockedTags(masteredTags);
254
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
207
255
 
208
- // Apply prerequisite gating as score multiplier
256
+ // Apply prerequisite gating + prereq boosting
209
257
  const gated: WeightedCard[] = [];
210
258
 
211
259
  for (const card of cards) {
@@ -215,9 +263,31 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
215
263
  unlockedTags,
216
264
  masteredTags
217
265
  );
218
- const LOCKED_PENALTY = 0.01;
219
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
220
- const action = isUnlocked ? 'passed' : 'penalized';
266
+ const LOCKED_PENALTY = 0.02;
267
+ let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
268
+ let action: 'passed' | 'penalized' | 'boosted' = isUnlocked ? 'passed' : 'penalized';
269
+ let finalReason = reason;
270
+
271
+ // Apply prereq boost to cards that passed gating (don't boost locked cards)
272
+ if (isUnlocked && preReqBoosts.size > 0) {
273
+ const cardTags = card.tags ?? [];
274
+ let maxBoost = 1.0;
275
+ const boostedPrereqs: string[] = [];
276
+
277
+ for (const tag of cardTags) {
278
+ const boost = preReqBoosts.get(tag);
279
+ if (boost && boost > maxBoost) {
280
+ maxBoost = boost;
281
+ boostedPrereqs.push(tag);
282
+ }
283
+ }
284
+
285
+ if (maxBoost > 1.0) {
286
+ finalScore *= maxBoost;
287
+ action = 'boosted';
288
+ finalReason = `${reason} | preReqBoost ×${maxBoost.toFixed(2)} for ${boostedPrereqs.join(', ')}`;
289
+ }
290
+ }
221
291
 
222
292
  gated.push({
223
293
  ...card,
@@ -230,7 +300,7 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
230
300
  strategyId: this.strategyId || 'NAVIGATION_STRATEGY-hierarchy',
231
301
  action,
232
302
  score: finalScore,
233
- reason,
303
+ reason: finalReason,
234
304
  },
235
305
  ],
236
306
  });
@@ -0,0 +1,95 @@
1
+ import type { CourseDBInterface } from '../../interfaces/courseDB';
2
+ import type { UserDBInterface } from '../../interfaces/userDB';
3
+ import { ContentNavigator } from '../index';
4
+ import type { WeightedCard } from '../index';
5
+ import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
6
+ import type { CardGenerator, GeneratorContext } from './types';
7
+ import { logger } from '@db/util/logger';
8
+
9
+ // ============================================================================
10
+ // PRESCRIBED CARDS GENERATOR
11
+ // ============================================================================
12
+ //
13
+ // A generator that always emits a configured list of card IDs at score 1.0.
14
+ //
15
+ // Use case: Cold-start curriculum bootstrapping. Ensures critical cards
16
+ // (e.g., intro-s, early WS exercises) are always in the candidate set
17
+ // regardless of ELO proximity sampling. Filters (hierarchy, priority)
18
+ // still run — cards whose utility has expired get penalized normally
19
+ // and drop out of the top-N selection.
20
+ //
21
+ // Config format:
22
+ // { "cardIds": ["c-intro-s-S", "c-ws-sit-abc123", ...] }
23
+ //
24
+ // ============================================================================
25
+
26
+ interface PrescribedConfig {
27
+ cardIds: string[];
28
+ }
29
+
30
+ export default class PrescribedCardsGenerator extends ContentNavigator implements CardGenerator {
31
+ name: string;
32
+ private config: PrescribedConfig;
33
+
34
+ constructor(
35
+ user: UserDBInterface,
36
+ course: CourseDBInterface,
37
+ strategyData: ContentNavigationStrategyData
38
+ ) {
39
+ super(user, course, strategyData);
40
+ this.name = strategyData.name || 'Prescribed Cards';
41
+
42
+ try {
43
+ const parsed = JSON.parse(strategyData.serializedData);
44
+ this.config = { cardIds: parsed.cardIds || [] };
45
+ } catch {
46
+ this.config = { cardIds: [] };
47
+ }
48
+
49
+ logger.debug(
50
+ `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
51
+ );
52
+ }
53
+
54
+ async getWeightedCards(limit: number, _context?: GeneratorContext): Promise<WeightedCard[]> {
55
+ if (this.config.cardIds.length === 0) {
56
+ return [];
57
+ }
58
+
59
+ const courseId = this.course.getCourseID();
60
+
61
+ // Filter out cards the user has already interacted with
62
+ const activeCards = await this.user.getActiveCards();
63
+ const activeIds = new Set(activeCards.map((ac) => ac.cardID));
64
+ const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
65
+
66
+ if (eligibleIds.length === 0) {
67
+ logger.debug('[Prescribed] All prescribed cards already active, returning empty');
68
+ return [];
69
+ }
70
+
71
+ // Emit at score 1.0 — CompositeGenerator deduplicates, and if ELO
72
+ // also surfaces the same card, the composite picks the higher score.
73
+ const cards: WeightedCard[] = eligibleIds.slice(0, limit).map((cardId) => ({
74
+ cardId,
75
+ courseId,
76
+ score: 1.0,
77
+ provenance: [
78
+ {
79
+ strategy: 'prescribed',
80
+ strategyName: this.strategyName || this.name,
81
+ strategyId: this.strategyId || 'NAVIGATION_STRATEGY-prescribed',
82
+ action: 'generated' as const,
83
+ score: 1.0,
84
+ reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`,
85
+ },
86
+ ],
87
+ }));
88
+
89
+ logger.info(
90
+ `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
91
+ );
92
+
93
+ return cards;
94
+ }
95
+ }
@@ -46,10 +46,20 @@ export type NavigatorConstructor = new (
46
46
  ) => ContentNavigator;
47
47
 
48
48
  /**
49
- * Registry mapping implementingClass names to navigator constructors.
49
+ * Entry in the navigator registry, storing the constructor and an optional
50
+ * pipeline role. The role is used by PipelineAssembler to classify
51
+ * consumer-registered navigators that aren't in the built-in Navigators enum.
52
+ */
53
+ interface NavigatorRegistryEntry {
54
+ constructor: NavigatorConstructor;
55
+ role?: NavigatorRole;
56
+ }
57
+
58
+ /**
59
+ * Registry mapping implementingClass names to navigator entries.
50
60
  * Populated by registerNavigator() and used by ContentNavigator.create().
51
61
  */
52
- const navigatorRegistry = new Map<string, NavigatorConstructor>();
62
+ const navigatorRegistry = new Map<string, NavigatorRegistryEntry>();
53
63
 
54
64
  /**
55
65
  * Register a navigator implementation.
@@ -57,15 +67,21 @@ const navigatorRegistry = new Map<string, NavigatorConstructor>();
57
67
  * Call this to make a navigator available for instantiation by
58
68
  * ContentNavigator.create() without relying on dynamic imports.
59
69
  *
60
- * @param implementingClass - The class name (e.g., 'elo', 'hierarchyDefinition')
70
+ * Passing a `role` is optional for built-in navigators (whose roles are in
71
+ * the hardcoded `NavigatorRoles` record), but **required** for consumer-
72
+ * defined navigators that need to participate in pipeline assembly.
73
+ *
74
+ * @param implementingClass - The class name (e.g., 'elo', 'letterGatingFilter')
61
75
  * @param constructor - The navigator class constructor
76
+ * @param role - Optional pipeline role (GENERATOR or FILTER)
62
77
  */
63
78
  export function registerNavigator(
64
79
  implementingClass: string,
65
- constructor: NavigatorConstructor
80
+ constructor: NavigatorConstructor,
81
+ role?: NavigatorRole
66
82
  ): void {
67
- navigatorRegistry.set(implementingClass, constructor);
68
- logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
83
+ navigatorRegistry.set(implementingClass, { constructor, role });
84
+ logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ''}`);
69
85
  }
70
86
 
71
87
  /**
@@ -75,7 +91,7 @@ export function registerNavigator(
75
91
  * @returns The constructor, or undefined if not registered
76
92
  */
77
93
  export function getRegisteredNavigator(implementingClass: string): NavigatorConstructor | undefined {
78
- return navigatorRegistry.get(implementingClass);
94
+ return navigatorRegistry.get(implementingClass)?.constructor;
79
95
  }
80
96
 
81
97
  /**
@@ -88,6 +104,16 @@ export function hasRegisteredNavigator(implementingClass: string): boolean {
88
104
  return navigatorRegistry.has(implementingClass);
89
105
  }
90
106
 
107
+ /**
108
+ * Get the registered role for a navigator, if one was provided at registration.
109
+ *
110
+ * @param implementingClass - The class name to look up
111
+ * @returns The role, or undefined if not registered or no role was specified
112
+ */
113
+ export function getRegisteredNavigatorRole(implementingClass: string): NavigatorRole | undefined {
114
+ return navigatorRegistry.get(implementingClass)?.role;
115
+ }
116
+
91
117
  /**
92
118
  * Get all registered navigator names.
93
119
  * Useful for debugging and testing.
@@ -114,8 +140,10 @@ export async function initializeNavigatorRegistry(): Promise<void> {
114
140
  import('./generators/elo'),
115
141
  import('./generators/srs'),
116
142
  ]);
143
+ const prescribedModule = await import('./generators/prescribed');
117
144
  registerNavigator('elo', eloModule.default);
118
145
  registerNavigator('srs', srsModule.default);
146
+ registerNavigator('prescribed', prescribedModule.default);
119
147
 
120
148
  // Import and register filters
121
149
  const [
@@ -320,6 +348,7 @@ export function getCardOrigin(card: WeightedCard): 'new' | 'review' | 'failed' {
320
348
  export enum Navigators {
321
349
  ELO = 'elo',
322
350
  SRS = 'srs',
351
+ PRESCRIBED = 'prescribed',
323
352
  HIERARCHY = 'hierarchyDefinition',
324
353
  INTERFERENCE = 'interferenceMitigator',
325
354
  RELATIVE_PRIORITY = 'relativePriority',
@@ -358,6 +387,7 @@ export enum NavigatorRole {
358
387
  export const NavigatorRoles: Record<Navigators, NavigatorRole> = {
359
388
  [Navigators.ELO]: NavigatorRole.GENERATOR,
360
389
  [Navigators.SRS]: NavigatorRole.GENERATOR,
390
+ [Navigators.PRESCRIBED]: NavigatorRole.GENERATOR,
361
391
  [Navigators.HIERARCHY]: NavigatorRole.FILTER,
362
392
  [Navigators.INTERFERENCE]: NavigatorRole.FILTER,
363
393
  [Navigators.RELATIVE_PRIORITY]: NavigatorRole.FILTER,
@@ -371,17 +401,24 @@ export const NavigatorRoles: Record<Navigators, NavigatorRole> = {
371
401
  * @returns true if the navigator is a generator, false otherwise
372
402
  */
373
403
  export function isGenerator(impl: string): boolean {
374
- return NavigatorRoles[impl as Navigators] === NavigatorRole.GENERATOR;
404
+ if (NavigatorRoles[impl as Navigators] === NavigatorRole.GENERATOR) return true;
405
+ // Fallback: check the registry for consumer-registered navigators
406
+ return getRegisteredNavigatorRole(impl) === NavigatorRole.GENERATOR;
375
407
  }
376
408
 
377
409
  /**
378
410
  * Check if a navigator implementation is a filter.
379
411
  *
380
- * @param impl - Navigator implementation name (e.g., 'elo', 'hierarchyDefinition')
412
+ * Checks the built-in NavigatorRoles enum first, then falls back to the
413
+ * navigator registry for consumer-registered navigators.
414
+ *
415
+ * @param impl - Navigator implementation name (e.g., 'elo', 'letterGatingFilter')
381
416
  * @returns true if the navigator is a filter, false otherwise
382
417
  */
383
418
  export function isFilter(impl: string): boolean {
384
- return NavigatorRoles[impl as Navigators] === NavigatorRole.FILTER;
419
+ if (NavigatorRoles[impl as Navigators] === NavigatorRole.FILTER) return true;
420
+ // Fallback: check the registry for consumer-registered navigators
421
+ return getRegisteredNavigatorRole(impl) === NavigatorRole.FILTER;
385
422
  }
386
423
 
387
424
  /**
@@ -591,4 +628,12 @@ export abstract class ContentNavigator implements StudyContentSource {
591
628
  async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
592
629
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
593
630
  }
631
+
632
+ /**
633
+ * Set ephemeral hints for the next pipeline run.
634
+ * No-op for non-Pipeline navigators. Pipeline overrides this.
635
+ */
636
+ setEphemeralHints(_hints: Record<string, unknown>): void {
637
+ // no-op — only Pipeline implements this
638
+ }
594
639
  }
@@ -174,10 +174,13 @@ Currently logged-in as ${this._username}.`
174
174
  const docsToDelete = allDocs.rows
175
175
  .filter((row) => {
176
176
  const id = row.id;
177
- // Delete user progress data but preserve core user documents
177
+ // Delete user progress data but preserve authentication and user identity
178
178
  return (
179
179
  id.startsWith(DocTypePrefixes[DocType.CARDRECORD]) || // Card interaction history
180
180
  id.startsWith(DocTypePrefixes[DocType.SCHEDULED_CARD]) || // Scheduled reviews
181
+ id.startsWith(DocTypePrefixes[DocType.STRATEGY_STATE]) || // Strategy state (user prefs, progression)
182
+ id.startsWith(DocTypePrefixes[DocType.USER_OUTCOME]) || // Evolutionary orchestration outcomes
183
+ id.startsWith(DocTypePrefixes[DocType.STRATEGY_LEARNING_STATE]) || // Strategy learning state
181
184
  id === BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
182
185
  id === BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
183
186
  id === BaseUser.DOC_IDS.CONFIG // User config