@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
@@ -92,6 +92,19 @@ export interface CourseDBInterface extends NavigationStrategyManager {
92
92
  */
93
93
  getAppliedTags(cardId: string): Promise<PouchDB.Query.Response<TagStub>>;
94
94
 
95
+ /**
96
+ * Get tags for multiple cards in a single batch query.
97
+ * More efficient than calling getAppliedTags() for each card.
98
+ *
99
+ * This method reduces redundant database operations when multiple filters
100
+ * need tag data for the same cards. The Pipeline uses this to pre-hydrate
101
+ * tags on WeightedCard objects before filters run.
102
+ *
103
+ * @param cardIds - Array of card IDs to fetch tags for
104
+ * @returns Map from cardId to array of tag names
105
+ */
106
+ getAppliedTagsBatch(cardIds: string[]): Promise<Map<string, string[]>>;
107
+
95
108
  /**
96
109
  * Add a tag to a card
97
110
  */
@@ -33,11 +33,6 @@ export interface NavigationStrategyManager {
33
33
  */
34
34
  updateNavigationStrategy(id: string, data: ContentNavigationStrategyData): Promise<void>;
35
35
 
36
- /**
37
- * @returns A content navigation strategy suitable to the current context.
38
- */
39
- surfaceNavigationStrategy(): Promise<ContentNavigationStrategyData>;
40
-
41
36
  // [ ] addons here like:
42
37
  // - determining Navigation Strategy from context of current user
43
38
  // - determining weighted averages of navigation strategies
@@ -56,6 +56,18 @@ export interface UserDBReader {
56
56
 
57
57
  getActivityRecords(): Promise<ActivityRecord[]>;
58
58
 
59
+ /**
60
+ * Get strategy-specific state for a course.
61
+ *
62
+ * Strategies use this to persist preferences, learned patterns, or temporal
63
+ * tracking data across sessions. Each strategy owns its own namespace.
64
+ *
65
+ * @param courseId - The course this state applies to
66
+ * @param strategyKey - Unique key identifying the strategy (typically class name)
67
+ * @returns The strategy's data payload, or null if no state exists
68
+ */
69
+ getStrategyState<T>(courseId: string, strategyKey: string): Promise<T | null>;
70
+
59
71
  /**
60
72
  * Get user's classroom registrations
61
73
  */
@@ -132,6 +144,26 @@ export interface UserDBWriter extends DocumentUpdater {
132
144
  * Reset all user data (progress, registrations, etc.) while preserving authentication
133
145
  */
134
146
  resetUserData(): Promise<{ status: Status; error?: string }>;
147
+
148
+ /**
149
+ * Store strategy-specific state for a course.
150
+ *
151
+ * Strategies use this to persist preferences, learned patterns, or temporal
152
+ * tracking data across sessions. Each strategy owns its own namespace.
153
+ *
154
+ * @param courseId - The course this state applies to
155
+ * @param strategyKey - Unique key identifying the strategy (typically class name)
156
+ * @param data - The strategy's data payload to store
157
+ */
158
+ putStrategyState<T>(courseId: string, strategyKey: string, data: T): Promise<void>;
159
+
160
+ /**
161
+ * Delete strategy-specific state for a course.
162
+ *
163
+ * @param courseId - The course this state applies to
164
+ * @param strategyKey - Unique key identifying the strategy (typically class name)
165
+ */
166
+ deleteStrategyState(courseId: string, strategyKey: string): Promise<void>;
135
167
  }
136
168
 
137
169
  /**
@@ -0,0 +1,268 @@
1
+ import { ContentNavigator } from './index';
2
+ import type { WeightedCard } from './index';
3
+ import type { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
4
+ import type { CourseDBInterface } from '../interfaces/courseDB';
5
+ import type { UserDBInterface } from '../interfaces/userDB';
6
+ import type { StudySessionNewItem, StudySessionReviewItem } from '../interfaces/contentSource';
7
+ import type { ScheduledCard } from '../types/user';
8
+ import type { CardGenerator, GeneratorContext } from './generators/types';
9
+ import { logger } from '../../util/logger';
10
+
11
+ // ============================================================================
12
+ // COMPOSITE GENERATOR
13
+ // ============================================================================
14
+ //
15
+ // Composes multiple generator strategies into a single generator.
16
+ //
17
+ // Use case: When a course has multiple generators (e.g., ELO + SRS), this
18
+ // class merges their outputs into a unified candidate list.
19
+ //
20
+ // Aggregation strategy:
21
+ // - Cards appearing in multiple generators get a frequency boost
22
+ // - Score = average(scores) * (1 + 0.1 * (appearances - 1))
23
+ // - This rewards cards that multiple generators agree on
24
+ //
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Aggregation modes for combining scores from multiple generators.
29
+ */
30
+ export enum AggregationMode {
31
+ /** Use the maximum score from any generator */
32
+ MAX = 'max',
33
+ /** Average all scores */
34
+ AVERAGE = 'average',
35
+ /** Average with frequency boost: avg * (1 + 0.1 * (n-1)) */
36
+ FREQUENCY_BOOST = 'frequencyBoost',
37
+ }
38
+
39
+ const DEFAULT_AGGREGATION_MODE = AggregationMode.FREQUENCY_BOOST;
40
+ const FREQUENCY_BOOST_FACTOR = 0.1;
41
+
42
+ /**
43
+ * Composes multiple generators into a single generator.
44
+ *
45
+ * Implements CardGenerator for use in Pipeline architecture.
46
+ * Also extends ContentNavigator for backward compatibility.
47
+ *
48
+ * Fetches candidates from all generators, deduplicates by cardId,
49
+ * and aggregates scores based on the configured mode.
50
+ */
51
+ export default class CompositeGenerator extends ContentNavigator implements CardGenerator {
52
+ /** Human-readable name for CardGenerator interface */
53
+ name: string = 'Composite Generator';
54
+
55
+ private generators: CardGenerator[];
56
+ private aggregationMode: AggregationMode;
57
+
58
+ constructor(
59
+ generators: CardGenerator[],
60
+ aggregationMode: AggregationMode = DEFAULT_AGGREGATION_MODE
61
+ ) {
62
+ super();
63
+ this.generators = generators;
64
+ this.aggregationMode = aggregationMode;
65
+
66
+ if (generators.length === 0) {
67
+ throw new Error('CompositeGenerator requires at least one generator');
68
+ }
69
+
70
+ logger.debug(
71
+ `[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Creates a CompositeGenerator from strategy data.
77
+ *
78
+ * This is a convenience factory for use by PipelineAssembler.
79
+ */
80
+ static async fromStrategies(
81
+ user: UserDBInterface,
82
+ course: CourseDBInterface,
83
+ strategies: ContentNavigationStrategyData[],
84
+ aggregationMode: AggregationMode = DEFAULT_AGGREGATION_MODE
85
+ ): Promise<CompositeGenerator> {
86
+ const generators = await Promise.all(
87
+ strategies.map((s) => ContentNavigator.create(user, course, s))
88
+ );
89
+ // Cast is safe because we know these are generators
90
+ return new CompositeGenerator(generators as unknown as CardGenerator[], aggregationMode);
91
+ }
92
+
93
+ /**
94
+ * Get weighted cards from all generators, merge and deduplicate.
95
+ *
96
+ * Cards appearing in multiple generators receive a score boost.
97
+ * Provenance tracks which generators produced each card and how scores were aggregated.
98
+ *
99
+ * This method supports both the legacy signature (limit only) and the
100
+ * CardGenerator interface signature (limit, context).
101
+ *
102
+ * @param limit - Maximum number of cards to return
103
+ * @param context - Optional GeneratorContext passed to child generators
104
+ */
105
+ async getWeightedCards(limit: number, context?: GeneratorContext): Promise<WeightedCard[]> {
106
+ // Fetch from all generators in parallel
107
+ const results = await Promise.all(
108
+ this.generators.map((g) => g.getWeightedCards(limit, context))
109
+ );
110
+
111
+ // Group by cardId
112
+ const byCardId = new Map<string, WeightedCard[]>();
113
+ for (const cards of results) {
114
+ for (const card of cards) {
115
+ const existing = byCardId.get(card.cardId) || [];
116
+ existing.push(card);
117
+ byCardId.set(card.cardId, existing);
118
+ }
119
+ }
120
+
121
+ // Aggregate scores
122
+ const merged: WeightedCard[] = [];
123
+ for (const [, cards] of byCardId) {
124
+ const aggregatedScore = this.aggregateScores(cards);
125
+ const finalScore = Math.min(1.0, aggregatedScore); // Clamp to [0, 1]
126
+
127
+ // Merge provenance from all generators that produced this card
128
+ const mergedProvenance = cards.flatMap((c) => c.provenance);
129
+
130
+ // Determine action based on whether score changed
131
+ const initialScore = cards[0].score;
132
+ const action =
133
+ finalScore > initialScore ? 'boosted' : finalScore < initialScore ? 'penalized' : 'passed';
134
+
135
+ // Build reason explaining the aggregation
136
+ const reason = this.buildAggregationReason(cards, finalScore);
137
+
138
+ // Append composite provenance entry
139
+ merged.push({
140
+ ...cards[0],
141
+ score: finalScore,
142
+ provenance: [
143
+ ...mergedProvenance,
144
+ {
145
+ strategy: 'composite',
146
+ strategyName: 'Composite Generator',
147
+ strategyId: 'COMPOSITE_GENERATOR',
148
+ action,
149
+ score: finalScore,
150
+ reason,
151
+ },
152
+ ],
153
+ });
154
+ }
155
+
156
+ // Sort by score descending and limit
157
+ return merged.sort((a, b) => b.score - a.score).slice(0, limit);
158
+ }
159
+
160
+ /**
161
+ * Build human-readable reason for score aggregation.
162
+ */
163
+ private buildAggregationReason(cards: WeightedCard[], finalScore: number): string {
164
+ const count = cards.length;
165
+ const scores = cards.map((c) => c.score.toFixed(2)).join(', ');
166
+
167
+ if (count === 1) {
168
+ return `Single generator, score ${finalScore.toFixed(2)}`;
169
+ }
170
+
171
+ const strategies = cards.map((c) => c.provenance[0]?.strategy || 'unknown').join(', ');
172
+
173
+ switch (this.aggregationMode) {
174
+ case AggregationMode.MAX:
175
+ return `Max of ${count} generators (${strategies}): scores [${scores}] → ${finalScore.toFixed(2)}`;
176
+
177
+ case AggregationMode.AVERAGE:
178
+ return `Average of ${count} generators (${strategies}): scores [${scores}] → ${finalScore.toFixed(2)}`;
179
+
180
+ case AggregationMode.FREQUENCY_BOOST: {
181
+ const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
182
+ const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
183
+ return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} × ${boost.toFixed(2)} → ${finalScore.toFixed(2)}`;
184
+ }
185
+
186
+ default:
187
+ return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Aggregate scores from multiple generators for the same card.
193
+ */
194
+ private aggregateScores(cards: WeightedCard[]): number {
195
+ const scores = cards.map((c) => c.score);
196
+
197
+ switch (this.aggregationMode) {
198
+ case AggregationMode.MAX:
199
+ return Math.max(...scores);
200
+
201
+ case AggregationMode.AVERAGE:
202
+ return scores.reduce((sum, s) => sum + s, 0) / scores.length;
203
+
204
+ case AggregationMode.FREQUENCY_BOOST: {
205
+ const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
206
+ const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
207
+ return avg * frequencyBoost;
208
+ }
209
+
210
+ default:
211
+ return scores[0];
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Get new cards from all generators, merged and deduplicated.
217
+ */
218
+ async getNewCards(n?: number): Promise<StudySessionNewItem[]> {
219
+ // For legacy method, need to filter to generators that have getNewCards
220
+ const legacyGenerators = this.generators.filter(
221
+ (g): g is CardGenerator & ContentNavigator => g instanceof ContentNavigator
222
+ );
223
+
224
+ const results = await Promise.all(legacyGenerators.map((g) => g.getNewCards(n)));
225
+
226
+ // Deduplicate by cardID
227
+ const seen = new Set<string>();
228
+ const merged: StudySessionNewItem[] = [];
229
+
230
+ for (const cards of results) {
231
+ for (const card of cards) {
232
+ if (!seen.has(card.cardID)) {
233
+ seen.add(card.cardID);
234
+ merged.push(card);
235
+ }
236
+ }
237
+ }
238
+
239
+ return n ? merged.slice(0, n) : merged;
240
+ }
241
+
242
+ /**
243
+ * Get pending reviews from all generators, merged and deduplicated.
244
+ */
245
+ async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
246
+ // For legacy method, need to filter to generators that have getPendingReviews
247
+ const legacyGenerators = this.generators.filter(
248
+ (g): g is CardGenerator & ContentNavigator => g instanceof ContentNavigator
249
+ );
250
+
251
+ const results = await Promise.all(legacyGenerators.map((g) => g.getPendingReviews()));
252
+
253
+ // Deduplicate by cardID
254
+ const seen = new Set<string>();
255
+ const merged: (StudySessionReviewItem & ScheduledCard)[] = [];
256
+
257
+ for (const reviews of results) {
258
+ for (const review of reviews) {
259
+ if (!seen.has(review.cardID)) {
260
+ seen.add(review.cardID);
261
+ merged.push(review);
262
+ }
263
+ }
264
+ }
265
+
266
+ return merged;
267
+ }
268
+ }
@@ -0,0 +1,318 @@
1
+ import { toCourseElo } from '@vue-skuilder/common';
2
+ import type { CourseDBInterface } from '../interfaces/courseDB';
3
+ import type { UserDBInterface } from '../interfaces/userDB';
4
+ import type { ScheduledCard } from '../types/user';
5
+ import { ContentNavigator } from './index';
6
+ import type { WeightedCard } from './index';
7
+ import type { CardFilter, FilterContext } from './filters/types';
8
+ import type { CardGenerator, GeneratorContext } from './generators/types';
9
+ import type { StudySessionNewItem, StudySessionReviewItem } from '../interfaces/contentSource';
10
+ import { logger } from '../../util/logger';
11
+
12
+ // ============================================================================
13
+ // PIPELINE LOGGING HELPERS
14
+ // ============================================================================
15
+ //
16
+ // Focused logging functions that can be toggled by commenting single lines.
17
+ // Use these to inspect pipeline behavior in development/production.
18
+ //
19
+
20
+ /**
21
+ * Log pipeline configuration on construction.
22
+ * Shows generator and filter chain structure.
23
+ */
24
+ function logPipelineConfig(generator: CardGenerator, filters: CardFilter[]): void {
25
+ const filterList = filters.length > 0
26
+ ? '\n - ' + filters.map(f => f.name).join('\n - ')
27
+ : ' none';
28
+
29
+ logger.info(
30
+ `[Pipeline] Configuration:\n` +
31
+ ` Generator: ${generator.name}\n` +
32
+ ` Filters:${filterList}`
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Log tag hydration results.
38
+ * Shows effectiveness of batch query (how many cards/tags were hydrated).
39
+ */
40
+ function logTagHydration(cards: WeightedCard[], tagsByCard: Map<string, string[]>): void {
41
+ const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
42
+ const cardsWithTags = Array.from(tagsByCard.values()).filter(tags => tags.length > 0).length;
43
+
44
+ logger.debug(
45
+ `[Pipeline] Tag hydration: ${cards.length} cards, ` +
46
+ `${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Log pipeline execution summary.
52
+ * Shows complete flow from generator through filters to final results.
53
+ */
54
+ function logExecutionSummary(
55
+ generatorName: string,
56
+ generatedCount: number,
57
+ filterCount: number,
58
+ finalCount: number,
59
+ topScores: number[]
60
+ ): void {
61
+ const scoreDisplay = topScores.length > 0
62
+ ? topScores.map(s => s.toFixed(2)).join(', ')
63
+ : 'none';
64
+
65
+ logger.info(
66
+ `[Pipeline] Execution: ${generatorName} produced ${generatedCount} → ` +
67
+ `${filterCount} filters → ${finalCount} results (top scores: ${scoreDisplay})`
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Log provenance trails for cards.
73
+ * Shows the complete scoring history for each card through the pipeline.
74
+ * Useful for debugging why cards scored the way they did.
75
+ */
76
+ function logCardProvenance(cards: WeightedCard[], maxCards: number = 3): void {
77
+ const cardsToLog = cards.slice(0, maxCards);
78
+
79
+ logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
80
+
81
+ for (const card of cardsToLog) {
82
+ logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
83
+
84
+ for (const entry of card.provenance) {
85
+ const scoreChange = entry.score.toFixed(3);
86
+ const action = entry.action.padEnd(9); // Align columns
87
+ logger.debug(
88
+ `[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
89
+ );
90
+ }
91
+ }
92
+ }
93
+
94
+ // ============================================================================
95
+ // PIPELINE
96
+ // ============================================================================
97
+ //
98
+ // Executes a navigation pipeline: generator → filters → sorted results.
99
+ //
100
+ // Architecture:
101
+ // cards = generator.getWeightedCards(limit, context)
102
+ // cards = filter1.transform(cards, context)
103
+ // cards = filter2.transform(cards, context)
104
+ // cards = filter3.transform(cards, context)
105
+ // return sorted(cards).slice(0, limit)
106
+ //
107
+ // Benefits:
108
+ // - Clear separation: generators produce, filters transform
109
+ // - No nested instantiation complexity
110
+ // - Filters don't need to know about each other
111
+ // - Shared context built once, passed to all stages
112
+ //
113
+ // ============================================================================
114
+
115
+ /**
116
+ * A navigation pipeline that runs a generator and applies filters sequentially.
117
+ *
118
+ * Implements StudyContentSource for backward compatibility with SessionController.
119
+ *
120
+ * ## Usage
121
+ *
122
+ * ```typescript
123
+ * const pipeline = new Pipeline(
124
+ * compositeGenerator, // or single generator
125
+ * [eloDistanceFilter, interferenceFilter],
126
+ * user,
127
+ * course
128
+ * );
129
+ *
130
+ * const cards = await pipeline.getWeightedCards(20);
131
+ * ```
132
+ */
133
+ export class Pipeline extends ContentNavigator {
134
+ private generator: CardGenerator;
135
+ private filters: CardFilter[];
136
+
137
+ /**
138
+ * Create a new pipeline.
139
+ *
140
+ * @param generator - The generator (or CompositeGenerator) that produces candidates
141
+ * @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
142
+ * @param user - User database interface
143
+ * @param course - Course database interface
144
+ */
145
+ constructor(
146
+ generator: CardGenerator,
147
+ filters: CardFilter[],
148
+ user: UserDBInterface,
149
+ course: CourseDBInterface
150
+ ) {
151
+ super();
152
+ this.generator = generator;
153
+ this.filters = filters;
154
+ this.user = user;
155
+ this.course = course;
156
+
157
+ // Toggle pipeline configuration logging:
158
+ logPipelineConfig(generator, filters);
159
+ }
160
+
161
+ /**
162
+ * Get weighted cards by running generator and applying filters.
163
+ *
164
+ * 1. Build shared context (user ELO, etc.)
165
+ * 2. Get candidates from generator (passing context)
166
+ * 3. Batch hydrate tags for all candidates
167
+ * 4. Apply each filter sequentially
168
+ * 5. Remove zero-score cards
169
+ * 6. Sort by score descending
170
+ * 7. Return top N
171
+ *
172
+ * @param limit - Maximum number of cards to return
173
+ * @returns Cards sorted by score descending
174
+ */
175
+ async getWeightedCards(limit: number): Promise<WeightedCard[]> {
176
+ // Build shared context once
177
+ const context = await this.buildContext();
178
+
179
+ // Over-fetch from generator to account for filtering
180
+ const overFetchMultiplier = 2 + this.filters.length * 0.5;
181
+ const fetchLimit = Math.ceil(limit * overFetchMultiplier);
182
+
183
+ logger.debug(
184
+ `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
185
+ );
186
+
187
+ // Get candidates from generator, passing context
188
+ let cards = await this.generator.getWeightedCards(fetchLimit, context);
189
+ const generatedCount = cards.length;
190
+
191
+ logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
192
+
193
+ // Batch hydrate tags before filters run
194
+ cards = await this.hydrateTags(cards);
195
+
196
+ // Apply filters sequentially
197
+ for (const filter of this.filters) {
198
+ const beforeCount = cards.length;
199
+ cards = await filter.transform(cards, context);
200
+ logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} → ${cards.length} cards`);
201
+ }
202
+
203
+ // Remove zero-score cards (hard filtered)
204
+ cards = cards.filter((c) => c.score > 0);
205
+
206
+ // Sort by score descending
207
+ cards.sort((a, b) => b.score - a.score);
208
+
209
+ // Return top N
210
+ const result = cards.slice(0, limit);
211
+
212
+ // Toggle execution summary logging:
213
+ const topScores = result.slice(0, 3).map(c => c.score);
214
+ logExecutionSummary(this.generator.name, generatedCount, this.filters.length, result.length, topScores);
215
+
216
+ // Toggle provenance logging (shows scoring history for top cards):
217
+ logCardProvenance(result, 3);
218
+
219
+ return result;
220
+ }
221
+
222
+ /**
223
+ * Batch hydrate tags for all cards.
224
+ *
225
+ * Fetches tags for all cards in a single database query and attaches them
226
+ * to the WeightedCard objects. Filters can then use card.tags instead of
227
+ * making individual getAppliedTags() calls.
228
+ *
229
+ * @param cards - Cards to hydrate
230
+ * @returns Cards with tags populated
231
+ */
232
+ private async hydrateTags(cards: WeightedCard[]): Promise<WeightedCard[]> {
233
+ if (cards.length === 0) {
234
+ return cards;
235
+ }
236
+
237
+ const cardIds = cards.map((c) => c.cardId);
238
+ const tagsByCard = await this.course!.getAppliedTagsBatch(cardIds);
239
+
240
+ // Toggle tag hydration logging:
241
+ logTagHydration(cards, tagsByCard);
242
+
243
+ return cards.map((card) => ({
244
+ ...card,
245
+ tags: tagsByCard.get(card.cardId) ?? [],
246
+ }));
247
+ }
248
+
249
+ /**
250
+ * Build shared context for generator and filters.
251
+ *
252
+ * Called once per getWeightedCards() invocation.
253
+ * Contains data that the generator and multiple filters might need.
254
+ *
255
+ * The context satisfies both GeneratorContext and FilterContext interfaces.
256
+ */
257
+ private async buildContext(): Promise<GeneratorContext & FilterContext> {
258
+ let userElo = 1000; // Default ELO
259
+
260
+ try {
261
+ const courseReg = await this.user!.getCourseRegDoc(this.course!.getCourseID());
262
+ const courseElo = toCourseElo(courseReg.elo);
263
+ userElo = courseElo.global.score;
264
+ } catch (e) {
265
+ logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
266
+ }
267
+
268
+ return {
269
+ user: this.user!,
270
+ course: this.course!,
271
+ userElo,
272
+ };
273
+ }
274
+
275
+ // ===========================================================================
276
+ // Legacy StudyContentSource methods
277
+ // ===========================================================================
278
+ //
279
+ // These delegate to the generator for backward compatibility.
280
+ // Eventually SessionController will use getWeightedCards() exclusively.
281
+ //
282
+
283
+ /**
284
+ * Get new cards via legacy API.
285
+ * Delegates to the generator if it supports the legacy interface.
286
+ */
287
+ async getNewCards(n?: number): Promise<StudySessionNewItem[]> {
288
+ // Check if generator has legacy method (ContentNavigator-based generators do)
289
+ if ('getNewCards' in this.generator && typeof this.generator.getNewCards === 'function') {
290
+ return (this.generator as ContentNavigator).getNewCards(n);
291
+ }
292
+ // Pure CardGenerator without legacy support - return empty
293
+ return [];
294
+ }
295
+
296
+ /**
297
+ * Get pending reviews via legacy API.
298
+ * Delegates to the generator if it supports the legacy interface.
299
+ */
300
+ async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
301
+ // Check if generator has legacy method (ContentNavigator-based generators do)
302
+ if (
303
+ 'getPendingReviews' in this.generator &&
304
+ typeof this.generator.getPendingReviews === 'function'
305
+ ) {
306
+ return (this.generator as ContentNavigator).getPendingReviews();
307
+ }
308
+ // Pure CardGenerator without legacy support - return empty
309
+ return [];
310
+ }
311
+
312
+ /**
313
+ * Get the course ID for this pipeline.
314
+ */
315
+ getCourseID(): string {
316
+ return this.course!.getCourseID();
317
+ }
318
+ }