@vue-skuilder/db 0.1.18 → 0.1.21

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 (87) hide show
  1. package/CLAUDE.md +2 -2
  2. package/dist/{classroomDB-BgfrVb8d.d.ts → contentSource-BP9hznNV.d.ts} +220 -197
  3. package/dist/{classroomDB-CTOenngH.d.cts → contentSource-DsJadoBU.d.cts} +220 -197
  4. package/dist/core/index.d.cts +80 -6
  5. package/dist/core/index.d.ts +80 -6
  6. package/dist/core/index.js +735 -1560
  7. package/dist/core/index.js.map +1 -1
  8. package/dist/core/index.mjs +708 -1539
  9. package/dist/core/index.mjs.map +1 -1
  10. package/dist/{dataLayerProvider-D6PoCwS6.d.cts → dataLayerProvider-CHYrQ5pB.d.cts} +1 -1
  11. package/dist/{dataLayerProvider-CZxC9GtB.d.ts → dataLayerProvider-MDTxXq2l.d.ts} +1 -1
  12. package/dist/impl/couch/index.d.cts +8 -23
  13. package/dist/impl/couch/index.d.ts +8 -23
  14. package/dist/impl/couch/index.js +723 -1578
  15. package/dist/impl/couch/index.js.map +1 -1
  16. package/dist/impl/couch/index.mjs +692 -1552
  17. package/dist/impl/couch/index.mjs.map +1 -1
  18. package/dist/impl/static/index.d.cts +25 -8
  19. package/dist/impl/static/index.d.ts +25 -8
  20. package/dist/impl/static/index.js +700 -1400
  21. package/dist/impl/static/index.js.map +1 -1
  22. package/dist/impl/static/index.mjs +688 -1393
  23. package/dist/impl/static/index.mjs.map +1 -1
  24. package/dist/{index-D-Fa4Smt.d.cts → index-B_j6u5E4.d.cts} +1 -1
  25. package/dist/{index-CD8BZz2k.d.ts → index-Dj0SEgk3.d.ts} +1 -1
  26. package/dist/index.d.cts +71 -63
  27. package/dist/index.d.ts +71 -63
  28. package/dist/index.js +1162 -1996
  29. package/dist/index.js.map +1 -1
  30. package/dist/index.mjs +1124 -1955
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/pouch/index.js +3 -0
  33. package/dist/pouch/index.js.map +1 -1
  34. package/dist/pouch/index.mjs +3 -0
  35. package/dist/pouch/index.mjs.map +1 -1
  36. package/dist/{types-CzPDLAK6.d.cts → types-Bn0itutr.d.cts} +1 -1
  37. package/dist/{types-CewsN87z.d.ts → types-DQaXnuoc.d.ts} +1 -1
  38. package/dist/{types-legacy-6ettoclI.d.cts → types-legacy-DDY4N-Uq.d.cts} +3 -1
  39. package/dist/{types-legacy-6ettoclI.d.ts → types-legacy-DDY4N-Uq.d.ts} +3 -1
  40. package/dist/util/packer/index.d.cts +3 -3
  41. package/dist/util/packer/index.d.ts +3 -3
  42. package/docs/navigators-architecture.md +115 -17
  43. package/package.json +4 -4
  44. package/src/core/index.ts +1 -0
  45. package/src/core/interfaces/classroomDB.ts +5 -13
  46. package/src/core/interfaces/contentSource.ts +6 -66
  47. package/src/core/interfaces/courseDB.ts +15 -7
  48. package/src/core/interfaces/userDB.ts +32 -0
  49. package/src/core/navigators/Pipeline.ts +136 -52
  50. package/src/core/navigators/PipelineAssembler.ts +1 -1
  51. package/src/core/navigators/defaults.ts +84 -0
  52. package/src/core/navigators/{hierarchyDefinition.ts → filters/hierarchyDefinition.ts} +15 -29
  53. package/src/core/navigators/filters/index.ts +3 -0
  54. package/src/core/navigators/filters/inferredPreferenceStub.ts +107 -0
  55. package/src/core/navigators/{interferenceMitigator.ts → filters/interferenceMitigator.ts} +11 -37
  56. package/src/core/navigators/{relativePriority.ts → filters/relativePriority.ts} +12 -38
  57. package/src/core/navigators/filters/userGoalStub.ts +136 -0
  58. package/src/core/navigators/filters/userTagPreference.ts +217 -0
  59. package/src/core/navigators/{CompositeGenerator.ts → generators/CompositeGenerator.ts} +15 -64
  60. package/src/core/navigators/{elo.ts → generators/elo.ts} +13 -63
  61. package/src/core/navigators/{srs.ts → generators/srs.ts} +11 -40
  62. package/src/core/navigators/generators/types.ts +1 -1
  63. package/src/core/navigators/index.ts +95 -91
  64. package/src/core/types/strategyState.ts +84 -0
  65. package/src/core/types/types-legacy.ts +2 -0
  66. package/src/impl/common/BaseUserDB.ts +74 -7
  67. package/src/impl/couch/adminDB.ts +1 -2
  68. package/src/impl/couch/classroomDB.ts +100 -103
  69. package/src/impl/couch/courseDB.ts +35 -91
  70. package/src/impl/couch/pouchdb-setup.ts +7 -0
  71. package/src/impl/static/StaticDataUnpacker.ts +50 -1
  72. package/src/impl/static/courseDB.ts +87 -37
  73. package/src/study/SessionController.ts +122 -202
  74. package/src/study/SourceMixer.ts +65 -0
  75. package/src/study/TagFilteredContentSource.ts +49 -92
  76. package/src/study/index.ts +1 -0
  77. package/src/study/services/CardHydrationService.ts +165 -81
  78. package/src/util/dataDirectory.ts +1 -1
  79. package/src/util/index.ts +0 -1
  80. package/tests/core/navigators/CompositeGenerator.test.ts +44 -168
  81. package/tests/core/navigators/Pipeline.test.ts +6 -72
  82. package/tests/core/navigators/PipelineAssembler.test.ts +8 -58
  83. package/tests/core/navigators/navigators.test.ts +118 -151
  84. package/docs/todo-pipeline-optimization.md +0 -117
  85. package/docs/todo-strategy-state-storage.md +0 -278
  86. package/src/core/navigators/hardcodedOrder.ts +0 -163
  87. package/src/util/tuiLogger.ts +0 -139
