@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.
- package/dist/{userDB-DNa0XPtn.d.ts → classroomDB-BgfrVb8d.d.ts} +357 -103
- package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CTOenngH.d.cts} +358 -104
- package/dist/core/index.d.cts +230 -0
- package/dist/core/index.d.ts +161 -23
- package/dist/core/index.js +1964 -154
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +1925 -121
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-CZxC9GtB.d.ts} +1 -1
- package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D6PoCwS6.d.cts} +1 -1
- package/dist/impl/couch/{index.d.mts → index.d.cts} +46 -5
- package/dist/impl/couch/index.d.ts +44 -3
- package/dist/impl/couch/index.js +1971 -171
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +1933 -134
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/{index.d.mts → index.d.cts} +5 -6
- package/dist/impl/static/index.d.ts +2 -3
- package/dist/impl/static/index.js +1614 -119
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +1585 -92
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-Bmll7Xse.d.mts → index-D-Fa4Smt.d.cts} +1 -1
- package/dist/{index.d.mts → index.d.cts} +97 -13
- package/dist/index.d.ts +90 -6
- package/dist/index.js +2085 -153
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2031 -106
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.js +3 -3
- package/dist/{types-Dbp5DaRR.d.mts → types-CzPDLAK6.d.cts} +1 -1
- package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
- package/dist/util/packer/index.js.map +1 -1
- package/dist/util/packer/index.mjs.map +1 -1
- package/docs/brainstorm-navigation-paradigm.md +369 -0
- package/docs/navigators-architecture.md +265 -0
- package/docs/todo-evolutionary-orchestration.md +310 -0
- package/docs/todo-nominal-tag-types.md +121 -0
- package/docs/todo-pipeline-optimization.md +117 -0
- package/docs/todo-strategy-authoring.md +401 -0
- package/docs/todo-strategy-state-storage.md +278 -0
- package/eslint.config.mjs +1 -1
- package/package.json +9 -4
- package/src/core/interfaces/contentSource.ts +88 -4
- package/src/core/interfaces/navigationStrategyManager.ts +0 -5
- package/src/core/navigators/CompositeGenerator.ts +268 -0
- package/src/core/navigators/Pipeline.ts +205 -0
- package/src/core/navigators/PipelineAssembler.ts +194 -0
- package/src/core/navigators/elo.ts +104 -15
- package/src/core/navigators/filters/eloDistance.ts +132 -0
- package/src/core/navigators/filters/index.ts +6 -0
- package/src/core/navigators/filters/types.ts +115 -0
- package/src/core/navigators/generators/index.ts +2 -0
- package/src/core/navigators/generators/types.ts +107 -0
- package/src/core/navigators/hardcodedOrder.ts +111 -12
- package/src/core/navigators/hierarchyDefinition.ts +266 -0
- package/src/core/navigators/index.ts +345 -3
- package/src/core/navigators/interferenceMitigator.ts +367 -0
- package/src/core/navigators/relativePriority.ts +267 -0
- package/src/core/navigators/srs.ts +195 -0
- package/src/impl/couch/classroomDB.ts +51 -0
- package/src/impl/couch/courseDB.ts +117 -39
- package/src/impl/static/courseDB.ts +0 -4
- package/src/study/SessionController.ts +149 -1
- package/src/study/TagFilteredContentSource.ts +255 -0
- package/src/study/index.ts +1 -0
- package/src/util/dataDirectory.test.ts +51 -22
- package/src/util/logger.ts +0 -1
- package/tests/core/navigators/CompositeGenerator.test.ts +455 -0
- package/tests/core/navigators/Pipeline.test.ts +405 -0
- package/tests/core/navigators/PipelineAssembler.test.ts +351 -0
- package/tests/core/navigators/SRSNavigator.test.ts +344 -0
- package/tests/core/navigators/eloDistanceFilter.test.ts +192 -0
- package/tests/core/navigators/navigators.test.ts +710 -0
- package/tsconfig.json +1 -1
- package/vitest.config.ts +29 -0
- package/dist/core/index.d.mts +0 -92
- /package/dist/{SyncStrategy-CyATpyLQ.d.mts → SyncStrategy-CyATpyLQ.d.cts} +0 -0
- /package/dist/pouch/{index.d.mts → index.d.cts} +0 -0
- /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 {
|
|
6
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|
|
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,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;
|