@vue-skuilder/db 0.1.32-e → 0.1.32-f

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.
@@ -3,9 +3,8 @@ import type { UserDBInterface } from '../../interfaces/userDB';
3
3
  import { ContentNavigator } from '../index';
4
4
  import type { WeightedCard } from '../index';
5
5
  import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
6
- import type { CardGenerator, GeneratorContext, GeneratorResult } from './types';
6
+ import type { CardGenerator, GeneratorContext, GeneratorResult, ReplanHints } from './types';
7
7
  import { logger } from '@db/util/logger';
8
- import type { ReplanHints } from '../../study/SessionController';
9
8
 
10
9
  // ============================================================================
11
10
  // PRESCRIBED CARDS GENERATOR
@@ -89,6 +88,7 @@ interface GroupRuntimeState {
89
88
  surfaceableTargets: string[];
90
89
  targetTags: Map<string, string[]>;
91
90
  supportCandidates: string[];
91
+ discoveredSupportCandidates: string[];
92
92
  supportTags: string[];
93
93
  pressureMultiplier: number;
94
94
  supportMultiplier: number;
@@ -108,11 +108,11 @@ const DEFAULT_HIERARCHY_DEPTH = 2;
108
108
  const DEFAULT_MIN_COUNT = 3;
109
109
  const BASE_TARGET_SCORE = 1.0;
110
110
  const BASE_SUPPORT_SCORE = 0.8;
111
+ const DISCOVERED_SUPPORT_SCORE = 12.0;
111
112
  const MAX_TARGET_MULTIPLIER = 8.0;
112
113
  const MAX_SUPPORT_MULTIPLIER = 4.0;
113
- const LOCKED_TAG_PREFIXES = ['concept:'];
114
- const LESSON_GATE_PENALTY_TAG_HINT = 'concept:';
115
- const PRESCRIBED_DEBUG_VERSION = 'testversion-prescribed-v2';
114
+
115
+ const PRESCRIBED_DEBUG_VERSION = 'testversion-prescribed-v3';
116
116
 
117
117
  function dedupe<T>(arr: T[]): T[] {
118
118
  return [...new Set(arr)];
@@ -135,6 +135,31 @@ function matchesTagPattern(tag: string, pattern: string): boolean {
135
135
  return re.test(tag);
136
136
  }
137
137
 
138
+ /**
139
+ * Extract the word stem from a card ID for deduplication.
140
+ * ML: c-ml-{word}-{blanks} → {word}
141
+ * WS: c-ws-{word}-{contrast} → {word}
142
+ * Other: full cardId as fallback.
143
+ */
144
+ function extractWordStem(cardId: string): string {
145
+ for (const prefix of ['c-ml-', 'c-ws-', 'c-spelling-']) {
146
+ if (cardId.startsWith(prefix)) {
147
+ const rest = cardId.slice(prefix.length);
148
+ const lastDash = rest.lastIndexOf('-');
149
+ return lastDash > 0 ? rest.slice(0, lastDash) : rest;
150
+ }
151
+ }
152
+ return cardId;
153
+ }
154
+
155
+ /** Fisher-Yates shuffle in place. */
156
+ function shuffleInPlace<T>(arr: T[]): void {
157
+ for (let i = arr.length - 1; i > 0; i--) {
158
+ const j = Math.floor(Math.random() * (i + 1));
159
+ [arr[i], arr[j]] = [arr[j], arr[i]];
160
+ }
161
+ }
162
+
138
163
  function pickTopByScore(cards: WeightedCard[], limit: number): WeightedCard[] {
139
164
  return [...cards]
140
165
  .sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId))
@@ -201,6 +226,22 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
201
226
  ? await this.course.getAppliedTagsBatch(allRelevantIds)
202
227
  : new Map<string, string[]>();
203
228
 
229
+ const courseTagDocs = await this.course.getCourseTagStubs().catch(
230
+ () =>
231
+ ({
232
+ rows: [],
233
+ offset: 0,
234
+ total_rows: 0,
235
+ }) as unknown as Awaited<ReturnType<CourseDBInterface['getCourseTagStubs']>>
236
+ );
237
+ const cardsByTag = new Map<string, string[]>();
238
+ for (const row of courseTagDocs.rows ?? []) {
239
+ const tagDoc = row.doc as { name?: string; taggedCards?: string[] } | undefined;
240
+ if (tagDoc?.name && Array.isArray(tagDoc.taggedCards)) {
241
+ cardsByTag.set(tagDoc.name, [...tagDoc.taggedCards]);
242
+ }
243
+ }
244
+
204
245
  const nextState: PrescribedProgressState = {
205
246
  updatedAt: isoNow(),
206
247
  groups: {},
@@ -217,12 +258,46 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
217
258
  activeIds,
218
259
  seenIds,
219
260
  tagsByCard,
261
+ cardsByTag,
220
262
  hierarchyConfigs,
221
263
  userTagElo,
222
264
  userGlobalElo,
223
265
  });
