@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.
- package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CZdMBiTU.d.ts} +427 -104
- package/dist/{userDB-DNa0XPtn.d.ts → classroomDB-PxDZTky3.d.cts} +427 -104
- package/dist/core/index.d.cts +304 -0
- package/dist/core/index.d.ts +237 -25
- package/dist/core/index.js +2246 -118
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +2235 -114
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D0MoZMjH.d.cts} +1 -1
- package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-D8o6ZnKW.d.ts} +1 -1
- package/dist/impl/couch/{index.d.mts → index.d.cts} +47 -5
- package/dist/impl/couch/index.d.ts +46 -4
- package/dist/impl/couch/index.js +2250 -134
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +2212 -97
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/{index.d.mts → index.d.cts} +6 -6
- package/dist/impl/static/index.d.ts +5 -5
- package/dist/impl/static/index.js +1950 -143
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +1922 -117
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-Bmll7Xse.d.mts → index-B_j6u5E4.d.cts} +1 -1
- package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
- package/dist/{index.d.mts → index.d.cts} +97 -13
- package/dist/index.d.ts +96 -12
- package/dist/index.js +2439 -180
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2386 -135
- package/dist/index.mjs.map +1 -1
- package/dist/pouch/index.js +3 -3
- package/dist/{types-Dbp5DaRR.d.mts → types-Bn0itutr.d.cts} +1 -1
- package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
- package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.cts} +3 -1
- package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-DDY4N-Uq.d.ts} +3 -1
- package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
- package/dist/util/packer/index.d.ts +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 +370 -0
- package/docs/todo-evolutionary-orchestration.md +310 -0
- package/docs/todo-nominal-tag-types.md +121 -0
- package/docs/todo-strategy-authoring.md +401 -0
- package/eslint.config.mjs +1 -1
- package/package.json +9 -4
- package/src/core/index.ts +1 -0
- package/src/core/interfaces/contentSource.ts +88 -4
- package/src/core/interfaces/courseDB.ts +13 -0
- package/src/core/interfaces/navigationStrategyManager.ts +0 -5
- package/src/core/interfaces/userDB.ts +32 -0
- package/src/core/navigators/CompositeGenerator.ts +268 -0
- package/src/core/navigators/Pipeline.ts +318 -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 +9 -0
- package/src/core/navigators/filters/types.ts +115 -0
- package/src/core/navigators/filters/userTagPreference.ts +232 -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 +404 -3
- package/src/core/navigators/inferredPreference.ts +107 -0
- package/src/core/navigators/interferenceMitigator.ts +355 -0
- package/src/core/navigators/relativePriority.ts +255 -0
- package/src/core/navigators/srs.ts +195 -0
- package/src/core/navigators/userGoal.ts +136 -0
- package/src/core/types/strategyState.ts +84 -0
- package/src/core/types/types-legacy.ts +2 -0
- package/src/impl/common/BaseUserDB.ts +74 -7
- package/src/impl/couch/adminDB.ts +1 -2
- package/src/impl/couch/classroomDB.ts +51 -0
- package/src/impl/couch/courseDB.ts +147 -49
- package/src/impl/static/courseDB.ts +11 -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 +406 -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
|
@@ -0,0 +1,255 @@
|
|
|
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
|
+
* CardFilter.transform implementation.
|
|
195
|
+
*
|
|
196
|
+
* Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
|
|
197
|
+
* cards with low-priority tags get reduced scores.
|
|
198
|
+
*/
|
|
199
|
+
async transform(cards: WeightedCard[], _context: FilterContext): Promise<WeightedCard[]> {
|
|
200
|
+
const adjusted: WeightedCard[] = await Promise.all(
|
|
201
|
+
cards.map(async (card) => {
|
|
202
|
+
const cardTags = card.tags ?? [];
|
|
203
|
+
const priority = this.computeCardPriority(cardTags);
|
|
204
|
+
const boostFactor = this.computeBoostFactor(priority);
|
|
205
|
+
const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
|
|
206
|
+
|
|
207
|
+
// Determine action based on boost factor
|
|
208
|
+
const action = boostFactor > 1.0 ? 'boosted' : boostFactor < 1.0 ? 'penalized' : 'passed';
|
|
209
|
+
|
|
210
|
+
// Build reason explaining priority adjustment
|
|
211
|
+
const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
...card,
|
|
215
|
+
score: finalScore,
|
|
216
|
+
provenance: [
|
|
217
|
+
...card.provenance,
|
|
218
|
+
{
|
|
219
|
+
strategy: 'relativePriority',
|
|
220
|
+
strategyName: this.strategyName || this.name,
|
|
221
|
+
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-priority',
|
|
222
|
+
action,
|
|
223
|
+
score: finalScore,
|
|
224
|
+
reason,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
return adjusted;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Legacy getWeightedCards - now throws as filters should not be used as generators.
|
|
236
|
+
*
|
|
237
|
+
* Use transform() via Pipeline instead.
|
|
238
|
+
*/
|
|
239
|
+
async getWeightedCards(_limit: number): Promise<WeightedCard[]> {
|
|
240
|
+
throw new Error(
|
|
241
|
+
'RelativePriorityNavigator is a filter and should not be used as a generator. ' +
|
|
242
|
+
'Use Pipeline with a generator and this filter via transform().'
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Legacy methods - stub implementations since filters don't generate cards
|
|
247
|
+
|
|
248
|
+
async getNewCards(_n?: number): Promise<StudySessionNewItem[]> {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import moment from 'moment';
|
|
2
|
+
import type { ScheduledCard } from '../types/user';
|
|
3
|
+
import type { CourseDBInterface } from '../interfaces/courseDB';
|
|
4
|
+
import type { UserDBInterface } from '../interfaces/userDB';
|
|
5
|
+
import { ContentNavigator } from './index';
|
|
6
|
+
import type { WeightedCard } from './index';
|
|
7
|
+
import type { ContentNavigationStrategyData } from '../types/contentNavigationStrategy';
|
|
8
|
+
import type { StudySessionReviewItem, StudySessionNewItem } from '../interfaces/contentSource';
|
|
9
|
+
import type { CardGenerator, GeneratorContext } from './generators/types';
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// SRS NAVIGATOR
|
|
13
|
+
// ============================================================================
|
|
14
|
+
//
|
|
15
|
+
// A generator strategy that scores review cards by urgency.
|
|
16
|
+
//
|
|
17
|
+
// Urgency is determined by two factors:
|
|
18
|
+
// 1. Overdueness - how far past the scheduled review time
|
|
19
|
+
// 2. Interval recency - shorter scheduled intervals indicate "novel content in progress"
|
|
20
|
+
//
|
|
21
|
+
// A card with a 3-day interval that's 2 days overdue is more urgent than a card
|
|
22
|
+
// with a 6-month interval that's 2 days overdue. The shorter interval represents
|
|
23
|
+
// active learning at higher resolution.
|
|
24
|
+
//
|
|
25
|
+
// This navigator only handles reviews - it does not generate new cards.
|
|
26
|
+
// For new cards, use ELONavigator or another generator via CompositeGenerator.
|
|
27
|
+
//
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Configuration for the SRS strategy.
|
|
32
|
+
* Currently minimal - the algorithm is not parameterized.
|
|
33
|
+
*/
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
35
|
+
export interface SRSConfig {
|
|
36
|
+
// Future: configurable urgency curves, thresholds, etc.
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A navigation strategy that scores review cards by urgency.
|
|
41
|
+
*
|
|
42
|
+
* Implements CardGenerator for use in Pipeline architecture.
|
|
43
|
+
* Also extends ContentNavigator for backward compatibility with legacy code.
|
|
44
|
+
*
|
|
45
|
+
* Higher scores indicate more urgent reviews:
|
|
46
|
+
* - Cards that are more overdue (relative to their interval) score higher
|
|
47
|
+
* - Cards with shorter intervals (recent learning) score higher
|
|
48
|
+
*
|
|
49
|
+
* Only returns cards that are actually due (reviewTime has passed).
|
|
50
|
+
* Does not generate new cards - use with CompositeGenerator for mixed content.
|
|
51
|
+
*/
|
|
52
|
+
export default class SRSNavigator extends ContentNavigator implements CardGenerator {
|
|
53
|
+
/** Human-readable name for CardGenerator interface */
|
|
54
|
+
name: string;
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
user: UserDBInterface,
|
|
58
|
+
course: CourseDBInterface,
|
|
59
|
+
strategyData?: ContentNavigationStrategyData
|
|
60
|
+
) {
|
|
61
|
+
super(user, course, strategyData as ContentNavigationStrategyData);
|
|
62
|
+
this.name = strategyData?.name || 'SRS';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get review cards scored by urgency.
|
|
67
|
+
*
|
|
68
|
+
* Score formula combines:
|
|
69
|
+
* - Relative overdueness: hoursOverdue / intervalHours
|
|
70
|
+
* - Interval recency: exponential decay favoring shorter intervals
|
|
71
|
+
*
|
|
72
|
+
* Cards not yet due are excluded (not scored as 0).
|
|
73
|
+
*
|
|
74
|
+
* This method supports both the legacy signature (limit only) and the
|
|
75
|
+
* CardGenerator interface signature (limit, context).
|
|
76
|
+
*
|
|
77
|
+
* @param limit - Maximum number of cards to return
|
|
78
|
+
* @param _context - Optional GeneratorContext (currently unused, but required for interface)
|
|
79
|
+
*/
|
|
80
|
+
async getWeightedCards(limit: number, _context?: GeneratorContext): Promise<WeightedCard[]> {
|
|
81
|
+
if (!this.user || !this.course) {
|
|
82
|
+
throw new Error('SRSNavigator requires user and course to be set');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
86
|
+
const now = moment.utc();
|
|
87
|
+
|
|
88
|
+
// Filter to only cards that are actually due
|
|
89
|
+
const dueReviews = reviews.filter((r) => now.isAfter(moment.utc(r.reviewTime)));
|
|
90
|
+
|
|
91
|
+
const scored = dueReviews.map((review) => {
|
|
92
|
+
const { score, reason } = this.computeUrgencyScore(review, now);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
cardId: review.cardId,
|
|
96
|
+
courseId: review.courseId,
|
|
97
|
+
score,
|
|
98
|
+
provenance: [
|
|
99
|
+
{
|
|
100
|
+
strategy: 'srs',
|
|
101
|
+
strategyName: this.strategyName || this.name,
|
|
102
|
+
strategyId: this.strategyId || 'NAVIGATION_STRATEGY-SRS-default',
|
|
103
|
+
action: 'generated' as const,
|
|
104
|
+
score,
|
|
105
|
+
reason,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Sort by score descending and limit
|
|
112
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compute urgency score for a review card.
|
|
117
|
+
*
|
|
118
|
+
* Two factors:
|
|
119
|
+
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
120
|
+
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
121
|
+
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
122
|
+
*
|
|
123
|
+
* 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
|
|
124
|
+
* - 24h interval → ~1.0 (very recent learning)
|
|
125
|
+
* - 30 days (720h) → ~0.56
|
|
126
|
+
* - 180 days → ~0.30
|
|
127
|
+
*
|
|
128
|
+
* Combined: base 0.5 + weighted average of factors * 0.45
|
|
129
|
+
* Result range: approximately 0.5 to 0.95
|
|
130
|
+
*/
|
|
131
|
+
private computeUrgencyScore(
|
|
132
|
+
review: ScheduledCard,
|
|
133
|
+
now: moment.Moment
|
|
134
|
+
): { score: number; reason: string } {
|
|
135
|
+
const scheduledAt = moment.utc(review.scheduledAt);
|
|
136
|
+
const due = moment.utc(review.reviewTime);
|
|
137
|
+
|
|
138
|
+
// Interval = time between scheduling and due date (minimum 1 hour to avoid division issues)
|
|
139
|
+
const intervalHours = Math.max(1, due.diff(scheduledAt, 'hours'));
|
|
140
|
+
const hoursOverdue = now.diff(due, 'hours');
|
|
141
|
+
|
|
142
|
+
// Relative overdueness: how late relative to the interval
|
|
143
|
+
const relativeOverdue = hoursOverdue / intervalHours;
|
|
144
|
+
|
|
145
|
+
// Interval recency factor: shorter intervals = more urgent
|
|
146
|
+
// Exponential decay with 720h (30 days) as the characteristic time
|
|
147
|
+
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
148
|
+
|
|
149
|
+
// Combined urgency: weighted average of relative overdue and recency
|
|
150
|
+
// Clamp relative overdue contribution to [0, 1] to avoid runaway scores
|
|
151
|
+
const overdueContribution = Math.min(1.0, Math.max(0, relativeOverdue));
|
|
152
|
+
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
153
|
+
|
|
154
|
+
// Final score: base 0.5 + urgency contribution, capped at 0.95
|
|
155
|
+
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
156
|
+
|
|
157
|
+
const reason =
|
|
158
|
+
`${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, ` +
|
|
159
|
+
`relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
|
|
160
|
+
|
|
161
|
+
return { score, reason };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get pending reviews in legacy format.
|
|
166
|
+
*
|
|
167
|
+
* Returns all pending reviews for the course, enriched with session item fields.
|
|
168
|
+
*/
|
|
169
|
+
async getPendingReviews(): Promise<(StudySessionReviewItem & ScheduledCard)[]> {
|
|
170
|
+
if (!this.user || !this.course) {
|
|
171
|
+
throw new Error('SRSNavigator requires user and course to be set');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const reviews = await this.user.getPendingReviews(this.course.getCourseID());
|
|
175
|
+
|
|
176
|
+
return reviews.map((r) => ({
|
|
177
|
+
...r,
|
|
178
|
+
contentSourceType: 'course' as const,
|
|
179
|
+
contentSourceID: this.course!.getCourseID(),
|
|
180
|
+
cardID: r.cardId,
|
|
181
|
+
courseID: r.courseId,
|
|
182
|
+
qualifiedID: `${r.courseId}-${r.cardId}`,
|
|
183
|
+
reviewID: r._id,
|
|
184
|
+
status: 'review' as const,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* SRS does not generate new cards.
|
|
190
|
+
* Use ELONavigator or another generator for new cards.
|
|
191
|
+
*/
|
|
192
|
+
async getNewCards(_n?: number): Promise<StudySessionNewItem[]> {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// USER GOAL NAVIGATOR — STUB
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// STATUS: NOT IMPLEMENTED — This file documents architectural intent only.
|
|
6
|
+
//
|
|
7
|
+
// ============================================================================
|
|
8
|
+
//
|
|
9
|
+
// ## Purpose
|
|
10
|
+
//
|
|
11
|
+
// Goals define WHAT the user wants to learn, as opposed to preferences which
|
|
12
|
+
// define HOW they want to learn. Goals affect:
|
|
13
|
+
//
|
|
14
|
+
// 1. **Content scoping**: Which tags/content are relevant to this user
|
|
15
|
+
// 2. **Progress tracking**: ELO is measured against goal-relevant content
|
|
16
|
+
// 3. **Completion criteria**: User is "done" when goal mastery is achieved
|
|
17
|
+
// 4. **Curriculum composition**: Goals enable cross-curriculum dependencies
|
|
18
|
+
//
|
|
19
|
+
// ## Goals vs Preferences
|
|
20
|
+
//
|
|
21
|
+
// | Aspect | Goal | Preference |
|
|
22
|
+
// |---------------|-------------------------------|-------------------------------|
|
|
23
|
+
// | Defines | Destination (what to learn) | Path (how to learn) |
|
|
24
|
+
// | Example | "Master ear-training" | "Skip text-heavy cards" |
|
|
25
|
+
// | Affects ELO | Yes — scopes what's tracked | No — just filters cards |
|
|
26
|
+
// | Completion | Yes — defines "done" | No — persists indefinitely |
|
|
27
|
+
// | Filter impl | UserGoalNavigator | UserTagPreferenceFilter |
|
|
28
|
+
//
|
|
29
|
+
// ## Curriculum Composition
|
|
30
|
+
//
|
|
31
|
+
// Goals enable software-style composition for curricula. A physics course
|
|
32
|
+
// can teach classical mechanics without owning the calculus prerequisites.
|
|
33
|
+
//
|
|
34
|
+
// Instead, it declares a dependency:
|
|
35
|
+
//
|
|
36
|
+
// ```typescript
|
|
37
|
+
// interface CurriculumDependency {
|
|
38
|
+
// // NPM-style package resolution
|
|
39
|
+
// curriculumId: string; // e.g., "@skuilder/calculus"
|
|
40
|
+
// version: string; // e.g., "^2.0.0" (semver)
|
|
41
|
+
//
|
|
42
|
+
// // Goal within that curriculum
|
|
43
|
+
// goal: string; // e.g., "differential-calculus"
|
|
44
|
+
//
|
|
45
|
+
// // How this maps to local prerequisites
|
|
46
|
+
// satisfiesLocalTags: string[]; // e.g., ["calculus-prereq"]
|
|
47
|
+
// }
|
|
48
|
+
// ```
|
|
49
|
+
//
|
|
50
|
+
// When a physics card requires "calculus-prereq", the system:
|
|
51
|
+
// 1. Checks if user has achieved the "differential-calculus" goal in @skuilder/calculus
|
|
52
|
+
// 2. If not, defers to that curriculum to teach the prerequisite
|
|
53
|
+
// 3. Returns to physics once the goal is satisfied
|
|
54
|
+
//
|
|
55
|
+
// This allows:
|
|
56
|
+
// - Specialized curricula (calculus experts author calculus content)
|
|
57
|
+
// - Reusable prerequisites across multiple courses
|
|
58
|
+
// - User can bring their own "calculus credential" from prior learning
|
|
59
|
+
//
|
|
60
|
+
// ## User Goal State (Proposed)
|
|
61
|
+
//
|
|
62
|
+
// ```typescript
|
|
63
|
+
// interface UserGoalState {
|
|
64
|
+
// // Primary goals — what the user wants to achieve
|
|
65
|
+
// targetTags: string[];
|
|
66
|
+
//
|
|
67
|
+
// // Excluded goals — content the user explicitly doesn't care about
|
|
68
|
+
// excludedTags: string[];
|
|
69
|
+
//
|
|
70
|
+
// // Cross-curriculum goals (for composition)
|
|
71
|
+
// externalGoals?: {
|
|
72
|
+
// curriculumId: string;
|
|
73
|
+
// goal: string;
|
|
74
|
+
// status: 'not-started' | 'in-progress' | 'achieved';
|
|
75
|
+
// }[];
|
|
76
|
+
//
|
|
77
|
+
// // When this goal configuration was set
|
|
78
|
+
// updatedAt: string;
|
|
79
|
+
// }
|
|
80
|
+
// ```
|
|
81
|
+
//
|
|
82
|
+
// ## Implementation Considerations
|
|
83
|
+
//
|
|
84
|
+
// 1. **ELO Scoping**: When goals are set, user ELO tracking should focus on
|
|
85
|
+
// goal-relevant tags. This may require changes to ELO update logic.
|
|
86
|
+
//
|
|
87
|
+
// 2. **Progress Reporting**: UI should show progress toward goals, not just
|
|
88
|
+
// overall course completion.
|
|
89
|
+
//
|
|
90
|
+
// 3. **Goal Achievement**: Need to define when a goal is "achieved" —
|
|
91
|
+
// probably ELO threshold + mastery percentage on goal-tagged content.
|
|
92
|
+
//
|
|
93
|
+
// 4. **Curriculum Registry**: For cross-curriculum composition, need a
|
|
94
|
+
// registry/resolver for curriculum packages (similar to npm registry).
|
|
95
|
+
//
|
|
96
|
+
// 5. **Interaction with HierarchyDefinition**: Goals should work with
|
|
97
|
+
// prerequisite chains — user can't skip prerequisites just because
|
|
98
|
+
// they're not part of their goal.
|
|
99
|
+
//
|
|
100
|
+
// ## Related Files
|
|
101
|
+
//
|
|
102
|
+
// - `filters/userTagPreference.ts` — Preferences (path constraints)
|
|
103
|
+
// - `hierarchyDefinition.ts` — Prerequisites (enforced regardless of goals)
|
|
104
|
+
// - `../types/strategyState.ts` — Storage mechanism for user state
|
|
105
|
+
//
|
|
106
|
+
// ## Next Steps
|
|
107
|
+
//
|
|
108
|
+
// 1. Design goal state schema in detail
|
|
109
|
+
// 2. Define goal achievement criteria
|
|
110
|
+
// 3. Implement goal-scoped ELO tracking
|
|
111
|
+
// 4. Build UI for goal configuration
|
|
112
|
+
// 5. Design curriculum dependency resolution
|
|
113
|
+
//
|
|
114
|
+
// ============================================================================
|
|
115
|
+
|
|
116
|
+
// Placeholder export to make this a valid module
|
|
117
|
+
export const USER_GOAL_NAVIGATOR_STUB = true;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @stub UserGoalNavigator
|
|
121
|
+
*
|
|
122
|
+
* A navigator that scopes learning to user-defined goals.
|
|
123
|
+
* See module-level documentation for architectural intent.
|
|
124
|
+
*
|
|
125
|
+
* NOT IMPLEMENTED — This is a design placeholder.
|
|
126
|
+
*/
|
|
127
|
+
export interface UserGoalState {
|
|
128
|
+
/** Tags the user wants to master (defines "success") */
|
|
129
|
+
targetTags: string[];
|
|
130
|
+
|
|
131
|
+
/** Tags the user explicitly doesn't care about */
|
|
132
|
+
excludedTags: string[];
|
|
133
|
+
|
|
134
|
+
/** ISO timestamp of last update */
|
|
135
|
+
updatedAt: string;
|
|
136
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { DocType, DocTypePrefixes } from './types-legacy';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Template literal type for strategy state document IDs.
|
|
5
|
+
*
|
|
6
|
+
* Format: `STRATEGY_STATE-{courseId}-{strategyKey}`
|
|
7
|
+
*/
|
|
8
|
+
export type StrategyStateId =
|
|
9
|
+
`${(typeof DocTypePrefixes)[DocType.STRATEGY_STATE]}::${string}::${string}`;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Document storing strategy-specific state in the user database.
|
|
13
|
+
*
|
|
14
|
+
* Each strategy can persist its own state (user preferences, learned patterns,
|
|
15
|
+
* temporal tracking, etc.) using this document type. The state is scoped to
|
|
16
|
+
* a (user, course, strategy) tuple.
|
|
17
|
+
*
|
|
18
|
+
* ## Use Cases
|
|
19
|
+
*
|
|
20
|
+
* 1. **Explicit user preferences**: User configures tag filters, difficulty
|
|
21
|
+
* preferences, or learning goals. UI writes to strategy state.
|
|
22
|
+
*
|
|
23
|
+
* 2. **Learned/temporal state**: Strategy tracks patterns over time, e.g.,
|
|
24
|
+
* "when did I last introduce confusable concepts together?"
|
|
25
|
+
*
|
|
26
|
+
* 3. **Adaptive personalization**: Strategy infers user preferences from
|
|
27
|
+
* behavior and stores them for future sessions.
|
|
28
|
+
*
|
|
29
|
+
* ## Storage Location
|
|
30
|
+
*
|
|
31
|
+
* These documents live in the **user database**, not the course database.
|
|
32
|
+
* They sync with the user's data across devices.
|
|
33
|
+
*
|
|
34
|
+
* ## Document ID Format
|
|
35
|
+
*
|
|
36
|
+
* `STRATEGY_STATE::{courseId}::{strategyKey}`
|
|
37
|
+
*
|
|
38
|
+
* Example: `STRATEGY_STATE::piano-basics::UserTagPreferenceFilter`
|
|
39
|
+
*
|
|
40
|
+
* @template T - The shape of the strategy-specific data payload
|
|
41
|
+
*/
|
|
42
|
+
export interface StrategyStateDoc<T = unknown> {
|
|
43
|
+
_id: StrategyStateId;
|
|
44
|
+
_rev?: string;
|
|
45
|
+
docType: DocType.STRATEGY_STATE;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The course this state applies to.
|
|
49
|
+
*/
|
|
50
|
+
courseId: string;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Unique key identifying the strategy instance.
|
|
54
|
+
* Typically the strategy class name (e.g., "UserTagPreferenceFilter",
|
|
55
|
+
* "InterferenceMitigatorNavigator").
|
|
56
|
+
*
|
|
57
|
+
* If a course has multiple instances of the same strategy type with
|
|
58
|
+
* different configurations, use a more specific key.
|
|
59
|
+
*/
|
|
60
|
+
strategyKey: string;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Strategy-specific data payload.
|
|
64
|
+
* Each strategy defines its own schema for this field.
|
|
65
|
+
*/
|
|
66
|
+
data: T;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* ISO timestamp of last update.
|
|
70
|
+
* Use `moment.utc(updatedAt)` to parse into a Moment object.
|
|
71
|
+
*/
|
|
72
|
+
updatedAt: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the document ID for a strategy state document.
|
|
77
|
+
*
|
|
78
|
+
* @param courseId - The course ID
|
|
79
|
+
* @param strategyKey - The strategy key (typically class name)
|
|
80
|
+
* @returns The document ID in format `STRATEGY_STATE::{courseId}::{strategyKey}`
|
|
81
|
+
*/
|
|
82
|
+
export function buildStrategyStateId(courseId: string, strategyKey: string): StrategyStateId {
|
|
83
|
+
return `STRATEGY_STATE::${courseId}::${strategyKey}`;
|
|
84
|
+
}
|
|
@@ -19,6 +19,7 @@ export enum DocType {
|
|
|
19
19
|
SCHEDULED_CARD = 'SCHEDULED_CARD',
|
|
20
20
|
TAG = 'TAG',
|
|
21
21
|
NAVIGATION_STRATEGY = 'NAVIGATION_STRATEGY',
|
|
22
|
+
STRATEGY_STATE = 'STRATEGY_STATE',
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface QualifiedCardID {
|
|
@@ -103,6 +104,7 @@ export const DocTypePrefixes = {
|
|
|
103
104
|
[DocType.VIEW]: 'VIEW',
|
|
104
105
|
[DocType.PEDAGOGY]: 'PEDAGOGY',
|
|
105
106
|
[DocType.NAVIGATION_STRATEGY]: 'NAVIGATION_STRATEGY',
|
|
107
|
+
[DocType.STRATEGY_STATE]: 'STRATEGY_STATE',
|
|
106
108
|
} as const;
|
|
107
109
|
|
|
108
110
|
export interface CardHistory<T extends CardRecord> {
|