@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,367 @@
|
|
|
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
|
+
* Configuration for the InterferenceMitigator strategy.
|
|
13
|
+
*
|
|
14
|
+
* Course authors define explicit interference relationships between tags.
|
|
15
|
+
* The mitigator discourages introducing new concepts that interfere with
|
|
16
|
+
* currently immature learnings.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* A single interference group with its own decay coefficient.
|
|
20
|
+
*/
|
|
21
|
+
export interface InterferenceGroup {
|
|
22
|
+
/** Tags that interfere with each other in this group */
|
|
23
|
+
tags: string[];
|
|
24
|
+
/** How strongly these tags interfere (0-1, default: 0.8). Higher = stronger avoidance. */
|
|
25
|
+
decay?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface InterferenceConfig {
|
|
29
|
+
/**
|
|
30
|
+
* Groups of tags that interfere with each other.
|
|
31
|
+
* Each group can have its own decay coefficient.
|
|
32
|
+
*
|
|
33
|
+
* Example: [
|
|
34
|
+
* { tags: ["b", "d", "p"], decay: 0.9 }, // visual similarity - strong
|
|
35
|
+
* { tags: ["d", "t"], decay: 0.7 }, // phonetic similarity - moderate
|
|
36
|
+
* { tags: ["m", "n"], decay: 0.8 }
|
|
37
|
+
* ]
|
|
38
|
+
*/
|
|
39
|
+
interferenceSets: InterferenceGroup[];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Threshold below which a tag is considered "immature" (still being learned).
|
|
43
|
+
* Immature tags trigger interference avoidance for their interference partners.
|
|
44
|
+
*/
|
|
45
|
+
maturityThreshold?: {
|
|
46
|
+
/** Minimum interaction count to be considered mature (default: 10) */
|
|
47
|
+
minCount?: number;
|
|
48
|
+
/** Minimum ELO score to be considered mature (optional) */
|
|
49
|
+
minElo?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Minimum elapsed time (in days) since first interaction to be considered mature.
|
|
52
|
+
* Prevents recent cramming success from indicating maturity.
|
|
53
|
+
* The skill should be "lindy" — maintained over time.
|
|
54
|
+
*/
|
|
55
|
+
minElapsedDays?: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Default decay for groups that don't specify their own (0-1, default: 0.8).
|
|
60
|
+
*/
|
|
61
|
+
defaultDecay?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DEFAULT_MIN_COUNT = 10;
|
|
65
|
+
const DEFAULT_MIN_ELAPSED_DAYS = 3;
|
|
66
|
+
const DEFAULT_INTERFERENCE_DECAY = 0.8;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A filter strategy that avoids introducing confusable concepts simultaneously.
|
|
70
|
+
*
|
|
71
|
+
* When a user is learning a concept (tag is "immature"), this strategy reduces
|
|
72
|
+
* scores for cards tagged with interfering concepts. This encourages the system
|
|
73
|
+
* to introduce new content that is maximally distant from current learning focus.
|
|
74
|
+
*
|
|
75
|
+
* Example: While learning 'd', prefer introducing 'x' over 'b' (visual interference)
|
|
76
|
+
* or 't' (phonetic interference).
|
|
77
|
+
*
|
|
78
|
+
* Implements CardFilter for use in Pipeline architecture.
|
|
79
|
+
* Also extends ContentNavigator for backward compatibility.
|
|
80
|
+
*/
|
|
81
|
+
export default class InterferenceMitigatorNavigator extends ContentNavigator implements CardFilter {
|
|
82
|
+
private config: InterferenceConfig;
|
|
83
|
+
private _strategyData: ContentNavigationStrategyData;
|
|
84
|
+
|
|
85
|
+
/** Human-readable name for CardFilter interface */
|
|
86
|
+
name: string;
|
|
87
|
+
|
|
88
|
+
/** Precomputed map: tag -> set of { partner, decay } it interferes with */
|
|
89
|
+
private interferenceMap: Map<string, Array<{ partner: string; decay: number }>>;
|
|
90
|
+
|
|
91
|
+
constructor(
|
|
92
|
+
user: UserDBInterface,
|
|
93
|
+
course: CourseDBInterface,
|
|
94
|
+
_strategyData: ContentNavigationStrategyData
|
|
95
|
+
) {
|
|
96
|
+
super(user, course, _strategyData);
|
|
97
|
+
this._strategyData = _strategyData;
|
|
98
|
+
this.config = this.parseConfig(_strategyData.serializedData);
|
|
99
|
+
this.interferenceMap = this.buildInterferenceMap();
|
|
100
|
+
this.name = _strategyData.name || 'Interference Mitigator';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private parseConfig(serializedData: string): InterferenceConfig {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(serializedData);
|
|
106
|
+
// Normalize legacy format (string[][]) to new format (InterferenceGroup[])
|
|
107
|
+
let sets: InterferenceGroup[] = parsed.interferenceSets || [];
|
|
108
|
+
if (sets.length > 0 && Array.isArray(sets[0])) {
|
|
109
|
+
// Legacy format: convert string[][] to InterferenceGroup[]
|
|
110
|
+
sets = (sets as unknown as string[][]).map((tags) => ({ tags }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
interferenceSets: sets,
|
|
115
|
+
maturityThreshold: {
|
|
116
|
+
minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT,
|
|
117
|
+
minElo: parsed.maturityThreshold?.minElo,
|
|
118
|
+
minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS,
|
|
119
|
+
},
|
|
120
|
+
defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY,
|
|
121
|
+
};
|
|
122
|
+
} catch {
|
|
123
|
+
return {
|
|
124
|
+
interferenceSets: [],
|
|
125
|
+
maturityThreshold: {
|
|
126
|
+
minCount: DEFAULT_MIN_COUNT,
|
|
127
|
+
minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS,
|
|
128
|
+
},
|
|
129
|
+
defaultDecay: DEFAULT_INTERFERENCE_DECAY,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a map from each tag to its interference partners with decay coefficients.
|
|
136
|
+
* If tags A, B, C are in an interference group with decay 0.8, then:
|
|
137
|
+
* - A interferes with B (decay 0.8) and C (decay 0.8)
|
|
138
|
+
* - B interferes with A (decay 0.8) and C (decay 0.8)
|
|
139
|
+
* - etc.
|
|
140
|
+
*/
|
|
141
|
+
private buildInterferenceMap(): Map<string, Array<{ partner: string; decay: number }>> {
|
|
142
|
+
const map = new Map<string, Array<{ partner: string; decay: number }>>();
|
|
143
|
+
|
|
144
|
+
for (const group of this.config.interferenceSets) {
|
|
145
|
+
const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
|
|
146
|
+
|
|
147
|
+
for (const tag of group.tags) {
|
|
148
|
+
if (!map.has(tag)) {
|
|
149
|
+
map.set(tag, []);
|
|
150
|
+
}
|
|
151
|
+
const partners = map.get(tag)!;
|
|
152
|
+
for (const other of group.tags) {
|
|
153
|
+
if (other !== tag) {
|
|
154
|
+
// Check if partner already exists (from overlapping groups)
|
|
155
|
+
const existing = partners.find((p) => p.partner === other);
|
|
156
|
+
if (existing) {
|
|
157
|
+
// Use the stronger (higher) decay
|
|
158
|
+
existing.decay = Math.max(existing.decay, decay);
|
|
159
|
+
} else {
|
|
160
|
+
partners.push({ partner: other, decay });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return map;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get the set of tags that are currently immature for this user.
|
|
172
|
+
* A tag is immature if the user has interacted with it but hasn't
|
|
173
|
+
* reached the maturity threshold.
|
|
174
|
+
*/
|
|
175
|
+
private async getImmatureTags(context: FilterContext): Promise<Set<string>> {
|
|
176
|
+
const immature = new Set<string>();
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
|
|
180
|
+
const userElo = toCourseElo(courseReg.elo);
|
|
181
|
+
|
|
182
|
+
const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT;
|
|
183
|
+
const minElo = this.config.maturityThreshold?.minElo;
|
|
184
|
+
|
|
185
|
+
// TODO: To properly check elapsed time, we need access to first interaction timestamp.
|
|
186
|
+
// For now, we use count as a proxy (more interactions = more time elapsed).
|
|
187
|
+
// Future: query card history for earliest timestamp per tag.
|
|
188
|
+
const minElapsedDays =
|
|
189
|
+
this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
|
|
190
|
+
const minCountForElapsed = minElapsedDays * 2; // Rough proxy: ~2 interactions per day
|
|
191
|
+
|
|
192
|
+
for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
|
|
193
|
+
// Only consider tags that have been started (count > 0)
|
|
194
|
+
if (tagElo.count === 0) continue;
|
|
195
|
+
|
|
196
|
+
// Check if below maturity threshold
|
|
197
|
+
const belowCount = tagElo.count < minCount;
|
|
198
|
+
const belowElo = minElo !== undefined && tagElo.score < minElo;
|
|
199
|
+
const belowElapsed = tagElo.count < minCountForElapsed; // Proxy for time
|
|
200
|
+
|
|
201
|
+
if (belowCount || belowElo || belowElapsed) {
|
|
202
|
+
immature.add(tagId);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
// If we can't get user data, assume no immature tags
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return immature;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get all tags that interfere with any immature tag, along with their decay coefficients.
|
|
214
|
+
* These are the tags we want to avoid introducing.
|
|
215
|
+
*/
|
|
216
|
+
private getTagsToAvoid(immatureTags: Set<string>): Map<string, number> {
|
|
217
|
+
const avoid = new Map<string, number>();
|
|
218
|
+
|
|
219
|
+
for (const immatureTag of immatureTags) {
|
|
220
|
+
const partners = this.interferenceMap.get(immatureTag);
|
|
221
|
+
if (partners) {
|
|
222
|
+
for (const { partner, decay } of partners) {
|
|
223
|
+
// Avoid the partner, but not if it's also immature
|
|
224
|
+
// (if both are immature, we're already learning both)
|
|
225
|
+
if (!immatureTags.has(partner)) {
|
|
226
|
+
// Use the strongest (highest) decay if partner appears multiple times
|
|
227
|
+
const existing = avoid.get(partner) ?? 0;
|
|
228
|
+
avoid.set(partner, Math.max(existing, decay));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return avoid;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get tags for a single card
|
|
239
|
+
*/
|
|
240
|
+
private async getCardTags(cardId: string, course: CourseDBInterface): Promise<string[]> {
|
|
241
|
+
try {
|
|
242
|
+
const tagResponse = await course.getAppliedTags(cardId);
|
|
243
|
+
return tagResponse.rows.map((row) => row.value?.name || row.key).filter(Boolean);
|
|
244
|
+
} catch {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Compute interference score reduction for a card.
|
|
251
|
+
* Returns: { multiplier, interfering tags, reason }
|
|
252
|
+
*/
|
|
253
|
+
private computeInterferenceEffect(
|
|
254
|
+
cardTags: string[],
|
|
255
|
+
tagsToAvoid: Map<string, number>,
|
|
256
|
+
immatureTags: Set<string>
|
|
257
|
+
): { multiplier: number; interferingTags: string[]; reason: string } {
|
|
258
|
+
if (tagsToAvoid.size === 0) {
|
|
259
|
+
return {
|
|
260
|
+
multiplier: 1.0,
|
|
261
|
+
interferingTags: [],
|
|
262
|
+
reason: 'No interference detected',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let multiplier = 1.0;
|
|
267
|
+
const interferingTags: string[] = [];
|
|
268
|
+
|
|
269
|
+
for (const tag of cardTags) {
|
|
270
|
+
const decay = tagsToAvoid.get(tag);
|
|
271
|
+
if (decay !== undefined) {
|
|
272
|
+
interferingTags.push(tag);
|
|
273
|
+
multiplier *= 1.0 - decay;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (interferingTags.length === 0) {
|
|
278
|
+
return {
|
|
279
|
+
multiplier: 1.0,
|
|
280
|
+
interferingTags: [],
|
|
281
|
+
reason: 'No interference detected',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Find which immature tags these interfere with
|
|
286
|
+
const causingTags = new Set<string>();
|
|
287
|
+
for (const tag of interferingTags) {
|
|
288
|
+
for (const immatureTag of immatureTags) {
|
|
289
|
+
const partners = this.interferenceMap.get(immatureTag);
|
|
290
|
+
if (partners?.some((p) => p.partner === tag)) {
|
|
291
|
+
causingTags.add(immatureTag);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const reason = `Interferes with immature tags ${Array.from(causingTags).join(', ')} (tags: ${interferingTags.join(', ')}, multiplier: ${multiplier.toFixed(2)})`;
|
|
297
|
+
|
|
298
|
+
return { multiplier, interferingTags, reason };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* CardFilter.transform implementation.
|
|
303
|
+
*
|
|
304
|
+
* Apply interference-aware scoring. Cards with tags that interfere with
|
|
305
|
+
* immature learnings get reduced scores.
|
|
306
|
+
*/
|
|
307
|
+
async transform(cards: WeightedCard[], context: FilterContext): Promise<WeightedCard[]> {
|
|
308
|
+
// Identify what to avoid
|
|
309
|
+
const immatureTags = await this.getImmatureTags(context);
|
|
310
|
+
const tagsToAvoid = this.getTagsToAvoid(immatureTags);
|
|
311
|
+
|
|
312
|
+
// Adjust scores based on interference
|
|
313
|
+
const adjusted: WeightedCard[] = [];
|
|
314
|
+
|
|
315
|
+
for (const card of cards) {
|
|
316
|
+
const cardTags = await this.getCardTags(card.cardId, context.course);
|
|
317
|
+
const { multiplier, reason } = this.computeInterferenceEffect(
|
|
318
|
+
cardTags,
|
|
319
|
+
tagsToAvoid,
|
|
320
|
+
immatureTags
|
|
321
|
+
);
|
|
322
|
+
const finalScore = card.score * multiplier;
|
|
323
|
+
|
|
324
|
+
const action = multiplier < 1.0 ? 'penalized' : multiplier > 1.0 ? 'boosted' : 'passed';
|
|
325
|
+
|
|
326
|
+
adjusted.push({
|
|
327
|
+
...card,
|
|
328
|
+
score: finalScore,
|
|
329
|
+
provenance: [
|
|
330
|
+
...card.provenance,
|
|
331
|
+
{
|
|
332
|
+
strategy: 'interferenceMitigator',
|
|
333
|
+
strategyName: this.strategyName || this.name,
|
|
334
|
+
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-interference',
|
|
335
|
+
action,
|
|
336
|
+
score: finalScore,
|
|
337
|
+
reason,
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return adjusted;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
348
|
+
*
|
|
349
|
+
* Use transform() via Pipeline instead.
|
|
350
|
+
*/
|
|
351
|
+
async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
|
|
352
|
+
throw new Error(
|
|
353
|
+
'InterferenceMitigatorNavigator is a filter and should not be used as a generator. ' +
|
|
354
|
+
'Use Pipeline with a generator and this filter via transform().'
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Legacy methods - stub implementations since filters don't generate cards
|
|
359
|
+
|
|
360
|
+
async getNewCards(_n?: number): Promise<StudySessionNewItem[]> {
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
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
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Configuration for the RelativePriority strategy.
|
|
12
|
+
*
|
|
13
|
+
* Course authors define priority weights for tags, allowing the system
|
|
14
|
+
* to prefer high-utility content (common, well-behaved patterns) over
|
|
15
|
+
* lower-utility content (rare, irregular patterns).
|
|
16
|
+
*
|
|
17
|
+
* Example use case: In phonics, prefer teaching 's' (common, consistent)
|
|
18
|
+
* before 'x' or 'z' (rare, sometimes irregular).
|
|
19
|
+
*/
|
|
20
|
+
export interface RelativePriorityConfig {
|
|
21
|
+
/**
|
|
22
|
+
* Map of tag ID to priority weight (0-1).
|
|
23
|
+
*
|
|
24
|
+
* 1.0 = highest priority (present first)
|
|
25
|
+
* 0.5 = neutral
|
|
26
|
+
* 0.0 = lowest priority (defer until later)
|
|
27
|
+
*
|
|
28
|
+
* Example:
|
|
29
|
+
* {
|
|
30
|
+
* "letter-s": 0.95,
|
|
31
|
+
* "letter-t": 0.90,
|
|
32
|
+
* "letter-x": 0.10,
|
|
33
|
+
* "letter-z": 0.05
|
|
34
|
+
* }
|
|
35
|
+
*/
|
|
36
|
+
tagPriorities: { [tagId: string]: number };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Priority for tags not explicitly listed (default: 0.5).
|
|
40
|
+
* 0.5 means unlisted tags have neutral effect on scoring.
|
|
41
|
+
*/
|
|
42
|
+
defaultPriority?: number;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How to combine priorities when a card has multiple tags.
|
|
46
|
+
*
|
|
47
|
+
* - 'max': Use the highest priority among the card's tags (default)
|
|
48
|
+
* - 'average': Average all tag priorities
|
|
49
|
+
* - 'min': Use the lowest priority (conservative)
|
|
50
|
+
*/
|
|
51
|
+
combineMode?: 'max' | 'average' | 'min';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How strongly priority influences the final score (0-1, default: 0.5).
|
|
55
|
+
*
|
|
56
|
+
* At 0.0: Priority has no effect (pure delegate scoring)
|
|
57
|
+
* At 0.5: Priority can boost/reduce scores by up to 25%
|
|
58
|
+
* At 1.0: Priority can boost/reduce scores by up to 50%
|
|
59
|
+
*
|
|
60
|
+
* The boost factor formula: 1 + (priority - 0.5) * priorityInfluence
|
|
61
|
+
* - Priority 1.0 with influence 0.5 → boost of 1.25
|
|
62
|
+
* - Priority 0.5 with influence 0.5 → boost of 1.00 (neutral)
|
|
63
|
+
* - Priority 0.0 with influence 0.5 → boost of 0.75
|
|
64
|
+
*/
|
|
65
|
+
priorityInfluence?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const DEFAULT_PRIORITY = 0.5;
|
|
69
|
+
const DEFAULT_PRIORITY_INFLUENCE = 0.5;
|
|
70
|
+
const DEFAULT_COMBINE_MODE: 'max' | 'average' | 'min' = 'max';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A filter strategy that boosts scores for high-utility content.
|
|
74
|
+
*
|
|
75
|
+
* Course authors assign priority weights to tags. Cards with high-priority
|
|
76
|
+
* tags get boosted scores, making them more likely to be presented first.
|
|
77
|
+
* This allows teaching the most useful, well-behaved concepts before
|
|
78
|
+
* moving on to rarer or more irregular ones.
|
|
79
|
+
*
|
|
80
|
+
* Example: When teaching phonics, prioritize common letters (s, t, a) over
|
|
81
|
+
* rare ones (x, z, q) by assigning higher priority weights to common letters.
|
|
82
|
+
*
|
|
83
|
+
* Implements CardFilter for use in Pipeline architecture.
|
|
84
|
+
* Also extends ContentNavigator for backward compatibility.
|
|
85
|
+
*/
|
|
86
|
+
export default class RelativePriorityNavigator extends ContentNavigator implements CardFilter {
|
|
87
|
+
private config: RelativePriorityConfig;
|
|
88
|
+
private _strategyData: ContentNavigationStrategyData;
|
|
89
|
+
|
|
90
|
+
/** Human-readable name for CardFilter interface */
|
|
91
|
+
name: string;
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
user: UserDBInterface,
|
|
95
|
+
course: CourseDBInterface,
|
|
96
|
+
_strategyData: ContentNavigationStrategyData
|
|
97
|
+
) {
|
|
98
|
+
super(user, course, _strategyData);
|
|
99
|
+
this._strategyData = _strategyData;
|
|
100
|
+
this.config = this.parseConfig(_strategyData.serializedData);
|
|
101
|
+
this.name = _strategyData.name || 'Relative Priority';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private parseConfig(serializedData: string): RelativePriorityConfig {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(serializedData);
|
|
107
|
+
return {
|
|
108
|
+
tagPriorities: parsed.tagPriorities || {},
|
|
109
|
+
defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
|
|
110
|
+
combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
|
|
111
|
+
priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE,
|
|
112
|
+
};
|
|
113
|
+
} catch {
|
|
114
|
+
// Return safe defaults if parsing fails
|
|
115
|
+
return {
|
|
116
|
+
tagPriorities: {},
|
|
117
|
+
defaultPriority: DEFAULT_PRIORITY,
|
|
118
|
+
combineMode: DEFAULT_COMBINE_MODE,
|
|
119
|
+
priorityInfluence: DEFAULT_PRIORITY_INFLUENCE,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Look up the priority for a tag.
|
|
126
|
+
*/
|
|
127
|
+
private getTagPriority(tagId: string): number {
|
|
128
|
+
return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Compute combined priority for a card based on its tags.
|
|
133
|
+
*/
|
|
134
|
+
private computeCardPriority(cardTags: string[]): number {
|
|
135
|
+
if (cardTags.length === 0) {
|
|
136
|
+
return this.config.defaultPriority ?? DEFAULT_PRIORITY;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const priorities = cardTags.map((tag) => this.getTagPriority(tag));
|
|
140
|
+
|
|
141
|
+
switch (this.config.combineMode) {
|
|
142
|
+
case 'max':
|
|
143
|
+
return Math.max(...priorities);
|
|
144
|
+
case 'min':
|
|
145
|
+
return Math.min(...priorities);
|
|
146
|
+
case 'average':
|
|
147
|
+
return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
|
|
148
|
+
default:
|
|
149
|
+
return Math.max(...priorities);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Compute boost factor based on priority.
|
|
155
|
+
*
|
|
156
|
+
* The formula: 1 + (priority - 0.5) * priorityInfluence
|
|
157
|
+
*
|
|
158
|
+
* This creates a multiplier centered around 1.0:
|
|
159
|
+
* - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
|
|
160
|
+
* - Priority 0.5 with any influence → 1.00 (neutral)
|
|
161
|
+
* - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
|
|
162
|
+
*/
|
|
163
|
+
private computeBoostFactor(priority: number): number {
|
|
164
|
+
const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
|
|
165
|
+
return 1 + (priority - 0.5) * influence;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build human-readable reason for priority adjustment.
|
|
170
|
+
*/
|
|
171
|
+
private buildPriorityReason(
|
|
172
|
+
cardTags: string[],
|
|
173
|
+
priority: number,
|
|
174
|
+
boostFactor: number,
|
|
175
|
+
finalScore: number
|
|
176
|
+
): string {
|
|
177
|
+
if (cardTags.length === 0) {
|
|
178
|
+
return `No tags, neutral priority (${priority.toFixed(2)})`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const tagList = cardTags.slice(0, 3).join(', ');
|
|
182
|
+
const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : '';
|
|
183
|
+
|
|
184
|
+
if (boostFactor === 1.0) {
|
|
185
|
+
return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
|
|
186
|
+
} else if (boostFactor > 1.0) {
|
|
187
|
+
return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} → boost ${boostFactor.toFixed(2)}x → ${finalScore.toFixed(2)})`;
|
|
188
|
+
} else {
|
|
189
|
+
return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} → reduce ${boostFactor.toFixed(2)}x → ${finalScore.toFixed(2)})`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get tags for a single card.
|
|
195
|
+
*/
|
|
196
|
+
private async getCardTags(cardId: string, course: CourseDBInterface): Promise<string[]> {
|
|
197
|
+
try {
|
|
198
|
+
const tagResponse = await course.getAppliedTags(cardId);
|
|
199
|
+
return tagResponse.rows.map((r) => r.doc?.name).filter((x): x is string => !!x);
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* CardFilter.transform implementation.
|
|
207
|
+
*
|
|
208
|
+
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
209
|
+
* cards with low-priority tags get reduced scores.
|
|
210
|
+
*/
|
|
211
|
+
async transform(cards: WeightedCard[], context: FilterContext): Promise<WeightedCard[]> {
|
|
212
|
+
const adjusted: WeightedCard[] = await Promise.all(
|
|
213
|
+
cards.map(async (card) => {
|
|
214
|
+
const cardTags = await this.getCardTags(card.cardId, context.course);
|
|
215
|
+
const priority = this.computeCardPriority(cardTags);
|
|
216
|
+
const boostFactor = this.computeBoostFactor(priority);
|
|
217
|
+
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
218
|
+
|
|
219
|
+
// Determine action based on boost factor
|
|
220
|
+
const action = boostFactor > 1.0 ? 'boosted' : boostFactor < 1.0 ? 'penalized' : 'passed';
|
|
221
|
+
|
|
222
|
+
// Build reason explaining priority adjustment
|
|
223
|
+
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
...card,
|
|
227
|
+
score: finalScore,
|
|
228
|
+
provenance: [
|
|
229
|
+
...card.provenance,
|
|
230
|
+
{
|
|
231
|
+
strategy: 'relativePriority',
|
|
232
|
+
strategyName: this.strategyName || this.name,
|
|
233
|
+
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-priority',
|
|
234
|
+
action,
|
|
235
|
+
score: finalScore,
|
|
236
|
+
reason,
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
};
|
|
240
|
+
})
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
return adjusted;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
248
|
+
*
|
|
249
|
+
* Use transform() via Pipeline instead.
|
|
250
|
+
*/
|
|
251
|
+
async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
|
|
252
|
+
throw new Error(
|
|
253
|
+
'RelativePriorityNavigator is a filter and should not be used as a generator. ' +
|
|
254
|
+
'Use Pipeline with a generator and this filter via transform().'
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Legacy methods - stub implementations since filters don't generate cards
|
|
259
|
+
|
|
260
|
+
async getNewCards(_n?: number): Promise<StudySessionNewItem[]> {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
}
|