@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.
- package/dist/core/index.d.cts +16 -12
- package/dist/core/index.d.ts +16 -12
- package/dist/core/index.js +2262 -223
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +2239 -196
- package/dist/core/index.mjs.map +1 -1
- package/dist/{contentSource-Bdwkvqa8.d.ts → dataLayerProvider-BAn-LRh5.d.ts} +626 -83
- package/dist/{contentSource-DF1nUbPQ.d.cts → dataLayerProvider-BJqBlMIl.d.cts} +626 -83
- package/dist/impl/couch/index.d.cts +17 -4
- package/dist/impl/couch/index.d.ts +17 -4
- package/dist/impl/couch/index.js +2306 -220
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +2294 -204
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +4 -5
- package/dist/impl/static/index.d.ts +4 -5
- package/dist/impl/static/index.js +2266 -227
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +2251 -208
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-BWvO-_rJ.d.ts → index-X6wHrURm.d.ts} +1 -1
- package/dist/{index-Ba7hYbHj.d.cts → index-m8MMGxxR.d.cts} +1 -1
- package/dist/index.d.cts +9 -444
- package/dist/index.d.ts +9 -444
- package/dist/index.js +9637 -8931
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +9539 -8833
- package/dist/index.mjs.map +1 -1
- package/dist/{types-CJrLM1Ew.d.ts → types-DZ5dUqbL.d.ts} +1 -1
- package/dist/{types-W8n-B6HG.d.cts → types-ZL8tOPQZ.d.cts} +1 -1
- package/dist/{types-legacy-JXDxinpU.d.cts → types-legacy-C7r0T4OV.d.cts} +1 -1
- package/dist/{types-legacy-JXDxinpU.d.ts → types-legacy-C7r0T4OV.d.ts} +1 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/navigators-architecture.md +2 -2
- package/package.json +2 -2
- package/src/core/interfaces/contentSource.ts +2 -1
- package/src/core/navigators/Pipeline.ts +47 -29
- package/src/core/navigators/PipelineDebugger.ts +49 -1
- package/src/core/navigators/filters/hierarchyDefinition.ts +88 -5
- package/src/core/navigators/generators/prescribed.ts +618 -43
- package/src/core/navigators/index.ts +2 -1
- package/src/impl/couch/CourseSyncService.ts +72 -4
- package/src/impl/couch/courseDB.ts +3 -2
- package/src/impl/static/courseDB.ts +3 -2
- package/src/study/SessionController.ts +79 -9
- package/src/study/services/EloService.ts +22 -3
- package/src/study/services/ResponseProcessor.ts +7 -3
- package/dist/dataLayerProvider-BKmVoyJR.d.ts +0 -67
- 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
|
|
13
|
+
// A stateful generator for authored, course-prescribed content.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
//
|
|
22
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 ${
|
|
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
|
+
}
|