@vue-skuilder/db 0.1.23 → 0.1.25
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/{contentSource-BP9hznNV.d.ts → contentSource-BmnmvH8C.d.ts} +268 -3
- package/dist/{contentSource-DsJadoBU.d.cts → contentSource-DfBbaLA-.d.cts} +268 -3
- package/dist/core/index.d.cts +310 -6
- package/dist/core/index.d.ts +310 -6
- package/dist/core/index.js +2606 -666
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +2564 -639
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-CHYrQ5pB.d.cts → dataLayerProvider-BeRXVMs5.d.cts} +1 -1
- package/dist/{dataLayerProvider-MDTxXq2l.d.ts → dataLayerProvider-CG9GfaAY.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +11 -3
- package/dist/impl/couch/index.d.ts +11 -3
- package/dist/impl/couch/index.js +2336 -656
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +2316 -631
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +4 -4
- package/dist/impl/static/index.d.ts +4 -4
- package/dist/impl/static/index.js +2312 -632
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +2315 -630
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-Dj0SEgk3.d.ts → index-BWvO-_rJ.d.ts} +1 -1
- package/dist/{index-B_j6u5E4.d.cts → index-Ba7hYbHj.d.cts} +1 -1
- package/dist/index.d.cts +278 -20
- package/dist/index.d.ts +278 -20
- package/dist/index.js +3603 -720
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3529 -674
- package/dist/index.mjs.map +1 -1
- package/dist/{types-DQaXnuoc.d.ts → types-CJrLM1Ew.d.ts} +1 -1
- package/dist/{types-Bn0itutr.d.cts → types-W8n-B6HG.d.cts} +1 -1
- package/dist/{types-legacy-DDY4N-Uq.d.cts → types-legacy-JXDxinpU.d.cts} +5 -1
- package/dist/{types-legacy-DDY4N-Uq.d.ts → types-legacy-JXDxinpU.d.ts} +5 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/brainstorm-navigation-paradigm.md +40 -34
- package/docs/future-orchestration-vision.md +216 -0
- package/docs/navigators-architecture.md +210 -9
- package/docs/todo-review-urgency-adaptation.md +205 -0
- package/docs/todo-strategy-authoring.md +8 -6
- package/package.json +3 -3
- package/src/core/index.ts +2 -0
- package/src/core/interfaces/contentSource.ts +7 -0
- package/src/core/interfaces/userDB.ts +50 -0
- package/src/core/navigators/Pipeline.ts +132 -5
- package/src/core/navigators/PipelineAssembler.ts +21 -22
- package/src/core/navigators/PipelineDebugger.ts +426 -0
- package/src/core/navigators/filters/WeightedFilter.ts +141 -0
- package/src/core/navigators/filters/types.ts +4 -0
- package/src/core/navigators/generators/CompositeGenerator.ts +82 -19
- package/src/core/navigators/generators/elo.ts +14 -1
- package/src/core/navigators/generators/srs.ts +146 -18
- package/src/core/navigators/generators/types.ts +4 -0
- package/src/core/navigators/index.ts +203 -13
- package/src/core/orchestration/gradient.ts +133 -0
- package/src/core/orchestration/index.ts +210 -0
- package/src/core/orchestration/learning.ts +250 -0
- package/src/core/orchestration/recording.ts +92 -0
- package/src/core/orchestration/signal.ts +67 -0
- package/src/core/types/contentNavigationStrategy.ts +38 -0
- package/src/core/types/learningState.ts +77 -0
- package/src/core/types/types-legacy.ts +4 -0
- package/src/core/types/userOutcome.ts +51 -0
- package/src/courseConfigRegistration.ts +107 -0
- package/src/factory.ts +6 -0
- package/src/impl/common/BaseUserDB.ts +16 -0
- package/src/impl/couch/user-course-relDB.ts +12 -0
- package/src/study/MixerDebugger.ts +555 -0
- package/src/study/SessionController.ts +159 -20
- package/src/study/SessionDebugger.ts +442 -0
- package/src/study/SourceMixer.ts +36 -17
- package/src/study/TODO-session-scheduling.md +133 -0
- package/src/study/index.ts +2 -0
- package/src/study/services/EloService.ts +79 -4
- package/src/study/services/ResponseProcessor.ts +130 -72
- package/src/study/services/SrsService.ts +9 -0
- package/tests/core/navigators/Pipeline.test.ts +2 -0
- package/tests/core/navigators/PipelineAssembler.test.ts +4 -4
- package/docs/todo-evolutionary-orchestration.md +0 -310
|
@@ -113,20 +113,66 @@ export default class CompositeGenerator extends ContentNavigator implements Card
|
|
|
113
113
|
this.generators.map((g) => g.getWeightedCards(limit, context))
|
|
114
114
|
);
|
|
115
115
|
|
|
116
|
-
//
|
|
117
|
-
const
|
|
118
|
-
|
|
116
|
+
// Log per-generator breakdown for transparency
|
|
117
|
+
const generatorSummaries: string[] = [];
|
|
118
|
+
results.forEach((cards, index) => {
|
|
119
|
+
const gen = this.generators[index];
|
|
120
|
+
const genName = gen.name || `Generator ${index}`;
|
|
121
|
+
const newCards = cards.filter((c) => c.provenance[0]?.reason?.includes('new card'));
|
|
122
|
+
const reviewCards = cards.filter((c) => c.provenance[0]?.reason?.includes('review'));
|
|
123
|
+
|
|
124
|
+
if (cards.length > 0) {
|
|
125
|
+
const topScore = Math.max(...cards.map((c) => c.score)).toFixed(2);
|
|
126
|
+
const parts: string[] = [];
|
|
127
|
+
if (newCards.length > 0) parts.push(`${newCards.length} new`);
|
|
128
|
+
if (reviewCards.length > 0) parts.push(`${reviewCards.length} reviews`);
|
|
129
|
+
const breakdown = parts.length > 0 ? parts.join(', ') : `${cards.length} cards`;
|
|
130
|
+
generatorSummaries.push(`${genName}: ${breakdown} (top: ${topScore})`);
|
|
131
|
+
} else {
|
|
132
|
+
generatorSummaries.push(`${genName}: 0 cards`);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
logger.info(`[Composite] Generator breakdown: ${generatorSummaries.join(' | ')}`);
|
|
136
|
+
|
|
137
|
+
// Group by cardId, tracking the weight of the generator that produced each instance
|
|
138
|
+
type WeightedResult = { card: WeightedCard; weight: number };
|
|
139
|
+
const byCardId = new Map<string, WeightedResult[]>();
|
|
140
|
+
|
|
141
|
+
results.forEach((cards, index) => {
|
|
142
|
+
// Access learnable weight if available
|
|
143
|
+
const gen = this.generators[index] as unknown as ContentNavigator;
|
|
144
|
+
|
|
145
|
+
// Determine effective weight
|
|
146
|
+
let weight = gen.learnable?.weight ?? 1.0;
|
|
147
|
+
let deviation: number | undefined;
|
|
148
|
+
|
|
149
|
+
if (gen.learnable && !gen.staticWeight && context.orchestration) {
|
|
150
|
+
// Access strategyId (protected field) via type assertion
|
|
151
|
+
const strategyId = (gen as any).strategyId;
|
|
152
|
+
if (strategyId) {
|
|
153
|
+
weight = context.orchestration.getEffectiveWeight(strategyId, gen.learnable);
|
|
154
|
+
deviation = context.orchestration.getDeviation(strategyId);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
119
158
|
for (const card of cards) {
|
|
159
|
+
// Record effective weight in provenance for transparency
|
|
160
|
+
if (card.provenance.length > 0) {
|
|
161
|
+
card.provenance[0].effectiveWeight = weight;
|
|
162
|
+
card.provenance[0].deviation = deviation;
|
|
163
|
+
}
|
|
164
|
+
|
|
120
165
|
const existing = byCardId.get(card.cardId) || [];
|
|
121
|
-
existing.push(card);
|
|
166
|
+
existing.push({ card, weight });
|
|
122
167
|
byCardId.set(card.cardId, existing);
|
|
123
168
|
}
|
|
124
|
-
}
|
|
169
|
+
});
|
|
125
170
|
|
|
126
171
|
// Aggregate scores
|
|
127
172
|
const merged: WeightedCard[] = [];
|
|
128
|
-
for (const [,
|
|
129
|
-
const
|
|
173
|
+
for (const [, items] of byCardId) {
|
|
174
|
+
const cards = items.map((i) => i.card);
|
|
175
|
+
const aggregatedScore = this.aggregateScores(items);
|
|
130
176
|
const finalScore = Math.min(1.0, aggregatedScore); // Clamp to [0, 1]
|
|
131
177
|
|
|
132
178
|
// Merge provenance from all generators that produced this card
|
|
@@ -138,7 +184,7 @@ export default class CompositeGenerator extends ContentNavigator implements Card
|
|
|
138
184
|
finalScore > initialScore ? 'boosted' : finalScore < initialScore ? 'penalized' : 'passed';
|
|
139
185
|
|
|
140
186
|
// Build reason explaining the aggregation
|
|
141
|
-
const reason = this.buildAggregationReason(
|
|
187
|
+
const reason = this.buildAggregationReason(items, finalScore);
|
|
142
188
|
|
|
143
189
|
// Append composite provenance entry
|
|
144
190
|
merged.push({
|
|
@@ -165,12 +211,18 @@ export default class CompositeGenerator extends ContentNavigator implements Card
|
|
|
165
211
|
/**
|
|
166
212
|
* Build human-readable reason for score aggregation.
|
|
167
213
|
*/
|
|
168
|
-
private buildAggregationReason(
|
|
214
|
+
private buildAggregationReason(
|
|
215
|
+
items: { card: WeightedCard; weight: number }[],
|
|
216
|
+
finalScore: number
|
|
217
|
+
): string {
|
|
218
|
+
const cards = items.map((i) => i.card);
|
|
169
219
|
const count = cards.length;
|
|
170
220
|
const scores = cards.map((c) => c.score.toFixed(2)).join(', ');
|
|
171
221
|
|
|
172
222
|
if (count === 1) {
|
|
173
|
-
|
|
223
|
+
const weightMsg =
|
|
224
|
+
Math.abs(items[0].weight - 1.0) > 0.001 ? ` (w=${items[0].weight.toFixed(2)})` : '';
|
|
225
|
+
return `Single generator, score ${finalScore.toFixed(2)}${weightMsg}`;
|
|
174
226
|
}
|
|
175
227
|
|
|
176
228
|
const strategies = cards.map((c) => c.provenance[0]?.strategy || 'unknown').join(', ');
|
|
@@ -180,12 +232,16 @@ export default class CompositeGenerator extends ContentNavigator implements Card
|
|
|
180
232
|
return `Max of ${count} generators (${strategies}): scores [${scores}] → ${finalScore.toFixed(2)}`;
|
|
181
233
|
|
|
182
234
|
case AggregationMode.AVERAGE:
|
|
183
|
-
return `
|
|
235
|
+
return `Weighted Avg of ${count} generators (${strategies}): scores [${scores}] → ${finalScore.toFixed(2)}`;
|
|
184
236
|
|
|
185
237
|
case AggregationMode.FREQUENCY_BOOST: {
|
|
186
|
-
|
|
238
|
+
// Recalculate basic weighted avg for display
|
|
239
|
+
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
|
|
240
|
+
const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
|
|
241
|
+
const avg = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
|
242
|
+
|
|
187
243
|
const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
|
|
188
|
-
return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} × ${boost.toFixed(2)} → ${finalScore.toFixed(2)}`;
|
|
244
|
+
return `Frequency boost from ${count} generators (${strategies}): w-avg ${avg.toFixed(2)} × ${boost.toFixed(2)} → ${finalScore.toFixed(2)}`;
|
|
189
245
|
}
|
|
190
246
|
|
|
191
247
|
default:
|
|
@@ -196,19 +252,26 @@ export default class CompositeGenerator extends ContentNavigator implements Card
|
|
|
196
252
|
/**
|
|
197
253
|
* Aggregate scores from multiple generators for the same card.
|
|
198
254
|
*/
|
|
199
|
-
private aggregateScores(
|
|
200
|
-
const scores =
|
|
255
|
+
private aggregateScores(items: { card: WeightedCard; weight: number }[]): number {
|
|
256
|
+
const scores = items.map((i) => i.card.score);
|
|
201
257
|
|
|
202
258
|
switch (this.aggregationMode) {
|
|
203
259
|
case AggregationMode.MAX:
|
|
204
260
|
return Math.max(...scores);
|
|
205
261
|
|
|
206
|
-
case AggregationMode.AVERAGE:
|
|
207
|
-
|
|
262
|
+
case AggregationMode.AVERAGE: {
|
|
263
|
+
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
|
|
264
|
+
if (totalWeight === 0) return 0;
|
|
265
|
+
const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
|
|
266
|
+
return weightedSum / totalWeight;
|
|
267
|
+
}
|
|
208
268
|
|
|
209
269
|
case AggregationMode.FREQUENCY_BOOST: {
|
|
210
|
-
const
|
|
211
|
-
const
|
|
270
|
+
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
|
|
271
|
+
const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
|
|
272
|
+
const avg = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
|
273
|
+
|
|
274
|
+
const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (items.length - 1);
|
|
212
275
|
return avg * frequencyBoost;
|
|
213
276
|
}
|
|
214
277
|
|
|
@@ -5,6 +5,7 @@ import type { WeightedCard } from '../index';
|
|
|
5
5
|
import { toCourseElo } from '@vue-skuilder/common';
|
|
6
6
|
import type { QualifiedCardID } from '../..';
|
|
7
7
|
import type { CardGenerator, GeneratorContext } from './types';
|
|
8
|
+
import { logger } from '@db/util/logger';
|
|
8
9
|
|
|
9
10
|
// ============================================================================
|
|
10
11
|
// ELO NAVIGATOR
|
|
@@ -113,6 +114,18 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
|
|
|
113
114
|
// Sort by score descending
|
|
114
115
|
scored.sort((a, b) => b.score - a.score);
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
const result = scored.slice(0, limit);
|
|
118
|
+
|
|
119
|
+
// Log summary for transparency
|
|
120
|
+
if (result.length > 0) {
|
|
121
|
+
const topScores = result.slice(0, 3).map((c) => c.score.toFixed(2)).join(', ');
|
|
122
|
+
logger.info(
|
|
123
|
+
`[ELO] Course ${this.course.getCourseID()}: ${result.length} new cards (top scores: ${topScores})`
|
|
124
|
+
);
|
|
125
|
+
} else {
|
|
126
|
+
logger.info(`[ELO] Course ${this.course.getCourseID()}: No new cards available`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return result;
|
|
117
130
|
}
|
|
118
131
|
}
|
|
@@ -14,26 +14,49 @@ import { logger } from '@db/util/logger';
|
|
|
14
14
|
//
|
|
15
15
|
// A generator strategy that scores review cards by urgency.
|
|
16
16
|
//
|
|
17
|
-
// Urgency is determined by
|
|
17
|
+
// Urgency is determined by three factors:
|
|
18
18
|
// 1. Overdueness - how far past the scheduled review time
|
|
19
19
|
// 2. Interval recency - shorter scheduled intervals indicate "novel content in progress"
|
|
20
|
+
// 3. Backlog pressure - when too many reviews pile up, urgency increases globally
|
|
20
21
|
//
|
|
21
22
|
// A card with a 3-day interval that's 2 days overdue is more urgent than a card
|
|
22
23
|
// with a 6-month interval that's 2 days overdue. The shorter interval represents
|
|
23
24
|
// active learning at higher resolution.
|
|
24
25
|
//
|
|
26
|
+
// DESIGN PHILOSOPHY: SRS scheduling times are "eligibility dates" not hard "due dates".
|
|
27
|
+
// When a card becomes eligible, it is "okish" to review now, but reviewing a little
|
|
28
|
+
// later may be optimal. We don't aim to always beat review queues to zero (death spiral),
|
|
29
|
+
// but rather maintain a healthy backlog of eligible reviews so the system can gracefully
|
|
30
|
+
// handle usage upticks or breaks.
|
|
31
|
+
//
|
|
25
32
|
// This navigator only handles reviews - it does not generate new cards.
|
|
26
33
|
// For new cards, use ELONavigator or another generator via CompositeGenerator.
|
|
27
34
|
//
|
|
28
35
|
// ============================================================================
|
|
29
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Default healthy backlog size.
|
|
39
|
+
* When due reviews exceed this, backlog pressure kicks in.
|
|
40
|
+
* Can be overridden via strategy config.
|
|
41
|
+
*/
|
|
42
|
+
const DEFAULT_HEALTHY_BACKLOG = 20;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Maximum backlog pressure contribution to score.
|
|
46
|
+
* At 3x healthy backlog, pressure maxes out.
|
|
47
|
+
*/
|
|
48
|
+
const MAX_BACKLOG_PRESSURE = 0.5;
|
|
49
|
+
|
|
30
50
|
/**
|
|
31
51
|
* Configuration for the SRS strategy.
|
|
32
|
-
* Currently minimal - the algorithm is not parameterized.
|
|
33
52
|
*/
|
|
34
|
-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
35
53
|
export interface SRSConfig {
|
|
36
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Target "healthy" backlog size.
|
|
56
|
+
* When due reviews exceed this, urgency increases globally.
|
|
57
|
+
* Default: 20
|
|
58
|
+
*/
|
|
59
|
+
healthyBacklog?: number;
|
|
37
60
|
}
|
|
38
61
|
|
|
39
62
|
/**
|
|
@@ -45,6 +68,7 @@ export interface SRSConfig {
|
|
|
45
68
|
* Higher scores indicate more urgent reviews:
|
|
46
69
|
* - Cards that are more overdue (relative to their interval) score higher
|
|
47
70
|
* - Cards with shorter intervals (recent learning) score higher
|
|
71
|
+
* - When backlog exceeds "healthy" threshold, all reviews get urgency boost
|
|
48
72
|
*
|
|
49
73
|
* Only returns cards that are actually due (reviewTime has passed).
|
|
50
74
|
* Does not generate new cards - use with CompositeGenerator for mixed content.
|
|
@@ -53,6 +77,9 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
53
77
|
/** Human-readable name for CardGenerator interface */
|
|
54
78
|
name: string;
|
|
55
79
|
|
|
80
|
+
/** Healthy backlog threshold - when exceeded, backlog pressure kicks in */
|
|
81
|
+
private healthyBacklog: number;
|
|
82
|
+
|
|
56
83
|
constructor(
|
|
57
84
|
user: UserDBInterface,
|
|
58
85
|
course: CourseDBInterface,
|
|
@@ -60,6 +87,23 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
60
87
|
) {
|
|
61
88
|
super(user, course, strategyData as ContentNavigationStrategyData);
|
|
62
89
|
this.name = strategyData?.name || 'SRS';
|
|
90
|
+
|
|
91
|
+
// Parse config from serializedData if available
|
|
92
|
+
const config = this.parseConfig(strategyData?.serializedData);
|
|
93
|
+
this.healthyBacklog = config.healthyBacklog ?? DEFAULT_HEALTHY_BACKLOG;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse configuration from serialized JSON.
|
|
98
|
+
*/
|
|
99
|
+
private parseConfig(serializedData?: string): SRSConfig {
|
|
100
|
+
if (!serializedData) return {};
|
|
101
|
+
try {
|
|
102
|
+
return JSON.parse(serializedData) as SRSConfig;
|
|
103
|
+
} catch {
|
|
104
|
+
logger.warn('[SRS] Failed to parse strategy config, using defaults');
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
63
107
|
}
|
|
64
108
|
|
|
65
109
|
/**
|
|
@@ -68,6 +112,7 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
68
112
|
* Score formula combines:
|
|
69
113
|
* - Relative overdueness: hoursOverdue / intervalHours
|
|
70
114
|
* - Interval recency: exponential decay favoring shorter intervals
|
|
115
|
+
* - Backlog pressure: boost when due reviews exceed healthy threshold
|
|
71
116
|
*
|
|
72
117
|
* Cards not yet due are excluded (not scored as 0).
|
|
73
118
|
*
|
|
@@ -82,14 +127,48 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
82
127
|
throw new Error('SRSNavigator requires user and course to be set');
|
|
83
128
|
}
|
|
84
129
|
|
|
85
|
-
const
|
|
130
|
+
const courseId = this.course.getCourseID();
|
|
131
|
+
const reviews = await this.user.getPendingReviews(courseId);
|
|
86
132
|
const now = moment.utc();
|
|
87
133
|
|
|
88
134
|
// Filter to only cards that are actually due
|
|
89
135
|
const dueReviews = reviews.filter((r) => now.isAfter(moment.utc(r.reviewTime)));
|
|
90
136
|
|
|
137
|
+
// Compute backlog pressure - applies globally to all reviews
|
|
138
|
+
const backlogPressure = this.computeBacklogPressure(dueReviews.length);
|
|
139
|
+
|
|
140
|
+
// Log review status for transparency
|
|
141
|
+
if (dueReviews.length > 0) {
|
|
142
|
+
const pressureNote =
|
|
143
|
+
backlogPressure > 0
|
|
144
|
+
? ` [backlog pressure: +${backlogPressure.toFixed(2)}]`
|
|
145
|
+
: ` [healthy backlog]`;
|
|
146
|
+
logger.info(
|
|
147
|
+
`[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
|
|
148
|
+
);
|
|
149
|
+
} else if (reviews.length > 0) {
|
|
150
|
+
// Reviews exist but none are due yet - show when next one is due
|
|
151
|
+
const sortedByDue = [...reviews].sort((a, b) =>
|
|
152
|
+
moment.utc(a.reviewTime).diff(moment.utc(b.reviewTime))
|
|
153
|
+
);
|
|
154
|
+
const nextDue = sortedByDue[0];
|
|
155
|
+
const nextDueTime = moment.utc(nextDue.reviewTime);
|
|
156
|
+
const untilDue = moment.duration(nextDueTime.diff(now));
|
|
157
|
+
const untilDueStr =
|
|
158
|
+
untilDue.asHours() < 1
|
|
159
|
+
? `${Math.round(untilDue.asMinutes())}m`
|
|
160
|
+
: untilDue.asHours() < 24
|
|
161
|
+
? `${Math.round(untilDue.asHours())}h`
|
|
162
|
+
: `${Math.round(untilDue.asDays())}d`;
|
|
163
|
+
logger.info(
|
|
164
|
+
`[SRS] Course ${courseId}: 0 reviews due now (${reviews.length} scheduled, next in ${untilDueStr})`
|
|
165
|
+
);
|
|
166
|
+
} else {
|
|
167
|
+
logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
|
|
168
|
+
}
|
|
169
|
+
|
|
91
170
|
const scored = dueReviews.map((review) => {
|
|
92
|
-
const { score, reason } = this.computeUrgencyScore(review, now);
|
|
171
|
+
const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
|
|
93
172
|
|
|
94
173
|
return {
|
|
95
174
|
cardId: review.cardId,
|
|
@@ -109,16 +188,41 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
109
188
|
};
|
|
110
189
|
});
|
|
111
190
|
|
|
112
|
-
logger.debug(`[srsNav] got ${scored.length} weighted cards`);
|
|
113
|
-
|
|
114
191
|
// Sort by score descending and limit
|
|
115
192
|
return scored.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
116
193
|
}
|
|
117
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Compute backlog pressure based on number of due reviews.
|
|
197
|
+
*
|
|
198
|
+
* Backlog pressure is 0 when at or below healthy threshold,
|
|
199
|
+
* and increases linearly above it, maxing out at MAX_BACKLOG_PRESSURE.
|
|
200
|
+
*
|
|
201
|
+
* Examples (with default healthyBacklog=20):
|
|
202
|
+
* - 10 due reviews → 0.00 (healthy)
|
|
203
|
+
* - 20 due reviews → 0.00 (at threshold)
|
|
204
|
+
* - 40 due reviews → 0.25 (2x threshold)
|
|
205
|
+
* - 60 due reviews → 0.50 (3x threshold, maxed)
|
|
206
|
+
*
|
|
207
|
+
* @param dueCount - Number of reviews currently due
|
|
208
|
+
* @returns Backlog pressure score to add to urgency (0 to MAX_BACKLOG_PRESSURE)
|
|
209
|
+
*/
|
|
210
|
+
private computeBacklogPressure(dueCount: number): number {
|
|
211
|
+
if (dueCount <= this.healthyBacklog) {
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Linear increase: at 2x healthy, pressure = 0.25; at 3x, pressure = 0.50
|
|
216
|
+
const excess = dueCount - this.healthyBacklog;
|
|
217
|
+
const pressure = (excess / this.healthyBacklog) * (MAX_BACKLOG_PRESSURE / 2);
|
|
218
|
+
|
|
219
|
+
return Math.min(MAX_BACKLOG_PRESSURE, pressure);
|
|
220
|
+
}
|
|
221
|
+
|
|
118
222
|
/**
|
|
119
223
|
* Compute urgency score for a review card.
|
|
120
224
|
*
|
|
121
|
-
*
|
|
225
|
+
* Three factors:
|
|
122
226
|
* 1. Relative overdueness = hoursOverdue / intervalHours
|
|
123
227
|
* - 2 days overdue on 3-day interval = 0.67 (urgent)
|
|
124
228
|
* - 2 days overdue on 180-day interval = 0.01 (not urgent)
|
|
@@ -128,12 +232,22 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
128
232
|
* - 30 days (720h) → ~0.56
|
|
129
233
|
* - 180 days → ~0.30
|
|
130
234
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
235
|
+
* 3. Backlog pressure = global boost when review backlog exceeds healthy threshold
|
|
236
|
+
* - At healthy backlog: 0
|
|
237
|
+
* - At 2x healthy: +0.25
|
|
238
|
+
* - At 3x+ healthy: +0.50 (max)
|
|
239
|
+
*
|
|
240
|
+
* Combined: base 0.5 + (urgency factors * 0.45) + backlog pressure
|
|
241
|
+
* Result range: 0.5 to 1.0 (uncapped to allow high-urgency reviews to compete with new cards)
|
|
242
|
+
*
|
|
243
|
+
* @param review - The scheduled card to score
|
|
244
|
+
* @param now - Current time
|
|
245
|
+
* @param backlogPressure - Pre-computed backlog pressure (0 to 0.5)
|
|
133
246
|
*/
|
|
134
247
|
private computeUrgencyScore(
|
|
135
248
|
review: ScheduledCard,
|
|
136
|
-
now: moment.Moment
|
|
249
|
+
now: moment.Moment,
|
|
250
|
+
backlogPressure: number
|
|
137
251
|
): { score: number; reason: string } {
|
|
138
252
|
const scheduledAt = moment.utc(review.scheduledAt);
|
|
139
253
|
const due = moment.utc(review.reviewTime);
|
|
@@ -154,13 +268,27 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
154
268
|
const overdueContribution = Math.min(1.0, Math.max(0, relativeOverdue));
|
|
155
269
|
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
156
270
|
|
|
157
|
-
// Final score: base 0.5 + urgency contribution
|
|
158
|
-
|
|
271
|
+
// Final score: base 0.5 + urgency contribution + backlog pressure
|
|
272
|
+
// Uncapped at 1.0 (no 0.95 ceiling) - allows high-urgency reviews to compete with new cards
|
|
273
|
+
const baseScore = 0.5 + urgency * 0.45;
|
|
274
|
+
const score = Math.min(1.0, baseScore + backlogPressure);
|
|
159
275
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
276
|
+
// Build reason string with all contributing factors
|
|
277
|
+
const reasonParts = [
|
|
278
|
+
`${Math.round(hoursOverdue)}h overdue`,
|
|
279
|
+
`interval: ${Math.round(intervalHours)}h`,
|
|
280
|
+
`relative: ${relativeOverdue.toFixed(2)}`,
|
|
281
|
+
`recency: ${recencyFactor.toFixed(2)}`,
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
if (backlogPressure > 0) {
|
|
285
|
+
reasonParts.push(`backlog: +${backlogPressure.toFixed(2)}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
reasonParts.push('review');
|
|
289
|
+
|
|
290
|
+
const reason = reasonParts.join(', ');
|
|
163
291
|
|
|
164
292
|
return { score, reason };
|
|
165
293
|
}
|
|
166
|
-
}
|
|
294
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { WeightedCard } from '../index';
|
|
2
2
|
import type { CourseDBInterface } from '../../interfaces/courseDB';
|
|
3
3
|
import type { UserDBInterface } from '../../interfaces/userDB';
|
|
4
|
+
import type { OrchestrationContext } from '../../orchestration';
|
|
4
5
|
|
|
5
6
|
// ============================================================================
|
|
6
7
|
// CARD GENERATOR INTERFACE
|
|
@@ -34,6 +35,9 @@ export interface GeneratorContext {
|
|
|
34
35
|
/** User's global ELO score for this course */
|
|
35
36
|
userElo: number;
|
|
36
37
|
|
|
38
|
+
/** Orchestration context for evolutionary weighting */
|
|
39
|
+
orchestration?: OrchestrationContext;
|
|
40
|
+
|
|
37
41
|
// Future extensions:
|
|
38
42
|
// - user's tag-level ELO data
|
|
39
43
|
// - course config
|