@vue-skuilder/db 0.1.32-e → 0.1.33-vite8-4

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,
@@ -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
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { Pipeline } from '../../../src/core/navigators/Pipeline';
3
3
  import { WeightedCard, ContentNavigator } from '../../../src/core/navigators/index';
4
4
  import { CardFilter, FilterContext } from '../../../src/core/navigators/filters/types';
5
+ import type { GeneratorResult } from '../../../src/core/navigators/generators/types';
5
6
 
6
7
  import { CourseDBInterface } from '../../../src/core/interfaces/courseDB';
7
8
  import { UserDBInterface } from '../../../src/core/interfaces/userDB';
@@ -22,8 +23,8 @@ class MockGenerator extends ContentNavigator {
22
23
  this.cards = cards;
23
24
  }
24
25
 
25
- async getWeightedCards(limit: number): Promise<WeightedCard[]> {
26
- return this.cards.slice(0, limit);
26
+ async getWeightedCards(limit: number): Promise<GeneratorResult> {
27
+ return { cards: this.cards.slice(0, limit) };
27
28
  }
28
29
  }
29
30
 
@@ -143,7 +144,7 @@ describe('Pipeline', () => {
143
144
  const generator = new MockGenerator(cards);
144
145
  const pipeline = new Pipeline(generator, [], mockUser, mockCourse);
145
146
 
146
- const result = await pipeline.getWeightedCards(10);
147
+ const { cards: result } = await pipeline.getWeightedCards(10);
147
148
 
148
149
  expect(result).toHaveLength(2);
149
150
  expect(result[0].cardId).toBe('card-1');
@@ -155,7 +156,7 @@ describe('Pipeline', () => {
155
156
  const generator = new MockGenerator(cards);
156
157
  const pipeline = new Pipeline(generator, [], mockUser, mockCourse);
157
158
 
158
- const result = await pipeline.getWeightedCards(10);
159
+ const { cards: result } = await pipeline.getWeightedCards(10);
159
160
 
160
161
  expect(result[0].cardId).toBe('high');
161
162
  expect(result[1].cardId).toBe('mid');
@@ -172,7 +173,7 @@ describe('Pipeline', () => {
172
173
  const generator = new MockGenerator(cards);
173
174
  const pipeline = new Pipeline(generator, [], mockUser, mockCourse);
174
175
 
175
- const result = await pipeline.getWeightedCards(2);
176
+ const { cards: result } = await pipeline.getWeightedCards(2);
176
177
 
177
178
  expect(result).toHaveLength(2);
178
179
  expect(result[0].cardId).toBe('card-1');
@@ -183,7 +184,7 @@ describe('Pipeline', () => {
183
184
  const generator = new MockGenerator([]);
184
185
  const pipeline = new Pipeline(generator, [], mockUser, mockCourse);
185
186
 
186
- const result = await pipeline.getWeightedCards(10);
187
+ const { cards: result } = await pipeline.getWeightedCards(10);
187
188
 
188
189
  expect(result).toHaveLength(0);
189
190
  });
@@ -196,7 +197,7 @@ describe('Pipeline', () => {
196
197
  const filter = createMultiplierFilter('Half', 0.5);
197
198
  const pipeline = new Pipeline(generator, [filter], mockUser, mockCourse);
198
199
 
199
- const result = await pipeline.getWeightedCards(10);
200
+ const { cards: result } = await pipeline.getWeightedCards(10);
200
201
 
201
202
  expect(result[0].score).toBe(0.5); // 1.0 * 0.5
202
203
  expect(result[1].score).toBe(0.4); // 0.8 * 0.5
@@ -209,7 +210,7 @@ describe('Pipeline', () => {
209
210
  const filter2 = createMultiplierFilter('Double', 2.0);
210
211
  const pipeline = new Pipeline(generator, [filter1, filter2], mockUser, mockCourse);
211
212
 
212
- const result = await pipeline.getWeightedCards(10);
213
+ const { cards: result } = await pipeline.getWeightedCards(10);
213
214
 
214
215
  // 1.0 * 0.5 * 2.0 = 1.0
215
216
  expect(result[0].score).toBe(1.0);
@@ -221,7 +222,7 @@ describe('Pipeline', () => {
221
222
  const filter = createBlockingFilter('Blocker', ['block']);
222
223
  const pipeline = new Pipeline(generator, [filter], mockUser, mockCourse);
223
224
 
224
- const result = await pipeline.getWeightedCards(10);
225
+ const { cards: result } = await pipeline.getWeightedCards(10);
225
226
 
226
227
  expect(result).toHaveLength(1);
227
228
  expect(result[0].cardId).toBe('keep');
@@ -234,7 +235,7 @@ describe('Pipeline', () => {
234
235
  const filter2 = createMultiplierFilter('Filter B', 0.8);
235
236
  const pipeline = new Pipeline(generator, [filter1, filter2], mockUser, mockCourse);
236
237
 
237
- const result = await pipeline.getWeightedCards(10);
238
+ const { cards: result } = await pipeline.getWeightedCards(10);
238
239
 
239
240
  expect(result[0].provenance).toHaveLength(3); // generator + 2 filters
240
241
  expect(result[0].provenance[0].strategyName).toBe('Test Generator');
@@ -253,12 +254,12 @@ describe('Pipeline', () => {
253
254
  // Order: A then B
254
255
  const generator1 = new MockGenerator([...cards]);
255
256
  const pipeline1 = new Pipeline(generator1, [filterA, filterB], mockUser, mockCourse);
256
- const result1 = await pipeline1.getWeightedCards(10);
257
+ const { cards: result1 } = await pipeline1.getWeightedCards(10);
257
258
 
258
259
  // Order: B then A
259
260
  const generator2 = new MockGenerator([...cards]);
260
261
  const pipeline2 = new Pipeline(generator2, [filterB, filterA], mockUser, mockCourse);
261
- const result2 = await pipeline2.getWeightedCards(10);
262
+ const { cards: result2 } = await pipeline2.getWeightedCards(10);
262
263
 
263
264
  // Both should yield 1.0 * 0.5 * 0.8 = 0.4
264
265
  expect(result1[0].score).toBeCloseTo(0.4);