@@ -14,12 +14,8 @@ import {
14
14
 
15
15
  import { CardRecord, CardHistory, CourseRegistrationDoc } from '@db/core';
16
16
  import { Loggable } from '@db/util';
17
- import { ScheduledCard } from '@db/core/types/user';
18
- import { WeightedCard, getCardOrigin } from '@db/core/navigators';
19
-
20
- function randomInt(min: number, max: number): number {
21
- return Math.floor(Math.random() * (max - min + 1)) + min;
22
- }
17
+ import { getCardOrigin } from '@db/core/navigators';
18
+ import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
23
19
 
24
20
  export interface StudySessionRecord {
25
21
  card: {
@@ -32,6 +28,7 @@ export interface StudySessionRecord {
32
28
  }
33
29
 
34
30
  import { DataLayerProvider } from '@db/core';
31
+ import { logger } from '@db/util/logger';
35
32
 
36
33
  export type SessionAction =
37
34
  | 'dismiss-success'
@@ -63,6 +60,7 @@ export class SessionController<TView = unknown> extends Loggable {
63
60
  private srsService: SrsService;
64
61
  private eloService: EloService;
65
62
  private hydrationService: CardHydrationService<TView>;
63
+ private mixer: SourceMixer;
66
64
 
67
65
  private sources: StudyContentSource[];
68
66
  // dataLayer and getViewComponent now injected into CardHydrationService
@@ -95,25 +93,29 @@ export class SessionController<TView = unknown> extends Loggable {
95
93
  private _intervalHandle: NodeJS.Timeout;
96
94
 
97
95
  /**
98
- *
96
+ * @param sources - Array of content sources to mix for the session
97
+ * @param time - Session duration in seconds
98
+ * @param dataLayer - Data layer provider
99
+ * @param getViewComponent - Function to resolve view components
100
+ * @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
99
101
  */
100
102
  constructor(
101
103
  sources: StudyContentSource[],
102
104
  time: number,
103
105
  dataLayer: DataLayerProvider,
104
- getViewComponent: (viewId: string) => TView
106
+ getViewComponent: (viewId: string) => TView,
107
+ mixer?: SourceMixer
105
108
  ) {
106
109
  super();
107
110
 
111
+ this.mixer = mixer || new QuotaRoundRobinMixer();
108
112
  this.srsService = new SrsService(dataLayer.getUserDB());
109
113
  this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
110
114
 
111
115
  this.hydrationService = new CardHydrationService<TView>(
112
116
  getViewComponent,
113
117
  (courseId: string) => dataLayer.getCourseDB(courseId),
114
- () => this._selectNextItemToHydrate(),
115
- (item: StudySessionItem) => this.removeItemFromQueue(item),
116
- () => this.hasAvailableCards()
118
+ () => this._getItemsToHydrate()
117
119
  );
118
120
 
119
121
  this.services = {
@@ -181,20 +183,14 @@ export class SessionController<TView = unknown> extends Loggable {
181
183
  }
182
184
 
183
185
  public async prepareSession() {
184
- try {
185
- // Use new getWeightedCards API if available, fall back to legacy methods
186
- const hasWeightedCards = this.sources.some((s) => typeof s.getWeightedCards === 'function');
187
-
188
- if (hasWeightedCards) {
189
- await this.getWeightedContent();
190
- } else {
191
- // Legacy path: separate calls for reviews and new cards
192
- await Promise.all([this.getScheduledReviews(), this.getNewCards()]);
193
- }
194
- } catch (e) {
195
- this.error('Error preparing study session:', e);
186
+ // All content sources must implement getWeightedCards()
187
+ if (this.sources.some((s) => typeof s.getWeightedCards !== 'function')) {
188
+ throw new Error(
189
+ '[SessionController] All content sources must implement getWeightedCards().'
190
+ );
196
191
  }
197
192
 
193
+ await this.getWeightedContent();
198
194
  await this.hydrationService.ensureHydratedCards();
199
195
 
200
196
  this._intervalHandle = setInterval(() => {
@@ -240,20 +236,12 @@ export class SessionController<TView = unknown> extends Loggable {
240
236
  return items;
241
237
  };
242
238
 
243
- const extractHydratedItems = () => {
244
- // We can't easily iterate the hydrated queue without dequeuing,
245
- // so we'll just report the count via hydratedCache.count below
246
-
247
- const items: any[] = [];
248
- return items;
249
- };
250
-
251
239
  return {
252
240
  api: {
253
241
  mode: supportsWeightedCards ? 'weighted' : 'legacy',
254
242
  description: supportsWeightedCards
255
243
  ? 'Using getWeightedCards() API with scored candidates'
256
- : 'Using legacy getNewCards()/getPendingReviews() API',
244
+ : 'ERROR: getWeightedCards() not a function.',
257
245
  },
258
246
  reviewQueue: {
259
247
  length: this.reviewQ.length,
@@ -272,194 +260,119 @@ export class SessionController<TView = unknown> extends Loggable {
272
260
  },
273
261
  hydratedCache: {
274
262
  count: this.hydrationService.hydratedCount,
275
- failedCacheSize: this.hydrationService.failedCacheSize,
276
- items: extractHydratedItems(),
263
+ cardIds: this.hydrationService.getHydratedCardIds(),
277
264
  },
278
265
  };
279
266
  }
280
267
 
281
268
  /**
282
- * Fetch content using the new getWeightedCards API.
283
- *
284
- * This method uses getWeightedCards() to get scored candidates, then uses the
285
- * scores to determine ordering. For reviews, we still need the full ScheduledCard
286
- * data from getPendingReviews(), so we fetch both and use scores for ordering.
269
+ * Fetch content using the getWeightedCards API and mix across sources.
287
270
  *
288
- * The hybrid approach:
289
- * 1. Fetch weighted cards to get scoring/ordering information
290
- * 2. Fetch full review data via legacy getPendingReviews()
291
- * 3. Order reviews by their weighted scores
292
- * 4. Add new cards ordered by their weighted scores
271
+ * This method:
272
+ * 1. Fetches weighted cards from each source
273
+ * 2. Fetches full review data (we need ScheduledCard fields for queue)
274
+ * 3. Uses SourceMixer to balance content across sources
275
+ * 4. Populates review and new card queues with mixed results
293
276
  */
294
277
  private async getWeightedContent() {
295
278
  const limit = 20; // Initial batch size per source
296
279
 
297
- // Collect weighted cards for scoring, and full review data for queue population
298
- const allWeighted: WeightedCard[] = [];
299
- const allReviews: (StudySessionReviewItem & ScheduledCard)[] = [];
300
- const allNewCards: StudySessionNewItem[] = [];
280
+ // Collect batches from each source
281
+ const batches: SourceBatch[] = [];
301
282
 
302
- for (const source of this.sources) {
283
+ for (let i = 0; i < this.sources.length; i++) {
284
+ const source = this.sources[i];
303
285
  try {
304
- // Always fetch full review data (we need ScheduledCard fields)
305
- const reviews = await source.getPendingReviews().catch((error) => {
306
- this.error(`Failed to get reviews for source:`, error);
307
- return [];
308
- });
309
- allReviews.push(...reviews);
286
+ // Fetch weighted cards for mixing
287
+ const weighted = await source.getWeightedCards!(limit);
310
288
 
311
- // Fetch weighted cards for scoring if available
312
- if (typeof source.getWeightedCards === 'function') {
313
- const weighted = await source.getWeightedCards(limit);
314
- allWeighted.push(...weighted);
315
- } else {
316
- // Fallback: fetch new cards directly and assign score=1.0
317
- const newCards = await source.getNewCards(limit);
318
- allNewCards.push(...newCards);
319
-
320
- // Create pseudo-weighted entries for ordering
321
- allWeighted.push(
322
- ...newCards.map((c) => ({
323
- cardId: c.cardID,
324
- courseId: c.courseID,
325
- score: 1.0,
326
- provenance: [
327
- {
328
- strategy: 'legacy',
329
- strategyName: 'Legacy Fallback',
330
- strategyId: 'legacy-fallback',
331
- action: 'generated' as const,
332
- score: 1.0,
333
- reason: 'Fallback to legacy getNewCards(), new card',
334
- },
335
- ],
336
- })),
337
- ...reviews.map((r) => ({
338
- cardId: r.cardID,
339
- courseId: r.courseID,
340
- score: 1.0,
341
- provenance: [
342
- {
343
- strategy: 'legacy',
344
- strategyName: 'Legacy Fallback',
345
- strategyId: 'legacy-fallback',
346
- action: 'generated' as const,
347
- score: 1.0,
348
- reason: 'Fallback to legacy getPendingReviews(), review',
349
- },
350
- ],
351
- }))
352
- );
353
- }
289
+ batches.push({
290
+ sourceIndex: i,
291
+ weighted,
292
+ });
354
293
  } catch (error) {
355
- this.error(`Failed to get content from source:`, error);
294
+ this.error(`Failed to get content from source ${i}:`, error);
295
+ // Re-throw if this is the only source - we can't proceed without any content
296
+ if (this.sources.length === 1) {
297
+ throw new Error(`Cannot start session: failed to load content from source ${i}`);
298
+ }
356
299
  }
357
300
  }
358
301
 
359
- // Build a score lookup map from weighted cards
360
- const scoreMap = new Map<string, number>();
361
- for (const w of allWeighted) {
362
- const key = `${w.courseId}::${w.cardId}`;
363
- scoreMap.set(key, w.score);
302
+ // Verify we got content from at least one source
303
+ if (batches.length === 0) {
304
+ throw new Error(
305
+ `Cannot start session: failed to load content from all ${this.sources.length} source(s). ` +
306
+ `Check logs for details.`
307
+ );
364
308
  }
365
309
 
366
- // Sort reviews by score (from weighted cards) descending
367
- const scoredReviews = allReviews.map((r) => ({
368
- review: r,
369
- score: scoreMap.get(`${r.courseID}::${r.cardID}`) ?? 1.0,
370
- }));
371
- scoredReviews.sort((a, b) => b.score - a.score);
372
-
373
- // Add reviews to queue in score order
374
- let report = 'Weighted content session created with:\n';
375
- for (const { review, score } of scoredReviews) {
376
- this.reviewQ.add(review, review.cardID);
377
- report += `Review: ${review.courseID}::${review.cardID} (score: ${score.toFixed(2)})\n`;
378
- }
310
+ // Mix weighted cards across sources using configured strategy
311
+ const mixedWeighted = this.mixer.mix(batches, limit * this.sources.length);
312
+
313
+ // Split mixed results by card origin
314
+ const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === 'review');
315
+ const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === 'new');
379
316
 
380
- // Get new cards from weighted list (filter out reviews)
381
- const newCardWeighted = allWeighted
382
- .filter((w) => getCardOrigin(w) === 'new')
383
- .sort((a, b) => b.score - a.score);
317
+ logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
384
318
 
385
- // Add new cards to queue in score order
386
- for (const card of newCardWeighted) {
319
+ // Populate review queue from mixed results (already sorted by mixer)
320
+ let report = 'Mixed content session created with:\n';
321
+ for (const w of reviewWeighted) {
322
+ const reviewItem: StudySessionReviewItem = {
323
+ cardID: w.cardId,
324
+ courseID: w.courseId,
325
+ contentSourceType: 'course',
326
+ contentSourceID: w.courseId,
327
+ reviewID: w.reviewID!,
328
+ status: 'review',
329
+ };
330
+ this.reviewQ.add(reviewItem, reviewItem.cardID);
331
+ report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})\n`;
332
+ }
333
+
334
+ // Populate new card queue from mixed results (already sorted by mixer)
335
+ for (const w of newWeighted) {
387
336
  const newItem: StudySessionNewItem = {
388
- cardID: card.cardId,
389
- courseID: card.courseId,
337
+ cardID: w.cardId,
338
+ courseID: w.courseId,
390
339
  contentSourceType: 'course',
391
- contentSourceID: card.courseId,
340
+ contentSourceID: w.courseId,
392
341
  status: 'new',
393
342
  };
394
- this.newQ.add(newItem, card.cardId);
395
- report += `New: ${card.courseId}::${card.cardId} (score: ${card.score.toFixed(2)})\n`;
343
+ this.newQ.add(newItem, newItem.cardID);
344
+ report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})\n`;
396
345
  }
397
346
 
398
347
  this.log(report);
399
348
  }
400
349
 
401
350
  /**
402
- * @deprecated Use getWeightedContent() instead. This method is kept for backward
403
- * compatibility with sources that don't support getWeightedCards().
351
+ * Returns items that should be pre-hydrated.
352
+ * Deterministic: top N items from each queue to ensure coverage.
353
+ * Failed queue items will typically already be hydrated (from initial render).
404
354
  */
405
- private async getScheduledReviews() {
406
- const reviews = await Promise.all(
407
- this.sources.map((c) =>
408
- c.getPendingReviews().catch((error) => {
409
- this.error(`Failed to get reviews for source ${c}:`, error);
410
- return [];
411
- })
412
- )
413
- );
414
-
415
- const dueCards: (StudySessionReviewItem & ScheduledCard)[] = [];
355
+ private _getItemsToHydrate(): StudySessionItem[] {
356
+ const items: StudySessionItem[] = [];
357
+ const ITEMS_PER_QUEUE = 2;
416
358
 
417
- while (reviews.length != 0 && reviews.some((r) => r.length > 0)) {
418
- // pick a random review source
419
- const index = randomInt(0, reviews.length - 1);
420
- const source = reviews[index];
421
-
422
- if (source.length === 0) {
423
- reviews.splice(index, 1);
424
- continue;
425
- } else {
426
- dueCards.push(source.shift()!);
427
- }
359
+ for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.reviewQ.length); i++) {
360
+ items.push(this.reviewQ.peek(i));
361
+ }
362
+ for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.newQ.length); i++) {
363
+ items.push(this.newQ.peek(i));
364
+ }
365
+ for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.failedQ.length); i++) {
366
+ items.push(this.failedQ.peek(i));
428
367
  }
429
368
 
430
- let report = 'Review session created with:\n';
431
- this.reviewQ.addAll(dueCards, (c) => c.cardID);
432
- report += dueCards.map((card) => `Card ${card.courseID}::${card.cardID} `).join('\n');
433
- this.log(report);
369
+ return items;
434
370
  }
435
371
 
436
372
  /**
437
- * @deprecated Use getWeightedContent() instead. This method is kept for backward
438
- * compatibility with sources that don't support getWeightedCards().
373
+ * Selects the next item to present to the user.
374
+ * Nondeterministic: uses probability to balance between queues based on session state.
439
375
  */
440
- private async getNewCards(n: number = 10) {
441
- const perCourse = Math.ceil(n / this.sources.length);
442
- const newContent = await Promise.all(this.sources.map((c) => c.getNewCards(perCourse)));
443
-
444
- // [ ] is this a noop?
445
- newContent.forEach((newContentFromSource) => {
446
- newContentFromSource.filter((c) => {
447
- return this._sessionRecord.find((record) => record.card.card_id === c.cardID) === undefined;
448
- });
449
- });
450
-
451
- while (n > 0 && newContent.some((nc) => nc.length > 0)) {
452
- for (let i = 0; i < newContent.length; i++) {
453
- if (newContent[i].length > 0) {
454
- const item = newContent[i].splice(0, 1)[0];
455
- this.log(`Adding new card: ${item.courseID}::${item.cardID}`);
456
- this.newQ.add(item, item.cardID);
457
- n--;
458
- }
459
- }
460
- }
461
- }
462
-
463
376
  private _selectNextItemToHydrate(): StudySessionItem | null {
464
377
  const choice = Math.random();
465
378
  let newBound: number = 0.1;
@@ -543,22 +456,28 @@ export class SessionController<TView = unknown> extends Loggable {
543
456
  return null;
544
457
  }
545
458
 
546
- let card = this.hydrationService.dequeueHydratedCard();
459
+ // Get what SessionController thinks should be next
460
+ const nextItem = this._selectNextItemToHydrate();
461
+ if (!nextItem) {
462
+ this._currentCard = null;
463
+ return null;
464
+ }
465
+
466
+ // Look up in hydration cache
467
+ let card = this.hydrationService.getHydratedCard(nextItem.cardID);
547
468
 
548
- // If no hydrated card but source cards available, wait for hydration
549
- if (!card && this.hasAvailableCards()) {
550
- card = await this.hydrationService.waitForHydratedCard();
469
+ // If not ready, wait for it
470
+ if (!card) {
471
+ card = await this.hydrationService.waitForCard(nextItem.cardID);
551
472
  }
552
473
 
474
+ // Remove from source queue now that we're consuming it
475
+ this.removeItemFromQueue(nextItem);
476
+
553
477
  // Trigger background hydration to maintain cache (async, non-blocking)
554
478
  await this.hydrationService.ensureHydratedCards();
555
479
 
556
- if (card) {
557
- this._currentCard = card;
558
- } else {
559
- this._currentCard = null;
560
- }
561
-
480
+ this._currentCard = card;
562
481
  return card;
563
482
  }
564
483
 
@@ -617,9 +536,11 @@ export class SessionController<TView = unknown> extends Loggable {
617
536
  // }
618
537
 
619
538
  if (action === 'dismiss-success') {
539
+ // Remove from hydration cache to free memory
540
+ this.hydrationService.removeCard(this._currentCard.item.cardID);
620
541
  // schedule a review - currently done in Study.vue
621
542
  } else if (action === 'marked-failed') {
622
- this.hydrationService.cacheFailedCard(this._currentCard);
543
+ // Card stays in hydration cache for re-use (no removeCard call)
623
544
 
624
545
  let failedItem: StudySessionFailedItem;
625
546
 
@@ -644,26 +565,25 @@ export class SessionController<TView = unknown> extends Loggable {
644
565
 
645
566
  this.failedQ.add(failedItem, failedItem.cardID);
646
567
  } else if (action === 'dismiss-error') {
647
- // some error logging?
568
+ // Remove from cache on error as well
569
+ this.hydrationService.removeCard(this._currentCard.item.cardID);
648
570
  } else if (action === 'dismiss-failed') {
649
- // handled by Study.vue
571
+ // Remove from cache - card has been fully processed after failure cleanup
572
+ this.hydrationService.removeCard(this._currentCard.item.cardID);
650
573
  }
651
574
  }
652
575
  }
653
576
 
654
- private hasAvailableCards(): boolean {
655
- return this.reviewQ.length > 0 || this.newQ.length > 0 || this.failedQ.length > 0;
656
- }
657
-
658
577
  /**
659
- * Helper method for CardHydrationService to remove items from appropriate queue.
578
+ * Remove an item from its source queue after consumption by nextCard().
660
579
  */
661
580
  private removeItemFromQueue(item: StudySessionItem): void {
662
- if (this.reviewQ.peek(0) === item) {
581
+ // Check each queue - item should be at the front of one of them
582
+ if (this.reviewQ.peek(0)?.cardID === item.cardID) {
663
583
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
664
- } else if (this.newQ.peek(0) === item) {
584
+ } else if (this.newQ.peek(0)?.cardID === item.cardID) {
665
585
  this.newQ.dequeue((queueItem) => queueItem.cardID);
666
- } else {
586
+ } else if (this.failedQ.peek(0)?.cardID === item.cardID) {
667
587
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
668
588
  }
669
589
  }
@@ -0,0 +1,65 @@
1
+ import { WeightedCard } from '@db/core/navigators';
2
+
3
+ /**
4
+ * Represents a batch of content fetched from a single StudyContentSource.
5
+ */
6
+ export interface SourceBatch {
7
+ sourceIndex: number;
8
+ weighted: WeightedCard[];
9
+ }
10
+
11
+ /**
12
+ * Strategy interface for mixing content from multiple sources into a unified
13
+ * set of weighted candidates.
14
+ *
15
+ * Different implementations can provide different balancing strategies:
16
+ * - QuotaRoundRobinMixer: Equal representation per source
17
+ * - MinMaxNormalizingMixer: Score normalization then global sort
18
+ * - PercentileBucketMixer: Bucketed round-robin
19
+ * etc.
20
+ */
21
+ export interface SourceMixer {
22
+ /**
23
+ * Mix weighted cards from multiple sources into a unified, ordered list.
24
+ *
25
+ * @param batches - Content batches from each source
26
+ * @param limit - Target number of cards to return
27
+ * @returns Mixed and ordered weighted cards
28
+ */
29
+ mix(batches: SourceBatch[], limit: number): WeightedCard[];
30
+ }
31
+
32
+ /**
33
+ * Simple quota-based mixer: allocates equal representation to each source,
34
+ * taking the top-N cards by score from each.
35
+ *
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.
39
+ *
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).
43
+ */
44
+ export class QuotaRoundRobinMixer implements SourceMixer {
45
+ mix(batches: SourceBatch[], limit: number): WeightedCard[] {
46
+ if (batches.length === 0) {
47
+ return [];
48
+ }
49
+
50
+ const quotaPerSource = Math.ceil(limit / batches.length);
51
+ const mixed: WeightedCard[] = [];
52
+
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);
56
+
57
+ // Take top quotaPerSource from this source
58
+ const topFromSource = sortedBatch.slice(0, quotaPerSource);
59
+ mixed.push(...topFromSource);
60
+ }
61
+
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);
64
+ }
65
+ }