@vue-skuilder/db 0.1.32-b → 0.1.32-c

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 (50) hide show
  1. package/dist/core/index.d.cts +16 -12
  2. package/dist/core/index.d.ts +16 -12
  3. package/dist/core/index.js +2262 -223
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/core/index.mjs +2239 -196
  6. package/dist/core/index.mjs.map +1 -1
  7. package/dist/{contentSource-Bdwkvqa8.d.ts → dataLayerProvider-BAn-LRh5.d.ts} +626 -83
  8. package/dist/{contentSource-DF1nUbPQ.d.cts → dataLayerProvider-BJqBlMIl.d.cts} +626 -83
  9. package/dist/impl/couch/index.d.cts +17 -4
  10. package/dist/impl/couch/index.d.ts +17 -4
  11. package/dist/impl/couch/index.js +2306 -220
  12. package/dist/impl/couch/index.js.map +1 -1
  13. package/dist/impl/couch/index.mjs +2294 -204
  14. package/dist/impl/couch/index.mjs.map +1 -1
  15. package/dist/impl/static/index.d.cts +4 -5
  16. package/dist/impl/static/index.d.ts +4 -5
  17. package/dist/impl/static/index.js +2266 -227
  18. package/dist/impl/static/index.js.map +1 -1
  19. package/dist/impl/static/index.mjs +2251 -208
  20. package/dist/impl/static/index.mjs.map +1 -1
  21. package/dist/{index-BWvO-_rJ.d.ts → index-X6wHrURm.d.ts} +1 -1
  22. package/dist/{index-Ba7hYbHj.d.cts → index-m8MMGxxR.d.cts} +1 -1
  23. package/dist/index.d.cts +9 -444
  24. package/dist/index.d.ts +9 -444
  25. package/dist/index.js +9637 -8931
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +9539 -8833
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/{types-CJrLM1Ew.d.ts → types-DZ5dUqbL.d.ts} +1 -1
  30. package/dist/{types-W8n-B6HG.d.cts → types-ZL8tOPQZ.d.cts} +1 -1
  31. package/dist/{types-legacy-JXDxinpU.d.cts → types-legacy-C7r0T4OV.d.cts} +1 -1
  32. package/dist/{types-legacy-JXDxinpU.d.ts → types-legacy-C7r0T4OV.d.ts} +1 -1
  33. package/dist/util/packer/index.d.cts +3 -3
  34. package/dist/util/packer/index.d.ts +3 -3
  35. package/docs/navigators-architecture.md +2 -2
  36. package/package.json +2 -2
  37. package/src/core/interfaces/contentSource.ts +2 -1
  38. package/src/core/navigators/Pipeline.ts +47 -29
  39. package/src/core/navigators/PipelineDebugger.ts +49 -1
  40. package/src/core/navigators/filters/hierarchyDefinition.ts +88 -5
  41. package/src/core/navigators/generators/prescribed.ts +618 -43
  42. package/src/core/navigators/index.ts +2 -1
  43. package/src/impl/couch/CourseSyncService.ts +72 -4
  44. package/src/impl/couch/courseDB.ts +3 -2
  45. package/src/impl/static/courseDB.ts +3 -2
  46. package/src/study/SessionController.ts +79 -9
  47. package/src/study/services/EloService.ts +22 -3
  48. package/src/study/services/ResponseProcessor.ts +7 -3
  49. package/dist/dataLayerProvider-BKmVoyJR.d.ts +0 -67
  50. package/dist/dataLayerProvider-BQdfJuBN.d.cts +0 -67
@@ -10,21 +10,125 @@ import { logger } from '@db/util/logger';
10
10
  // PRESCRIBED CARDS GENERATOR
11
11
  // ============================================================================
12
12
  //
13
- // A generator that always emits a configured list of card IDs at score 1.0.
13
+ // A stateful generator for authored, course-prescribed content.
14
14
  //
