@vue-skuilder/db 0.1.17 → 0.1.20

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 (92) hide show
  1. package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CZdMBiTU.d.ts} +427 -104
  2. package/dist/{userDB-DNa0XPtn.d.ts → classroomDB-PxDZTky3.d.cts} +427 -104
  3. package/dist/core/index.d.cts +304 -0
  4. package/dist/core/index.d.ts +237 -25
  5. package/dist/core/index.js +2246 -118
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +2235 -114
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D0MoZMjH.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-D8o6ZnKW.d.ts} +1 -1
  11. package/dist/impl/couch/{index.d.mts → index.d.cts} +47 -5
  12. package/dist/impl/couch/index.d.ts +46 -4
  13. package/dist/impl/couch/index.js +2250 -134
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +2212 -97
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/{index.d.mts → index.d.cts} +6 -6
  18. package/dist/impl/static/index.d.ts +5 -5
  19. package/dist/impl/static/index.js +1950 -143
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +1922 -117
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-Bmll7Xse.d.mts → index-B_j6u5E4.d.cts} +1 -1
  24. package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
  25. package/dist/{index.d.mts → index.d.cts} +97 -13
  26. package/dist/index.d.ts +96 -12
  27. package/dist/index.js +2439 -180
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +2386 -135
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/pouch/index.js +3 -3
  32. package/dist/{types-Dbp5DaRR.d.mts → types-Bn0itutr.d.cts} +1 -1
  33. package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
  34. package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.cts} +3 -1
  35. package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-DDY4N-Uq.d.ts} +3 -1
  36. package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
  37. package/dist/util/packer/index.d.ts +3 -3
  38. package/dist/util/packer/index.js.map +1 -1
  39. package/dist/util/packer/index.mjs.map +1 -1
  40. package/docs/brainstorm-navigation-paradigm.md +369 -0
  41. package/docs/navigators-architecture.md +370 -0
  42. package/docs/todo-evolutionary-orchestration.md +310 -0
  43. package/docs/todo-nominal-tag-types.md +121 -0
  44. package/docs/todo-strategy-authoring.md +401 -0
  45. package/eslint.config.mjs +1 -1
  46. package/package.json +9 -4
  47. package/src/core/index.ts +1 -0
  48. package/src/core/interfaces/contentSource.ts +88 -4
  49. package/src/core/interfaces/courseDB.ts +13 -0
  50. package/src/core/interfaces/navigationStrategyManager.ts +0 -5
  51. package/src/core/interfaces/userDB.ts +32 -0
  52. package/src/core/navigators/CompositeGenerator.ts +268 -0
  53. package/src/core/navigators/Pipeline.ts +318 -0
  54. package/src/core/navigators/PipelineAssembler.ts +194 -0
  55. package/src/core/navigators/elo.ts +104 -15
  56. package/src/core/navigators/filters/eloDistance.ts +132 -0
  57. package/src/core/navigators/filters/index.ts +9 -0
  58. package/src/core/navigators/filters/types.ts +115 -0
  59. package/src/core/navigators/filters/userTagPreference.ts +232 -0
  60. package/src/core/navigators/generators/index.ts +2 -0
  61. package/src/core/navigators/generators/types.ts +107 -0
  62. package/src/core/navigators/hardcodedOrder.ts +111 -12
  63. package/src/core/navigators/hierarchyDefinition.ts +266 -0
  64. package/src/core/navigators/index.ts +404 -3
  65. package/src/core/navigators/inferredPreference.ts +107 -0
  66. package/src/core/navigators/interferenceMitigator.ts +355 -0
  67. package/src/core/navigators/relativePriority.ts +255 -0
  68. package/src/core/navigators/srs.ts +195 -0
  69. package/src/core/navigators/userGoal.ts +136 -0
  70. package/src/core/types/strategyState.ts +84 -0
  71. package/src/core/types/types-legacy.ts +2 -0
  72. package/src/impl/common/BaseUserDB.ts +74 -7
  73. package/src/impl/couch/adminDB.ts +1 -2
  74. package/src/impl/couch/classroomDB.ts +51 -0
  75. package/src/impl/couch/courseDB.ts +147 -49
  76. package/src/impl/static/courseDB.ts +11 -4
  77. package/src/study/SessionController.ts +149 -1
  78. package/src/study/TagFilteredContentSource.ts +255 -0
  79. package/src/study/index.ts +1 -0
  80. package/src/util/dataDirectory.test.ts +51 -22
  81. package/src/util/logger.ts +0 -1
  82. package/tests/core/navigators/CompositeGenerator.test.ts +455 -0
  83. package/tests/core/navigators/Pipeline.test.ts +406 -0
  84. package/tests/core/navigators/PipelineAssembler.test.ts +351 -0
  85. package/tests/core/navigators/SRSNavigator.test.ts +344 -0
  86. package/tests/core/navigators/eloDistanceFilter.test.ts +192 -0
  87. package/tests/core/navigators/navigators.test.ts +710 -0
  88. package/tsconfig.json +1 -1
  89. package/vitest.config.ts +29 -0
  90. package/dist/core/index.d.mts +0 -92
  91. /package/dist/{SyncStrategy-CyATpyLQ.d.mts → SyncStrategy-CyATpyLQ.d.cts} +0 -0
  92. /package/dist/pouch/{index.d.mts → index.d.cts} +0 -0
