@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
@@ -30,16 +30,16 @@ export interface SourceMixer {
30
30
  }
31
31
 
32
32
  /**
33
- * Simple quota-based mixer: allocates equal representation to each source,
34
- * taking the top-N cards by score from each.
33
+ * Quota-based mixer with interleaved output.
35
34
  *
36
- * Guarantees balanced representation across sources regardless of absolute
37
- * score differences. A low-scoring source gets the same quota as a high-scoring
38
- * source.
35
+ * Allocates equal representation to each source (top-N by score), then
36
+ * interleaves the results by dealing from a randomly-shuffled source order.
37
+ * Within each source, cards are dealt in score-descending order.
39
38
  *
40
- * This is the KISS approach - simple, predictable, and fair in terms of
41
- * source representation (though not necessarily optimal in terms of absolute
42
- * card quality).
39
+ * This ensures that cards from different courses are spread throughout the
40
+ * queue rather than clustered by score bands, which matters because
41
+ * SessionController consumes queues front-to-back and sessions often end
42
+ * before reaching the tail.
43
43
  */
44
44
  export class QuotaRoundRobinMixer implements SourceMixer {
45
45
  mix(batches: SourceBatch[], limit: number): WeightedCard[] {
@@ -48,18 +48,37 @@ export class QuotaRoundRobinMixer implements SourceMixer {
48
48
  }
49
49
 
50
50
  const quotaPerSource = Math.ceil(limit / batches.length);
51
- const mixed: WeightedCard[] = [];
52
51
 
53
- for (const batch of batches) {
54
- // Sort this source's cards by score descending
55
- const sortedBatch = [...batch.weighted].sort((a, b) => b.score - a.score);
52
+ // Build per-source stacks sorted by score descending, capped at quota
53
+ const sourceStacks: WeightedCard[][] = batches.map((batch) => {
54
+ return [...batch.weighted].sort((a, b) => b.score - a.score).slice(0, quotaPerSource);
55
+ });
56
56
 
57
- // Take top quotaPerSource from this source
58
- const topFromSource = sortedBatch.slice(0, quotaPerSource);
59
- mixed.push(...topFromSource);
57
+ // Shuffle the source ordering so no course is systematically first
58
+ for (let i = sourceStacks.length - 1; i > 0; i--) {
59
+ const j = Math.floor(Math.random() * (i + 1));
60
+ [sourceStacks[i], sourceStacks[j]] = [sourceStacks[j], sourceStacks[i]];
60
61
  }
61
62
 
62
- // Sort the mixed result by score descending and return up to limit
63
- return mixed.sort((a, b) => b.score - a.score).slice(0, limit);
63
+ // Interleave: deal one card from each source in rotation
64
+ const result: WeightedCard[] = [];
65
+ let exhausted = 0;
66
+ const cursors = new Array(sourceStacks.length).fill(0);
67
+
68
+ while (result.length < limit && exhausted < sourceStacks.length) {
69
+ exhausted = 0;
70
+ for (let s = 0; s < sourceStacks.length; s++) {
71
+ if (result.length >= limit) break;
72
+
73
+ if (cursors[s] < sourceStacks[s].length) {
74
+ result.push(sourceStacks[s][cursors[s]]);
75
+ cursors[s]++;
76
+ } else {
77
+ exhausted++;
78
+ }
79
+ }
80
+ }
81
+
82
+ return result;
64
83
  }
65
84
  }
@@ -0,0 +1,133 @@
1
+ # Session Scheduling: Observed Deficiencies & Future Work
2
+
3
+ ## Context
4
+
5
+ Observed during debugging of multi-course study sessions (Feb 2026). A 5-minute
6
+ session with 6 courses was scheduling ~101 cards (all available reviews) despite
7
+ only having time for ~20. Cards were globally sorted by score, causing
8
+ lower-scoring courses to be systematically unreached before session timeout.
9
+
10
+ Debuggers added: `window.skuilder.mixer`, `window.skuilder.session`.
11
+
12
+ ## Completed
13
+
14
+ - **Mixer interleaving**: `QuotaRoundRobinMixer` now interleaves output by
15
+ dealing from a randomly-shuffled source order instead of global score sort.
16
+ This spreads courses throughout the queue so time-cutoff doesn't
17
+ systematically exclude lower-scoring sources.
18
+
19
+ - **MixerDebugger**: Captures cross-source mixing decisions. Shows input
20
+ batches, score distributions, selection rates, source balance analysis.
21
+
22
+ - **SessionDebugger**: Tracks runtime card presentation order, course
23
+ interleaving patterns, clustering detection.
24
+
25
+ ## Phase A: Right-Size Initial Load
26
+
27
+ **Problem**: `limit = 20` per source is hardcoded with no time-budget awareness.
28
+ For 6 sources that's up to 120 candidates for a 5-minute session.
29
+
30
+ **Approach**: Compute a realistic `targetCards` from session time and per-card
31
+ time estimates:
32
+
33
+ ```
34
+ targetCards = sessionTimeSeconds / avgCardTimeSeconds
35
+ quotaPerSource = ceil(targetCards / numSources)
36
+ ```
37
+
38
+ **Data available**: `CardRecord.timeSpent` (ms) exists on every historical
39
+ record. `CardHistory.records[]` has full per-card history. Could compute:
40
+ - Global user average (blunt but simple)
41
+ - Per-origin averages (reviews typically faster than new cards)
42
+ - Per-course averages (some courses have harder cards)
43
+
44
+ **Also**: `estimateReviewTime()` currently uses a flat `5 * reviewQ.length`.
45
+ Should use actual per-card time data, at minimum per-origin averages.
46
+
47
+ **Considerations**:
48
+ - Don't under-schedule — running out of cards mid-session is worse than
49
+ over-scheduling slightly
50
+ - Historical data may not exist for new users; need a reasonable default
51
+ - New cards take longer than reviews; weight accordingly
52
+
53
+ ## Phase B: Interleaving in _selectNextItemToHydrate
54
+
55
+ **Problem**: Even with interleaved mixer output, the queue split into
56
+ `reviewQ`/`newQ` can re-cluster by course if one course dominates reviews.
57
+ `_selectNextItemToHydrate` always takes `peek(0)` — course-blind.
58
+
59
+ **Approach**: After picking which queue, scan forward with small lookahead
60
+ to prefer a different course than the last presented card.
61
+
62
+ ```
63
+ 1. Pick queue (existing probability logic)
64
+ 2. If peek(0).courseID === lastPresentedCourseID:
65
+ Scan peek(1)..peek(LOOKAHEAD) for different course
66
+ 3. If found: dequeue from that position
67
+ 4. If not found within lookahead: take peek(0) anyway
68
+ ```
69
+
70
+ **Requires**: `dequeueAt(index)` method on `ItemQueue` (currently only
71
+ dequeues from front).
72
+
73
+ **Trade-offs**:
74
+ - Lookahead window size (3-5 is probably right)
75
+ - Don't starve a dominant course forever — bounded lookahead handles this
76
+ - Interacts with hydration: the card at peek(3) may not be pre-hydrated.
77
+ Hydration window is currently 2 per queue. May need to increase slightly
78
+ or accept occasional hydration waits.
79
+
80
+ ## Phase C: Rolling Refill (Mid-Session Recalibration)
81
+
82
+ **Problem**: All scheduling decisions are made at session start. No ability to
83
+ adapt to actual session pace, user performance, or course balance during the
84
+ session.
85
+
86
+ **Current state**: Queues loaded once in `prepareSession()`. Only mutation
87
+ during session is consumption + failure re-queuing. Hydration pre-caches 2 per
88
+ queue (6 total) — so the vast majority of queued items are just waiting.
89
+
90
+ **Approach**: Small initial load + async refill when queues run low.
91
+
92
+ ```
93
+ 1. Initial load: ~8-10 cards per source (enough for first few minutes)
94
+ 2. Monitor queue depth after each card presentation
95
+ 3. When queue drops below threshold (3-4 cards):
96
+ → Trigger async refill from source(s)
97
+ → Refill decisions factor in:
98
+ - Actual pace (cards completed / time elapsed)
99
+ - Course balance (which courses are underrepresented)
100
+ - User performance (more failures = slower pace)
101
+ 4. Refill populates queue; hydration service picks up new items naturally
102
+ ```
103
+
104
+ **Key constraint**: Next card must always be pre-cached and ready without a DB
105
+ lookup. This is satisfied because refills happen at queue level (async,
106
+ background), and the hydration service already runs after each card.
107
+
108
+ **Architecture considerations**:
109
+ - `StudyContentSource.getWeightedCards()` would need to be callable
110
+ mid-session (currently only called in `prepareSession`)
111
+ - Need to track which cards were already fetched to avoid duplicates
112
+ (ItemQueue.seenCardIds partially handles this)
113
+ - Refill trigger should be non-blocking — fire and forget, let hydration
114
+ service handle the rest
115
+ - Consider whether sources need a "cursor" or "offset" to avoid re-fetching
116
+ the same top-N cards
117
+
118
+ ## Related Observations
119
+
120
+ - **Score normalization**: Different courses/navigators produce scores on
121
+ different effective ranges (e.g., 0.36-0.49 vs 0.70-0.98). The interleaving
122
+ fix addresses the presentation problem, but a MinMaxNormalizingMixer or
123
+ similar could address the selection problem for cases where quota-based
124
+ selection isn't desired.
125
+
126
+ - **No user-level pace tracking**: The system has per-card `timeSpent` data but
127
+ no aggregated user pace metric. A lightweight running average (e.g.,
128
+ exponential moving average of last 20 cards) would be useful for both
129
+ Phase A (initial load sizing) and Phase C (refill decisions).
130
+
131
+ - **`estimateCleanupTime` scope**: Currently only uses current-session records
132
+ for failed cards. Could also use historical `CardHistory` for cards the user
133
+ has seen before, giving better estimates for review failures.
@@ -2,3 +2,5 @@ export * from './SessionController';
2
2
  export * from './SourceMixer';
3
3
  export * from './SpacedRepetition';
4
4
  export * from './TagFilteredContentSource';
5
+ export * from './MixerDebugger';
6
+ export * from './SessionDebugger';
@@ -1,4 +1,9 @@
1
- import { adjustCourseScores, toCourseElo } from '@vue-skuilder/common';
1
+ import {
2
+ adjustCourseScores,
3
+ adjustCourseScoresPerTag,
4
+ toCourseElo,
5
+ TaggedPerformance,
6
+ } from '@vue-skuilder/common';
2
7
  import { DataLayerProvider, UserDBInterface, CourseRegistrationDoc } from '@db/core';
3
8
  import { StudySessionRecord } from '../SessionController';
4
9
  import { logger } from '@db/util/logger';
@@ -19,7 +24,7 @@ export class EloService {
19
24
  * Updates both user and card ELO ratings based on user performance.
20
25
  * @param userScore Score between 0-1 representing user performance
21
26
  * @param course_id Course identifier
22
- * @param card_id Card identifier
27
+ * @param card_id Card identifier
23
28
  * @param userCourseRegDoc User's course registration document (will be mutated)
24
29
  * @param currentCard Current card session record
25
30
  * @param k Optional K-factor for ELO calculation
@@ -36,7 +41,9 @@ export class EloService {
36
41
  logger.warn(`k value interpretation not currently implemented`);
37
42
  }
38
43
  const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
39
- const userElo = toCourseElo(userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo);
44
+ const userElo = toCourseElo(
45
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo
46
+ );
40
47
  const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
41
48
 
42
49
  if (cardElo && userElo) {
@@ -82,4 +89,72 @@ export class EloService {
82
89
  }
83
90
  }
84
91
  }
85
- }
92
+
93
+ /**
94
+ * Updates both user and card ELO ratings with per-tag granularity.
95
+ * Tags in taggedPerformance but not on card will be created dynamically.
96
+ *
97
+ * @param taggedPerformance Performance object with _global and per-tag scores
98
+ * @param course_id Course identifier
99
+ * @param card_id Card identifier
100
+ * @param userCourseRegDoc User's course registration document (will be mutated)
101
+ * @param currentCard Current card session record
102
+ */
103
+ public async updateUserAndCardEloPerTag(
104
+ taggedPerformance: TaggedPerformance,
105
+ course_id: string,
106
+ card_id: string,
107
+ userCourseRegDoc: CourseRegistrationDoc,
108
+ currentCard: StudySessionRecord
109
+ ): Promise<void> {
110
+ const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
111
+ const userElo = toCourseElo(
112
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo
113
+ );
114
+ const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
115
+
116
+ if (cardElo && userElo) {
117
+ const eloUpdate = adjustCourseScoresPerTag(userElo, cardElo, taggedPerformance);
118
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id)!.elo = eloUpdate.userElo;
119
+
120
+ const results = await Promise.allSettled([
121
+ this.user.updateUserElo(course_id, eloUpdate.userElo),
122
+ courseDB.updateCardElo(card_id, eloUpdate.cardElo),
123
+ ]);
124
+
125
+ // Check the results of each operation
126
+ const userEloStatus = results[0].status === 'fulfilled';
127
+ const cardEloStatus = results[1].status === 'fulfilled';
128
+
129
+ if (userEloStatus && cardEloStatus) {
130
+ const user = (results[0] as PromiseFulfilledResult<any>).value;
131
+ const card = (results[1] as PromiseFulfilledResult<any>).value;
132
+
133
+ if (user.ok && card && card.ok) {
134
+ const tagCount = Object.keys(taggedPerformance).length - 1; // exclude _global
135
+ logger.info(
136
+ `[EloService] Updated ELOS (per-tag, ${tagCount} tags):
137
+ \tUser: ${JSON.stringify(eloUpdate.userElo)})
138
+ \tCard: ${JSON.stringify(eloUpdate.cardElo)})
139
+ `
140
+ );
141
+ }
142
+ } else {
143
+ // Log which operations succeeded and which failed
144
+ logger.warn(
145
+ `[EloService] Partial ELO update (per-tag):
146
+ \tUser ELO update: ${userEloStatus ? 'SUCCESS' : 'FAILED'}
147
+ \tCard ELO update: ${cardEloStatus ? 'SUCCESS' : 'FAILED'}`
148
+ );
149
+
150
+ if (!userEloStatus && results[0].status === 'rejected') {
151
+ logger.error('[EloService] User ELO update error:', results[0].reason);
152
+ }
153
+
154
+ if (!cardEloStatus && results[1].status === 'rejected') {
155
+ logger.error('[EloService] Card ELO update error:', results[1].reason);
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
@@ -10,6 +10,17 @@ import { logger } from '@db/util/logger';
10
10
  import { ResponseResult, StudySessionRecord } from '../SessionController';
11
11
  import { EloService } from './EloService';
12
12
  import { SrsService } from './SrsService';
13
+ import { Performance, isTaggedPerformance, TaggedPerformance } from '@vue-skuilder/common';
14
+
15
+ /**
16
+ * Parsed performance data for ELO updates.
17
+ */
18
+ interface ParsedPerformance {
19
+ /** Global score for SRS and global ELO [0, 1] */
20
+ globalScore: number;
21
+ /** Per-tag scores, or null if using simple numeric performance */
22
+ taggedPerformance: TaggedPerformance | null;
23
+ }
13
24
 
14
25
  /**
15
26
  * Service responsible for orchestrating the complete response processing workflow.
@@ -24,6 +35,39 @@ export class ResponseProcessor {
24
35
  this.eloService = eloService;
25
36
  }
26
37
 
38
+ /**
39
+ * Parses performance data into global score and optional per-tag scores.
40
+ *
41
+ * @param performance - Numeric or structured performance from QuestionRecord
42
+ * @returns Parsed performance with global score and optional tag scores
43
+ */
44
+ private parsePerformance(performance: Performance): ParsedPerformance {
45
+ if (typeof performance === 'number') {
46
+ // Simple numeric performance - backward compatible
47
+ return {
48
+ globalScore: performance,
49
+ taggedPerformance: null,
50
+ };
51
+ }
52
+
53
+ // Structured TaggedPerformance with _global and per-tag scores
54
+ if (isTaggedPerformance(performance)) {
55
+ return {
56
+ globalScore: performance._global,
57
+ taggedPerformance: performance,
58
+ };
59
+ }
60
+
61
+ // Fallback for unexpected structure - treat as neutral
62
+ logger.warn('[ResponseProcessor] Unexpected performance structure, using neutral score', {
63
+ performance,
64
+ });
65
+ return {
66
+ globalScore: 0.5,
67
+ taggedPerformance: null,
68
+ };
69
+ }
70
+
27
71
  /**
28
72
  * Processes a user's response to a card, handling SRS scheduling and ELO updates.
29
73
  * @param cardRecord User's response record
@@ -60,43 +104,9 @@ export class ResponseProcessor {
60
104
  };
61
105
  }
62
106
 
63
- // Debug logging for response processing
64
- // logger.debug('[ResponseProcessor] Processing response', {
65
- // cardId,
66
- // courseId,
67
- // isCorrect: cardRecord.isCorrect,
68
- // performance: cardRecord.performance,
69
- // priorAttempts: cardRecord.priorAttemps,
70
- // currentSessionViews: sessionViews,
71
- // maxSessionViews,
72
- // maxAttemptsPerView,
73
- // currentCardRecordsLength: currentCard.records.length,
74
- // studySessionSourceType: studySessionItem.contentSourceType,
75
- // studySessionSourceID: studySessionItem.contentSourceID,
76
- // studySessionItemId: studySessionItem.cardID,
77
- // studySessionItemType: studySessionItem.contentSourceType,
78
-
79
- // cardRecordTimestamp: cardRecord.timeStamp,
80
- // cardRecordResponseTime: cardRecord.timeSpent,
81
- // });
82
-
83
107
  try {
84
108
  const history = await cardHistory;
85
109
 
86
- // Debug logging for card history
87
- // logger.debug('[ResponseProcessor] History loaded:', {
88
- // cardId,
89
- // historyRecordsCount: history.records.length,
90
- // historyRecords: history.records.map((record) => ({
91
- // timeStamp: record.timeStamp,
92
- // isCorrect: 'isCorrect' in record ? record.isCorrect : 'N/A',
93
- // performance: 'performance' in record ? record.performance : 'N/A',
94
- // priorAttempts: 'priorAttemps' in record ? record.priorAttemps : 'N/A',
95
- // })),
96
- // firstInteraction: history.records.length === 1,
97
- // lastRecord: history.records[history.records.length - 1],
98
- // });
99
-
100
110
  // Handle correct responses
101
111
  if (cardRecord.isCorrect) {
102
112
  return this.processCorrectResponse(
@@ -145,40 +155,57 @@ export class ResponseProcessor {
145
155
  // Schedule the card for future review based on performance (async, non-blocking)
146
156
  void this.srsService.scheduleReview(history, studySessionItem);
147
157
 
158
+ // Parse performance (may be numeric or structured)
159
+ const { globalScore, taggedPerformance } = this.parsePerformance(cardRecord.performance);
160
+
148
161
  // Update ELO ratings
149
- if (history.records.length === 1) {
150
- // First interaction with this card - standard ELO update (async, non-blocking)
151
- const userScore = 0.5 + (cardRecord.performance as number) / 2;
152
- void this.eloService.updateUserAndCardElo(
153
- userScore,
162
+ if (taggedPerformance) {
163
+ // Per-tag ELO update
164
+ void this.eloService.updateUserAndCardEloPerTag(
165
+ taggedPerformance,
154
166
  courseId,
155
167
  cardId,
156
168
  courseRegistrationDoc,
157
169
  currentCard
158
170
  );
171
+ logger.info(
172
+ `[ResponseProcessor] Processed correct response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
173
+ );
159
174
  } else {
160
- // Multiple interactions - reduce K-factor to limit ELO volatility (async, non-blocking)
161
- const k = Math.ceil(32 / history.records.length);
162
- const userScore = 0.5 + (cardRecord.performance as number) / 2;
163
- void this.eloService.updateUserAndCardElo(
164
- userScore,
165
- courseId,
166
- cardId,
167
- courseRegistrationDoc,
168
- currentCard,
169
- k
175
+ // Standard single-score ELO update (backward compatible)
176
+ const userScore = 0.5 + globalScore / 2;
177
+
178
+ if (history.records.length === 1) {
179
+ // First interaction with this card - standard ELO update
180
+ void this.eloService.updateUserAndCardElo(
181
+ userScore,
182
+ courseId,
183
+ cardId,
184
+ courseRegistrationDoc,
185
+ currentCard
186
+ );
187
+ } else {
188
+ // Multiple interactions - reduce K-factor to limit ELO volatility
189
+ const k = Math.ceil(32 / history.records.length);
190
+ void this.eloService.updateUserAndCardElo(
191
+ userScore,
192
+ courseId,
193
+ cardId,
194
+ courseRegistrationDoc,
195
+ currentCard,
196
+ k
197
+ );
198
+ }
199
+ logger.info(
200
+ '[ResponseProcessor] Processed correct response with SRS scheduling and ELO update'
170
201
  );
171
202
  }
172
203
 
173
- logger.info(
174
- '[ResponseProcessor] Processed correct response with SRS scheduling and ELO update'
175
- );
176
-
177
204
  return {
178
205
  nextCardAction: 'dismiss-success',
179
206
  shouldLoadNextCard: true,
180
207
  isCorrect: true,
181
- performanceScore: cardRecord.performance as number,
208
+ performanceScore: globalScore,
182
209
  shouldClearFeedbackShadow: true,
183
210
  };
184
211
  } else {
@@ -186,11 +213,13 @@ export class ResponseProcessor {
186
213
  '[ResponseProcessor] Processed correct response (retry attempt - no scheduling/ELO)'
187
214
  );
188
215
 
216
+ const { globalScore } = this.parsePerformance(cardRecord.performance);
217
+
189
218
  return {
190
219
  nextCardAction: 'marked-failed',
191
220
  shouldLoadNextCard: true,
192
221
  isCorrect: true,
193
- performanceScore: cardRecord.performance as number,
222
+ performanceScore: globalScore,
194
223
  shouldClearFeedbackShadow: true,
195
224
  };
196
225
  }
@@ -210,16 +239,34 @@ export class ResponseProcessor {
210
239
  maxSessionViews: number,
211
240
  sessionViews: number
212
241
  ): ResponseResult {
213
- // Update ELO for first-time failures (not subsequent attempts on same card) (async, non-blocking)
242
+ // Parse performance (may be numeric or structured)
243
+ const { taggedPerformance } = this.parsePerformance(cardRecord.performance);
244
+
245
+ // Update ELO for first-time failures (not subsequent attempts on same card)
214
246
  if (history.records.length !== 1 && cardRecord.priorAttemps === 0) {
215
- void this.eloService.updateUserAndCardElo(
216
- 0, // Failed response = 0 score
217
- courseId,
218
- cardId,
219
- courseRegistrationDoc,
220
- currentCard
221
- );
222
- logger.info('[ResponseProcessor] Processed incorrect response with ELO update');
247
+ if (taggedPerformance) {
248
+ // Per-tag ELO update for incorrect response
249
+ void this.eloService.updateUserAndCardEloPerTag(
250
+ taggedPerformance,
251
+ courseId,
252
+ cardId,
253
+ courseRegistrationDoc,
254
+ currentCard
255
+ );
256
+ logger.info(
257
+ `[ResponseProcessor] Processed incorrect response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
258
+ );
259
+ } else {
260
+ // Standard single-score ELO update
261
+ void this.eloService.updateUserAndCardElo(
262
+ 0, // Failed response = 0 score
263
+ courseId,
264
+ cardId,
265
+ courseRegistrationDoc,
266
+ currentCard
267
+ );
268
+ logger.info('[ResponseProcessor] Processed incorrect response with ELO update');
269
+ }
223
270
  } else {
224
271
  logger.info('[ResponseProcessor] Processed incorrect response (no ELO update needed)');
225
272
  }
@@ -227,14 +274,25 @@ export class ResponseProcessor {
227
274
  // Determine navigation based on attempt limits
228
275
  if (currentCard.records.length >= maxAttemptsPerView) {
229
276
  if (sessionViews >= maxSessionViews) {
230
- // Too many session views - dismiss completely with ELO penalty (async, non-blocking)
231
- void this.eloService.updateUserAndCardElo(
232
- 0,
233
- courseId,
234
- cardId,
235
- courseRegistrationDoc,
236
- currentCard
237
- );
277
+ // Too many session views - dismiss completely with ELO penalty
278
+ if (taggedPerformance) {
279
+ // Use tagged performance for final failure
280
+ void this.eloService.updateUserAndCardEloPerTag(
281
+ taggedPerformance,
282
+ courseId,
283
+ cardId,
284
+ courseRegistrationDoc,
285
+ currentCard
286
+ );
287
+ } else {
288
+ void this.eloService.updateUserAndCardElo(
289
+ 0,
290
+ courseId,
291
+ cardId,
292
+ courseRegistrationDoc,
293
+ currentCard
294
+ );
295
+ }
238
296
  return {
239
297
  nextCardAction: 'dismiss-failed',
240
298
  shouldLoadNextCard: true,
@@ -14,6 +14,15 @@ export class SrsService {
14
14
  this.user = user;
15
15
  }
16
16
 
17
+ /**
18
+ * Remove a scheduled review from the user's database.
19
+ * Used to clean up orphaned reviews (e.g., card deleted from course DB).
20
+ */
21
+ removeReview(reviewID: string): void {
22
+ logger.info(`[SrsService] Removing orphaned scheduled review: ${reviewID}`);
23
+ void this.user.removeScheduledCardReview(reviewID);
24
+ }
25
+
17
26
  /**
18
27
  * Calculates the next review time for a card based on its history and
19
28
  * schedules it in the user's database.
@@ -90,6 +90,7 @@ function createMockContext(): { user: UserDBInterface; course: CourseDBInterface
90
90
  getCourseRegDoc: vi.fn().mockResolvedValue({
91
91
  elo: { global: { score: 1000, count: 10 }, tags: {} },
92
92
  }),
93
+ getUsername: vi.fn().mockReturnValue('test-user'),
93
94
  } as unknown as UserDBInterface;
94
95
 
95
96
  const mockCourse = {
@@ -294,6 +295,7 @@ describe('Pipeline', () => {
294
295
 
295
296
  const failingUser = {
296
297
  getCourseRegDoc: vi.fn().mockRejectedValue(new Error('Not registered')),
298
+ getUsername: vi.fn().mockReturnValue('failing-user'),
297
299
  } as unknown as UserDBInterface;
298
300
 
299
301
  let capturedElo = 0;
@@ -175,15 +175,15 @@ describe('PipelineAssembler', () => {
175
175
  expect(result.warnings).toEqual([]);
176
176
  });
177
177
 
178
- it('uses default ELO when filters exist but no generator', async () => {
178
+ it('uses default ELO and SRS when filters exist but no generator', async () => {
179
179
  const hierarchy = createStrategy('hierarchy', 'hierarchyDefinition');
180
180
  const input = createInput([hierarchy]);
181
181
  const result = await assembler.assemble(input);
182
182
 
183
183
  expect(result.pipeline).toBeInstanceOf(Pipeline);
184
- expect(result.generatorStrategies).toHaveLength(1);
185
- expect(result.generatorStrategies[0].implementingClass).toBe('elo');
186
- expect(result.generatorStrategies[0].name).toBe('ELO (default)');
184
+ expect(result.generatorStrategies).toHaveLength(2);
185
+ const strategyNames = result.generatorStrategies.map((s) => s.name).sort();
186
+ expect(strategyNames).toEqual(['ELO (default)', 'SRS (default)']);
187
187
  expect(result.filterStrategies).toEqual([hierarchy]);
188
188
  expect(result.warnings).toEqual([]);
189
189
  });