15
- // Use case: Cold-start curriculum bootstrapping. Ensures critical cards
16
- // (e.g., intro-s, early WS exercises) are always in the candidate set
17
- // regardless of ELO proximity sampling. Filters (hierarchy, priority)
18
- // still run cards whose utility has expired get penalized normally
19
- // and drop out of the top-N selection.
15
+ // Unlike ELO/SRS, prescribed content is explicitly authored curriculum intent.
16
+ // This generator therefore tracks whether prescribed targets have actually been
17
+ // encountered by the user, applies progressive pressure to stale/pending target
18
+ // groups, and can emit upstream support cards when direct targets remain
19
+ // blocked behind prerequisite chains.
20
20
  //
21
- // Config format:
22
- // { "cardIds": ["c-intro-s-S", "c-ws-sit-abc123", ...] }
21
+ // The first intended use case is intro-card reliability:
22
+ //
23
+ // - direct targets: intro cards that must eventually surface
24
+ // - support cards: low-complexity cards that help satisfy prereqs for blocked
25
+ // intro targets
26
+ //
27
+ // Prescribed content still participates in the normal pipeline. Hierarchy,
28
+ // lesson gating, letter gating, interference, and priority filters continue to
29
+ // shape final ordering.
23
30
  //
24
31
  // ============================================================================
25
32
 
33
+ interface HierarchyWalkConfig {
34
+ enabled?: boolean;
35
+ maxDepth?: number;
36
+ }
37
+
38
+ interface PrescribedGroupConfig {
39
+ id: string;
40
+ targetCardIds: string[];
41
+ supportCardIds?: string[];
42
+ supportTagPatterns?: string[];
43
+ freshnessWindowSessions?: number;
44
+ maxDirectTargetsPerRun?: number;
45
+ maxSupportCardsPerRun?: number;
46
+ hierarchyWalk?: HierarchyWalkConfig;
47
+ retireOnEncounter?: boolean;
48
+ }
49
+
26
50
  interface PrescribedConfig {
27
- cardIds: string[];
51
+ groups: PrescribedGroupConfig[];
52
+ }
53
+
54
+ interface GroupCardState {
55
+ encounteredCardIds: string[];
56
+ lastSurfacedAt: string | null;
57
+ sessionsSinceSurfaced: number;
58
+ lastSupportAt: string | null;
59
+ blockedTargetIds: string[];
60
+ lastResolvedSupportTags: string[];
61
+ }
62
+
63
+ interface PrescribedProgressState {
64
+ updatedAt: string;
65
+ groups: Record<string, GroupCardState>;
66
+ }
67
+
68
+ interface TagPrerequisite {
69
+ tag: string;
70
+ masteryThreshold?: {
71
+ minElo?: number;
72
+ minCount?: number;
73
+ };
74
+ preReqBoost?: number;
75
+ targetBoost?: number;
76
+ }
77
+
78
+ interface HierarchyConfig {
79
+ prerequisites: Record<string, TagPrerequisite[]>;
80
+ }
81
+
82
+ interface GroupRuntimeState {
83
+ group: PrescribedGroupConfig;
84
+ encounteredTargets: Set<string>;
85
+ pendingTargets: string[];
86
+ blockedTargets: string[];
87
+ surfaceableTargets: string[];
88
+ targetTags: Map<string, string[]>;
89
+ supportCandidates: string[];
90
+ supportTags: string[];
91
+ pressureMultiplier: number;
92
+ supportMultiplier: number;
93
+ }
94
+
95
+ const DEFAULT_FRESHNESS_WINDOW = 3;
96
+ const DEFAULT_MAX_DIRECT_PER_RUN = 3;
97
+ const DEFAULT_MAX_SUPPORT_PER_RUN = 3;
98
+ const DEFAULT_HIERARCHY_DEPTH = 2;
99
+ const DEFAULT_MIN_COUNT = 3;
100
+ const BASE_TARGET_SCORE = 1.0;
101
+ const BASE_SUPPORT_SCORE = 0.8;
102
+ const MAX_TARGET_MULTIPLIER = 8.0;
103
+ const MAX_SUPPORT_MULTIPLIER = 4.0;
104
+ const LOCKED_TAG_PREFIXES = ['concept:'];
105
+ const LESSON_GATE_PENALTY_TAG_HINT = 'concept:';
106
+
107
+ function dedupe<T>(arr: T[]): T[] {
108
+ return [...new Set(arr)];
109
+ }
110
+
111
+ function isoNow(): string {
112
+ return new Date().toISOString();
113
+ }
114
+
115
+ function clamp(value: number, min: number, max: number): number {
116
+ return Math.max(min, Math.min(max, value));
117
+ }
118
+
119
+ function matchesTagPattern(tag: string, pattern: string): boolean {
120
+ if (pattern === '*') return true;
121
+ const escaped = pattern
122
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
123
+ .replace(/\*/g, '.*');
124
+ const re = new RegExp(`^${escaped}$`);
125
+ return re.test(tag);
126
+ }
127
+
128
+ function pickTopByScore(cards: WeightedCard[], limit: number): WeightedCard[] {
129
+ return [...cards]
130
+ .sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId))
131
+ .slice(0, limit);
28
132
  }