@@ -0,0 +1,266 @@
1
+ import type { ScheduledCard } from '../types/user';
2
+ import type { CourseDBInterface } from '../interfaces/courseDB';
3
+ import type { UserDBInterface } from '../interfaces/userDB';
4
+ import { ContentNavigator } from './index';
5
+ import type { WeightedCard } from './index';
6
+ import type { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
7
+ import type { StudySessionReviewItem, StudySessionNewItem } from '..';
8
+ import type { CardFilter, FilterContext } from './filters/types';
9
+ import { toCourseElo } from '@vue-skuilder/common';
10
+
11
+ /**
12
+ * A single prerequisite requirement for a tag.
13
+ * Each prerequisite refers to one tag with its own mastery threshold.
14
+ */
15
+ interface TagPrerequisite {
16
+ /** The tag that must be mastered */
17
+ tag: string;
18
+ /** Thresholds for considering this prerequisite tag "mastered" */
19
+ masteryThreshold?: {
20
+ /** Minimum ELO score for mastery. If not set, uses avgElo comparison */
21
+ minElo?: number;
22
+ /** Minimum interaction count (default: 3) */
23
+ minCount?: number;
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Configuration for the HierarchyDefinition strategy
29
+ */
30
+ export interface HierarchyConfig {
31
+ /** Map of tag ID to its list of prerequisites (each with individual thresholds) */
32
+ prerequisites: {
33
+ [tagId: string]: TagPrerequisite[];
34
+ };
35
+ }
36
+
37
+ const DEFAULT_MIN_COUNT = 3;
38
+
39
+ /**
40
+ * A filter strategy that gates cards based on prerequisite mastery.
41
+ *
42
+ * Cards are locked until the user masters all prerequisite tags.
43
+ * Locked cards receive score: 0 (hard filter).
44
+ *
45
+ * Mastery is determined by:
46
+ * - User's ELO for the tag exceeds threshold (or avgElo if not specified)
47
+ * - User has minimum interaction count with the tag
48
+ *
49
+ * Tags with no prerequisites are always unlocked.
50
+ *
51
+ * Implements CardFilter for use in Pipeline architecture.
52
+ * Also extends ContentNavigator for backward compatibility.
53
+ */
54
+ export default class HierarchyDefinitionNavigator extends ContentNavigator implements CardFilter {
55
+ private config: HierarchyConfig;
56
+ private _strategyData: ContentNavigationStrategyData;
57
+
58
+ /** Human-readable name for CardFilter interface */
59
+ name: string;
60
+
61
+ constructor(
62
+ user: UserDBInterface,
63
+ course: CourseDBInterface,
64
+ _strategyData: ContentNavigationStrategyData
65
+ ) {
66
+ super(user, course, _strategyData);
67
+ this._strategyData = _strategyData;
68
+ this.config = this.parseConfig(_strategyData.serializedData);
69
+ this.name = _strategyData.name || 'Hierarchy Definition';
70
+ }
71
+
72
+ private parseConfig(serializedData: string): HierarchyConfig {
73
+ try {
74
+ const parsed = JSON.parse(serializedData);
75
+ return {
76
+ prerequisites: parsed.prerequisites || {},
77
+ };
78
+ } catch {
79
+ // Return safe defaults if parsing fails
80
+ return {
81
+ prerequisites: {},
82
+ };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Check if a specific prerequisite is satisfied
88
+ */
89
+ private isPrerequisiteMet(
90
+ prereq: TagPrerequisite,
91
+ userTagElo: { score: number; count: number } | undefined,
92
+ userGlobalElo: number
93
+ ): boolean {
94
+ if (!userTagElo) return false;
95
+
96
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
97
+ if (userTagElo.count < minCount) return false;
98
+
99
+ if (prereq.masteryThreshold?.minElo !== undefined) {
100
+ return userTagElo.score >= prereq.masteryThreshold.minElo;
101
+ } else {
102
+ // Default: user ELO for tag > global user ELO (proxy for "above average")
103
+ return userTagElo.score >= userGlobalElo;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Get the set of tags the user has mastered.
109
+ * A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
110
+ */
111
+ private async getMasteredTags(context: FilterContext): Promise<Set<string>> {
112
+ const mastered = new Set<string>();
113
+
114
+ try {
115
+ const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
116
+ const userElo = toCourseElo(courseReg.elo);
117
+
118
+ // Collect all unique prerequisite tags and check mastery for each
119
+ for (const prereqs of Object.values(this.config.prerequisites)) {
120
+ for (const prereq of prereqs) {
121
+ const tagElo = userElo.tags[prereq.tag];
122
+ if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
123
+ mastered.add(prereq.tag);
124
+ }
125
+ }
126
+ }
127
+ } catch {
128
+ // If we can't get user data, return empty set (no tags mastered)
129
+ }
130
+
131
+ return mastered;
132
+ }
133
+
134
+ /**
135
+ * Get the set of tags that are unlocked (prerequisites met)
136
+ */
137
+ private getUnlockedTags(masteredTags: Set<string>): Set<string> {
138
+ const unlocked = new Set<string>();
139
+
140
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
141
+ const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
142
+ if (allPrereqsMet) {
143
+ unlocked.add(tagId);
144
+ }
145
+ }
146
+
147
+ return unlocked;
148
+ }
149
+
150
+ /**
151
+ * Check if a tag has prerequisites defined in config
152
+ */
153
+ private hasPrerequisites(tagId: string): boolean {
154
+ return tagId in this.config.prerequisites;
155
+ }
156
+
157
+ /**
158
+ * Check if a card is unlocked and generate reason.
159
+ */
160
+ private async checkCardUnlock(
161
+ card: WeightedCard,
162
+ course: CourseDBInterface,
163
+ unlockedTags: Set<string>,
164
+ masteredTags: Set<string>
165
+ ): Promise<{ isUnlocked: boolean; reason: string }> {
166
+ try {
167
+ // Pipeline hydrates tags before filters run
168
+ const cardTags = card.tags ?? [];
169
+
170
+ // Check each tag's prerequisite status
171
+ const lockedTags = cardTags.filter(
172
+ (tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
173
+ );
174
+
175
+ if (lockedTags.length === 0) {
176
+ const tagList = cardTags.length > 0 ? cardTags.join(', ') : 'none';
177
+ return {
178
+ isUnlocked: true,
179
+ reason: `Prerequisites met, tags: ${tagList}`,
180
+ };
181
+ }
182
+
183
+ // Find missing prerequisites for locked tags
184
+ const missingPrereqs = lockedTags.flatMap((tag) => {
185
+ const prereqs = this.config.prerequisites[tag] || [];
186
+ return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
187
+ });
188
+
189
+ return {
190
+ isUnlocked: false,
191
+ reason: `Blocked: missing prerequisites ${missingPrereqs.join(', ')} for tags ${lockedTags.join(', ')}`,
192
+ };
193
+ } catch {
194
+ // If we can't get tags, assume unlocked (fail open)
195
+ return {
196
+ isUnlocked: true,
197
+ reason: 'Prerequisites check skipped (tag lookup failed)',
198
+ };
199
+ }
200
+ }
201
+
202
+ /**
203
+ * CardFilter.transform implementation.
204
+ *
205
+ * Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
206
+ */
207
+ async transform(cards: WeightedCard[], context: FilterContext): Promise<WeightedCard[]> {
208
+ // Get mastery state
209
+ const masteredTags = await this.getMasteredTags(context);
210
+ const unlockedTags = this.getUnlockedTags(masteredTags);
211
+
212
+ // Apply prerequisite gating as score multiplier
213
+ const gated: WeightedCard[] = [];
214
+
215
+ for (const card of cards) {
216
+ const { isUnlocked, reason } = await this.checkCardUnlock(
217
+ card,
218
+ context.course,
219
+ unlockedTags,
220
+ masteredTags
221
+ );
222
+ const finalScore = isUnlocked ? card.score : 0;
223
+ const action = isUnlocked ? 'passed' : 'penalized';
224
+
225
+ gated.push({
226
+ ...card,
227
+ score: finalScore,
228
+ provenance: [
229
+ ...card.provenance,
230
+ {
231
+ strategy: 'hierarchyDefinition',
232
+ strategyName: this.strategyName || this.name,
233
+ strategyId: this.strategyId || 'NAVIGATION_STRATEGY-hierarchy',
234
+ action,
235
+ score: finalScore,
236
+ reason,
237
+ },
238
+ ],
239
+ });
240
+ }
241
+
242
+ return gated;
243
+ }
244
+
245
+ /**
246
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
247
+ *
248
+ * Use transform() via Pipeline instead.
249
+ */
250
+ async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
251
+ throw new Error(
252
+ 'HierarchyDefinitionNavigator is a filter and should not be used as a generator. ' +
253
+ 'Use Pipeline with a generator and this filter via transform().'
254
+ );
255
+ }
256
+
257
+ // Legacy methods - stub implementations since filters don't generate cards
258
+
259
+ async getNewCards(_n?: number): Promise<StudySessionNewItem[]> {
260
+ return [];
261
+ }
262
+
263
+ async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
264
+ return [];
265
+ }
266
+ }
@@ -5,23 +5,341 @@ import {
5
5
  StudySessionReviewItem,
6
6
  StudySessionNewItem,
7
7
  } from '..';
8
+
9
+ // Re-export filter types
10
+ export type { CardFilter, FilterContext, CardFilterFactory } from './filters/types';
11
+
12
+ // Re-export generator types
13
+ export type { CardGenerator, GeneratorContext, CardGeneratorFactory } from './generators/types';
14
+
8
15
  import { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
9
16
  import { ScheduledCard } from '../types/user';
10
17
  import { logger } from '../../util/logger';
11
18
 
19
+ // ============================================================================
20
+ // NAVIGATION STRATEGY API
21
+ // ============================================================================
22
+ //
23
+ // This module defines the ContentNavigator base class and the WeightedCard type,
24
+ // which form the foundation of the pluggable navigation strategy system.
25
+ //
26
+ // KEY CONCEPTS:
27
+ //
28
+ // 1. WeightedCard - A card with a suitability score (0-1) and provenance trail.
29
+ // The provenance tracks how each strategy in the pipeline contributed to
30
+ // the card's final score, ensuring transparency and debuggability.
31
+ //
32
+ // 2. ContentNavigator - Abstract base class for backward compatibility.
33
+ // New code should use CardGenerator or CardFilter interfaces directly.
34
+ //
35
+ // 3. CardGenerator vs CardFilter:
36
+ // - Generators (ELO, SRS, HardcodedOrder) produce candidate cards with scores
37
+ // - Filters (Hierarchy, Interference, Priority, EloDistance) transform scores
38
+ //
39
+ // 4. Pipeline architecture:
40
+ // Pipeline(generator, [filter1, filter2, ...]) executes:
41
+ // cards = generator.getWeightedCards()
42
+ // cards = filter1.transform(cards, context)
43
+ // cards = filter2.transform(cards, context)
44
+ // return sorted(cards)
45
+ //
46
+ // 5. Provenance tracking - Each strategy adds an entry explaining its contribution.
47
+ // This makes the system transparent and debuggable.
48
+ //
49
+ // ============================================================================
50
+
51
+ /**
52
+ * Tracks a single strategy's contribution to a card's final score.
53
+ *
54
+ * Each strategy in the pipeline adds a StrategyContribution entry to the
55
+ * card's provenance array, creating an audit trail of scoring decisions.
56
+ */
57
+ export interface StrategyContribution {
58
+ /**
59
+ * Strategy type (implementing class name).
60
+ * Examples: 'elo', 'hierarchyDefinition', 'interferenceMitigator'
61
+ */
62
+ strategy: string;
63
+
64
+ /**
65
+ * Human-readable name identifying this specific strategy instance.
66
+ * Extracted from ContentNavigationStrategyData.name.
67
+ * Courses may have multiple instances of the same strategy type with
68
+ * different configurations.
69
+ *
70
+ * Examples:
71
+ * - "ELO (default)"
72
+ * - "Interference: b/d/p confusion"
73
+ * - "Interference: phonetic confusables"
74
+ * - "Priority: Common letters first"
75
+ */
76
+ strategyName: string;
77
+
78
+ /**
79
+ * Unique database document ID for this strategy instance.
80
+ * Extracted from ContentNavigationStrategyData._id.
81
+ * Use this to fetch the full strategy configuration document.
82
+ *
83
+ * Examples:
84
+ * - "NAVIGATION_STRATEGY-ELO-default"
85
+ * - "NAVIGATION_STRATEGY-interference-bdp"
86
+ * - "NAVIGATION_STRATEGY-priority-common-letters"
87
+ */
88
+ strategyId: string;
89
+
90
+ /**
91
+ * What the strategy did:
92
+ * - 'generated': Strategy produced this card (generators only)
93
+ * - 'passed': Strategy evaluated but didn't change score (transparent pass-through)
94
+ * - 'boosted': Strategy increased the score
95
+ * - 'penalized': Strategy decreased the score
96
+ */
97
+ action: 'generated' | 'passed' | 'boosted' | 'penalized';
98
+
99
+ /** Score after this strategy's processing */
100
+ score: number;
101
+
102
+ /**
103
+ * Human-readable explanation of the strategy's decision.
104
+ *
105
+ * Examples:
106
+ * - "ELO distance 75, new card"
107
+ * - "Prerequisites met: letter-sounds"
108
+ * - "Interferes with immature tag 'd' (decay 0.8)"
109
+ * - "High-priority tag 's' (0.95) → boost 1.15x"
110
+ *
111
+ * Required for transparency - silent adjusters are anti-patterns.
112
+ */
113
+ reason: string;
114
+ }
115
+
116
+ /**
117
+ * A card with a suitability score and provenance trail.
118
+ *
119
+ * Scores range from 0-1:
120
+ * - 1.0 = fully suitable
121
+ * - 0.0 = hard filter (e.g., prerequisite not met)
122
+ * - 0.5 = neutral
123
+ * - Intermediate values = soft preference
124
+ *
125
+ * Provenance tracks the scoring pipeline:
126
+ * - First entry: Generator that produced the card
127
+ * - Subsequent entries: Filters that transformed the score
128
+ * - Each entry includes action and human-readable reason
129
+ */
130
+ export interface WeightedCard {
131
+ cardId: string;
132
+ courseId: string;
133
+ /** Suitability score from 0-1 */
134
+ score: number;
135
+ /**
136
+ * Audit trail of strategy contributions.
137
+ * First entry is from the generator, subsequent entries from filters.
138
+ */
139
+ provenance: StrategyContribution[];
140
+ /**
141
+ * Pre-fetched tags. Populated by Pipeline before filters run.
142
+ * Filters should use this instead of querying getAppliedTags() individually.
143
+ */
144
+ tags?: string[];
145
+ }
146
+
147
+ /**
148
+ * Extract card origin from provenance trail.
149
+ *
150
+ * The first provenance entry (from the generator) indicates whether
151
+ * this is a new card, review, or failed card. We parse the reason
152
+ * string to extract this information.
153
+ *
154
+ * @param card - Card with provenance trail
155
+ * @returns Card origin ('new', 'review', or 'failed')
156
+ */
157
+ export function getCardOrigin(card: WeightedCard): 'new' | 'review' | 'failed' {
158
+ if (card.provenance.length === 0) {
159
+ throw new Error('Card has no provenance - cannot determine origin');
160
+ }
161
+
162
+ const firstEntry = card.provenance[0];
163
+ const reason = firstEntry.reason.toLowerCase();
164
+
165
+ if (reason.includes('failed')) {
166
+ return 'failed';
167
+ }
168
+ if (reason.includes('review')) {
169
+ return 'review';
170
+ }
171
+ return 'new';
172
+ }
173
+
12
174
  export enum Navigators {
13
175
  ELO = 'elo',
176
+ SRS = 'srs',
14
177
  HARDCODED = 'hardcodedOrder',
178
+ HIERARCHY = 'hierarchyDefinition',
179
+ INTERFERENCE = 'interferenceMitigator',
180
+ RELATIVE_PRIORITY = 'relativePriority',
181
+ USER_TAG_PREFERENCE = 'userTagPreference',
182
+ }
183
+
184
+ // ============================================================================
185
+ // NAVIGATOR ROLE CLASSIFICATION
186
+ // ============================================================================
187
+ //
188
+ // Navigators are classified as either generators or filters:
189
+ // - Generators: Produce candidate cards (ELO, SRS, HardcodedOrder)
190
+ // - Filters: Transform/score candidates (Hierarchy, Interference, RelativePriority)
191
+ //
192
+ // This classification is used by PipelineAssembler to build pipelines:
193
+ // 1. Instantiate generators (possibly into a CompositeGenerator)
194
+ // 2. Instantiate filters
195
+ // 3. Create Pipeline(generator, filters)
196
+ //
197
+ // ============================================================================
198
+
199
+ /**
200
+ * Role classification for navigation strategies.
201
+ *
202
+ * - GENERATOR: Produces candidate cards with initial scores
203
+ * - FILTER: Transforms cards with score multipliers
204
+ */
205
+ export enum NavigatorRole {
206
+ GENERATOR = 'generator',
207
+ FILTER = 'filter',
208
+ }
209
+
210
+ /**
211
+ * Registry mapping navigator implementations to their roles.
212
+ */
213
+ export const NavigatorRoles: Record<Navigators, NavigatorRole> = {
214
+ [Navigators.ELO]: NavigatorRole.GENERATOR,
215
+ [Navigators.SRS]: NavigatorRole.GENERATOR,
216
+ [Navigators.HARDCODED]: NavigatorRole.GENERATOR,
217
+ [Navigators.HIERARCHY]: NavigatorRole.FILTER,
218
+ [Navigators.INTERFERENCE]: NavigatorRole.FILTER,
219
+ [Navigators.RELATIVE_PRIORITY]: NavigatorRole.FILTER,
220
+ [Navigators.USER_TAG_PREFERENCE]: NavigatorRole.FILTER,
221
+ };
222
+
223
+ /**
224
+ * Check if a navigator implementation is a generator.
225
+ *
226
+ * @param impl - Navigator implementation name (e.g., 'elo', 'hierarchyDefinition')
227
+ * @returns true if the navigator is a generator, false otherwise
228
+ */
229
+ export function isGenerator(impl: string): boolean {
230
+ return NavigatorRoles[impl as Navigators] === NavigatorRole.GENERATOR;
15
231
  }
16
232
 
17
233
  /**
18
- * A content-navigator provides runtime steering of study sessions.
234
+ * Check if a navigator implementation is a filter.
235
+ *
236
+ * @param impl - Navigator implementation name (e.g., 'elo', 'hierarchyDefinition')
237
+ * @returns true if the navigator is a filter, false otherwise
238
+ */
239
+ export function isFilter(impl: string): boolean {
240
+ return NavigatorRoles[impl as Navigators] === NavigatorRole.FILTER;
241
+ }
242
+
243
+ /**
244
+ * Abstract base class for navigation strategies.
245
+ *
246
+ * This class exists primarily for backward compatibility with legacy code.
247
+ * New code should use CardGenerator or CardFilter interfaces directly.
248
+ *
249
+ * The class implements StudyContentSource for compatibility with SessionController.
250
+ * Once SessionController migrates to use getWeightedCards() exclusively,
251
+ * the legacy methods can be removed.
19
252
  */
20
253
  export abstract class ContentNavigator implements StudyContentSource {
254
+ /** User interface for this navigation session */
255
+ protected user?: UserDBInterface;
256
+
257
+ /** Course interface for this navigation session */
258
+ protected course?: CourseDBInterface;
259
+
260
+ /** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
261
+ protected strategyName?: string;
262
+
263
+ /** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
264
+ protected strategyId?: string;
265
+
266
+ /**
267
+ * Constructor for standard navigators.
268
+ * Call this from subclass constructors to initialize common fields.
269
+ *
270
+ * Note: CompositeGenerator doesn't use this pattern and should call super() without args.
271
+ */
272
+ constructor(
273
+ user?: UserDBInterface,
274
+ course?: CourseDBInterface,
275
+ strategyData?: ContentNavigationStrategyData
276
+ ) {
277
+ if (user && course && strategyData) {
278
+ this.user = user;
279
+ this.course = course;
280
+ this.strategyName = strategyData.name;
281
+ this.strategyId = strategyData._id;
282
+ }
283
+ }
284
+
285
+ // ============================================================================
286
+ // STRATEGY STATE HELPERS
287
+ // ============================================================================
288
+ //
289
+ // These methods allow strategies to persist their own state (user preferences,
290
+ // learned patterns, temporal tracking) in the user database.
291
+ //
292
+ // ============================================================================
293
+
294
+ /**
295
+ * Unique key identifying this strategy for state storage.
296
+ *
297
+ * Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
298
+ * Override in subclasses if multiple instances of the same strategy type
299
+ * need separate state storage.
300
+ */
301
+ protected get strategyKey(): string {
302
+ return this.constructor.name;
303
+ }
304
+
305
+ /**
306
+ * Get this strategy's persisted state for the current course.
307
+ *
308
+ * @returns The strategy's data payload, or null if no state exists
309
+ * @throws Error if user or course is not initialized
310
+ */
311
+ protected async getStrategyState<T>(): Promise<T | null> {
312
+ if (!this.user || !this.course) {
313
+ throw new Error(
314
+ `Cannot get strategy state: navigator not properly initialized. ` +
315
+ `Ensure user and course are provided to constructor.`
316
+ );
317
+ }
318
+ return this.user.getStrategyState<T>(this.course.getCourseID(), this.strategyKey);
319
+ }
320
+
321
+ /**
322
+ * Persist this strategy's state for the current course.
323
+ *
324
+ * @param data - The strategy's data payload to store
325
+ * @throws Error if user or course is not initialized
326
+ */
327
+ protected async putStrategyState<T>(data: T): Promise<void> {
328
+ if (!this.user || !this.course) {
329
+ throw new Error(
330
+ `Cannot put strategy state: navigator not properly initialized. ` +
331
+ `Ensure user and course are provided to constructor.`
332
+ );
333
+ }
334
+ return this.user.putStrategyState<T>(this.course.getCourseID(), this.strategyKey, data);
335
+ }
336
+
21
337
  /**
338
+ * Factory method to create navigator instances dynamically.
22
339
  *
23
- * @param user
24
- * @param strategyData
340
+ * @param user - User interface
341
+ * @param course - Course interface
342
+ * @param strategyData - Strategy configuration document
25
343
  * @returns the runtime object used to steer a study session.
26
344
  */
27
345
  static async create(
@@ -53,6 +371,89 @@ export abstract class ContentNavigator implements StudyContentSource {
53
371
  return new NavigatorImpl(user, course, strategyData);
54
372
  }
55
373
 
374
+ /**
375
+ * Get cards scheduled for review.
376
+ *
377
+ * @deprecated This method is part of the legacy StudyContentSource interface.
378
+ * New strategies should focus on implementing CardGenerator.getWeightedCards() instead.
379
+ */
56
380
  abstract getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]>;
381
+
382
+ /**
383
+ * Get new cards for introduction.
384
+ *
385
+ * @deprecated This method is part of the legacy StudyContentSource interface.
386
+ * New strategies should focus on implementing CardGenerator.getWeightedCards() instead.
387
+ *
388
+ * @param n - Maximum number of new cards to return
389
+ */
57
390
  abstract getNewCards(n?: number): Promise<StudySessionNewItem[]>;
391
+
392
+ /**
393
+ * Get cards with suitability scores and provenance trails.
394
+ *
395
+ * **This is the PRIMARY API for navigation strategies.**
396
+ *
397
+ * Returns cards ranked by suitability score (0-1). Higher scores indicate
398
+ * better candidates for presentation. Each card includes a provenance trail
399
+ * documenting how strategies contributed to the final score.
400
+ *
401
+ * ## For Generators
402
+ * Override this method to generate candidates and compute scores based on
403
+ * your strategy's logic (e.g., ELO proximity, review urgency). Create the
404
+ * initial provenance entry with action='generated'.
405
+ *
406
+ * ## Default Implementation
407
+ * The base class provides a backward-compatible default that:
408
+ * 1. Calls legacy getNewCards() and getPendingReviews()
409
+ * 2. Assigns score=1.0 to all cards
410
+ * 3. Creates minimal provenance from legacy methods
411
+ * 4. Returns combined results up to limit
412
+ *
413
+ * This allows existing strategies to work without modification while
414
+ * new strategies can override with proper scoring and provenance.
415
+ *
416
+ * @param limit - Maximum cards to return
417
+ * @returns Cards sorted by score descending, with provenance trails
418
+ */
419
+ async getWeightedCards(limit: number): Promise<WeightedCard[]> {
420
+ // Default implementation: delegate to legacy methods, assign score=1.0
421
+ const newCards = await this.getNewCards(limit);
422
+ const reviews = await this.getPendingReviews();
423
+
424
+ const weighted: WeightedCard[] = [
425
+ ...newCards.map((c) => ({
426
+ cardId: c.cardID,
427
+ courseId: c.courseID,
428
+ score: 1.0,
429
+ provenance: [
430
+ {
431
+ strategy: 'legacy',
432
+ strategyName: this.strategyName || 'Legacy API',
433
+ strategyId: this.strategyId || 'legacy-fallback',
434
+ action: 'generated' as const,
435
+ score: 1.0,
436
+ reason: 'Generated via legacy getNewCards(), new card',
437
+ },
438
+ ],
439
+ })),
440
+ ...reviews.map((r) => ({
441
+ cardId: r.cardID,
442
+ courseId: r.courseID,
443
+ score: 1.0,
444
+ provenance: [
445
+ {
446
+ strategy: 'legacy',
447
+ strategyName: this.strategyName || 'Legacy API',
448
+ strategyId: this.strategyId || 'legacy-fallback',
449
+ action: 'generated' as const,
450
+ score: 1.0,
451
+ reason: 'Generated via legacy getPendingReviews(), review',
452
+ },
453
+ ],
454
+ })),
455
+ ];
456
+
457
+ return weighted.slice(0, limit);
458
+ }
58
459
  }