@vue-skuilder/db 0.1.16 → 0.1.18

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 (80) hide show
  1. package/dist/{userDB-DNa0XPtn.d.ts → classroomDB-BgfrVb8d.d.ts} +357 -103
  2. package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CTOenngH.d.cts} +358 -104
  3. package/dist/core/index.d.cts +230 -0
  4. package/dist/core/index.d.ts +161 -23
  5. package/dist/core/index.js +1964 -154
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +1925 -121
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-CZxC9GtB.d.ts} +1 -1
  10. package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D6PoCwS6.d.cts} +1 -1
  11. package/dist/impl/couch/{index.d.mts → index.d.cts} +46 -5
  12. package/dist/impl/couch/index.d.ts +44 -3
  13. package/dist/impl/couch/index.js +1971 -171
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +1933 -134
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/{index.d.mts → index.d.cts} +5 -6
  18. package/dist/impl/static/index.d.ts +2 -3
  19. package/dist/impl/static/index.js +1614 -119
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +1585 -92
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-Bmll7Xse.d.mts → index-D-Fa4Smt.d.cts} +1 -1
  24. package/dist/{index.d.mts → index.d.cts} +97 -13
  25. package/dist/index.d.ts +90 -6
  26. package/dist/index.js +2085 -153
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +2031 -106
  29. package/dist/index.mjs.map +1 -1
  30. package/dist/pouch/index.js +3 -3
  31. package/dist/{types-Dbp5DaRR.d.mts → types-CzPDLAK6.d.cts} +1 -1
  32. package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
  33. package/dist/util/packer/index.js.map +1 -1
  34. package/dist/util/packer/index.mjs.map +1 -1
  35. package/docs/brainstorm-navigation-paradigm.md +369 -0
  36. package/docs/navigators-architecture.md +265 -0
  37. package/docs/todo-evolutionary-orchestration.md +310 -0
  38. package/docs/todo-nominal-tag-types.md +121 -0
  39. package/docs/todo-pipeline-optimization.md +117 -0
  40. package/docs/todo-strategy-authoring.md +401 -0
  41. package/docs/todo-strategy-state-storage.md +278 -0
  42. package/eslint.config.mjs +1 -1
  43. package/package.json +9 -4
  44. package/src/core/interfaces/contentSource.ts +88 -4
  45. package/src/core/interfaces/navigationStrategyManager.ts +0 -5
  46. package/src/core/navigators/CompositeGenerator.ts +268 -0
  47. package/src/core/navigators/Pipeline.ts +205 -0
  48. package/src/core/navigators/PipelineAssembler.ts +194 -0
  49. package/src/core/navigators/elo.ts +104 -15
  50. package/src/core/navigators/filters/eloDistance.ts +132 -0
  51. package/src/core/navigators/filters/index.ts +6 -0
  52. package/src/core/navigators/filters/types.ts +115 -0
  53. package/src/core/navigators/generators/index.ts +2 -0
  54. package/src/core/navigators/generators/types.ts +107 -0
  55. package/src/core/navigators/hardcodedOrder.ts +111 -12
  56. package/src/core/navigators/hierarchyDefinition.ts +266 -0
  57. package/src/core/navigators/index.ts +345 -3
  58. package/src/core/navigators/interferenceMitigator.ts +367 -0
  59. package/src/core/navigators/relativePriority.ts +267 -0
  60. package/src/core/navigators/srs.ts +195 -0
  61. package/src/impl/couch/classroomDB.ts +51 -0
  62. package/src/impl/couch/courseDB.ts +117 -39
  63. package/src/impl/static/courseDB.ts +0 -4
  64. package/src/study/SessionController.ts +149 -1
  65. package/src/study/TagFilteredContentSource.ts +255 -0
  66. package/src/study/index.ts +1 -0
  67. package/src/util/dataDirectory.test.ts +51 -22
  68. package/src/util/logger.ts +0 -1
  69. package/tests/core/navigators/CompositeGenerator.test.ts +455 -0
  70. package/tests/core/navigators/Pipeline.test.ts +405 -0
  71. package/tests/core/navigators/PipelineAssembler.test.ts +351 -0
  72. package/tests/core/navigators/SRSNavigator.test.ts +344 -0
  73. package/tests/core/navigators/eloDistanceFilter.test.ts +192 -0
  74. package/tests/core/navigators/navigators.test.ts +710 -0
  75. package/tsconfig.json +1 -1
  76. package/vitest.config.ts +29 -0
  77. package/dist/core/index.d.mts +0 -92
  78. /package/dist/{SyncStrategy-CyATpyLQ.d.mts → SyncStrategy-CyATpyLQ.d.cts} +0 -0
  79. /package/dist/pouch/{index.d.mts → index.d.cts} +0 -0
  80. /package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-6ettoclI.d.cts} +0 -0