224
266
 
225
267
  groupRuntimes.push(runtime);
268
+
269
+ logger.info(
270
+ `[Prescribed] Group '${group.id}': ` +
271
+ `${group.targetCardIds.length} targets total, ` +
272
+ `${runtime.encounteredTargets.size} encountered, ` +
273
+ `${runtime.pendingTargets.length} pending ` +
274
+ `(${runtime.surfaceableTargets.length} surfaceable, ${runtime.blockedTargets.length} blocked), ` +
275
+ `${runtime.supportCandidates.length} authored support candidates, ` +
276
+ `${runtime.discoveredSupportCandidates.length} discovered support candidates, ` +
277
+ `pressure=${runtime.pressureMultiplier.toFixed(2)}`
278
+ );
279
+ if (runtime.blockedTargets.length > 0) {
280
+ logger.info(
281
+ `[Prescribed] Group '${group.id}' blocked targets: ${runtime.blockedTargets.join(', ')}`
282
+ );
283
+ logger.info(
284
+ `[Prescribed] Group '${group.id}' support tags needed: ${runtime.supportTags.join(', ') || '(none)'}`
285
+ );
286
+ logger.info(
287
+ `[Prescribed] Group '${group.id}' escalation mode: ` +
288
+ (runtime.supportCandidates.length > 0
289
+ ? 'direct-support'
290
+ : runtime.discoveredSupportCandidates.length > 0
291
+ ? 'inserted-support-candidates'
292
+ : 'boost-only')
293
+ );
294
+ if (runtime.discoveredSupportCandidates.length > 0) {
295
+ logger.info(
296
+ `[Prescribed] Group '${group.id}' discovered support candidates: ${runtime.discoveredSupportCandidates.join(', ')}`
297
+ );
298
+ }
299
+ }
300
+
226
301
  nextState.groups[group.id] = this.buildNextGroupState(runtime, progress.groups[group.id]);
227
302
 
228
303
  const directCards = this.buildDirectTargetCards(
@@ -235,8 +310,13 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
235
310
  courseId,
236
311
  emittedIds
237
312
  );
313
+ const discoveredSupportCards = this.buildDiscoveredSupportCards(
314
+ runtime,
315
+ courseId,
316
+ emittedIds
317
+ );
238
318
 
239
- emitted.push(...directCards, ...supportCards);
319
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
240
320
  }
241
321
 
242
322
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
@@ -251,8 +331,21 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
251
331
  }
252
332
  : undefined;
253
333
 