29
133
 
30
134
  export default class PrescribedCardsGenerator extends ContentNavigator implements CardGenerator {
@@ -38,58 +142,529 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
38
142
  ) {
39
143
  super(user, course, strategyData);
40
144
  this.name = strategyData.name || 'Prescribed Cards';
41
-
42
- try {
43
- const parsed = JSON.parse(strategyData.serializedData);
44
- this.config = { cardIds: parsed.cardIds || [] };
45
- } catch {
46
- this.config = { cardIds: [] };
47
- }
145
+ this.config = this.parseConfig(strategyData.serializedData);
48
146
 
49
147
  logger.debug(
50
- `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
148
+ `[Prescribed] Initialized with ${this.config.groups.length} groups and ` +
149
+ `${this.config.groups.reduce((n, g) => n + g.targetCardIds.length, 0)} targets`
51
150
  );
52
151
  }
53
152
 
54
- async getWeightedCards(limit: number, _context?: GeneratorContext): Promise<WeightedCard[]> {
55
- if (this.config.cardIds.length === 0) {
153
+ protected override get strategyKey(): string {
154
+ return 'PrescribedProgress';
155
+ }
156
+
157
+ async getWeightedCards(limit: number, context?: GeneratorContext): Promise<WeightedCard[]> {
158
+ if (this.config.groups.length === 0 || limit <= 0) {
56
159
  return [];
57
160
  }
58
161
 
59
162
  const courseId = this.course.getCourseID();
60
-
61
- // Filter out cards the user has already interacted with
62
163
  const activeCards = await this.user.getActiveCards();
63
164
  const activeIds = new Set(activeCards.map((ac) => ac.cardID));
64
- const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
65
165
 
66
- if (eligibleIds.length === 0) {
67
- logger.debug('[Prescribed] All prescribed cards already active, returning empty');
166
+ const seenCards = await this.user.getSeenCards(courseId).catch(() => []);
167
+ const seenIds = new Set(seenCards);
168
+
169
+ const progress = (await this.getStrategyState<PrescribedProgressState>()) ?? {
170
+ updatedAt: isoNow(),
171
+ groups: {},
172
+ };
173
+
174
+ const hierarchyConfigs = await this.loadHierarchyConfigs();
175
+ const courseReg = await this.user.getCourseRegDoc(courseId).catch(() => null);
176
+ const userGlobalElo =
177
+ typeof courseReg?.elo === 'number'
178
+ ? courseReg.elo
179
+ : courseReg?.elo?.global?.score ?? context?.userElo ?? 1000;
180
+ const userTagElo =
181
+ typeof courseReg?.elo === 'number'
182
+ ? {}
183
+ : courseReg?.elo?.tags ?? {};
184
+
185
+ const allTargetIds = dedupe(this.config.groups.flatMap((g) => g.targetCardIds));
186
+ const allSupportIds = dedupe(this.config.groups.flatMap((g) => g.supportCardIds ?? []));
187
+ const allRelevantIds = dedupe([...allTargetIds, ...allSupportIds]);
188
+
189
+ const tagsByCard =
190
+ allRelevantIds.length > 0
191
+ ? await this.course.getAppliedTagsBatch(allRelevantIds)
192
+ : new Map<string, string[]>();
193
+
194
+ const nextState: PrescribedProgressState = {
195
+ updatedAt: isoNow(),
196
+ groups: {},
197
+ };
198
+
199
+ const emitted: WeightedCard[] = [];
200
+ const emittedIds = new Set<string>();
201
+
202
+ for (const group of this.config.groups) {
203
+ const runtime = this.buildGroupRuntimeState({
204
+ group,
205
+ priorState: progress.groups[group.id],
206
+ activeIds,
207
+ seenIds,
208
+ tagsByCard,
209
+ hierarchyConfigs,
210
+ userTagElo,
211
+ userGlobalElo,
212
+ });
213
+
214
+ nextState.groups[group.id] = this.buildNextGroupState(runtime, progress.groups[group.id]);
215
+
216
+ const directCards = this.buildDirectTargetCards(
217
+ runtime,
218
+ courseId,
219
+ emittedIds
220
+ );
221
+ const supportCards = this.buildSupportCards(
222
+ runtime,
223
+ courseId,
224
+ emittedIds
225
+ );
226
+
227
+ emitted.push(...directCards, ...supportCards);
228
+ }
229
+
230
+ if (emitted.length === 0) {
231
+ logger.debug('[Prescribed] No prescribed targets/support emitted this run');
232
+ await this.putStrategyState(nextState).catch((e) => {
233
+ logger.debug(`[Prescribed] Failed to persist empty-state update: ${e}`);
234
+ });
68
235
  return [];
69
236
  }
70
237
 
71
- // Emit at score 1.0 — CompositeGenerator deduplicates, and if ELO
72
- // also surfaces the same card, the composite picks the higher score.
73
- const cards: WeightedCard[] = eligibleIds.slice(0, limit).map((cardId) => ({
74
- cardId,
75
- courseId,
76
- score: 1.0,
77
- provenance: [
78
- {
79
- strategy: 'prescribed',
80
- strategyName: this.strategyName || this.name,
81
- strategyId: this.strategyId || 'NAVIGATION_STRATEGY-prescribed',
82
- action: 'generated' as const,
83
- score: 1.0,
84
- reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`,
85
- },
86
- ],
87
- }));
238
+ const finalCards = pickTopByScore(emitted, limit);
239
+
240
+ const surfacedByGroup = new Map<string, { targetIds: string[]; supportIds: string[] }>();
241
+ for (const card of finalCards) {
242
+ const prov = card.provenance[0];
243
+ const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
244
+ const mode = prov?.reason.includes('mode=support') ? 'supportIds' : 'targetIds';
245
+ if (!groupId) continue;
246
+ if (!surfacedByGroup.has(groupId)) {
247
+ surfacedByGroup.set(groupId, { targetIds: [], supportIds: [] });
248
+ }
249
+ surfacedByGroup.get(groupId)![mode].push(card.cardId);
250
+ }
251
+
252
+ for (const group of this.config.groups) {
253
+ const groupState = nextState.groups[group.id];
254
+ const surfaced = surfacedByGroup.get(group.id);
255
+ if (surfaced && (surfaced.targetIds.length > 0 || surfaced.supportIds.length > 0)) {
256
+ groupState.lastSurfacedAt = isoNow();
257
+ groupState.sessionsSinceSurfaced = 0;
258
+ if (surfaced.supportIds.length > 0) {
259
+ groupState.lastSupportAt = isoNow();
260
+ }
261
+ }
262
+ }
263
+
264
+ await this.putStrategyState(nextState).catch((e) => {
265
+ logger.debug(`[Prescribed] Failed to persist prescribed progress: ${e}`);
266
+ });
88
267
 