@@ -0,0 +1,194 @@
1
+ import type { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
2
+ import { ContentNavigator, isGenerator, isFilter, Navigators } from './index';
3
+ import type { CardFilter } from './filters/types';
4
+ import type { CardGenerator } from './generators/types';
5
+ import { Pipeline } from './Pipeline';
6
+ import { DocType } from '../types/types-legacy';
7
+ import { logger } from '../../util/logger';
8
+ import type { CourseDBInterface } from '../interfaces/courseDB';
9
+ import type { UserDBInterface } from '../interfaces/userDB';
10
+ import CompositeGenerator from './CompositeGenerator';
11
+
12
+ // ============================================================================
13
+ // PIPELINE ASSEMBLER
14
+ // ============================================================================
15
+ //
16
+ // Assembles navigation strategies into a Pipeline instance.
17
+ //
18
+ // This class is DB-agnostic: it receives strategy documents and returns an
19
+ // assembled, ready-to-use Pipeline. This separation enables:
20
+ // 1. Use with different DB implementations (Couch, Static, etc.)
21
+ // 2. Future dynamic/evolutionary strategy selection
22
+ // 3. Easy unit testing without DB mocking
23
+ //
24
+ // Pipeline assembly:
25
+ // 1. Separate strategies into generators and filters by role
26
+ // 2. Instantiate generator(s) - wrap multiple in CompositeGenerator
27
+ // 3. Instantiate filters
28
+ // 4. Return Pipeline(generator, filters)
29
+ //
30
+ // ============================================================================
31
+
32
+ /**
33
+ * Input for pipeline assembly.
34
+ */
35
+ export interface PipelineAssemblerInput {
36
+ /** All strategy documents to assemble into a pipeline */
37
+ strategies: ContentNavigationStrategyData[];
38
+ /** User database interface (required for instantiation) */
39
+ user: UserDBInterface;
40
+ /** Course database interface (required for instantiation) */
41
+ course: CourseDBInterface;
42
+ }
43
+
44
+ /**
45
+ * Result of pipeline assembly.
46
+ */
47
+ export interface PipelineAssemblyResult {
48
+ /** The assembled pipeline, or null if assembly failed */
49
+ pipeline: Pipeline | null;
50
+ /** Generator strategies found (for informational purposes) */
51
+ generatorStrategies: ContentNavigationStrategyData[];
52
+ /** Filter strategies found (for informational purposes) */
53
+ filterStrategies: ContentNavigationStrategyData[];
54
+ /** Warnings encountered during assembly (logged but non-fatal) */
55
+ warnings: string[];
56
+ }
57
+
58
+ /**
59
+ * Assembles navigation strategies into a Pipeline.
60
+ *
61
+ * Instantiates generators and filters from strategy documents and
62
+ * composes them into a ready-to-use Pipeline instance.
63
+ */
64
+ export class PipelineAssembler {
65
+ /**
66
+ * Assembles a navigation pipeline from strategy documents.
67
+ *
68
+ * 1. Separates into generators and filters by role
69
+ * 2. Validates at least one generator exists (or creates default ELO)
70
+ * 3. Instantiates generators - wraps multiple in CompositeGenerator
71
+ * 4. Instantiates filters
72
+ * 5. Returns Pipeline(generator, filters)
73
+ *
74
+ * @param input - Strategy documents plus user/course interfaces
75
+ * @returns Assembled pipeline and any warnings
76
+ */
77
+ async assemble(input: PipelineAssemblerInput): Promise<PipelineAssemblyResult> {
78
+ const { strategies, user, course } = input;
79
+ const warnings: string[] = [];
80
+
81
+ if (strategies.length === 0) {
82
+ return {
83
+ pipeline: null,
84
+ generatorStrategies: [],
85
+ filterStrategies: [],
86
+ warnings,
87
+ };
88
+ }
89
+
90
+ // Separate generators from filters
91
+ const generatorStrategies: ContentNavigationStrategyData[] = [];
92
+ const filterStrategies: ContentNavigationStrategyData[] = [];
93
+
94
+ for (const s of strategies) {
95
+ if (isGenerator(s.implementingClass)) {
96
+ generatorStrategies.push(s);
97
+ } else if (isFilter(s.implementingClass)) {
98
+ filterStrategies.push(s);
99
+ } else {
100
+ // Unknown strategy type — skip with warning
101
+ warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
102
+ }
103
+ }
104
+
105
+ // If no generator but filters exist, use default ELO generator
106
+ if (generatorStrategies.length === 0) {
107
+ if (filterStrategies.length > 0) {
108
+ logger.debug(
109
+ '[PipelineAssembler] No generator found, using default ELO with configured filters'
110
+ );
111
+ generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
112
+ } else {
113
+ warnings.push('No generator strategy found');
114
+ return {
115
+ pipeline: null,
116
+ generatorStrategies: [],
117
+ filterStrategies: [],
118
+ warnings,
119
+ };
120
+ }
121
+ }
122
+
123
+ // Instantiate generators
124
+ let generator: CardGenerator;
125
+
126
+ if (generatorStrategies.length === 1) {
127
+ // Single generator
128
+ const nav = await ContentNavigator.create(user, course, generatorStrategies[0]);
129
+ generator = nav as unknown as CardGenerator;
130
+ logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
131
+ } else {
132
+ // Multiple generators - wrap in CompositeGenerator
133
+ logger.debug(
134
+ `[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(', ')}`
135
+ );
136
+ generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
137
+ }
138
+
139
+ // Instantiate filters
140
+ const filters: CardFilter[] = [];
141
+
142
+ // Sort filters alphabetically for deterministic ordering
143
+ const sortedFilterStrategies = [...filterStrategies].sort((a, b) =>
144
+ a.name.localeCompare(b.name)
145
+ );
146
+
147
+ for (const filterStrategy of sortedFilterStrategies) {
148
+ try {
149
+ const nav = await ContentNavigator.create(user, course, filterStrategy);
150
+ // The navigator implements CardFilter
151
+ if ('transform' in nav && typeof nav.transform === 'function') {
152
+ filters.push(nav as unknown as CardFilter);
153
+ logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
154
+ } else {
155
+ warnings.push(
156
+ `Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
157
+ );
158
+ }
159
+ } catch (e) {
160
+ warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
161
+ }
162
+ }
163
+
164
+ // Build pipeline
165
+ const pipeline = new Pipeline(generator, filters, user, course);
166
+
167
+ logger.debug(
168
+ `[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
169
+ );
170
+
171
+ return {
172
+ pipeline,
173
+ generatorStrategies,
174
+ filterStrategies: sortedFilterStrategies,
175
+ warnings,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Creates a default ELO generator strategy.
181
+ * Used when filters are configured but no generator is specified.
182
+ */
183
+ private makeDefaultEloStrategy(courseId: string): ContentNavigationStrategyData {
184
+ return {
185
+ _id: 'NAVIGATION_STRATEGY-ELO-default',
186
+ course: courseId,
187
+ docType: DocType.NAVIGATION_STRATEGY,
188
+ name: 'ELO (default)',
189
+ description: 'Default ELO-based generator',
190
+ implementingClass: Navigators.ELO,
191
+ serializedData: '',
192
+ };
193
+ }
194
+ }
@@ -1,27 +1,54 @@
1
- import { ScheduledCard } from '../types/user';
2
- import { CourseDBInterface } from '../interfaces/courseDB';
3
- import { UserDBInterface } from '../interfaces/userDB';
1
+ import type { ScheduledCard } from '../types/user';
2
+ import type { CourseDBInterface } from '../interfaces/courseDB';
3
+ import type { UserDBInterface } from '../interfaces/userDB';
4
4
  import { ContentNavigator } from './index';
5
- import { CourseElo } from '@vue-skuilder/common';
6
- import { StudySessionReviewItem, StudySessionNewItem, QualifiedCardID } from '..';
5
+ import type { WeightedCard } from './index';
6
+ import type { CourseElo } from '@vue-skuilder/common';
7
+ import { toCourseElo } from '@vue-skuilder/common';
8
+ import type { StudySessionReviewItem, StudySessionNewItem, QualifiedCardID } from '..';
9
+ import type { CardGenerator, GeneratorContext } from './generators/types';
7
10
 
8
- export default class ELONavigator extends ContentNavigator {
9
- user: UserDBInterface;
10
- course: CourseDBInterface;
11
+ // ============================================================================
12
+ // ELO NAVIGATOR
13
+ // ============================================================================
14
+ //
15
+ // A generator strategy that selects new cards based on ELO proximity.
16
+ //
17
+ // Cards closer to the user's skill level (ELO) receive higher scores.
18
+ // This ensures learners see content matched to their current ability.
19
+ //
20
+ // NOTE: This generator only handles NEW cards. Reviews are handled by
21
+ // SRSNavigator. Use CompositeGenerator to combine both.
22
+ //
23
+ // ============================================================================
24
+
25
+ /**
26
+ * A navigation strategy that scores new cards by ELO proximity.
27
+ *
28
+ * Implements CardGenerator for use in Pipeline architecture.
29
+ * Also extends ContentNavigator for backward compatibility with legacy code.
30
+ *
31
+ * Higher scores indicate better ELO match:
32
+ * - Cards at user's ELO level score highest
33
+ * - Score decreases with ELO distance
34
+ *
35
+ * Only returns new cards - use SRSNavigator for reviews.
36
+ */
37
+ export default class ELONavigator extends ContentNavigator implements CardGenerator {
38
+ /** Human-readable name for CardGenerator interface */
39
+ name: string;
11
40
 
12
41
  constructor(
13
42
  user: UserDBInterface,
14
- course: CourseDBInterface
43
+ course: CourseDBInterface,
44
+ strategyData?: { name: string; _id: string }
15
45
  // The ELO strategy is non-parameterized.
16
46
  //
17
47
  // It instead relies on existing meta data from the course and user with respect to
18
- //
19
- //
20
- // strategy?: ContentNavigationStrategyData
48
+ // ELO scores - it uses those to select cards matched to user skill level.
21
49
  ) {
22
- super();
23
- this.user = user;
24
- this.course = course;
50
+ super(user, course, strategyData as any);
51
+ this.name = strategyData?.name || 'ELO';
25
52
  }
26
53
 
27
54
  async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
@@ -76,4 +103,66 @@ export default class ELONavigator extends ContentNavigator {
76
103
  };
77
104
  });
78
105
  }
106
+
107
+ /**
108
+ * Get new cards with suitability scores based on ELO distance.
109
+ *
110
+ * Cards closer to user's ELO get higher scores.
111
+ * Score formula: max(0, 1 - distance / 500)
112
+ *
113
+ * NOTE: This generator only handles NEW cards. Reviews are handled by
114
+ * SRSNavigator. Use CompositeGenerator to combine both.
115
+ *
116
+ * This method supports both the legacy signature (limit only) and the
117
+ * CardGenerator interface signature (limit, context).
118
+ *
119
+ * @param limit - Maximum number of cards to return
120
+ * @param context - Optional GeneratorContext (used when called via Pipeline)
121
+ */
122
+ async getWeightedCards(limit: number, context?: GeneratorContext): Promise<WeightedCard[]> {
123
+ // Determine user ELO - from context if available, otherwise fetch
124
+ let userGlobalElo: number;
125
+ if (context?.userElo !== undefined) {
126
+ userGlobalElo = context.userElo;
127
+ } else {
128
+ const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
129
+ const userElo = toCourseElo(courseReg.elo);
130
+ userGlobalElo = userElo.global.score;
131
+ }
132
+
133
+ // Get new cards (existing logic)
134
+ const newCards = await this.getNewCards(limit);
135
+
136
+ // Get ELO data for all cards in one batch
137
+ const cardIds = newCards.map((c) => c.cardID);
138
+ const cardEloData = await this.course.getCardEloData(cardIds);
139
+
140
+ // Score new cards by ELO distance
141
+ const scored: WeightedCard[] = newCards.map((c, i) => {
142
+ const cardElo = cardEloData[i]?.global?.score ?? 1000;
143
+ const distance = Math.abs(cardElo - userGlobalElo);
144
+ const score = Math.max(0, 1 - distance / 500);
145
+
146
+ return {
147
+ cardId: c.cardID,
148
+ courseId: c.courseID,
149
+ score,
150
+ provenance: [
151
+ {
152
+ strategy: 'elo',
153
+ strategyName: this.strategyName || this.name,
154
+ strategyId: this.strategyId || 'NAVIGATION_STRATEGY-ELO-default',
155
+ action: 'generated',
156
+ score,
157
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), new card`,
158
+ },
159
+ ],
160
+ };
161
+ });
162
+
163
+ // Sort by score descending
164
+ scored.sort((a, b) => b.score - a.score);
165
+
166
+ return scored.slice(0, limit);
167
+ }
79
168
  }
@@ -0,0 +1,132 @@
1
+ import type { WeightedCard } from '../index';
2
+ import type { CardFilter, FilterContext } from './types';
3
+
4
+ // ============================================================================
5
+ // ELO DISTANCE FILTER
6
+ // ============================================================================
7
+ //
8
+ // Penalizes cards that are far from the user's current ELO using a smooth curve.
9
+ //
10
+ // This filter addresses cross-strategy coordination:
11
+ // - SRS generates reviews based on scheduling
12
+ // - But some scheduled cards may be "below" the user's current level
13
+ // - Or "above" (shouldn't happen often, but possible)
14
+ //
15
+ // By applying ELO distance penalties, we can:
16
+ // - Deprioritize reviews the user has "moved beyond"
17
+ // - Deprioritize cards that are too hard for current skill level
18
+ //
19
+ // The penalty curve is smooth (no discontinuities) using a Gaussian-like decay.
20
+ //
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Configuration for the ELO distance filter.
25
+ */
26
+ export interface EloDistanceConfig {
27
+ /**
28
+ * The ELO distance at which the multiplier is ~0.6 (one standard deviation).
29
+ * Default: 200 ELO points.
30
+ *
31
+ * - At distance 0: multiplier ≈ 1.0
32
+ * - At distance = halfLife: multiplier ≈ 0.6
33
+ * - At distance = 2 * halfLife: multiplier ≈ 0.37
34
+ * - At distance = 3 * halfLife: multiplier ≈ 0.22
35
+ */
36
+ halfLife?: number;
37
+
38
+ /**
39
+ * Minimum multiplier (floor) to prevent scores from going too low.
40
+ * Default: 0.3
41
+ */
42
+ minMultiplier?: number;
43
+
44
+ /**
45
+ * Maximum multiplier (ceiling). Usually 1.0 (no boost for close cards).
46
+ * Default: 1.0
47
+ */
48
+ maxMultiplier?: number;
49
+ }
50
+
51
+ const DEFAULT_HALF_LIFE = 200;
52
+ const DEFAULT_MIN_MULTIPLIER = 0.3;
53
+ const DEFAULT_MAX_MULTIPLIER = 1.0;
54
+
55
+ /**
56
+ * Compute the multiplier for a given ELO distance using Gaussian decay.
57
+ *
58
+ * Formula: minMultiplier + (maxMultiplier - minMultiplier) * exp(-(distance/halfLife)^2)
59
+ *
60
+ * This produces a smooth bell curve centered at distance=0:
61
+ * - At distance 0: multiplier = maxMultiplier (1.0)
62
+ * - As distance increases: multiplier smoothly decays toward minMultiplier
63
+ * - No discontinuities or sudden jumps
64
+ */
65
+ function computeMultiplier(
66
+ distance: number,
67
+ halfLife: number,
68
+ minMultiplier: number,
69
+ maxMultiplier: number
70
+ ): number {
71
+ // Gaussian decay: exp(-(d/h)^2)
72
+ const normalizedDistance = distance / halfLife;
73
+ const decay = Math.exp(-(normalizedDistance * normalizedDistance));
74
+
75
+ // Scale between min and max
76
+ return minMultiplier + (maxMultiplier - minMultiplier) * decay;
77
+ }
78
+
79
+ /**
80
+ * Create an ELO distance filter.
81
+ *
82
+ * Penalizes cards that are far from the user's current ELO level
83
+ * using a smooth Gaussian decay curve. No discontinuities.
84
+ *
85
+ * @param config - Optional configuration for the decay curve
86
+ * @returns A CardFilter that applies ELO distance penalties
87
+ */
88
+ export function createEloDistanceFilter(config?: EloDistanceConfig): CardFilter {
89
+ const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
90
+ const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
91
+ const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
92
+
93
+ return {
94
+ name: 'ELO Distance Filter',
95
+
96
+ async transform(cards: WeightedCard[], context: FilterContext): Promise<WeightedCard[]> {
97
+ const { course, userElo } = context;
98
+
99
+ // Batch fetch ELO data for all cards
100
+ const cardIds = cards.map((c) => c.cardId);
101
+ const cardElos = await course.getCardEloData(cardIds);
102
+
103
+ return cards.map((card, i) => {
104
+ const cardElo = cardElos[i]?.global?.score ?? 1000;
105
+ const distance = Math.abs(cardElo - userElo);
106
+ const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
107
+ const newScore = card.score * multiplier;
108
+
109
+ const action = multiplier < maxMultiplier - 0.01 ? 'penalized' : 'passed';
110
+
111
+ return {
112
+ ...card,
113
+ score: newScore,
114
+ provenance: [
115
+ ...card.provenance,
116
+ {
117
+ strategy: 'eloDistance',
118
+ strategyName: 'ELO Distance Filter',
119
+ strategyId: 'ELO_DISTANCE_FILTER',
120
+ action,
121
+ score: newScore,
122
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) → ${multiplier.toFixed(2)}x`,
123
+ },
124
+ ],
125
+ };
126
+ });
127
+ },
128
+ };
129
+ }
130
+
131
+ // Export defaults for testing
132
+ export { DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER };
@@ -0,0 +1,6 @@
1
+ // Filter types and interfaces
2
+ export type { CardFilter, FilterContext, CardFilterFactory } from './types';
3
+
4
+ // Filter implementations
5
+ export { createEloDistanceFilter } from './eloDistance';
6
+ export type { EloDistanceConfig } from './eloDistance';
@@ -0,0 +1,115 @@
1
+ import type { WeightedCard } from '../index';
2
+ import type { CourseDBInterface } from '../../interfaces/courseDB';
3
+ import type { UserDBInterface } from '../../interfaces/userDB';
4
+
5
+ // ============================================================================
6
+ // CARD FILTER INTERFACE
7
+ // ============================================================================
8
+ //
9
+ // Filters are pure transforms on a list of WeightedCards.
10
+ // They replace the delegate-wrapping pattern with a simpler model:
11
+ //
12
+ // cards = Generator.getWeightedCards()
13
+ // cards = Filter1.transform(cards, context)
14
+ // cards = Filter2.transform(cards, context)
15
+ //
16
+ // Benefits:
17
+ // - No nested instantiation
18
+ // - Filters don't need to know about delegates
19
+ // - Easy to add/remove/reorder filters
20
+ // - Natural place to hydrate shared data before filter pass
21
+ //
22
+ // All filters should be score multipliers (including score: 0 for exclusion).
23
+ // This means filter order doesn't affect final scores.
24
+ //
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Shared context available to all filters in a pipeline.
29
+ *
30
+ * Built once per getWeightedCards() call and passed to each filter.
31
+ * This avoids repeated lookups for common data like user ELO.
32
+ */
33
+ export interface FilterContext {
34
+ /** User database interface */
35
+ user: UserDBInterface;
36
+
37
+ /** Course database interface */
38
+ course: CourseDBInterface;
39
+
40
+ /** User's global ELO score for this course */
41
+ userElo: number;
42
+
43
+ // Future extensions:
44
+ // - hydrated tags for all cards (batch lookup)
45
+ // - user's tag-level ELO data
46
+ // - course config
47
+ }
48
+
49
+ /**
50
+ * A filter that transforms a list of weighted cards.
51
+ *
52
+ * Filters are pure transforms - they receive cards and context,
53
+ * and return a modified list of cards. No delegate wrapping,
54
+ * no side effects beyond provenance tracking.
55
+ *
56
+ * ## Implementation Guidelines
57
+ *
58
+ * 1. **Append provenance**: Every filter should add a StrategyContribution
59
+ * entry documenting its decision for each card.
60
+ *
61
+ * 2. **Use multipliers**: Adjust scores by multiplying, not replacing.
62
+ * This ensures filter order doesn't matter.
63
+ *
64
+ * 3. **Score 0 for exclusion**: To exclude a card, set score to 0.
65
+ * Don't filter it out - let provenance show why it was excluded.
66
+ *
67
+ * 4. **Don't sort**: The Pipeline handles final sorting.
68
+ * Filters just transform scores.
69
+ *
70
+ * ## Example Implementation
71
+ *
72
+ * ```typescript
73
+ * const myFilter: CardFilter = {
74
+ * name: 'My Filter',
75
+ * async transform(cards, context) {
76
+ * return cards.map(card => {
77
+ * const multiplier = computeMultiplier(card, context);
78
+ * const newScore = card.score * multiplier;
79
+ * return {
80
+ * ...card,
81
+ * score: newScore,
82
+ * provenance: [...card.provenance, {
83
+ * strategy: 'myFilter',
84
+ * strategyName: 'My Filter',
85
+ * strategyId: 'MY_FILTER',
86
+ * action: multiplier < 1 ? 'penalized' : 'passed',
87
+ * score: newScore,
88
+ * reason: 'Explanation of decision'
89
+ * }]
90
+ * };
91
+ * });
92
+ * }
93
+ * };
94
+ * ```
95
+ */
96
+ export interface CardFilter {
97
+ /** Human-readable name for this filter */
98
+ name: string;
99
+
100
+ /**
101
+ * Transform a list of weighted cards.
102
+ *
103
+ * @param cards - Cards to transform (already scored by generator)
104
+ * @param context - Shared context (user, course, userElo, etc.)
105
+ * @returns Transformed cards with updated scores and provenance
106
+ */
107
+ transform(cards: WeightedCard[], context: FilterContext): Promise<WeightedCard[]>;
108
+ }
109
+
110
+ /**
111
+ * Factory function type for creating filters from configuration.
112
+ *
113
+ * Used by PipelineAssembler to instantiate filters from strategy documents.
114
+ */
115
+ export type CardFilterFactory<TConfig = unknown> = (config: TConfig) => CardFilter;
@@ -0,0 +1,2 @@
1
+ // Generator types and interfaces
2
+ export type { CardGenerator, GeneratorContext, CardGeneratorFactory } from './types';