334
+ if (hints) {
335
+ const tagEntries = Object.entries(hints.boostTags ?? {}) as Array<[string, number]>;
336
+ logger.info(
337
+ `[Prescribed] Emitting ${tagEntries.length} boost hint(s): ` +
338
+ tagEntries.map(([tag, mult]) => `${tag}×${mult.toFixed(1)}`).join(', ')
339
+ );
340
+ } else {
341
+ logger.info('[Prescribed] No hints to emit (no blocked targets or no support tags)');
342
+ }
343
+
254
344
  if (emitted.length === 0) {
255
- logger.debug('[Prescribed] No prescribed targets/support emitted this run');
345
+ logger.info(
346
+ '[Prescribed] 0 cards emitted (all targets blocked, authored/discovered support candidates exhausted)' +
347
+ (hints ? ' — boost hints emitted but may not survive filters' : '')
348
+ );
256
349
  await this.putStrategyState(nextState).catch((e) => {
257
350
  logger.debug(`[Prescribed] Failed to persist empty-state update: ${e}`);
258
351
  });
@@ -292,7 +385,8 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
292
385
  logger.info(
293
386
  `[Prescribed] Emitting ${finalCards.length} cards ` +
294
387
  `(${finalCards.filter((c) => c.provenance[0]?.reason.includes('mode=target')).length} target, ` +
295
- `${finalCards.filter((c) => c.provenance[0]?.reason.includes('mode=support')).length} support)`
388
+ `${finalCards.filter((c) => c.provenance[0]?.reason.includes('mode=support')).length} support, ` +
389
+ `${finalCards.filter((c) => c.provenance[0]?.reason.includes('mode=discovered-support')).length} discovered support)`
296
390
  );
297
391
 
298
392
  return hints ? { cards: finalCards, hints } : { cards: finalCards };
@@ -325,14 +419,22 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
325
419
 
326
420
  private parseConfig(serializedData: string): PrescribedConfig {
327
421
  try {
328
- const parsed = JSON.parse(serializedData);
422
+ const parsed = JSON.parse(serializedData) as { groups?: unknown[] };
329
423
  const groupsRaw = Array.isArray(parsed.groups) ? parsed.groups : [];
330
424
  const groups: PrescribedGroupConfig[] = groupsRaw
331
- .map((raw: any, i: number) => ({
425
+ .map((raw: any, i: number): PrescribedGroupConfig => ({
332
426
  id: typeof raw.id === 'string' && raw.id.trim().length > 0 ? raw.id : `group-${i + 1}`,
333
- targetCardIds: dedupe(Array.isArray(raw.targetCardIds) ? raw.targetCardIds.filter((v: unknown) => typeof v === 'string') : []),
334
- supportCardIds: dedupe(Array.isArray(raw.supportCardIds) ? raw.supportCardIds.filter((v: unknown) => typeof v === 'string') : []),
335
- supportTagPatterns: dedupe(Array.isArray(raw.supportTagPatterns) ? raw.supportTagPatterns.filter((v: unknown) => typeof v === 'string') : []),
427
+ targetCardIds: dedupe(
428
+ (Array.isArray(raw.targetCardIds) ? raw.targetCardIds.filter((v: unknown): v is string => typeof v === 'string') : [])
429
+ ),
430
+ supportCardIds: dedupe(
431
+ (Array.isArray(raw.supportCardIds) ? raw.supportCardIds.filter((v: unknown): v is string => typeof v === 'string') : [])
432
+ ),
433
+ supportTagPatterns: dedupe(
434
+ (Array.isArray(raw.supportTagPatterns)
435
+ ? raw.supportTagPatterns.filter((v: unknown): v is string => typeof v === 'string')
436
+ : [])
437
+ ),
336
438
  freshnessWindowSessions:
337
439
  typeof raw.freshnessWindowSessions === 'number' ? raw.freshnessWindowSessions : DEFAULT_FRESHNESS_WINDOW,
338
440
  maxDirectTargetsPerRun:
@@ -358,12 +460,12 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
358
460
 
359
461
  private async loadHierarchyConfigs(): Promise<HierarchyConfig[]> {
360
462
  try {
361
- const strategies = await this.course.getNavigationStrategies();
463
+ const strategies = await this.course.getAllNavigationStrategies();
362
464
  return strategies
363
- .filter((s) => s.implementingClass === 'hierarchyDefinition')
364
- .map((s) => {
465
+ .filter((s: ContentNavigationStrategyData) => s.implementingClass === 'hierarchyDefinition')
466
+ .map((s: ContentNavigationStrategyData) => {
365
467
  try {
366
- const parsed = JSON.parse(s.serializedData);
468
+ const parsed = JSON.parse(s.serializedData) as { prerequisites?: Record<string, TagPrerequisite[]> };
367
469
  return {
368
470
  prerequisites: parsed.prerequisites || {},
369
471
  } as HierarchyConfig;
@@ -383,6 +485,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
383
485
  activeIds: Set<string>;
384
486
  seenIds: Set<string>;
385
487
  tagsByCard: Map<string, string[]>;
488
+ cardsByTag: Map<string, string[]>;
386
489
  hierarchyConfigs: HierarchyConfig[];
387
490
  userTagElo: Record<string, { score: number; count: number }>;
388
491
  userGlobalElo: number;
@@ -393,6 +496,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
393
496
  activeIds,
394
497
  seenIds,
395
498
  tagsByCard,
499
+ cardsByTag,
396
500
  hierarchyConfigs,
397
501
  userTagElo,
398
502
  userGlobalElo,
@@ -468,6 +572,28 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
468
572
  ),
469
573
  ]).filter((id) => !activeIds.has(id) && !seenIds.has(id));
470
574
 
575
+ const discoveredSupportCandidates =
576
+ blockedTargets.length > 0 && supportTags.size > 0 && supportCandidates.length === 0
577
+ ? this.findDiscoveredSupportCards({
578
+ supportTags: [...supportTags],
579
+ cardsByTag,
580
+ activeIds,
581
+ seenIds,
582
+ excludedIds: new Set([
583
+ ...group.targetCardIds,
584
+ ...(group.supportCardIds ?? []),
585
+ ]),
586
+ limit: group.maxSupportCardsPerRun ?? DEFAULT_MAX_SUPPORT_PER_RUN,
587
+ })
588
+ : [];
589
+
590
+ if (blockedTargets.length > 0 && supportTags.size > 0 && discoveredSupportCandidates.length === 0) {
591
+ logger.info(
592
+ `[Prescribed] Group '${group.id}' discovered 0 broader support candidates ` +
593
+ `(blocked=${blockedTargets.length}; authoredSupport=${supportCandidates.length})`
594
+ );
595
+ }
596
+
471
597
  const sessionsSinceSurfaced = priorState?.sessionsSinceSurfaced ?? 0;
472
598
  const freshnessWindow = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
473
599
  const staleSessions = Math.max(0, sessionsSinceSurfaced - freshnessWindow);
@@ -488,6 +614,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
488
614
  surfaceableTargets,
489
615
  targetTags,
490
616
  supportCandidates,
617
+ discoveredSupportCandidates,
491
618
  supportTags: [...supportTags],
492
619
  pressureMultiplier,
493
620
  supportMultiplier,
@@ -594,6 +721,50 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
594
721
  return cards;
595
722
  }
596
723
 
724
+ private buildDiscoveredSupportCards(
725
+ runtime: GroupRuntimeState,
726
+ courseId: string,
727
+ emittedIds: Set<string>
728
+ ): WeightedCard[] {
729
+ if (runtime.blockedTargets.length === 0 || runtime.discoveredSupportCandidates.length === 0) {
730
+ return [];
731
+ }
732
+
733
+ const maxSupport = runtime.group.maxSupportCardsPerRun ?? DEFAULT_MAX_SUPPORT_PER_RUN;
734
+ const supportIds = runtime.discoveredSupportCandidates
735
+ .filter((id) => !emittedIds.has(id))
736
+ .slice(0, maxSupport);
737
+
738
+ const cards: WeightedCard[] = [];
739
+ for (const cardId of supportIds) {
740
+ emittedIds.add(cardId);
741
+ cards.push({
742
+ cardId,
743
+ courseId,
744
+ score: DISCOVERED_SUPPORT_SCORE * runtime.supportMultiplier,
745
+ provenance: [
746
+ {
747
+ strategy: 'prescribed',
748
+ strategyName: this.strategyName || this.name,
749
+ strategyId: this.strategyId || 'NAVIGATION_STRATEGY-prescribed',
750
+ action: 'generated' as const,
751
+ score: DISCOVERED_SUPPORT_SCORE * runtime.supportMultiplier,
752
+ reason:
753
+ `mode=discovered-support;group=${runtime.group.id};pending=${runtime.pendingTargets.length};` +
754
+ `blocked=${runtime.blockedTargets.length};` +
755
+ `blockedTargets=${runtime.blockedTargets.join('|') || 'none'};` +
756
+ `supportCard=${cardId};` +
757
+ `supportTags=${runtime.supportTags.join('|') || 'none'};` +
758
+ `multiplier=${runtime.supportMultiplier.toFixed(2)};` +
759
+ `testversion=${runtime.debugVersion}`,
760
+ },
761
+ ],
762
+ });
763
+ }
764
+
765
+ return cards;
766
+ }
767
+
597
768
  private findSupportCardsByTags(
598
769
  group: PrescribedGroupConfig,
599
770
  tagsByCard: Map<string, string[]>,
@@ -626,6 +797,63 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
626
797
  return [...candidates];
627
798
  }
628
799
 
800
+ private findDiscoveredSupportCards(args: {
801
+ supportTags: string[];
802
+ cardsByTag: Map<string, string[]>;
803
+ activeIds: Set<string>;
804
+ seenIds: Set<string>;
805
+ excludedIds: Set<string>;
806
+ limit: number;
807
+ }): string[] {
808
+ const { supportTags, cardsByTag, activeIds, seenIds, excludedIds, limit } = args;
809
+
810
+ const byCardId = new Map<string, { cardId: string; matches: number }>();
811
+
812
+ for (const supportTag of supportTags) {
813
+ const taggedCards = cardsByTag.get(supportTag) ?? [];
814
+ for (const cardId of taggedCards) {
815
+ if (activeIds.has(cardId) || seenIds.has(cardId) || excludedIds.has(cardId)) {
816
+ continue;
817
+ }
818
+ const existing = byCardId.get(cardId);
819
+ if (existing) {
820
+ existing.matches += 1;
821
+ } else {
822
+ byCardId.set(cardId, { cardId, matches: 1 });
823
+ }
824
+ }
825
+ }
826
+
827
+ const candidates = [...byCardId.values()]
828
+ .sort((a, b) => b.matches - a.matches || a.cardId.localeCompare(b.cardId));
829
+
830
+ // Diversify by word stem — avoid returning 4 variants of "year".
831
+ // ML cards follow c-ml-{word}-{blanks}, so the stem is everything before
832
+ // the last dash-delimited segment of digits/commas.
833
+ const usedStems = new Set<string>();
834
+ const diverse: typeof candidates = [];
835
+ const deferred: typeof candidates = [];
836
+
837
+ for (const entry of candidates) {
838
+ const stem = extractWordStem(entry.cardId);
839
+ if (!usedStems.has(stem)) {
840
+ usedStems.add(stem);
841
+ diverse.push(entry);
842
+ } else {
843
+ deferred.push(entry);
844
+ }
845
+ }
846
+
847
+ // Combine diverse-first, then deferred for overflow, and shuffle within
848
+ // each tier so we don't always pick the same card for a given word.
849
+ shuffleInPlace(diverse);
850
+ shuffleInPlace(deferred);
851
+
852
+ return [...diverse, ...deferred]
853
+ .slice(0, limit)
854
+ .map((entry) => entry.cardId);
855
+ }
856
+
629
857
  private resolveBlockedSupportTags(
630
858
  targetTags: string[],
631
859
  hierarchyConfigs: HierarchyConfig[],
@@ -697,7 +925,6 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
697
925
  out: Set<string>
698
926
  ): void {
699
927
  if (depth < 0 || visited.has(tag)) return;
700
- if (this.isHardGatedTag(tag)) return;
701
928
 
702
929
  visited.add(tag);
703
930
 
@@ -732,10 +959,7 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
732
959
  }
733
960
  }
734
961
 
735
- private isHardGatedTag(tag: string): boolean {
736
- return LOCKED_TAG_PREFIXES.some((prefix) => tag.startsWith(prefix)) &&
737
- tag.startsWith(LESSON_GATE_PENALTY_TAG_HINT);
738
- }
962
+
739
963
 
740
964
  private isPrerequisiteMet(
741
965
  prereq: TagPrerequisite,
@@ -524,7 +524,7 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
524
524
  ): Promise<PouchDB.Core.GetMeta & PouchDB.Core.Document<T>> {
525
525
  // Use this.db (local when available) for read operations.
526
526
  // Falls back to the standalone helper (always remote) only if needed.
527
- return await this.db.get<T>(id, options) as PouchDB.Core.GetMeta & PouchDB.Core.Document<T>;
527
+ return await this.db.get<T>(id, options ?? {}) as PouchDB.Core.GetMeta & PouchDB.Core.Document<T>;
528
528
  }
529
529
 
530
530
  async getCourseDocs<T extends SkuilderCourseData>(
@@ -629,7 +629,8 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
629
629
  );
630
630
  return pipeline;
631
631
  } catch (e) {
632
- logger.error(`[courseDB] Error creating navigator: ${e}`);
632
+ const msg = e instanceof Error ? `${e.message}\n${e.stack}` : JSON.stringify(e);
633
+ logger.error(`[courseDB] Error creating navigator: ${msg}`);
633
634
  throw e;
634
635
  }
635
636
  }
@@ -66,6 +66,7 @@ export interface StudySessionRecord {
66
66
  course_id: string;
67
67
  card_id: string;
68
68
  card_elo: number;
69
+ tags: string[];
69
70
  };
70
71
  item: StudySessionItem;
71
72
  records: CardRecord[];
@@ -54,6 +54,7 @@ export interface HydratedCard<TView = unknown> {
54
54
  item: StudySessionItem;
55
55
  view: TView;
56
56
  data: any[];
57
+ tags: string[];
57
58
  }
58
59
 
59
60
  /**
@@ -195,7 +196,10 @@ export class CardHydrationService<TView = unknown> {
195
196
 
196
197
  try {
197
198
  const courseDB = this.getCourseDB(item.courseID);
198
- const cardData = await courseDB.getCourseDoc<CardData>(item.cardID);
199
+ const [cardData, tagsByCard] = await Promise.all([
200
+ courseDB.getCourseDoc<CardData>(item.cardID),
201
+ courseDB.getAppliedTagsBatch([item.cardID]),
202
+ ]);
199
203
 
200
204
  if (!isCourseElo(cardData.elo)) {
201
205
  cardData.elo = toCourseElo(cardData.elo);
@@ -234,6 +238,7 @@ export class CardHydrationService<TView = unknown> {
234
238
  item,
235
239
  view,
236
240
  data,
241
+ tags: tagsByCard.get(item.cardID) ?? [],
237
242
  });
238
243
 
239
244
  logger.debug(`[CardHydrationService] Hydrated card ${item.cardID}`);
@@ -3,7 +3,7 @@ import CompositeGenerator, {
3
3
  AggregationMode,
4
4
  } from '../../../src/core/navigators/generators/CompositeGenerator';
5
5
  import { ContentNavigator, WeightedCard } from '../../../src/core/navigators/index';
6
- import { GeneratorContext } from '../../../src/core/navigators/generators/types';
6
+ import { GeneratorContext, GeneratorResult } from '../../../src/core/navigators/generators/types';
7
7
  import { UserDBInterface } from '../../../src/core/interfaces/userDB';
8
8
  import { CourseDBInterface } from '../../../src/core/interfaces/courseDB';
9
9
 
@@ -41,8 +41,8 @@ class MockGenerator extends ContentNavigator {
41
41
  this.mockWeightedCards = cards;
42
42
  }
43
43
 
44
- async getWeightedCards(limit: number): Promise<WeightedCard[]> {
45
- return this.mockWeightedCards.slice(0, limit);
44
+ async getWeightedCards(limit: number): Promise<GeneratorResult> {
45
+ return { cards: this.mockWeightedCards.slice(0, limit) };
46
46
  }
47
47
  }
48
48
 
@@ -96,7 +96,7 @@ describe('CompositeGenerator', () => {
96
96
  ]);
97
97
 
98
98
  const composite = new CompositeGenerator([generator]);
99
- const result = await composite.getWeightedCards(10, mockContext);
99
+ const { cards: result } = await composite.getWeightedCards(10, mockContext);
100
100
 
101
101
  expect(result).toHaveLength(2);
102
102
  expect(result[0].cardId).toBe('card-1');
@@ -114,7 +114,7 @@ describe('CompositeGenerator', () => {
114
114
  ]);
115
115
 
116
116
  const composite = new CompositeGenerator([generator]);
117
- const result = await composite.getWeightedCards(2, mockContext);
117
+ const { cards: result } = await composite.getWeightedCards(2, mockContext);
118
118
 
119
119
  expect(result).toHaveLength(2);
120
120
  expect(result[0].cardId).toBe('card-1');
@@ -137,7 +137,7 @@ describe('CompositeGenerator', () => {
137
137
  ]);
138
138
 
139
139
  const composite = new CompositeGenerator([gen1, gen2], AggregationMode.AVERAGE);
140
- const result = await composite.getWeightedCards(10, mockContext);
140
+ const { cards: result } = await composite.getWeightedCards(10, mockContext);
141
141
 
142
142
  // Should have 3 unique cards
143
143
  expect(result).toHaveLength(3);
@@ -157,7 +157,7 @@ describe('CompositeGenerator', () => {
157
157
  gen2.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.9, 'new')]);
158
158
 
159
159
  const composite = new CompositeGenerator([gen1, gen2], AggregationMode.MAX);
160
- const result = await composite.getWeightedCards(10, mockContext);
160
+ const { cards: result } = await composite.getWeightedCards(10, mockContext);
161
161
 
162
162
  expect(result).toHaveLength(1);
163
163
  expect(result[0].score).toBe(0.9);
@@ -173,7 +173,7 @@ describe('CompositeGenerator', () => {
173
173
  gen2.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.6, 'new')]);
174
174
 
175
175
  const composite = new CompositeGenerator([gen1, gen2], AggregationMode.AVERAGE);
176
- const result = await composite.getWeightedCards(10, mockContext);
176
+ const { cards: result } = await composite.getWeightedCards(10, mockContext);
177
177
 
178
178
  expect(result).toHaveLength(1);
179
179
  expect(result[0].score).toBeCloseTo(0.7); // (0.8 + 0.6) / 2
@@ -190,7 +190,7 @@ describe('CompositeGenerator', () => {
190
190
  gen3.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.6, 'new')]);
191
191
 
192
192
  const composite = new CompositeGenerator([gen1, gen2, gen3], AggregationMode.AVERAGE);
193
- const result = await composite.getWeightedCards(10, mockContext);
193
+ const { cards: result } = await composite.getWeightedCards(10, mockContext);
194
194
 
195
195
  expect(result).toHaveLength(1);
196
196
  expect(result[0].score).toBeCloseTo(0.7); // (0.9 + 0.6 + 0.6) / 3
@@ -206,7 +206,7 @@ describe('CompositeGenerator', () => {
206
206
  gen2.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.6, 'new')]);
207
207
 
208
208
  const composite = new CompositeGenerator([gen1, gen2]); // default mode
209
- const result = await composite.getWeightedCards(10, mockContext);
209
+ const { cards: result } = await composite.getWeightedCards(10, mockContext);
210
210
 
211
211
  expect(result).toHaveLength(1);
212
212
  // avg = (0.8 + 0.6) / 2 = 0.7
@@ -226,7 +226,7 @@ describe('CompositeGenerator', () => {
226
226
  gen3.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.6, 'new')]);
227
227
 
228
228
  const composite = new CompositeGenerator([gen1, gen2, gen3]);
229
- const result = await composite.getWeightedCards(10, mockContext);
229
+ const { cards: result } = await composite.getWeightedCards(10, mockContext);
230
230
 
231
231
  expect(result).toHaveLength(1);
232
232
  // avg = 0.6
@@ -243,7 +243,7 @@ describe('CompositeGenerator', () => {
243
243
  gen2.setWeightedCards([makeWeightedCard('card-2', 'course-1', 0.6, 'new')]);
244
244
 
245
245
  const composite = new CompositeGenerator([gen1, gen2]);
246
- const result = await composite.getWeightedCards(10, mockContext);
246
+ const { cards: result } = await composite.getWeightedCards(10, mockContext);
247
247
 
248
248
  expect(result).toHaveLength(2);
249
249
  // No boost for single-generator cards
@@ -254,42 +254,6 @@ describe('CompositeGenerator', () => {
254
254
  });
255
255
  });
256
256
 
257
- describe('score clamping', () => {
258
- it('clamps boosted scores to maximum of 1.0', async () => {
259
- const gen1 = new MockGenerator();
260
- gen1.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.9, 'new')]);
261
-
262
- const gen2 = new MockGenerator();
263
- gen2.setWeightedCards([makeWeightedCard('card-1', 'course-1', 0.9, 'new')]);
264
-
265
- const composite = new CompositeGenerator([gen1, gen2]);
266
- const result = await composite.getWeightedCards(10, mockContext);
267
-
268
- expect(result).toHaveLength(1);
269
- // avg = 0.9, boost = 1.1, result = 0.99 (would be 0.99, not clamped)
270
- // But with higher scores:
271
- expect(result[0].score).toBeLessThanOrEqual(1.0);
272
- });
273
-
274
- it('clamps to 1.0 when boosted score exceeds maximum', async () => {
275
- const gen1 = new MockGenerator();
276
- gen1.setWeightedCards([makeWeightedCard('card-1', 'course-1', 1.0, 'new')]);
277
-
278
- const gen2 = new MockGenerator();
279
- gen2.setWeightedCards([makeWeightedCard('card-1', 'course-1', 1.0, 'new')]);
280
-
281
- const gen3 = new MockGenerator();
282
- gen3.setWeightedCards([makeWeightedCard('card-1', 'course-1', 1.0, 'new')]);
283
-
284
- const composite = new CompositeGenerator([gen1, gen2, gen3]);
285
- const result = await composite.getWeightedCards(10, mockContext);
286
-
287
- expect(result).toHaveLength(1);
288
- // avg = 1.0, boost = 1.2, result would be 1.2 but clamped to 1.0
289
- expect(result[0].score).toBe(1.0);
290
- });
291
- });
292
-
293
257
  describe('sorting and limiting', () => {
294
258
  it('returns cards sorted by score descending', async () => {
295
259
  const gen1 = new MockGenerator();
@@ -300,7 +264,7 @@ describe('CompositeGenerator', () => {
300
264
  ]);
301
265
 
302
266
  const composite = new CompositeGenerator([gen1]);
303
- const result = await composite.getWeightedCards(10, mockContext);
267
+ const { cards: result } = await composite.getWeightedCards(10, mockContext);
304
268
 
305
269
  expect(result).toHaveLength(3);
306
270
  expect(result[0].cardId).toBe('card-high');
@@ -319,7 +283,7 @@ describe('CompositeGenerator', () => {
319
283
  gen2.setWeightedCards([makeWeightedCard('card-boosted', 'course-1', 0.5, 'new')]);
320
284
 
321
285
  const composite = new CompositeGenerator([gen1, gen2]);
322
- const result = await composite.getWeightedCards(10, mockContext);
286
+ const { cards: result } = await composite.getWeightedCards(10, mockContext);
323
287
 
324
288
  expect(result).toHaveLength(2);
325
289
  // card-boosted: avg=0.5, boost=1.1, final=0.55