@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.
Files changed (92) hide show
  1. package/dist/{userDB-BqwxtJ_7.d.mts → classroomDB-CZdMBiTU.d.ts} +427 -104
  2. package/dist/{userDB-DNa0XPtn.d.ts → classroomDB-PxDZTky3.d.cts} +427 -104
  3. package/dist/core/index.d.cts +304 -0
  4. package/dist/core/index.d.ts +237 -25
  5. package/dist/core/index.js +2246 -118
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +2235 -114
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-VlngD19_.d.mts → dataLayerProvider-D0MoZMjH.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-BV5iZqt_.d.ts → dataLayerProvider-D8o6ZnKW.d.ts} +1 -1
  11. package/dist/impl/couch/{index.d.mts → index.d.cts} +47 -5
  12. package/dist/impl/couch/index.d.ts +46 -4
  13. package/dist/impl/couch/index.js +2250 -134
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +2212 -97
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/{index.d.mts → index.d.cts} +6 -6
  18. package/dist/impl/static/index.d.ts +5 -5
  19. package/dist/impl/static/index.js +1950 -143
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +1922 -117
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-Bmll7Xse.d.mts → index-B_j6u5E4.d.cts} +1 -1
  24. package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
  25. package/dist/{index.d.mts → index.d.cts} +97 -13
  26. package/dist/index.d.ts +96 -12
  27. package/dist/index.js +2439 -180
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +2386 -135
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/pouch/index.js +3 -3
  32. package/dist/{types-Dbp5DaRR.d.mts → types-Bn0itutr.d.cts} +1 -1
  33. package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
  34. package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.cts} +3 -1
  35. package/dist/{types-legacy-6ettoclI.d.mts → types-legacy-DDY4N-Uq.d.ts} +3 -1
  36. package/dist/util/packer/{index.d.mts → index.d.cts} +3 -3
  37. package/dist/util/packer/index.d.ts +3 -3
  38. package/dist/util/packer/index.js.map +1 -1
  39. package/dist/util/packer/index.mjs.map +1 -1
  40. package/docs/brainstorm-navigation-paradigm.md +369 -0
  41. package/docs/navigators-architecture.md +370 -0
  42. package/docs/todo-evolutionary-orchestration.md +310 -0
  43. package/docs/todo-nominal-tag-types.md +121 -0
  44. package/docs/todo-strategy-authoring.md +401 -0
  45. package/eslint.config.mjs +1 -1
  46. package/package.json +9 -4
  47. package/src/core/index.ts +1 -0
  48. package/src/core/interfaces/contentSource.ts +88 -4
  49. package/src/core/interfaces/courseDB.ts +13 -0
  50. package/src/core/interfaces/navigationStrategyManager.ts +0 -5
  51. package/src/core/interfaces/userDB.ts +32 -0
  52. package/src/core/navigators/CompositeGenerator.ts +268 -0
  53. package/src/core/navigators/Pipeline.ts +318 -0
  54. package/src/core/navigators/PipelineAssembler.ts +194 -0
  55. package/src/core/navigators/elo.ts +104 -15
  56. package/src/core/navigators/filters/eloDistance.ts +132 -0
  57. package/src/core/navigators/filters/index.ts +9 -0
  58. package/src/core/navigators/filters/types.ts +115 -0
  59. package/src/core/navigators/filters/userTagPreference.ts +232 -0
  60. package/src/core/navigators/generators/index.ts +2 -0
  61. package/src/core/navigators/generators/types.ts +107 -0
  62. package/src/core/navigators/hardcodedOrder.ts +111 -12
  63. package/src/core/navigators/hierarchyDefinition.ts +266 -0
  64. package/src/core/navigators/index.ts +404 -3
  65. package/src/core/navigators/inferredPreference.ts +107 -0
  66. package/src/core/navigators/interferenceMitigator.ts +355 -0
  67. package/src/core/navigators/relativePriority.ts +255 -0
  68. package/src/core/navigators/srs.ts +195 -0
  69. package/src/core/navigators/userGoal.ts +136 -0
  70. package/src/core/types/strategyState.ts +84 -0
  71. package/src/core/types/types-legacy.ts +2 -0
  72. package/src/impl/common/BaseUserDB.ts +74 -7
  73. package/src/impl/couch/adminDB.ts +1 -2
  74. package/src/impl/couch/classroomDB.ts +51 -0
  75. package/src/impl/couch/courseDB.ts +147 -49
  76. package/src/impl/static/courseDB.ts +11 -4
  77. package/src/study/SessionController.ts +149 -1
  78. package/src/study/TagFilteredContentSource.ts +255 -0
  79. package/src/study/index.ts +1 -0
  80. package/src/util/dataDirectory.test.ts +51 -22
  81. package/src/util/logger.ts +0 -1
  82. package/tests/core/navigators/CompositeGenerator.test.ts +455 -0
  83. package/tests/core/navigators/Pipeline.test.ts +406 -0
  84. package/tests/core/navigators/PipelineAssembler.test.ts +351 -0
  85. package/tests/core/navigators/SRSNavigator.test.ts +344 -0
  86. package/tests/core/navigators/eloDistanceFilter.test.ts +192 -0
  87. package/tests/core/navigators/navigators.test.ts +710 -0
  88. package/tsconfig.json +1 -1
  89. package/vitest.config.ts +29 -0
  90. package/dist/core/index.d.mts +0 -92
  91. /package/dist/{SyncStrategy-CyATpyLQ.d.mts → SyncStrategy-CyATpyLQ.d.cts} +0 -0
  92. /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
+ });