89
268
  logger.info(
90
- `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
269
+ `[Prescribed] Emitting ${finalCards.length} cards ` +
270
+ `(${finalCards.filter((c) => c.provenance[0]?.reason.includes('mode=target')).length} target, ` +
271
+ `${finalCards.filter((c) => c.provenance[0]?.reason.includes('mode=support')).length} support)`
91
272
  );
92
273
 
274
+ return finalCards;
275
+ }
276
+
277
+ private parseConfig(serializedData: string): PrescribedConfig {
278
+ try {
279
+ const parsed = JSON.parse(serializedData);
280
+ const groupsRaw = Array.isArray(parsed.groups) ? parsed.groups : [];
281
+ const groups: PrescribedGroupConfig[] = groupsRaw
282
+ .map((raw: any, i: number) => ({
283
+ id: typeof raw.id === 'string' && raw.id.trim().length > 0 ? raw.id : `group-${i + 1}`,
284
+ targetCardIds: dedupe(Array.isArray(raw.targetCardIds) ? raw.targetCardIds.filter((v: unknown) => typeof v === 'string') : []),
285
+ supportCardIds: dedupe(Array.isArray(raw.supportCardIds) ? raw.supportCardIds.filter((v: unknown) => typeof v === 'string') : []),
286
+ supportTagPatterns: dedupe(Array.isArray(raw.supportTagPatterns) ? raw.supportTagPatterns.filter((v: unknown) => typeof v === 'string') : []),
287
+ freshnessWindowSessions:
288
+ typeof raw.freshnessWindowSessions === 'number' ? raw.freshnessWindowSessions : DEFAULT_FRESHNESS_WINDOW,
289
+ maxDirectTargetsPerRun:
290
+ typeof raw.maxDirectTargetsPerRun === 'number' ? raw.maxDirectTargetsPerRun : DEFAULT_MAX_DIRECT_PER_RUN,
291
+ maxSupportCardsPerRun:
292
+ typeof raw.maxSupportCardsPerRun === 'number' ? raw.maxSupportCardsPerRun : DEFAULT_MAX_SUPPORT_PER_RUN,
293
+ hierarchyWalk: {
294
+ enabled: raw.hierarchyWalk?.enabled !== false,
295
+ maxDepth:
296
+ typeof raw.hierarchyWalk?.maxDepth === 'number'
297
+ ? raw.hierarchyWalk.maxDepth
298
+ : DEFAULT_HIERARCHY_DEPTH,
299
+ },
300
+ retireOnEncounter: raw.retireOnEncounter !== false,
301
+ }))
302
+ .filter((g) => g.targetCardIds.length > 0);
303
+
304
+ return { groups };
305
+ } catch {
306
+ return { groups: [] };
307
+ }
308
+ }
309
+
310
+ private async loadHierarchyConfigs(): Promise<HierarchyConfig[]> {
311
+ try {
312
+ const strategies = await this.course.getNavigationStrategies();
313
+ return strategies
314
+ .filter((s) => s.implementingClass === 'hierarchyDefinition')
315
+ .map((s) => {
316
+ try {
317
+ const parsed = JSON.parse(s.serializedData);
318
+ return {
319
+ prerequisites: parsed.prerequisites || {},
320
+ } as HierarchyConfig;
321
+ } catch {
322
+ return { prerequisites: {} };
323
+ }
324
+ });
325
+ } catch (e) {
326
+ logger.debug(`[Prescribed] Failed to load hierarchy configs: ${e}`);
327
+ return [];
328
+ }
329
+ }
330
+
331
+ private buildGroupRuntimeState(args: {
332
+ group: PrescribedGroupConfig;
333
+ priorState?: GroupCardState;
334
+ activeIds: Set<string>;
335
+ seenIds: Set<string>;
336
+ tagsByCard: Map<string, string[]>;
337
+ hierarchyConfigs: HierarchyConfig[];
338
+ userTagElo: Record<string, { score: number; count: number }>;
339
+ userGlobalElo: number;
340
+ }): GroupRuntimeState {
341
+ const {
342
+ group,
343
+ priorState,
344
+ activeIds,
345
+ seenIds,
346
+ tagsByCard,
347
+ hierarchyConfigs,
348
+ userTagElo,
349
+ userGlobalElo,
350
+ } = args;
351
+
352
+ const encounteredTargets = new Set<string>();
353
+ for (const cardId of group.targetCardIds) {
354
+ if (activeIds.has(cardId) || seenIds.has(cardId)) {
355
+ encounteredTargets.add(cardId);
356
+ }
357
+ }
358
+
359
+ if (priorState?.encounteredCardIds?.length) {
360
+ for (const cardId of priorState.encounteredCardIds) {
361
+ encounteredTargets.add(cardId);
362
+ }
363
+ }
364
+
365
+ const pendingTargets = group.targetCardIds.filter((id) => !encounteredTargets.has(id));
366
+ const targetTags = new Map<string, string[]>();
367
+ for (const cardId of pendingTargets) {
368
+ targetTags.set(cardId, tagsByCard.get(cardId) ?? []);
369
+ }
370
+
371
+ const blockedTargets: string[] = [];
372
+ const surfaceableTargets: string[] = [];
373
+ const supportTags = new Set<string>();
374
+
375
+ for (const cardId of pendingTargets) {
376
+ const tags = targetTags.get(cardId) ?? [];
377
+ const resolution = this.resolveBlockedSupportTags(
378
+ tags,
379
+ hierarchyConfigs,
380
+ userTagElo,
381
+ userGlobalElo,
382
+ group.hierarchyWalk?.enabled !== false,
383
+ group.hierarchyWalk?.maxDepth ?? DEFAULT_HIERARCHY_DEPTH
384
+ );
385
+
386
+ if (resolution.blocked) {
387
+ blockedTargets.push(cardId);
388
+ resolution.supportTags.forEach((t) => supportTags.add(t));
389
+ } else {
390
+ surfaceableTargets.push(cardId);
391
+ }
392
+ }
393
+
394
+ const supportCandidates = dedupe([
395
+ ...(group.supportCardIds ?? []),
396
+ ...this.findSupportCardsByTags(
397
+ group,
398
+ tagsByCard,
399
+ [...supportTags]
400
+ ),
401
+ ]).filter((id) => !activeIds.has(id) && !seenIds.has(id));
402
+
403
+ const sessionsSinceSurfaced = priorState?.sessionsSinceSurfaced ?? 0;
404
+ const freshnessWindow = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
405
+ const staleSessions = Math.max(0, sessionsSinceSurfaced - freshnessWindow);
406
+
407
+ const pressureMultiplier = pendingTargets.length === 0
408
+ ? 1.0
409
+ : clamp(1 + staleSessions * 0.75 + Math.min(2, pendingTargets.length * 0.1), 1.0, MAX_TARGET_MULTIPLIER);
410
+
411
+ const supportMultiplier = blockedTargets.length === 0
412
+ ? 1.0
413
+ : clamp(1 + staleSessions * 0.5 + Math.min(1.5, blockedTargets.length * 0.15), 1.0, MAX_SUPPORT_MULTIPLIER);
414
+
415
+ return {
416
+ group,
417
+ encounteredTargets,
418
+ pendingTargets,
419
+ blockedTargets,
420
+ surfaceableTargets,
421
+ targetTags,
422
+ supportCandidates,
423
+ supportTags: [...supportTags],
424
+ pressureMultiplier,
425
+ supportMultiplier,
426
+ };
427
+ }
428
+
429
+ private buildNextGroupState(runtime: GroupRuntimeState, prior?: GroupCardState): GroupCardState {
430
+ const carriedSessions = prior?.sessionsSinceSurfaced ?? 0;
431
+ const surfacedThisRun = false;
432
+
433
+ return {
434
+ encounteredCardIds: [...runtime.encounteredTargets].sort(),
435
+ lastSurfacedAt: prior?.lastSurfacedAt ?? null,
436
+ sessionsSinceSurfaced: surfacedThisRun ? 0 : carriedSessions + 1,
437
+ lastSupportAt: prior?.lastSupportAt ?? null,
438
+ blockedTargetIds: [...runtime.blockedTargets].sort(),
439
+ lastResolvedSupportTags: [...runtime.supportTags].sort(),
440
+ };
441
+ }
442
+
443
+ private buildDirectTargetCards(
444
+ runtime: GroupRuntimeState,
445
+ courseId: string,
446
+ emittedIds: Set<string>
447
+ ): WeightedCard[] {
448
+ const maxDirect = runtime.group.maxDirectTargetsPerRun ?? DEFAULT_MAX_DIRECT_PER_RUN;
449
+
450
+ const directIds = runtime.surfaceableTargets
451
+ .filter((id) => !emittedIds.has(id))
452
+ .slice(0, maxDirect);
453
+
454
+ const cards: WeightedCard[] = [];
455
+ for (const cardId of directIds) {
456
+ emittedIds.add(cardId);
457
+ cards.push({
458
+ cardId,
459
+ courseId,
460
+ score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
461
+ provenance: [
462
+ {
463
+ strategy: 'prescribed',
464
+ strategyName: this.strategyName || this.name,
465
+ strategyId: this.strategyId || 'NAVIGATION_STRATEGY-prescribed',
466
+ action: 'generated' as const,
467
+ score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
468
+ reason:
469
+ `mode=target;group=${runtime.group.id};pending=${runtime.pendingTargets.length};` +
470
+ `surfaceable=${runtime.surfaceableTargets.length};blocked=${runtime.blockedTargets.length};` +
471
+ `multiplier=${runtime.pressureMultiplier.toFixed(2)}`,
472
+ },
473
+ ],
474
+ });
475
+ }
476
+
93
477
  return cards;
94
478
  }
95
- }
479
+
480
+ private buildSupportCards(
481
+ runtime: GroupRuntimeState,
482
+ courseId: string,
483
+ emittedIds: Set<string>
484
+ ): WeightedCard[] {
485
+ if (runtime.blockedTargets.length === 0 || runtime.supportCandidates.length === 0) {
486
+ return [];
487
+ }
488
+
489
+ const maxSupport = runtime.group.maxSupportCardsPerRun ?? DEFAULT_MAX_SUPPORT_PER_RUN;
490
+ const supportIds = runtime.supportCandidates
491
+ .filter((id) => !emittedIds.has(id))
492
+ .slice(0, maxSupport);
493
+
494
+ const cards: WeightedCard[] = [];
495
+ for (const cardId of supportIds) {
496
+ emittedIds.add(cardId);
497
+ cards.push({
498
+ cardId,
499
+ courseId,
500
+ score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
501
+ provenance: [
502
+ {
503
+ strategy: 'prescribed',
504
+ strategyName: this.strategyName || this.name,
505
+ strategyId: this.strategyId || 'NAVIGATION_STRATEGY-prescribed',
506
+ action: 'generated' as const,
507
+ score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
508
+ reason:
509
+ `mode=support;group=${runtime.group.id};blocked=${runtime.blockedTargets.length};` +
510
+ `supportTags=${runtime.supportTags.join('|') || 'none'};` +
511
+ `multiplier=${runtime.supportMultiplier.toFixed(2)}`,
512
+ },
513
+ ],
514
+ });
515
+ }
516
+
517
+ return cards;
518
+ }
519
+
520
+ private findSupportCardsByTags(
521
+ group: PrescribedGroupConfig,
522
+ tagsByCard: Map<string, string[]>,
523
+ supportTags: string[]
524
+ ): string[] {
525
+ if (supportTags.length === 0) {
526
+ return [];
527
+ }
528
+
529
+ const explicitSupportIds = group.supportCardIds ?? [];
530
+ const explicitPatterns = group.supportTagPatterns ?? [];
531
+ if (explicitSupportIds.length === 0 && explicitPatterns.length === 0) {
532
+ return [];
533
+ }
534
+
535
+ const candidates = new Set<string>();
536
+
537
+ for (const cardId of explicitSupportIds) {
538
+ const cardTags = tagsByCard.get(cardId) ?? [];
539
+ const matchesResolved = supportTags.some((supportTag) => cardTags.includes(supportTag));
540
+ const matchesPattern = explicitPatterns.some((pattern) =>
541
+ cardTags.some((tag) => matchesTagPattern(tag, pattern))
542
+ );
543
+
544
+ if (matchesResolved || matchesPattern) {
545
+ candidates.add(cardId);
546
+ }
547
+ }
548
+
549
+ return [...candidates];
550
+ }
551
+
552
+ private resolveBlockedSupportTags(
553
+ targetTags: string[],
554
+ hierarchyConfigs: HierarchyConfig[],
555
+ userTagElo: Record<string, { score: number; count: number }>,
556
+ userGlobalElo: number,
557
+ hierarchyWalkEnabled: boolean,
558
+ maxDepth: number
559
+ ): { blocked: boolean; supportTags: string[] } {
560
+ if (!hierarchyWalkEnabled || targetTags.length === 0 || hierarchyConfigs.length === 0) {
561
+ return {
562
+ blocked: false,
563
+ supportTags: [],
564
+ };
565
+ }
566
+
567
+ const supportTags = new Set<string>();
568
+ let blocked = false;
569
+
570
+ for (const targetTag of targetTags) {
571
+ for (const hierarchy of hierarchyConfigs) {
572
+ const prereqs = hierarchy.prerequisites[targetTag];
573
+ if (!prereqs || prereqs.length === 0) continue;
574
+
575
+ const unmet = prereqs.filter(
576
+ (pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
577
+ );
578
+
579
+ if (unmet.length === 0) {
580
+ continue;
581
+ }
582
+
583
+ blocked = true;
584
+ for (const prereq of unmet) {
585
+ this.collectSupportTagsRecursive(
586
+ prereq.tag,
587
+ hierarchyConfigs,
588
+ userTagElo,
589
+ userGlobalElo,
590
+ maxDepth,
591
+ new Set<string>(),
592
+ supportTags
593
+ );
594
+ }
595
+ }
596
+ }
597
+
598
+ return { blocked, supportTags: [...supportTags] };
599
+ }
600
+
601
+ private collectSupportTagsRecursive(
602
+ tag: string,
603
+ hierarchyConfigs: HierarchyConfig[],
604
+ userTagElo: Record<string, { score: number; count: number }>,
605
+ userGlobalElo: number,
606
+ depth: number,
607
+ visited: Set<string>,
608
+ out: Set<string>
609
+ ): void {
610
+ if (depth < 0 || visited.has(tag)) return;
611
+ if (this.isHardGatedTag(tag)) return;
612
+
613
+ visited.add(tag);
614
+
615
+ let walkedFurther = false;
616
+
617
+ for (const hierarchy of hierarchyConfigs) {
618
+ const prereqs = hierarchy.prerequisites[tag];
619
+ if (!prereqs || prereqs.length === 0) continue;
620
+
621
+ const unmet = prereqs.filter(
622
+ (pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
623
+ );
624
+
625
+ if (unmet.length > 0 && depth > 0) {
626
+ walkedFurther = true;
627
+ for (const prereq of unmet) {
628
+ this.collectSupportTagsRecursive(
629
+ prereq.tag,
630
+ hierarchyConfigs,
631
+ userTagElo,
632
+ userGlobalElo,
633
+ depth - 1,
634
+ visited,
635
+ out
636
+ );
637
+ }
638
+ }
639
+ }
640
+
641
+ if (!walkedFurther) {
642
+ out.add(tag);
643
+ }
644
+ }
645
+
646
+ private isHardGatedTag(tag: string): boolean {
647
+ return LOCKED_TAG_PREFIXES.some((prefix) => tag.startsWith(prefix)) &&
648
+ tag.startsWith(LESSON_GATE_PENALTY_TAG_HINT);
649
+ }
650
+
651
+ private isPrerequisiteMet(
652
+ prereq: TagPrerequisite,
653
+ userTagElo: { score: number; count: number } | undefined,
654
+ userGlobalElo: number
655
+ ): boolean {
656
+ if (!userTagElo) return false;
657
+
658
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
659
+ if (userTagElo.count < minCount) return false;
660
+
661
+ if (prereq.masteryThreshold?.minElo !== undefined) {
662
+ return userTagElo.score >= prereq.masteryThreshold.minElo;
663
+ }
664
+ if (prereq.masteryThreshold?.minCount !== undefined) {
665
+ return true;
666
+ }
667
+
668
+ return userTagElo.score >= userGlobalElo;
669
+ }
670
+ }