@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.
Files changed (80) hide show
  1. package/dist/{contentSource-BP9hznNV.d.ts → contentSource-BmnmvH8C.d.ts} +268 -3
  2. package/dist/{contentSource-DsJadoBU.d.cts → contentSource-DfBbaLA-.d.cts} +268 -3
  3. package/dist/core/index.d.cts +310 -6
  4. package/dist/core/index.d.ts +310 -6
  5. package/dist/core/index.js +2606 -666
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +2564 -639
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-CHYrQ5pB.d.cts → dataLayerProvider-BeRXVMs5.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-MDTxXq2l.d.ts → dataLayerProvider-CG9GfaAY.d.ts} +1 -1
  11. package/dist/impl/couch/index.d.cts +11 -3
  12. package/dist/impl/couch/index.d.ts +11 -3
  13. package/dist/impl/couch/index.js +2336 -656
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +2316 -631
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +4 -4
  18. package/dist/impl/static/index.d.ts +4 -4
  19. package/dist/impl/static/index.js +2312 -632
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +2315 -630
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-Dj0SEgk3.d.ts → index-BWvO-_rJ.d.ts} +1 -1
  24. package/dist/{index-B_j6u5E4.d.cts → index-Ba7hYbHj.d.cts} +1 -1
  25. package/dist/index.d.cts +278 -20
  26. package/dist/index.d.ts +278 -20
  27. package/dist/index.js +3603 -720
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +3529 -674
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/{types-DQaXnuoc.d.ts → types-CJrLM1Ew.d.ts} +1 -1
  32. package/dist/{types-Bn0itutr.d.cts → types-W8n-B6HG.d.cts} +1 -1
  33. package/dist/{types-legacy-DDY4N-Uq.d.cts → types-legacy-JXDxinpU.d.cts} +5 -1
  34. package/dist/{types-legacy-DDY4N-Uq.d.ts → types-legacy-JXDxinpU.d.ts} +5 -1
  35. package/dist/util/packer/index.d.cts +3 -3
  36. package/dist/util/packer/index.d.ts +3 -3
  37. package/docs/brainstorm-navigation-paradigm.md +40 -34
  38. package/docs/future-orchestration-vision.md +216 -0
  39. package/docs/navigators-architecture.md +210 -9
  40. package/docs/todo-review-urgency-adaptation.md +205 -0
  41. package/docs/todo-strategy-authoring.md +8 -6
  42. package/package.json +3 -3
  43. package/src/core/index.ts +2 -0
  44. package/src/core/interfaces/contentSource.ts +7 -0
  45. package/src/core/interfaces/userDB.ts +50 -0
  46. package/src/core/navigators/Pipeline.ts +132 -5
  47. package/src/core/navigators/PipelineAssembler.ts +21 -22
  48. package/src/core/navigators/PipelineDebugger.ts +426 -0
  49. package/src/core/navigators/filters/WeightedFilter.ts +141 -0
  50. package/src/core/navigators/filters/types.ts +4 -0
  51. package/src/core/navigators/generators/CompositeGenerator.ts +82 -19
  52. package/src/core/navigators/generators/elo.ts +14 -1
  53. package/src/core/navigators/generators/srs.ts +146 -18
  54. package/src/core/navigators/generators/types.ts +4 -0
  55. package/src/core/navigators/index.ts +203 -13
  56. package/src/core/orchestration/gradient.ts +133 -0
  57. package/src/core/orchestration/index.ts +210 -0
  58. package/src/core/orchestration/learning.ts +250 -0
  59. package/src/core/orchestration/recording.ts +92 -0
  60. package/src/core/orchestration/signal.ts +67 -0
  61. package/src/core/types/contentNavigationStrategy.ts +38 -0
  62. package/src/core/types/learningState.ts +77 -0
  63. package/src/core/types/types-legacy.ts +4 -0
  64. package/src/core/types/userOutcome.ts +51 -0
  65. package/src/courseConfigRegistration.ts +107 -0
  66. package/src/factory.ts +6 -0
  67. package/src/impl/common/BaseUserDB.ts +16 -0
  68. package/src/impl/couch/user-course-relDB.ts +12 -0
  69. package/src/study/MixerDebugger.ts +555 -0
  70. package/src/study/SessionController.ts +159 -20
  71. package/src/study/SessionDebugger.ts +442 -0
  72. package/src/study/SourceMixer.ts +36 -17
  73. package/src/study/TODO-session-scheduling.md +133 -0
  74. package/src/study/index.ts +2 -0
  75. package/src/study/services/EloService.ts +79 -4
  76. package/src/study/services/ResponseProcessor.ts +130 -72
  77. package/src/study/services/SrsService.ts +9 -0
  78. package/tests/core/navigators/Pipeline.test.ts +2 -0
  79. package/tests/core/navigators/PipelineAssembler.test.ts +4 -4
  80. 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
- // Group by cardId
117
- const byCardId = new Map<string, WeightedCard[]>();
118
- for (const cards of results) {
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 [, cards] of byCardId) {
129
- const aggregatedScore = this.aggregateScores(cards);
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(cards, finalScore);
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(cards: WeightedCard[], finalScore: number): string {
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
- return `Single generator, score ${finalScore.toFixed(2)}`;
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 `Average of ${count} generators (${strategies}): scores [${scores}] → ${finalScore.toFixed(2)}`;
235
+ return `Weighted Avg of ${count} generators (${strategies}): scores [${scores}] → ${finalScore.toFixed(2)}`;
184
236
 
185
237
  case AggregationMode.FREQUENCY_BOOST: {
186
- const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
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(cards: WeightedCard[]): number {
200
- const scores = cards.map((c) => c.score);
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
- return scores.reduce((sum, s) => sum + s, 0) / scores.length;
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 avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
211
- const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
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
- return scored.slice(0, limit);
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 two factors:
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
- // Future: configurable urgency curves, thresholds, etc.
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 reviews = await this.user.getPendingReviews(this.course.getCourseID());
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
- * Two factors:
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
- * Combined: base 0.5 + weighted average of factors * 0.45
132
- * Result range: approximately 0.5 to 0.95
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, capped at 0.95
158
- const score = Math.min(0.95, 0.5 + urgency * 0.45);
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
- const reason =
161
- `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, ` +
162
- `relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
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