@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,351 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
PipelineAssembler,
|
|
4
|
+
PipelineAssemblerInput,
|
|
5
|
+
} from '../../../src/core/navigators/PipelineAssembler';
|
|
6
|
+
import { ContentNavigationStrategyData } from '../../../src/core/types/contentNavigationStrategy';
|
|
7
|
+
import { DocType } from '../../../src/core';
|
|
8
|
+
import { Pipeline } from '../../../src/core/navigators/Pipeline';
|
|
9
|
+
|
|
10
|
+
// Mock the dynamic import in ContentNavigator.create
|
|
11
|
+
vi.mock('../../../src/core/navigators/elo', () => ({
|
|
12
|
+
default: class MockELONavigator {
|
|
13
|
+
name = 'ELO';
|
|
14
|
+
constructor(
|
|
15
|
+
public user: any,
|
|
16
|
+
public course: any,
|
|
17
|
+
public strategyData: any
|
|
18
|
+
) {}
|
|
19
|
+
async getWeightedCards() {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
async getNewCards() {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
async getPendingReviews() {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('../../../src/core/navigators/srs', () => ({
|
|
32
|
+
default: class MockSRSNavigator {
|
|
33
|
+
name = 'SRS';
|
|
34
|
+
constructor(
|
|
35
|
+
public user: any,
|
|
36
|
+
public course: any,
|
|
37
|
+
public strategyData: any
|
|
38
|
+
) {}
|
|
39
|
+
async getWeightedCards() {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
async getNewCards() {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
async getPendingReviews() {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
vi.mock('../../../src/core/navigators/hardcodedOrder', () => ({
|
|
52
|
+
default: class MockHardcodedOrderNavigator {
|
|
53
|
+
name = 'Hardcoded Order';
|
|
54
|
+
constructor(
|
|
55
|
+
public user: any,
|
|
56
|
+
public course: any,
|
|
57
|
+
public strategyData: any
|
|
58
|
+
) {}
|
|
59
|
+
async getWeightedCards() {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
async getNewCards() {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
async getPendingReviews() {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
vi.mock('../../../src/core/navigators/hierarchyDefinition', () => ({
|
|
72
|
+
default: class MockHierarchyDefinitionNavigator {
|
|
73
|
+
name = 'Hierarchy Definition';
|
|
74
|
+
constructor(
|
|
75
|
+
public user: any,
|
|
76
|
+
public course: any,
|
|
77
|
+
public strategyData: any
|
|
78
|
+
) {
|
|
79
|
+
this.name = strategyData.name || 'Hierarchy Definition';
|
|
80
|
+
}
|
|
81
|
+
async transform(cards: any[]) {
|
|
82
|
+
return cards;
|
|
83
|
+
}
|
|
84
|
+
async getWeightedCards() {
|
|
85
|
+
throw new Error('Filter should not be used as generator');
|
|
86
|
+
}
|
|
87
|
+
async getNewCards() {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
async getPendingReviews() {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
vi.mock('../../../src/core/navigators/interferenceMitigator', () => ({
|
|
97
|
+
default: class MockInterferenceMitigatorNavigator {
|
|
98
|
+
name = 'Interference Mitigator';
|
|
99
|
+
constructor(
|
|
100
|
+
public user: any,
|
|
101
|
+
public course: any,
|
|
102
|
+
public strategyData: any
|
|
103
|
+
) {
|
|
104
|
+
this.name = strategyData.name || 'Interference Mitigator';
|
|
105
|
+
}
|
|
106
|
+
async transform(cards: any[]) {
|
|
107
|
+
return cards;
|
|
108
|
+
}
|
|
109
|
+
async getWeightedCards() {
|
|
110
|
+
throw new Error('Filter should not be used as generator');
|
|
111
|
+
}
|
|
112
|
+
async getNewCards() {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
async getPendingReviews() {
|
|
116
|
+
return [];
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
vi.mock('../../../src/core/navigators/relativePriority', () => ({
|
|
122
|
+
default: class MockRelativePriorityNavigator {
|
|
123
|
+
name = 'Relative Priority';
|
|
124
|
+
constructor(
|
|
125
|
+
public user: any,
|
|
126
|
+
public course: any,
|
|
127
|
+
public strategyData: any
|
|
128
|
+
) {
|
|
129
|
+
this.name = strategyData.name || 'Relative Priority';
|
|
130
|
+
}
|
|
131
|
+
async transform(cards: any[]) {
|
|
132
|
+
return cards;
|
|
133
|
+
}
|
|
134
|
+
async getWeightedCards() {
|
|
135
|
+
throw new Error('Filter should not be used as generator');
|
|
136
|
+
}
|
|
137
|
+
async getNewCards() {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
async getPendingReviews() {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
describe('PipelineAssembler', () => {
|
|
147
|
+
const assembler = new PipelineAssembler();
|
|
148
|
+
|
|
149
|
+
// Mock user and course for instantiation
|
|
150
|
+
const mockUser = {
|
|
151
|
+
getCourseRegDoc: vi
|
|
152
|
+
.fn()
|
|
153
|
+
.mockResolvedValue({ elo: { global: { score: 1000, count: 0 }, tags: {} } }),
|
|
154
|
+
getPendingReviews: vi.fn().mockResolvedValue([]),
|
|
155
|
+
getActiveCards: vi.fn().mockResolvedValue([]),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const mockCourse = {
|
|
159
|
+
getCourseID: vi.fn().mockReturnValue('test-course'),
|
|
160
|
+
getCardEloData: vi.fn().mockResolvedValue([]),
|
|
161
|
+
getAppliedTags: vi.fn().mockResolvedValue({ rows: [] }),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
function createStrategy(
|
|
165
|
+
name: string,
|
|
166
|
+
implementingClass: string,
|
|
167
|
+
serializedData = '{}'
|
|
168
|
+
): ContentNavigationStrategyData {
|
|
169
|
+
return {
|
|
170
|
+
_id: `NAVIGATION_STRATEGY-${name}`,
|
|
171
|
+
course: 'test-course',
|
|
172
|
+
docType: DocType.NAVIGATION_STRATEGY,
|
|
173
|
+
name,
|
|
174
|
+
description: `Test strategy: ${name}`,
|
|
175
|
+
implementingClass,
|
|
176
|
+
serializedData,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function createInput(strategies: ContentNavigationStrategyData[]): PipelineAssemblerInput {
|
|
181
|
+
return {
|
|
182
|
+
strategies,
|
|
183
|
+
user: mockUser as any,
|
|
184
|
+
course: mockCourse as any,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
vi.clearAllMocks();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('empty input', () => {
|
|
193
|
+
it('returns null pipeline when no strategy documents exist', async () => {
|
|
194
|
+
const input = createInput([]);
|
|
195
|
+
const result = await assembler.assemble(input);
|
|
196
|
+
|
|
197
|
+
expect(result.pipeline).toBeNull();
|
|
198
|
+
expect(result.generatorStrategies).toEqual([]);
|
|
199
|
+
expect(result.filterStrategies).toEqual([]);
|
|
200
|
+
expect(result.warnings).toEqual([]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('generator-only scenarios', () => {
|
|
205
|
+
it('returns pipeline with single generator when no filters exist', async () => {
|
|
206
|
+
const elo = createStrategy('elo-strategy', 'elo');
|
|
207
|
+
const input = createInput([elo]);
|
|
208
|
+
const result = await assembler.assemble(input);
|
|
209
|
+
|
|
210
|
+
expect(result.pipeline).toBeInstanceOf(Pipeline);
|
|
211
|
+
expect(result.generatorStrategies).toEqual([elo]);
|
|
212
|
+
expect(result.filterStrategies).toEqual([]);
|
|
213
|
+
expect(result.warnings).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('creates CompositeGenerator when multiple generators exist', async () => {
|
|
217
|
+
const elo1 = createStrategy('elo-1', 'elo');
|
|
218
|
+
const elo2 = createStrategy('elo-2', 'srs');
|
|
219
|
+
const input = createInput([elo1, elo2]);
|
|
220
|
+
const result = await assembler.assemble(input);
|
|
221
|
+
|
|
222
|
+
expect(result.pipeline).toBeInstanceOf(Pipeline);
|
|
223
|
+
expect(result.generatorStrategies).toEqual([elo1, elo2]);
|
|
224
|
+
expect(result.warnings).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('uses default ELO when filters exist but no generator', async () => {
|
|
228
|
+
const hierarchy = createStrategy('hierarchy', 'hierarchyDefinition');
|
|
229
|
+
const input = createInput([hierarchy]);
|
|
230
|
+
const result = await assembler.assemble(input);
|
|
231
|
+
|
|
232
|
+
expect(result.pipeline).toBeInstanceOf(Pipeline);
|
|
233
|
+
expect(result.generatorStrategies).toHaveLength(1);
|
|
234
|
+
expect(result.generatorStrategies[0].implementingClass).toBe('elo');
|
|
235
|
+
expect(result.generatorStrategies[0].name).toBe('ELO (default)');
|
|
236
|
+
expect(result.filterStrategies).toEqual([hierarchy]);
|
|
237
|
+
expect(result.warnings).toEqual([]);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('filter handling', () => {
|
|
242
|
+
it('includes single filter in pipeline', async () => {
|
|
243
|
+
const elo = createStrategy('elo', 'elo');
|
|
244
|
+
const hierarchy = createStrategy('hierarchy', 'hierarchyDefinition', '{"prerequisites": {}}');
|
|
245
|
+
const input = createInput([elo, hierarchy]);
|
|
246
|
+
const result = await assembler.assemble(input);
|
|
247
|
+
|
|
248
|
+
expect(result.pipeline).toBeInstanceOf(Pipeline);
|
|
249
|
+
expect(result.generatorStrategies).toEqual([elo]);
|
|
250
|
+
expect(result.filterStrategies).toEqual([hierarchy]);
|
|
251
|
+
expect(result.warnings).toEqual([]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('sorts filters alphabetically for deterministic ordering', async () => {
|
|
255
|
+
const elo = createStrategy('elo', 'elo');
|
|
256
|
+
const relativePriority = createStrategy(
|
|
257
|
+
'z-relative-priority',
|
|
258
|
+
'relativePriority',
|
|
259
|
+
'{"tagPriorities": {}}'
|
|
260
|
+
);
|
|
261
|
+
const hierarchy = createStrategy(
|
|
262
|
+
'a-hierarchy',
|
|
263
|
+
'hierarchyDefinition',
|
|
264
|
+
'{"prerequisites": {}}'
|
|
265
|
+
);
|
|
266
|
+
const interference = createStrategy(
|
|
267
|
+
'm-interference',
|
|
268
|
+
'interferenceMitigator',
|
|
269
|
+
'{"interferenceSets": []}'
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const input = createInput([elo, relativePriority, hierarchy, interference]);
|
|
273
|
+
const result = await assembler.assemble(input);
|
|
274
|
+
|
|
275
|
+
expect(result.pipeline).toBeInstanceOf(Pipeline);
|
|
276
|
+
expect(result.generatorStrategies).toEqual([elo]);
|
|
277
|
+
|
|
278
|
+
// Filters should be sorted alphabetically by name
|
|
279
|
+
expect(result.filterStrategies.map((f) => f.name)).toEqual([
|
|
280
|
+
'a-hierarchy',
|
|
281
|
+
'm-interference',
|
|
282
|
+
'z-relative-priority',
|
|
283
|
+
]);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('error handling', () => {
|
|
288
|
+
it('skips unknown strategy types with warning', async () => {
|
|
289
|
+
const elo = createStrategy('elo', 'elo');
|
|
290
|
+
const unknown = createStrategy('unknown', 'unknownStrategyType');
|
|
291
|
+
const hierarchy = createStrategy('hierarchy', 'hierarchyDefinition');
|
|
292
|
+
|
|
293
|
+
const input = createInput([elo, unknown, hierarchy]);
|
|
294
|
+
const result = await assembler.assemble(input);
|
|
295
|
+
|
|
296
|
+
expect(result.pipeline).toBeInstanceOf(Pipeline);
|
|
297
|
+
expect(result.generatorStrategies).toEqual([elo]);
|
|
298
|
+
expect(result.filterStrategies).toEqual([hierarchy]);
|
|
299
|
+
expect(result.warnings).toContain(
|
|
300
|
+
"Unknown strategy type 'unknownStrategyType', skipping: unknown"
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe('complex scenarios', () => {
|
|
306
|
+
it('handles multiple generators and multiple filters', async () => {
|
|
307
|
+
const elo = createStrategy('elo', 'elo');
|
|
308
|
+
const hardcoded = createStrategy('hardcoded', 'hardcodedOrder');
|
|
309
|
+
const hierarchy = createStrategy('hierarchy', 'hierarchyDefinition');
|
|
310
|
+
const relativePriority = createStrategy('priority', 'relativePriority');
|
|
311
|
+
|
|
312
|
+
const input = createInput([elo, hardcoded, hierarchy, relativePriority]);
|
|
313
|
+
const result = await assembler.assemble(input);
|
|
314
|
+
|
|
315
|
+
// Should have both generators
|
|
316
|
+
expect(result.generatorStrategies).toEqual([elo, hardcoded]);
|
|
317
|
+
|
|
318
|
+
// Should have both filters (sorted alphabetically)
|
|
319
|
+
expect(result.filterStrategies.map((f) => f.name)).toEqual(['hierarchy', 'priority']);
|
|
320
|
+
|
|
321
|
+
// Should produce a valid pipeline
|
|
322
|
+
expect(result.pipeline).toBeInstanceOf(Pipeline);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('maintains deterministic ordering regardless of input order', async () => {
|
|
326
|
+
const elo = createStrategy('elo', 'elo');
|
|
327
|
+
const filterA = createStrategy('a-filter', 'hierarchyDefinition');
|
|
328
|
+
const filterB = createStrategy('b-filter', 'relativePriority');
|
|
329
|
+
const filterC = createStrategy('c-filter', 'interferenceMitigator');
|
|
330
|
+
|
|
331
|
+
// Try different input orders
|
|
332
|
+
const input1 = createInput([filterC, elo, filterA, filterB]);
|
|
333
|
+
const input2 = createInput([filterB, filterA, filterC, elo]);
|
|
334
|
+
|
|
335
|
+
const result1 = await assembler.assemble(input1);
|
|
336
|
+
const result2 = await assembler.assemble(input2);
|
|
337
|
+
|
|
338
|
+
// Both should produce filters in same alphabetical order
|
|
339
|
+
expect(result1.filterStrategies.map((f) => f.name)).toEqual([
|
|
340
|
+
'a-filter',
|
|
341
|
+
'b-filter',
|
|
342
|
+
'c-filter',
|
|
343
|
+
]);
|
|
344
|
+
expect(result2.filterStrategies.map((f) => f.name)).toEqual([
|
|
345
|
+
'a-filter',
|
|
346
|
+
'b-filter',
|
|
347
|
+
'c-filter',
|
|
348
|
+
]);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import moment from 'moment';
|
|
3
|
+
import { ScheduledCard } from '../../../src/core/types/user';
|
|
4
|
+
import { getCardOrigin, WeightedCard } from '../../../src/core/navigators/index';
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// SRS NAVIGATOR SCORING TESTS
|
|
8
|
+
// ============================================================================
|
|
9
|
+
//
|
|
10
|
+
// Tests for the SRS Navigator's urgency scoring formula.
|
|
11
|
+
// The formula considers:
|
|
12
|
+
// 1. Relative overdueness: hoursOverdue / intervalHours
|
|
13
|
+
// 2. Interval recency: shorter intervals indicate active learning
|
|
14
|
+
//
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compute urgency score for a review card.
|
|
19
|
+
* This is a standalone implementation of the scoring logic for testing.
|
|
20
|
+
*/
|
|
21
|
+
function computeUrgencyScore(
|
|
22
|
+
scheduledAt: moment.Moment,
|
|
23
|
+
reviewTime: moment.Moment,
|
|
24
|
+
now: moment.Moment
|
|
25
|
+
): { score: number; relativeOverdue: number; recencyFactor: number; intervalHours: number } {
|
|
26
|
+
// Interval = time between scheduling and due date (minimum 1 hour)
|
|
27
|
+
const intervalHours = Math.max(1, reviewTime.diff(scheduledAt, 'hours'));
|
|
28
|
+
const hoursOverdue = now.diff(reviewTime, 'hours');
|
|
29
|
+
|
|
30
|
+
// Relative overdueness: how late relative to the interval
|
|
31
|
+
const relativeOverdue = hoursOverdue / intervalHours;
|
|
32
|
+
|
|
33
|
+
// Interval recency factor: shorter intervals = more urgent
|
|
34
|
+
const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
|
|
35
|
+
|
|
36
|
+
// Combined urgency
|
|
37
|
+
const overdueContribution = Math.min(1.0, Math.max(0, relativeOverdue));
|
|
38
|
+
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
39
|
+
|
|
40
|
+
// Final score
|
|
41
|
+
const score = Math.min(0.95, 0.5 + urgency * 0.45);
|
|
42
|
+
|
|
43
|
+
return { score, relativeOverdue, recencyFactor, intervalHours };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Helper to create a mock scheduled card for testing
|
|
48
|
+
*/
|
|
49
|
+
function createMockReview(
|
|
50
|
+
scheduledAt: moment.Moment,
|
|
51
|
+
reviewTime: moment.Moment,
|
|
52
|
+
cardId: string = 'card-1'
|
|
53
|
+
): ScheduledCard {
|
|
54
|
+
return {
|
|
55
|
+
_id: `SCHEDULED_CARD-${cardId}`,
|
|
56
|
+
cardId,
|
|
57
|
+
courseId: 'test-course',
|
|
58
|
+
scheduledAt: scheduledAt.toISOString(),
|
|
59
|
+
reviewTime: reviewTime.toISOString(),
|
|
60
|
+
scheduledFor: 'course',
|
|
61
|
+
schedulingAgentId: 'test-course',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('SRS urgency scoring formula', () => {
|
|
66
|
+
const now = moment.utc('2024-01-15T12:00:00Z');
|
|
67
|
+
|
|
68
|
+
describe('relative overdueness', () => {
|
|
69
|
+
it('should compute higher relative overdueness for shorter intervals', () => {
|
|
70
|
+
// 3-day interval, 2 days overdue
|
|
71
|
+
const shortInterval = computeUrgencyScore(
|
|
72
|
+
moment.utc('2024-01-09T12:00:00Z'), // scheduled 6 days ago
|
|
73
|
+
moment.utc('2024-01-12T12:00:00Z'), // due 3 days ago (3-day interval)
|
|
74
|
+
now
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// 30-day interval, 2 days overdue
|
|
78
|
+
const longInterval = computeUrgencyScore(
|
|
79
|
+
moment.utc('2023-12-14T12:00:00Z'), // scheduled 32 days ago
|
|
80
|
+
moment.utc('2024-01-13T12:00:00Z'), // due 2 days ago (30-day interval)
|
|
81
|
+
now
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Short interval should have MUCH higher relative overdueness
|
|
85
|
+
expect(shortInterval.relativeOverdue).toBeGreaterThan(0.9); // ~1.0 (3 days / 3 days)
|
|
86
|
+
expect(longInterval.relativeOverdue).toBeLessThan(0.1); // ~0.067 (2 days / 30 days)
|
|
87
|
+
expect(shortInterval.relativeOverdue).toBeGreaterThan(longInterval.relativeOverdue * 10);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should clamp relative overdueness at 1.0 for score calculation', () => {
|
|
91
|
+
// Extremely overdue: 10 days overdue on 3-day interval
|
|
92
|
+
const extremelyOverdue = computeUrgencyScore(
|
|
93
|
+
moment.utc('2024-01-02T12:00:00Z'), // scheduled 13 days ago
|
|
94
|
+
moment.utc('2024-01-05T12:00:00Z'), // due 10 days ago (3-day interval)
|
|
95
|
+
now
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// relativeOverdue is unclamped for diagnostics
|
|
99
|
+
expect(extremelyOverdue.relativeOverdue).toBeGreaterThan(3);
|
|
100
|
+
|
|
101
|
+
// But score should still be within bounds
|
|
102
|
+
expect(extremelyOverdue.score).toBeLessThanOrEqual(0.95);
|
|
103
|
+
expect(extremelyOverdue.score).toBeGreaterThan(0.5);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('interval recency factor', () => {
|
|
108
|
+
it('should return ~1.0 for very short intervals (24h)', () => {
|
|
109
|
+
const result = computeUrgencyScore(
|
|
110
|
+
moment.utc('2024-01-14T12:00:00Z'), // scheduled 1 day ago
|
|
111
|
+
moment.utc('2024-01-15T11:00:00Z'), // due 1 hour ago (23h interval)
|
|
112
|
+
now
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(result.recencyFactor).toBeGreaterThan(0.95);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should return ~0.56 for 30-day intervals', () => {
|
|
119
|
+
const result = computeUrgencyScore(
|
|
120
|
+
moment.utc('2023-12-15T12:00:00Z'), // scheduled 31 days ago
|
|
121
|
+
moment.utc('2024-01-14T12:00:00Z'), // due 1 day ago (30-day interval)
|
|
122
|
+
now
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// 0.3 + 0.7 * exp(-720/720) = 0.3 + 0.7 * 0.368 ≈ 0.56
|
|
126
|
+
expect(result.recencyFactor).toBeCloseTo(0.56, 1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should return ~0.30 for very long intervals (180+ days)', () => {
|
|
130
|
+
const result = computeUrgencyScore(
|
|
131
|
+
moment.utc('2023-07-10T12:00:00Z'), // scheduled 189 days ago
|
|
132
|
+
moment.utc('2024-01-14T12:00:00Z'), // due 1 day ago (188-day interval)
|
|
133
|
+
now
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Approaches 0.3 asymptotically
|
|
137
|
+
expect(result.recencyFactor).toBeLessThan(0.35);
|
|
138
|
+
expect(result.recencyFactor).toBeGreaterThanOrEqual(0.3);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('combined scoring', () => {
|
|
143
|
+
it('should score 3-day interval 2-days-overdue higher than 180-day interval 2-days-overdue', () => {
|
|
144
|
+
// The key example from the plan
|
|
145
|
+
const shortInterval = computeUrgencyScore(
|
|
146
|
+
moment.utc('2024-01-09T12:00:00Z'), // 3-day interval
|
|
147
|
+
moment.utc('2024-01-12T12:00:00Z'), // 3 days overdue (72h)
|
|
148
|
+
now
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const longInterval = computeUrgencyScore(
|
|
152
|
+
moment.utc('2023-07-10T12:00:00Z'), // 180-day interval
|
|
153
|
+
moment.utc('2024-01-13T12:00:00Z'), // 2 days overdue (48h)
|
|
154
|
+
now
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
expect(shortInterval.score).toBeGreaterThan(longInterval.score);
|
|
158
|
+
// Short interval should be significantly higher
|
|
159
|
+
expect(shortInterval.score - longInterval.score).toBeGreaterThan(0.1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should produce scores within expected range', () => {
|
|
163
|
+
// Just due (0h overdue, 24h interval)
|
|
164
|
+
const justDue = computeUrgencyScore(
|
|
165
|
+
moment.utc('2024-01-14T12:00:00Z'),
|
|
166
|
+
moment.utc('2024-01-15T12:00:00Z'),
|
|
167
|
+
now
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Very overdue (7 days on 1 day interval)
|
|
171
|
+
const veryOverdue = computeUrgencyScore(
|
|
172
|
+
moment.utc('2024-01-07T12:00:00Z'),
|
|
173
|
+
moment.utc('2024-01-08T12:00:00Z'),
|
|
174
|
+
now
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// All scores should be in [0.5, 0.95] range
|
|
178
|
+
expect(justDue.score).toBeGreaterThanOrEqual(0.5);
|
|
179
|
+
expect(justDue.score).toBeLessThanOrEqual(0.95);
|
|
180
|
+
expect(veryOverdue.score).toBeGreaterThanOrEqual(0.5);
|
|
181
|
+
expect(veryOverdue.score).toBeLessThanOrEqual(0.95);
|
|
182
|
+
|
|
183
|
+
// Very overdue should be higher
|
|
184
|
+
expect(veryOverdue.score).toBeGreaterThan(justDue.score);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should match expected scores from plan table', () => {
|
|
188
|
+
// From the plan:
|
|
189
|
+
// | Interval | Overdue | Relative | Recency | Score |
|
|
190
|
+
// | 3 days | 2 days | 0.67 | 0.93 | 0.87 |
|
|
191
|
+
// | 30 days | 2 days | 0.07 | 0.65 | 0.68 |
|
|
192
|
+
// | 180 days | 2 days | 0.01 | 0.35 | 0.59 |
|
|
193
|
+
// | 1 day | 6 hours | 0.25 | 0.99 | 0.78 |
|
|
194
|
+
|
|
195
|
+
const case1 = computeUrgencyScore(
|
|
196
|
+
moment.utc('2024-01-10T12:00:00Z'), // 3-day interval
|
|
197
|
+
moment.utc('2024-01-13T12:00:00Z'), // 2 days overdue
|
|
198
|
+
now
|
|
199
|
+
);
|
|
200
|
+
expect(case1.score).toBeCloseTo(0.87, 1);
|
|
201
|
+
|
|
202
|
+
const case2 = computeUrgencyScore(
|
|
203
|
+
moment.utc('2023-12-14T12:00:00Z'), // 30-day interval
|
|
204
|
+
moment.utc('2024-01-13T12:00:00Z'), // 2 days overdue
|
|
205
|
+
now
|
|
206
|
+
);
|
|
207
|
+
expect(case2.score).toBeCloseTo(0.68, 1);
|
|
208
|
+
|
|
209
|
+
const case3 = computeUrgencyScore(
|
|
210
|
+
moment.utc('2023-07-19T12:00:00Z'), // 180-day interval
|
|
211
|
+
moment.utc('2024-01-13T12:00:00Z'), // 2 days overdue
|
|
212
|
+
now
|
|
213
|
+
);
|
|
214
|
+
expect(case3.score).toBeCloseTo(0.59, 1);
|
|
215
|
+
|
|
216
|
+
// 1 day interval, 6 hours overdue
|
|
217
|
+
const case4 = computeUrgencyScore(
|
|
218
|
+
moment.utc('2024-01-14T06:00:00Z'), // 1-day interval (24h)
|
|
219
|
+
moment.utc('2024-01-15T06:00:00Z'), // 6 hours overdue
|
|
220
|
+
now
|
|
221
|
+
);
|
|
222
|
+
expect(case4.score).toBeCloseTo(0.78, 1);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('edge cases', () => {
|
|
227
|
+
it('should handle exactly due cards (0 hours overdue)', () => {
|
|
228
|
+
const exactlyDue = computeUrgencyScore(
|
|
229
|
+
moment.utc('2024-01-14T12:00:00Z'),
|
|
230
|
+
moment.utc('2024-01-15T12:00:00Z'), // exactly now
|
|
231
|
+
now
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// Should still get a reasonable score from recency factor
|
|
235
|
+
expect(exactlyDue.score).toBeGreaterThan(0.5);
|
|
236
|
+
expect(exactlyDue.relativeOverdue).toBe(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should handle very short intervals (1 hour)', () => {
|
|
240
|
+
const shortInterval = computeUrgencyScore(
|
|
241
|
+
moment.utc('2024-01-15T10:00:00Z'),
|
|
242
|
+
moment.utc('2024-01-15T11:00:00Z'), // 1 hour interval, 1 hour overdue
|
|
243
|
+
now
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// Very short interval should have high recency
|
|
247
|
+
expect(shortInterval.recencyFactor).toBeGreaterThan(0.99);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should use minimum 1 hour interval to avoid division issues', () => {
|
|
251
|
+
// Simultaneous schedule and due (edge case)
|
|
252
|
+
const zeroInterval = computeUrgencyScore(
|
|
253
|
+
moment.utc('2024-01-15T11:00:00Z'),
|
|
254
|
+
moment.utc('2024-01-15T11:00:00Z'), // same time
|
|
255
|
+
now
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(zeroInterval.intervalHours).toBe(1);
|
|
259
|
+
expect(Number.isFinite(zeroInterval.score)).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('SRS Navigator card filtering', () => {
|
|
265
|
+
it('should only include cards that are actually due', () => {
|
|
266
|
+
const now = moment.utc('2024-01-15T12:00:00Z');
|
|
267
|
+
|
|
268
|
+
const dueCard = createMockReview(
|
|
269
|
+
moment.utc('2024-01-12T12:00:00Z'),
|
|
270
|
+
moment.utc('2024-01-14T12:00:00Z'), // due yesterday
|
|
271
|
+
'due-card'
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const futureCard = createMockReview(
|
|
275
|
+
moment.utc('2024-01-14T12:00:00Z'),
|
|
276
|
+
moment.utc('2024-01-16T12:00:00Z'), // due tomorrow
|
|
277
|
+
'future-card'
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const reviews = [dueCard, futureCard];
|
|
281
|
+
const dueReviews = reviews.filter((r) => now.isAfter(moment.utc(r.reviewTime)));
|
|
282
|
+
|
|
283
|
+
expect(dueReviews).toHaveLength(1);
|
|
284
|
+
expect(dueReviews[0].cardId).toBe('due-card');
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('SRS Navigator provenance', () => {
|
|
289
|
+
it('should mark cards as reviews in provenance reason', () => {
|
|
290
|
+
// The reason string should contain "review" for getCardOrigin to work
|
|
291
|
+
const reason = `48h overdue (interval: 72h, relative: 0.67), recency: 0.93, review`;
|
|
292
|
+
expect(reason.toLowerCase()).toContain('review');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should work with getCardOrigin helper', () => {
|
|
296
|
+
const card: WeightedCard = {
|
|
297
|
+
cardId: 'test-card',
|
|
298
|
+
courseId: 'test-course',
|
|
299
|
+
score: 0.85,
|
|
300
|
+
provenance: [
|
|
301
|
+
{
|
|
302
|
+
strategy: 'srs',
|
|
303
|
+
strategyName: 'SRS',
|
|
304
|
+
strategyId: 'NAVIGATION_STRATEGY-SRS-default',
|
|
305
|
+
action: 'generated',
|
|
306
|
+
score: 0.85,
|
|
307
|
+
reason: '48h overdue (interval: 72h, relative: 0.67), recency: 0.93, review',
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
expect(getCardOrigin(card)).toBe('review');
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('SRS Navigator sorting', () => {
|
|
317
|
+
it('should sort cards by score descending', () => {
|
|
318
|
+
const now = moment.utc('2024-01-15T12:00:00Z');
|
|
319
|
+
|
|
320
|
+
// More urgent (short interval, very overdue)
|
|
321
|
+
const urgent = computeUrgencyScore(
|
|
322
|
+
moment.utc('2024-01-11T12:00:00Z'), // 3-day interval
|
|
323
|
+
moment.utc('2024-01-14T12:00:00Z'), // 1 day overdue
|
|
324
|
+
now
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// Less urgent (long interval, slightly overdue)
|
|
328
|
+
const lessUrgent = computeUrgencyScore(
|
|
329
|
+
moment.utc('2023-12-15T12:00:00Z'), // 30-day interval
|
|
330
|
+
moment.utc('2024-01-14T12:00:00Z'), // 1 day overdue
|
|
331
|
+
now
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const cards = [
|
|
335
|
+
{ cardId: 'less-urgent', score: lessUrgent.score },
|
|
336
|
+
{ cardId: 'urgent', score: urgent.score },
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
const sorted = cards.sort((a, b) => b.score - a.score);
|
|
340
|
+
|
|
341
|
+
expect(sorted[0].cardId).toBe('urgent');
|
|
342
|
+
expect(sorted[1].cardId).toBe('less-urgent');
|
|
343
|
+
});
|
|
344
|
+
});
|