@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
@@ -529,13 +529,20 @@ function captureRun(report) {
529
529
  runHistory.pop();
530
530
  }
531
531
  }
532
- function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards) {
532
+ function parseCardElo(provenance) {
533
+ const eloEntry = provenance.find((p) => p.strategy === "elo");
534
+ if (!eloEntry?.reason) return void 0;
535
+ const match = eloEntry.reason.match(/card:\s*(\d+)/);
536
+ return match ? parseInt(match[1], 10) : void 0;
537
+ }
538
+ function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards, userElo) {
533
539
  const selectedIds = new Set(selectedCards.map((c) => c.cardId));
534
540
  const cards = allCards.map((card) => ({
535
541
  cardId: card.cardId,
536
542
  courseId: card.courseId,
537
543
  origin: getOrigin(card),
538
544
  finalScore: card.score,
545
+ cardElo: parseCardElo(card.provenance),
539
546
  provenance: card.provenance,
540
547
  tags: card.tags,
541
548
  selected: selectedIds.has(card.cardId)
@@ -545,6 +552,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
545
552
  return {
546
553
  courseId,
547
554
  courseName,
555
+ userElo,
548
556
  generatorName,
549
557
  generators,
550
558
  generatedCount,
@@ -565,6 +573,7 @@ function printRunSummary(run) {
565
573
  console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
566
574
  logger.info(`Run ID: ${run.runId}`);
567
575
  logger.info(`Time: ${run.timestamp.toISOString()}`);
576
+ logger.info(`User ELO: ${run.userElo ?? "unknown"}`);
568
577
  logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
569
578
  if (run.generators && run.generators.length > 0) {
570
579
  console.group("Generator breakdown:");
@@ -651,8 +660,12 @@ var init_PipelineDebugger = __esm({
651
660
  console.group(`\u{1F3B4} Card: ${cardId}`);
652
661
  logger.info(`Course: ${card.courseId}`);
653
662
  logger.info(`Origin: ${card.origin}`);
663
+ logger.info(`Card ELO: ${card.cardElo ?? "unknown"} | User ELO: ${run.userElo ?? "unknown"}`);
654
664
  logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
655
665
  logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
666
+ if (card.tags && card.tags.length > 0) {
667
+ logger.info(`Tags (${card.tags.length}): ${card.tags.join(", ")}`);
668
+ }
656
669
  logger.info("Provenance:");
657
670
  logger.info(formatProvenance(card.provenance));
658
671
  console.groupEnd();
@@ -816,6 +829,27 @@ var init_PipelineDebugger = __esm({
816
829
  }
817
830
  return _activePipeline.diagnoseCardSpace({ threshold });
818
831
  },
832
+ /**
833
+ * Show user's per-tag ELO data. Useful for diagnosing hierarchy gate status.
834
+ *
835
+ * @param tagFilter - Optional glob pattern(s) to filter tags.
836
+ * Examples: 'gpc:expose:*', 'gpc:intro:t-T', ['gpc:expose:t-*', 'gpc:intro:t-*']
837
+ */
838
+ async showTagElo(tagFilter) {
839
+ if (!_activePipeline) {
840
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
841
+ return;
842
+ }
843
+ const status = await _activePipeline.getTagEloStatus(tagFilter);
844
+ const entries = Object.entries(status).sort(([a], [b]) => a.localeCompare(b));
845
+ if (entries.length === 0) {
846
+ logger.info(`[Pipeline Debug] No tag ELO data found${tagFilter ? ` for pattern: ${tagFilter}` : ""}.`);
847
+ return;
848
+ }
849
+ console.table(
850
+ Object.fromEntries(entries.map(([tag, data]) => [tag, { score: Math.round(data.score), count: data.count }]))
851
+ );
852
+ },
819
853
  /**
820
854
  * Show help.
821
855
  */
@@ -827,6 +861,7 @@ Commands:
827
861
  .showLastRun() Show summary of most recent pipeline run
828
862
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
829
863
  .showCard(cardId) Show provenance trail for a specific card
864
+ .showTagElo(pattern) Show user's tag ELO data (async). E.g. 'gpc:expose:*'
830
865
  .explainReviews() Analyze why reviews were/weren't selected
831
866
  .diagnoseCardSpace() Scan full card space through filters (async)
832
867
  .showRegistry() Show navigator registry (classes + roles)
@@ -1140,60 +1175,423 @@ var prescribed_exports = {};
1140
1175
  __export(prescribed_exports, {
1141
1176
  default: () => PrescribedCardsGenerator
1142
1177
  });
1143
- var PrescribedCardsGenerator;
1178
+ function dedupe(arr) {
1179
+ return [...new Set(arr)];
1180
+ }
1181
+ function isoNow() {
1182
+ return (/* @__PURE__ */ new Date()).toISOString();
1183
+ }
1184
+ function clamp(value, min, max) {
1185
+ return Math.max(min, Math.min(max, value));
1186
+ }
1187
+ function matchesTagPattern(tag, pattern) {
1188
+ if (pattern === "*") return true;
1189
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1190
+ const re = new RegExp(`^${escaped}$`);
1191
+ return re.test(tag);
1192
+ }
1193
+ function pickTopByScore(cards, limit) {
1194
+ return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1195
+ }
1196
+ var DEFAULT_FRESHNESS_WINDOW, DEFAULT_MAX_DIRECT_PER_RUN, DEFAULT_MAX_SUPPORT_PER_RUN, DEFAULT_HIERARCHY_DEPTH, DEFAULT_MIN_COUNT, BASE_TARGET_SCORE, BASE_SUPPORT_SCORE, MAX_TARGET_MULTIPLIER, MAX_SUPPORT_MULTIPLIER, LOCKED_TAG_PREFIXES, LESSON_GATE_PENALTY_TAG_HINT, PrescribedCardsGenerator;
1144
1197
  var init_prescribed = __esm({
1145
1198
  "src/core/navigators/generators/prescribed.ts"() {
1146
1199
  "use strict";
1147
1200
  init_navigators();
1148
1201
  init_logger();
1202
+ DEFAULT_FRESHNESS_WINDOW = 3;
1203
+ DEFAULT_MAX_DIRECT_PER_RUN = 3;
1204
+ DEFAULT_MAX_SUPPORT_PER_RUN = 3;
1205
+ DEFAULT_HIERARCHY_DEPTH = 2;
1206
+ DEFAULT_MIN_COUNT = 3;
1207
+ BASE_TARGET_SCORE = 1;
1208
+ BASE_SUPPORT_SCORE = 0.8;
1209
+ MAX_TARGET_MULTIPLIER = 8;
1210
+ MAX_SUPPORT_MULTIPLIER = 4;
1211
+ LOCKED_TAG_PREFIXES = ["concept:"];
1212
+ LESSON_GATE_PENALTY_TAG_HINT = "concept:";
1149
1213
  PrescribedCardsGenerator = class extends ContentNavigator {
1150
1214
  name;
1151
1215
  config;
1152
1216
  constructor(user, course, strategyData) {
1153
1217
  super(user, course, strategyData);
1154
1218
  this.name = strategyData.name || "Prescribed Cards";
1155
- try {
1156
- const parsed = JSON.parse(strategyData.serializedData);
1157
- this.config = { cardIds: parsed.cardIds || [] };
1158
- } catch {
1159
- this.config = { cardIds: [] };
1160
- }
1219
+ this.config = this.parseConfig(strategyData.serializedData);
1161
1220
  logger.debug(
1162
- `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1221
+ `[Prescribed] Initialized with ${this.config.groups.length} groups and ${this.config.groups.reduce((n, g) => n + g.targetCardIds.length, 0)} targets`
1163
1222
  );
1164
1223
  }
1165
- async getWeightedCards(limit, _context) {
1166
- if (this.config.cardIds.length === 0) {
1224
+ get strategyKey() {
1225
+ return "PrescribedProgress";
1226
+ }
1227
+ async getWeightedCards(limit, context) {
1228
+ if (this.config.groups.length === 0 || limit <= 0) {
1167
1229
  return [];
1168
1230
  }
1169
1231
  const courseId = this.course.getCourseID();
1170
1232
  const activeCards = await this.user.getActiveCards();
1171
1233
  const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1172
- const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1173
- if (eligibleIds.length === 0) {
1174
- logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1234
+ const seenCards = await this.user.getSeenCards(courseId).catch(() => []);
1235
+ const seenIds = new Set(seenCards);
1236
+ const progress = await this.getStrategyState() ?? {
1237
+ updatedAt: isoNow(),
1238
+ groups: {}
1239
+ };
1240
+ const hierarchyConfigs = await this.loadHierarchyConfigs();
1241
+ const courseReg = await this.user.getCourseRegDoc(courseId).catch(() => null);
1242
+ const userGlobalElo = typeof courseReg?.elo === "number" ? courseReg.elo : courseReg?.elo?.global?.score ?? context?.userElo ?? 1e3;
1243
+ const userTagElo = typeof courseReg?.elo === "number" ? {} : courseReg?.elo?.tags ?? {};
1244
+ const allTargetIds = dedupe(this.config.groups.flatMap((g) => g.targetCardIds));
1245
+ const allSupportIds = dedupe(this.config.groups.flatMap((g) => g.supportCardIds ?? []));
1246
+ const allRelevantIds = dedupe([...allTargetIds, ...allSupportIds]);
1247
+ const tagsByCard = allRelevantIds.length > 0 ? await this.course.getAppliedTagsBatch(allRelevantIds) : /* @__PURE__ */ new Map();
1248
+ const nextState = {
1249
+ updatedAt: isoNow(),
1250
+ groups: {}
1251
+ };
1252
+ const emitted = [];
1253
+ const emittedIds = /* @__PURE__ */ new Set();
1254
+ for (const group of this.config.groups) {
1255
+ const runtime = this.buildGroupRuntimeState({
1256
+ group,
1257
+ priorState: progress.groups[group.id],
1258
+ activeIds,
1259
+ seenIds,
1260
+ tagsByCard,
1261
+ hierarchyConfigs,
1262
+ userTagElo,
1263
+ userGlobalElo
1264
+ });
1265
+ nextState.groups[group.id] = this.buildNextGroupState(runtime, progress.groups[group.id]);
1266
+ const directCards = this.buildDirectTargetCards(
1267
+ runtime,
1268
+ courseId,
1269
+ emittedIds
1270
+ );
1271
+ const supportCards = this.buildSupportCards(
1272
+ runtime,
1273
+ courseId,
1274
+ emittedIds
1275
+ );
1276
+ emitted.push(...directCards, ...supportCards);
1277
+ }
1278
+ if (emitted.length === 0) {
1279
+ logger.debug("[Prescribed] No prescribed targets/support emitted this run");
1280
+ await this.putStrategyState(nextState).catch((e) => {
1281
+ logger.debug(`[Prescribed] Failed to persist empty-state update: ${e}`);
1282
+ });
1175
1283
  return [];
1176
1284
  }
1177
- const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1178
- cardId,
1179
- courseId,
1180
- score: 1,
1181
- provenance: [
1182
- {
1183
- strategy: "prescribed",
1184
- strategyName: this.strategyName || this.name,
1185
- strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1186
- action: "generated",
1187
- score: 1,
1188
- reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1285
+ const finalCards = pickTopByScore(emitted, limit);
1286
+ const surfacedByGroup = /* @__PURE__ */ new Map();
1287
+ for (const card of finalCards) {
1288
+ const prov = card.provenance[0];
1289
+ const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
1290
+ const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
1291
+ if (!groupId) continue;
1292
+ if (!surfacedByGroup.has(groupId)) {
1293
+ surfacedByGroup.set(groupId, { targetIds: [], supportIds: [] });
1294
+ }
1295
+ surfacedByGroup.get(groupId)[mode].push(card.cardId);
1296
+ }
1297
+ for (const group of this.config.groups) {
1298
+ const groupState = nextState.groups[group.id];
1299
+ const surfaced = surfacedByGroup.get(group.id);
1300
+ if (surfaced && (surfaced.targetIds.length > 0 || surfaced.supportIds.length > 0)) {
1301
+ groupState.lastSurfacedAt = isoNow();
1302
+ groupState.sessionsSinceSurfaced = 0;
1303
+ if (surfaced.supportIds.length > 0) {
1304
+ groupState.lastSupportAt = isoNow();
1189
1305
  }
1190
- ]
1191
- }));
1306
+ }
1307
+ }
1308
+ await this.putStrategyState(nextState).catch((e) => {
1309
+ logger.debug(`[Prescribed] Failed to persist prescribed progress: ${e}`);
1310
+ });
1192
1311
  logger.info(
1193
- `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1312
+ `[Prescribed] Emitting ${finalCards.length} cards (${finalCards.filter((c) => c.provenance[0]?.reason.includes("mode=target")).length} target, ${finalCards.filter((c) => c.provenance[0]?.reason.includes("mode=support")).length} support)`
1194
1313
  );
1314
+ return finalCards;
1315
+ }
1316
+ parseConfig(serializedData) {
1317
+ try {
1318
+ const parsed = JSON.parse(serializedData);
1319
+ const groupsRaw = Array.isArray(parsed.groups) ? parsed.groups : [];
1320
+ const groups = groupsRaw.map((raw, i) => ({
1321
+ id: typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : `group-${i + 1}`,
1322
+ targetCardIds: dedupe(Array.isArray(raw.targetCardIds) ? raw.targetCardIds.filter((v) => typeof v === "string") : []),
1323
+ supportCardIds: dedupe(Array.isArray(raw.supportCardIds) ? raw.supportCardIds.filter((v) => typeof v === "string") : []),
1324
+ supportTagPatterns: dedupe(Array.isArray(raw.supportTagPatterns) ? raw.supportTagPatterns.filter((v) => typeof v === "string") : []),
1325
+ freshnessWindowSessions: typeof raw.freshnessWindowSessions === "number" ? raw.freshnessWindowSessions : DEFAULT_FRESHNESS_WINDOW,
1326
+ maxDirectTargetsPerRun: typeof raw.maxDirectTargetsPerRun === "number" ? raw.maxDirectTargetsPerRun : DEFAULT_MAX_DIRECT_PER_RUN,
1327
+ maxSupportCardsPerRun: typeof raw.maxSupportCardsPerRun === "number" ? raw.maxSupportCardsPerRun : DEFAULT_MAX_SUPPORT_PER_RUN,
1328
+ hierarchyWalk: {
1329
+ enabled: raw.hierarchyWalk?.enabled !== false,
1330
+ maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
1331
+ },
1332
+ retireOnEncounter: raw.retireOnEncounter !== false
1333
+ })).filter((g) => g.targetCardIds.length > 0);
1334
+ return { groups };
1335
+ } catch {
1336
+ return { groups: [] };
1337
+ }
1338
+ }
1339
+ async loadHierarchyConfigs() {
1340
+ try {
1341
+ const strategies = await this.course.getNavigationStrategies();
1342
+ return strategies.filter((s) => s.implementingClass === "hierarchyDefinition").map((s) => {
1343
+ try {
1344
+ const parsed = JSON.parse(s.serializedData);
1345
+ return {
1346
+ prerequisites: parsed.prerequisites || {}
1347
+ };
1348
+ } catch {
1349
+ return { prerequisites: {} };
1350
+ }
1351
+ });
1352
+ } catch (e) {
1353
+ logger.debug(`[Prescribed] Failed to load hierarchy configs: ${e}`);
1354
+ return [];
1355
+ }
1356
+ }
1357
+ buildGroupRuntimeState(args) {
1358
+ const {
1359
+ group,
1360
+ priorState,
1361
+ activeIds,
1362
+ seenIds,
1363
+ tagsByCard,
1364
+ hierarchyConfigs,
1365
+ userTagElo,
1366
+ userGlobalElo
1367
+ } = args;
1368
+ const encounteredTargets = /* @__PURE__ */ new Set();
1369
+ for (const cardId of group.targetCardIds) {
1370
+ if (activeIds.has(cardId) || seenIds.has(cardId)) {
1371
+ encounteredTargets.add(cardId);
1372
+ }
1373
+ }
1374
+ if (priorState?.encounteredCardIds?.length) {
1375
+ for (const cardId of priorState.encounteredCardIds) {
1376
+ encounteredTargets.add(cardId);
1377
+ }
1378
+ }
1379
+ const pendingTargets = group.targetCardIds.filter((id) => !encounteredTargets.has(id));
1380
+ const targetTags = /* @__PURE__ */ new Map();
1381
+ for (const cardId of pendingTargets) {
1382
+ targetTags.set(cardId, tagsByCard.get(cardId) ?? []);
1383
+ }
1384
+ const blockedTargets = [];
1385
+ const surfaceableTargets = [];
1386
+ const supportTags = /* @__PURE__ */ new Set();
1387
+ for (const cardId of pendingTargets) {
1388
+ const tags = targetTags.get(cardId) ?? [];
1389
+ const resolution = this.resolveBlockedSupportTags(
1390
+ tags,
1391
+ hierarchyConfigs,
1392
+ userTagElo,
1393
+ userGlobalElo,
1394
+ group.hierarchyWalk?.enabled !== false,
1395
+ group.hierarchyWalk?.maxDepth ?? DEFAULT_HIERARCHY_DEPTH
1396
+ );
1397
+ if (resolution.blocked) {
1398
+ blockedTargets.push(cardId);
1399
+ resolution.supportTags.forEach((t) => supportTags.add(t));
1400
+ } else {
1401
+ surfaceableTargets.push(cardId);
1402
+ }
1403
+ }
1404
+ const supportCandidates = dedupe([
1405
+ ...group.supportCardIds ?? [],
1406
+ ...this.findSupportCardsByTags(
1407
+ group,
1408
+ tagsByCard,
1409
+ [...supportTags]
1410
+ )
1411
+ ]).filter((id) => !activeIds.has(id) && !seenIds.has(id));
1412
+ const sessionsSinceSurfaced = priorState?.sessionsSinceSurfaced ?? 0;
1413
+ const freshnessWindow = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
1414
+ const staleSessions = Math.max(0, sessionsSinceSurfaced - freshnessWindow);
1415
+ const pressureMultiplier = pendingTargets.length === 0 ? 1 : clamp(1 + staleSessions * 0.75 + Math.min(2, pendingTargets.length * 0.1), 1, MAX_TARGET_MULTIPLIER);
1416
+ const supportMultiplier = blockedTargets.length === 0 ? 1 : clamp(1 + staleSessions * 0.5 + Math.min(1.5, blockedTargets.length * 0.15), 1, MAX_SUPPORT_MULTIPLIER);
1417
+ return {
1418
+ group,
1419
+ encounteredTargets,
1420
+ pendingTargets,
1421
+ blockedTargets,
1422
+ surfaceableTargets,
1423
+ targetTags,
1424
+ supportCandidates,
1425
+ supportTags: [...supportTags],
1426
+ pressureMultiplier,
1427
+ supportMultiplier
1428
+ };
1429
+ }
1430
+ buildNextGroupState(runtime, prior) {
1431
+ const carriedSessions = prior?.sessionsSinceSurfaced ?? 0;
1432
+ const surfacedThisRun = false;
1433
+ return {
1434
+ encounteredCardIds: [...runtime.encounteredTargets].sort(),
1435
+ lastSurfacedAt: prior?.lastSurfacedAt ?? null,
1436
+ sessionsSinceSurfaced: surfacedThisRun ? 0 : carriedSessions + 1,
1437
+ lastSupportAt: prior?.lastSupportAt ?? null,
1438
+ blockedTargetIds: [...runtime.blockedTargets].sort(),
1439
+ lastResolvedSupportTags: [...runtime.supportTags].sort()
1440
+ };
1441
+ }
1442
+ buildDirectTargetCards(runtime, courseId, emittedIds) {
1443
+ const maxDirect = runtime.group.maxDirectTargetsPerRun ?? DEFAULT_MAX_DIRECT_PER_RUN;
1444
+ const directIds = runtime.surfaceableTargets.filter((id) => !emittedIds.has(id)).slice(0, maxDirect);
1445
+ const cards = [];
1446
+ for (const cardId of directIds) {
1447
+ emittedIds.add(cardId);
1448
+ cards.push({
1449
+ cardId,
1450
+ courseId,
1451
+ score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
1452
+ provenance: [
1453
+ {
1454
+ strategy: "prescribed",
1455
+ strategyName: this.strategyName || this.name,
1456
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1457
+ action: "generated",
1458
+ score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
1459
+ reason: `mode=target;group=${runtime.group.id};pending=${runtime.pendingTargets.length};surfaceable=${runtime.surfaceableTargets.length};blocked=${runtime.blockedTargets.length};multiplier=${runtime.pressureMultiplier.toFixed(2)}`
1460
+ }
1461
+ ]
1462
+ });
1463
+ }
1464
+ return cards;
1465
+ }
1466
+ buildSupportCards(runtime, courseId, emittedIds) {
1467
+ if (runtime.blockedTargets.length === 0 || runtime.supportCandidates.length === 0) {
1468
+ return [];
1469
+ }
1470
+ const maxSupport = runtime.group.maxSupportCardsPerRun ?? DEFAULT_MAX_SUPPORT_PER_RUN;
1471
+ const supportIds = runtime.supportCandidates.filter((id) => !emittedIds.has(id)).slice(0, maxSupport);
1472
+ const cards = [];
1473
+ for (const cardId of supportIds) {
1474
+ emittedIds.add(cardId);
1475
+ cards.push({
1476
+ cardId,
1477
+ courseId,
1478
+ score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
1479
+ provenance: [
1480
+ {
1481
+ strategy: "prescribed",
1482
+ strategyName: this.strategyName || this.name,
1483
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1484
+ action: "generated",
1485
+ score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
1486
+ reason: `mode=support;group=${runtime.group.id};blocked=${runtime.blockedTargets.length};supportTags=${runtime.supportTags.join("|") || "none"};multiplier=${runtime.supportMultiplier.toFixed(2)}`
1487
+ }
1488
+ ]
1489
+ });
1490
+ }
1195
1491
  return cards;
1196
1492
  }
1493
+ findSupportCardsByTags(group, tagsByCard, supportTags) {
1494
+ if (supportTags.length === 0) {
1495
+ return [];
1496
+ }
1497
+ const explicitSupportIds = group.supportCardIds ?? [];
1498
+ const explicitPatterns = group.supportTagPatterns ?? [];
1499
+ if (explicitSupportIds.length === 0 && explicitPatterns.length === 0) {
1500
+ return [];
1501
+ }
1502
+ const candidates = /* @__PURE__ */ new Set();
1503
+ for (const cardId of explicitSupportIds) {
1504
+ const cardTags = tagsByCard.get(cardId) ?? [];
1505
+ const matchesResolved = supportTags.some((supportTag) => cardTags.includes(supportTag));
1506
+ const matchesPattern = explicitPatterns.some(
1507
+ (pattern) => cardTags.some((tag) => matchesTagPattern(tag, pattern))
1508
+ );
1509
+ if (matchesResolved || matchesPattern) {
1510
+ candidates.add(cardId);
1511
+ }
1512
+ }
1513
+ return [...candidates];
1514
+ }
1515
+ resolveBlockedSupportTags(targetTags, hierarchyConfigs, userTagElo, userGlobalElo, hierarchyWalkEnabled, maxDepth) {
1516
+ if (!hierarchyWalkEnabled || targetTags.length === 0 || hierarchyConfigs.length === 0) {
1517
+ return {
1518
+ blocked: false,
1519
+ supportTags: []
1520
+ };
1521
+ }
1522
+ const supportTags = /* @__PURE__ */ new Set();
1523
+ let blocked = false;
1524
+ for (const targetTag of targetTags) {
1525
+ for (const hierarchy of hierarchyConfigs) {
1526
+ const prereqs = hierarchy.prerequisites[targetTag];
1527
+ if (!prereqs || prereqs.length === 0) continue;
1528
+ const unmet = prereqs.filter(
1529
+ (pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
1530
+ );
1531
+ if (unmet.length === 0) {
1532
+ continue;
1533
+ }
1534
+ blocked = true;
1535
+ for (const prereq of unmet) {
1536
+ this.collectSupportTagsRecursive(
1537
+ prereq.tag,
1538
+ hierarchyConfigs,
1539
+ userTagElo,
1540
+ userGlobalElo,
1541
+ maxDepth,
1542
+ /* @__PURE__ */ new Set(),
1543
+ supportTags
1544
+ );
1545
+ }
1546
+ }
1547
+ }
1548
+ return { blocked, supportTags: [...supportTags] };
1549
+ }
1550
+ collectSupportTagsRecursive(tag, hierarchyConfigs, userTagElo, userGlobalElo, depth, visited, out) {
1551
+ if (depth < 0 || visited.has(tag)) return;
1552
+ if (this.isHardGatedTag(tag)) return;
1553
+ visited.add(tag);
1554
+ let walkedFurther = false;
1555
+ for (const hierarchy of hierarchyConfigs) {
1556
+ const prereqs = hierarchy.prerequisites[tag];
1557
+ if (!prereqs || prereqs.length === 0) continue;
1558
+ const unmet = prereqs.filter(
1559
+ (pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
1560
+ );
1561
+ if (unmet.length > 0 && depth > 0) {
1562
+ walkedFurther = true;
1563
+ for (const prereq of unmet) {
1564
+ this.collectSupportTagsRecursive(
1565
+ prereq.tag,
1566
+ hierarchyConfigs,
1567
+ userTagElo,
1568
+ userGlobalElo,
1569
+ depth - 1,
1570
+ visited,
1571
+ out
1572
+ );
1573
+ }
1574
+ }
1575
+ }
1576
+ if (!walkedFurther) {
1577
+ out.add(tag);
1578
+ }
1579
+ }
1580
+ isHardGatedTag(tag) {
1581
+ return LOCKED_TAG_PREFIXES.some((prefix) => tag.startsWith(prefix)) && tag.startsWith(LESSON_GATE_PENALTY_TAG_HINT);
1582
+ }
1583
+ isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1584
+ if (!userTagElo) return false;
1585
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
1586
+ if (userTagElo.count < minCount) return false;
1587
+ if (prereq.masteryThreshold?.minElo !== void 0) {
1588
+ return userTagElo.score >= prereq.masteryThreshold.minElo;
1589
+ }
1590
+ if (prereq.masteryThreshold?.minCount !== void 0) {
1591
+ return true;
1592
+ }
1593
+ return userTagElo.score >= userGlobalElo;
1594
+ }
1197
1595
  };
1198
1596
  }
1199
1597
  });
@@ -1557,13 +1955,13 @@ __export(hierarchyDefinition_exports, {
1557
1955
  default: () => HierarchyDefinitionNavigator
1558
1956
  });
1559
1957
  import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
1560
- var DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
1958
+ var DEFAULT_MIN_COUNT2, HierarchyDefinitionNavigator;
1561
1959
  var init_hierarchyDefinition = __esm({
1562
1960
  "src/core/navigators/filters/hierarchyDefinition.ts"() {
1563
1961
  "use strict";
1564
1962
  init_navigators();
1565
1963
  init_logger();
1566
- DEFAULT_MIN_COUNT = 3;
1964
+ DEFAULT_MIN_COUNT2 = 3;
1567
1965
  HierarchyDefinitionNavigator = class extends ContentNavigator {
1568
1966
  config;
1569
1967
  /** Human-readable name for CardFilter interface */
@@ -1590,7 +1988,7 @@ var init_hierarchyDefinition = __esm({
1590
1988
  */
1591
1989
  isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1592
1990
  if (!userTagElo) return false;
1593
- const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
1991
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
1594
1992
  if (userTagElo.count < minCount) return false;
1595
1993
  if (prereq.masteryThreshold?.minElo !== void 0) {
1596
1994
  return userTagElo.score >= prereq.masteryThreshold.minElo;
@@ -1691,18 +2089,55 @@ var init_hierarchyDefinition = __esm({
1691
2089
  }
1692
2090
  return boosts;
1693
2091
  }
2092
+ /**
2093
+ * Build a map of gated tag → max configured targetBoost for all *open* gates.
2094
+ *
2095
+ * When a gate opens (prereqs met), cards carrying the gated tag get boosted —
2096
+ * ensuring newly-unlocked content surfaces promptly. The boost is a static
2097
+ * multiplier; natural ELO/SRS deprioritization after interaction handles decay.
2098
+ */
2099
+ getTargetBoosts(unlockedTags) {
2100
+ const boosts = /* @__PURE__ */ new Map();
2101
+ const configKeys = Object.keys(this.config.prerequisites);
2102
+ const unlockedArr = [...unlockedTags];
2103
+ logger.info(
2104
+ `[HierarchyDefinition:targetBoost:trace] ${this.name} | configKeys=${configKeys.length}, unlocked=${unlockedArr.length} (${unlockedArr.slice(0, 5).join(", ")}${unlockedArr.length > 5 ? "..." : ""})`
2105
+ );
2106
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
2107
+ if (!unlockedTags.has(tagId)) continue;
2108
+ logger.info(
2109
+ `[HierarchyDefinition:targetBoost:trace] UNLOCKED ${tagId}: ${prereqs.length} prereqs, raw=${JSON.stringify(prereqs.map((p) => ({ tag: p.tag, tb: p.targetBoost })))}`
2110
+ );
2111
+ for (const prereq of prereqs) {
2112
+ if (!prereq.targetBoost || prereq.targetBoost <= 1) continue;
2113
+ const existing = boosts.get(tagId) ?? 1;
2114
+ boosts.set(tagId, Math.max(existing, prereq.targetBoost));
2115
+ }
2116
+ }
2117
+ if (boosts.size > 0) {
2118
+ logger.info(
2119
+ `[HierarchyDefinition] targetBoosts active: ${[...boosts.entries()].map(([t, b]) => `${t}=\xD7${b}`).join(", ")}`
2120
+ );
2121
+ } else {
2122
+ logger.info(
2123
+ `[HierarchyDefinition:targetBoost:trace] no targetBoosts found despite ${unlockedArr.length} unlocked tags`
2124
+ );
2125
+ }
2126
+ return boosts;
2127
+ }
1694
2128
  /**
1695
2129
  * CardFilter.transform implementation.
1696
2130
  *
1697
- * Two effects:
1698
- * 1. Cards with locked tags receive score * 0.05 (gating penalty)
1699
- * 2. Cards carrying prereq tags of closed gates receive a configured
1700
- * boost (preReqBoost), steering toward content that unlocks gates
2131
+ * Three effects:
2132
+ * 1. Cards with locked tags receive score * 0.02 (gating penalty)
2133
+ * 2. Cards carrying prereq tags of closed gates receive preReqBoost
2134
+ * 3. Cards carrying gated tags of open gates receive targetBoost
1701
2135
  */
1702
2136
  async transform(cards, context) {
1703
2137
  const masteredTags = await this.getMasteredTags(context);
1704
2138
  const unlockedTags = this.getUnlockedTags(masteredTags);
1705
2139
  const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
2140
+ const targetBoosts = this.getTargetBoosts(unlockedTags);
1706
2141
  const gated = [];
1707
2142
  for (const card of cards) {
1708
2143
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1735,6 +2170,26 @@ var init_hierarchyDefinition = __esm({
1735
2170
  );
1736
2171
  }
1737
2172
  }
2173
+ if (isUnlocked && targetBoosts.size > 0) {
2174
+ const cardTags = card.tags ?? [];
2175
+ let maxTargetBoost = 1;
2176
+ const boostedTargets = [];
2177
+ for (const tag of cardTags) {
2178
+ const boost = targetBoosts.get(tag);
2179
+ if (boost && boost > maxTargetBoost) {
2180
+ maxTargetBoost = boost;
2181
+ boostedTargets.push(tag);
2182
+ }
2183
+ }
2184
+ if (maxTargetBoost > 1) {
2185
+ finalScore *= maxTargetBoost;
2186
+ action = "boosted";
2187
+ finalReason = `${finalReason} | targetBoost \xD7${maxTargetBoost.toFixed(2)} for ${boostedTargets.join(", ")}`;
2188
+ logger.info(
2189
+ `[HierarchyDefinition] targetBoost \xD7${maxTargetBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedTargets.join(", ")}] (score: ${card.score.toFixed(3)} \u2192 ${finalScore.toFixed(3)})`
2190
+ );
2191
+ }
2192
+ }
1738
2193
  gated.push({
1739
2194
  ...card,
1740
2195
  score: finalScore,
@@ -1920,12 +2375,12 @@ __export(interferenceMitigator_exports, {
1920
2375
  default: () => InterferenceMitigatorNavigator
1921
2376
  });
1922
2377
  import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
1923
- var DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
2378
+ var DEFAULT_MIN_COUNT3, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
1924
2379
  var init_interferenceMitigator = __esm({
1925
2380
  "src/core/navigators/filters/interferenceMitigator.ts"() {
1926
2381
  "use strict";
1927
2382
  init_navigators();
1928
- DEFAULT_MIN_COUNT2 = 10;
2383
+ DEFAULT_MIN_COUNT3 = 10;
1929
2384
  DEFAULT_MIN_ELAPSED_DAYS = 3;
1930
2385
  DEFAULT_INTERFERENCE_DECAY = 0.8;
1931
2386
  InterferenceMitigatorNavigator = class extends ContentNavigator {
@@ -1950,7 +2405,7 @@ var init_interferenceMitigator = __esm({
1950
2405
  return {
1951
2406
  interferenceSets: sets,
1952
2407
  maturityThreshold: {
1953
- minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
2408
+ minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT3,
1954
2409
  minElo: parsed.maturityThreshold?.minElo,
1955
2410
  minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
1956
2411
  },
@@ -1960,7 +2415,7 @@ var init_interferenceMitigator = __esm({
1960
2415
  return {
1961
2416
  interferenceSets: [],
1962
2417
  maturityThreshold: {
1963
- minCount: DEFAULT_MIN_COUNT2,
2418
+ minCount: DEFAULT_MIN_COUNT3,
1964
2419
  minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
1965
2420
  },
1966
2421
  defaultDecay: DEFAULT_INTERFERENCE_DECAY
@@ -2007,7 +2462,7 @@ var init_interferenceMitigator = __esm({
2007
2462
  try {
2008
2463
  const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
2009
2464
  const userElo = toCourseElo4(courseReg.elo);
2010
- const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
2465
+ const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT3;
2011
2466
  const minElo = this.config.maturityThreshold?.minElo;
2012
2467
  const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
2013
2468
  const minCountForElapsed = minElapsedDays * 2;
@@ -2262,170 +2717,1728 @@ var init_relativePriority = __esm({
2262
2717
  };
2263
2718
  })
2264
2719
  );
2265
- return adjusted;
2266
- }
2720
+ return adjusted;
2721
+ }
2722
+ /**
2723
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
2724
+ *
2725
+ * Use transform() via Pipeline instead.
2726
+ */
2727
+ async getWeightedCards(_limit) {
2728
+ throw new Error(
2729
+ "RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2730
+ );
2731
+ }
2732
+ };
2733
+ }
2734
+ });
2735
+
2736
+ // src/core/navigators/filters/types.ts
2737
+ var types_exports2 = {};
2738
+ var init_types2 = __esm({
2739
+ "src/core/navigators/filters/types.ts"() {
2740
+ "use strict";
2741
+ }
2742
+ });
2743
+
2744
+ // src/core/navigators/filters/userGoalStub.ts
2745
+ var userGoalStub_exports = {};
2746
+ __export(userGoalStub_exports, {
2747
+ USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
2748
+ });
2749
+ var USER_GOAL_NAVIGATOR_STUB;
2750
+ var init_userGoalStub = __esm({
2751
+ "src/core/navigators/filters/userGoalStub.ts"() {
2752
+ "use strict";
2753
+ USER_GOAL_NAVIGATOR_STUB = true;
2754
+ }
2755
+ });
2756
+
2757
+ // import("./filters/**/*") in src/core/navigators/index.ts
2758
+ var globImport_filters;
2759
+ var init_2 = __esm({
2760
+ 'import("./filters/**/*") in src/core/navigators/index.ts'() {
2761
+ globImport_filters = __glob({
2762
+ "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
2763
+ "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
2764
+ "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
2765
+ "./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
2766
+ "./filters/inferredPreferenceStub.ts": () => Promise.resolve().then(() => (init_inferredPreferenceStub(), inferredPreferenceStub_exports)),
2767
+ "./filters/interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
2768
+ "./filters/relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
2769
+ "./filters/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
2770
+ "./filters/userGoalStub.ts": () => Promise.resolve().then(() => (init_userGoalStub(), userGoalStub_exports)),
2771
+ "./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports))
2772
+ });
2773
+ }
2774
+ });
2775
+
2776
+ // src/core/orchestration/gradient.ts
2777
+ var init_gradient = __esm({
2778
+ "src/core/orchestration/gradient.ts"() {
2779
+ "use strict";
2780
+ init_logger();
2781
+ }
2782
+ });
2783
+
2784
+ // src/core/orchestration/learning.ts
2785
+ var init_learning = __esm({
2786
+ "src/core/orchestration/learning.ts"() {
2787
+ "use strict";
2788
+ init_contentNavigationStrategy();
2789
+ init_types_legacy();
2790
+ init_logger();
2791
+ }
2792
+ });
2793
+
2794
+ // src/core/orchestration/signal.ts
2795
+ var init_signal = __esm({
2796
+ "src/core/orchestration/signal.ts"() {
2797
+ "use strict";
2798
+ }
2799
+ });
2800
+
2801
+ // src/core/orchestration/recording.ts
2802
+ var init_recording = __esm({
2803
+ "src/core/orchestration/recording.ts"() {
2804
+ "use strict";
2805
+ init_signal();
2806
+ init_types_legacy();
2807
+ init_logger();
2808
+ }
2809
+ });
2810
+
2811
+ // src/core/orchestration/index.ts
2812
+ function fnv1a(str) {
2813
+ let hash = 2166136261;
2814
+ for (let i = 0; i < str.length; i++) {
2815
+ hash ^= str.charCodeAt(i);
2816
+ hash = Math.imul(hash, 16777619);
2817
+ }
2818
+ return hash >>> 0;
2819
+ }
2820
+ function computeDeviation(userId, strategyId, salt) {
2821
+ const input = `${userId}:${strategyId}:${salt}`;
2822
+ const hash = fnv1a(input);
2823
+ const normalized = hash / 4294967296;
2824
+ return normalized * 2 - 1;
2825
+ }
2826
+ function computeSpread(confidence) {
2827
+ const clampedConfidence = Math.max(0, Math.min(1, confidence));
2828
+ return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
2829
+ }
2830
+ function computeEffectiveWeight(learnable, userId, strategyId, salt) {
2831
+ const deviation = computeDeviation(userId, strategyId, salt);
2832
+ const spread = computeSpread(learnable.confidence);
2833
+ const adjustment = deviation * spread * learnable.weight;
2834
+ const effective = learnable.weight + adjustment;
2835
+ return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
2836
+ }
2837
+ async function createOrchestrationContext(user, course) {
2838
+ let courseConfig;
2839
+ try {
2840
+ courseConfig = await course.getCourseConfig();
2841
+ } catch (e) {
2842
+ logger.error(`[Orchestration] Failed to load course config: ${e}`);
2843
+ courseConfig = {
2844
+ name: "Unknown",
2845
+ description: "",
2846
+ public: false,
2847
+ deleted: false,
2848
+ creator: "",
2849
+ admins: [],
2850
+ moderators: [],
2851
+ dataShapes: [],
2852
+ questionTypes: [],
2853
+ orchestration: { salt: "default" }
2854
+ };
2855
+ }
2856
+ const userId = user.getUsername();
2857
+ const salt = courseConfig.orchestration?.salt || "default_salt";
2858
+ return {
2859
+ user,
2860
+ course,
2861
+ userId,
2862
+ courseConfig,
2863
+ getEffectiveWeight(strategyId, learnable) {
2864
+ return computeEffectiveWeight(learnable, userId, strategyId, salt);
2865
+ },
2866
+ getDeviation(strategyId) {
2867
+ return computeDeviation(userId, strategyId, salt);
2868
+ }
2869
+ };
2870
+ }
2871
+ var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
2872
+ var init_orchestration = __esm({
2873
+ "src/core/orchestration/index.ts"() {
2874
+ "use strict";
2875
+ init_logger();
2876
+ init_gradient();
2877
+ init_learning();
2878
+ init_signal();
2879
+ init_recording();
2880
+ MIN_SPREAD = 0.1;
2881
+ MAX_SPREAD = 0.5;
2882
+ MIN_WEIGHT = 0.1;
2883
+ MAX_WEIGHT = 3;
2884
+ }
2885
+ });
2886
+
2887
+ // src/study/SpacedRepetition.ts
2888
+ import moment4 from "moment";
2889
+ import { isTaggedPerformance } from "@vue-skuilder/common";
2890
+ var duration;
2891
+ var init_SpacedRepetition = __esm({
2892
+ "src/study/SpacedRepetition.ts"() {
2893
+ "use strict";
2894
+ init_util();
2895
+ init_logger();
2896
+ duration = moment4.duration;
2897
+ }
2898
+ });
2899
+
2900
+ // src/study/services/SrsService.ts
2901
+ import moment5 from "moment";
2902
+ var init_SrsService = __esm({
2903
+ "src/study/services/SrsService.ts"() {
2904
+ "use strict";
2905
+ init_couch();
2906
+ init_SpacedRepetition();
2907
+ init_logger();
2908
+ }
2909
+ });
2910
+
2911
+ // src/study/services/EloService.ts
2912
+ import {
2913
+ adjustCourseScores,
2914
+ adjustCourseScoresPerTag,
2915
+ toCourseElo as toCourseElo5
2916
+ } from "@vue-skuilder/common";
2917
+ var init_EloService = __esm({
2918
+ "src/study/services/EloService.ts"() {
2919
+ "use strict";
2920
+ init_logger();
2921
+ }
2922
+ });
2923
+
2924
+ // src/study/services/ResponseProcessor.ts
2925
+ import { isTaggedPerformance as isTaggedPerformance2 } from "@vue-skuilder/common";
2926
+ var init_ResponseProcessor = __esm({
2927
+ "src/study/services/ResponseProcessor.ts"() {
2928
+ "use strict";
2929
+ init_core();
2930
+ init_logger();
2931
+ }
2932
+ });
2933
+
2934
+ // src/study/services/CardHydrationService.ts
2935
+ import {
2936
+ displayableDataToViewData,
2937
+ isCourseElo,
2938
+ toCourseElo as toCourseElo6
2939
+ } from "@vue-skuilder/common";
2940
+ var init_CardHydrationService = __esm({
2941
+ "src/study/services/CardHydrationService.ts"() {
2942
+ "use strict";
2943
+ init_logger();
2944
+ }
2945
+ });
2946
+
2947
+ // src/study/ItemQueue.ts
2948
+ var init_ItemQueue = __esm({
2949
+ "src/study/ItemQueue.ts"() {
2950
+ "use strict";
2951
+ }
2952
+ });
2953
+
2954
+ // src/util/packer/types.ts
2955
+ var init_types3 = __esm({
2956
+ "src/util/packer/types.ts"() {
2957
+ "use strict";
2958
+ }
2959
+ });
2960
+
2961
+ // src/util/packer/CouchDBToStaticPacker.ts
2962
+ var init_CouchDBToStaticPacker = __esm({
2963
+ "src/util/packer/CouchDBToStaticPacker.ts"() {
2964
+ "use strict";
2965
+ init_types_legacy();
2966
+ init_logger();
2967
+ }
2968
+ });
2969
+
2970
+ // src/util/packer/index.ts
2971
+ var init_packer = __esm({
2972
+ "src/util/packer/index.ts"() {
2973
+ "use strict";
2974
+ init_types3();
2975
+ init_CouchDBToStaticPacker();
2976
+ }
2977
+ });
2978
+
2979
+ // src/util/migrator/types.ts
2980
+ var DEFAULT_MIGRATION_OPTIONS;
2981
+ var init_types4 = __esm({
2982
+ "src/util/migrator/types.ts"() {
2983
+ "use strict";
2984
+ DEFAULT_MIGRATION_OPTIONS = {
2985
+ chunkBatchSize: 100,
2986
+ validateRoundTrip: false,
2987
+ cleanupOnFailure: true,
2988
+ timeout: 3e5
2989
+ // 5 minutes
2990
+ };
2991
+ }
2992
+ });
2993
+
2994
+ // src/util/migrator/FileSystemAdapter.ts
2995
+ var FileSystemError;
2996
+ var init_FileSystemAdapter = __esm({
2997
+ "src/util/migrator/FileSystemAdapter.ts"() {
2998
+ "use strict";
2999
+ FileSystemError = class extends Error {
3000
+ constructor(message, operation, filePath, cause) {
3001
+ super(message);
3002
+ this.operation = operation;
3003
+ this.filePath = filePath;
3004
+ this.cause = cause;
3005
+ this.name = "FileSystemError";
3006
+ }
3007
+ };
3008
+ }
3009
+ });
3010
+
3011
+ // src/util/migrator/validation.ts
3012
+ async function validateStaticCourse(staticPath, fs) {
3013
+ const validation = {
3014
+ valid: true,
3015
+ manifestExists: false,
3016
+ chunksExist: false,
3017
+ attachmentsExist: false,
3018
+ errors: [],
3019
+ warnings: []
3020
+ };
3021
+ try {
3022
+ if (fs) {
3023
+ const stats = await fs.stat(staticPath);
3024
+ if (!stats.isDirectory()) {
3025
+ validation.errors.push(`Path is not a directory: ${staticPath}`);
3026
+ validation.valid = false;
3027
+ return validation;
3028
+ }
3029
+ } else if (!nodeFS) {
3030
+ validation.errors.push("File system access not available - validation skipped");
3031
+ validation.valid = false;
3032
+ return validation;
3033
+ } else {
3034
+ const stats = await nodeFS.promises.stat(staticPath);
3035
+ if (!stats.isDirectory()) {
3036
+ validation.errors.push(`Path is not a directory: ${staticPath}`);
3037
+ validation.valid = false;
3038
+ return validation;
3039
+ }
3040
+ }
3041
+ let manifestPath = `${staticPath}/manifest.json`;
3042
+ try {
3043
+ if (fs) {
3044
+ manifestPath = fs.joinPath(staticPath, "manifest.json");
3045
+ if (await fs.exists(manifestPath)) {
3046
+ validation.manifestExists = true;
3047
+ const manifestContent = await fs.readFile(manifestPath);
3048
+ const manifest = JSON.parse(manifestContent);
3049
+ validation.courseId = manifest.courseId;
3050
+ validation.courseName = manifest.courseName;
3051
+ if (!manifest.version || !manifest.courseId || !manifest.chunks || !Array.isArray(manifest.chunks)) {
3052
+ validation.errors.push("Invalid manifest structure");
3053
+ validation.valid = false;
3054
+ }
3055
+ } else {
3056
+ validation.errors.push(`Manifest not found: ${manifestPath}`);
3057
+ validation.valid = false;
3058
+ }
3059
+ } else {
3060
+ manifestPath = `${staticPath}/manifest.json`;
3061
+ await nodeFS.promises.access(manifestPath);
3062
+ validation.manifestExists = true;
3063
+ const manifestContent = await nodeFS.promises.readFile(manifestPath, "utf8");
3064
+ const manifest = JSON.parse(manifestContent);
3065
+ validation.courseId = manifest.courseId;
3066
+ validation.courseName = manifest.courseName;
3067
+ if (!manifest.version || !manifest.courseId || !manifest.chunks || !Array.isArray(manifest.chunks)) {
3068
+ validation.errors.push("Invalid manifest structure");
3069
+ validation.valid = false;
3070
+ }
3071
+ }
3072
+ } catch (error) {
3073
+ const errorMessage = error instanceof FileSystemError ? error.message : `Manifest not found or invalid: ${manifestPath}`;
3074
+ validation.errors.push(errorMessage);
3075
+ validation.valid = false;
3076
+ }
3077
+ let chunksPath = `${staticPath}/chunks`;
3078
+ try {
3079
+ if (fs) {
3080
+ chunksPath = fs.joinPath(staticPath, "chunks");
3081
+ if (await fs.exists(chunksPath)) {
3082
+ const chunksStats = await fs.stat(chunksPath);
3083
+ if (chunksStats.isDirectory()) {
3084
+ validation.chunksExist = true;
3085
+ } else {
3086
+ validation.errors.push(`Chunks path is not a directory: ${chunksPath}`);
3087
+ validation.valid = false;
3088
+ }
3089
+ } else {
3090
+ validation.errors.push(`Chunks directory not found: ${chunksPath}`);
3091
+ validation.valid = false;
3092
+ }
3093
+ } else {
3094
+ chunksPath = `${staticPath}/chunks`;
3095
+ const chunksStats = await nodeFS.promises.stat(chunksPath);
3096
+ if (chunksStats.isDirectory()) {
3097
+ validation.chunksExist = true;
3098
+ } else {
3099
+ validation.errors.push(`Chunks path is not a directory: ${chunksPath}`);
3100
+ validation.valid = false;
3101
+ }
3102
+ }
3103
+ } catch (error) {
3104
+ const errorMessage = error instanceof FileSystemError ? error.message : `Chunks directory not found: ${chunksPath}`;
3105
+ validation.errors.push(errorMessage);
3106
+ validation.valid = false;
3107
+ }
3108
+ let attachmentsPath;
3109
+ try {
3110
+ if (fs) {
3111
+ attachmentsPath = fs.joinPath(staticPath, "attachments");
3112
+ if (await fs.exists(attachmentsPath)) {
3113
+ const attachmentsStats = await fs.stat(attachmentsPath);
3114
+ if (attachmentsStats.isDirectory()) {
3115
+ validation.attachmentsExist = true;
3116
+ }
3117
+ } else {
3118
+ validation.warnings.push(
3119
+ `Attachments directory not found: ${attachmentsPath} (this is OK if course has no attachments)`
3120
+ );
3121
+ }
3122
+ } else {
3123
+ attachmentsPath = `${staticPath}/attachments`;
3124
+ const attachmentsStats = await nodeFS.promises.stat(attachmentsPath);
3125
+ if (attachmentsStats.isDirectory()) {
3126
+ validation.attachmentsExist = true;
3127
+ }
3128
+ }
3129
+ } catch (error) {
3130
+ attachmentsPath = attachmentsPath || `${staticPath}/attachments`;
3131
+ const warningMessage = error instanceof FileSystemError ? error.message : `Attachments directory not found: ${attachmentsPath} (this is OK if course has no attachments)`;
3132
+ validation.warnings.push(warningMessage);
3133
+ }
3134
+ } catch (error) {
3135
+ validation.errors.push(
3136
+ `Failed to validate static course: ${error instanceof Error ? error.message : String(error)}`
3137
+ );
3138
+ validation.valid = false;
3139
+ }
3140
+ return validation;
3141
+ }
3142
+ async function validateMigration(targetDB, expectedCounts, manifest) {
3143
+ const validation = {
3144
+ valid: true,
3145
+ documentCountMatch: false,
3146
+ attachmentIntegrity: false,
3147
+ viewFunctionality: false,
3148
+ issues: []
3149
+ };
3150
+ try {
3151
+ logger.info("Starting migration validation...");
3152
+ const actualCounts = await getActualDocumentCounts(targetDB);
3153
+ validation.documentCountMatch = compareDocumentCounts(
3154
+ expectedCounts,
3155
+ actualCounts,
3156
+ validation.issues
3157
+ );
3158
+ await validateCourseConfig(targetDB, manifest, validation.issues);
3159
+ validation.viewFunctionality = await validateViews(targetDB, manifest, validation.issues);
3160
+ validation.attachmentIntegrity = await validateAttachmentIntegrity(targetDB, validation.issues);
3161
+ validation.valid = validation.documentCountMatch && validation.viewFunctionality && validation.attachmentIntegrity;
3162
+ logger.info(`Migration validation completed. Valid: ${validation.valid}`);
3163
+ if (validation.issues.length > 0) {
3164
+ logger.info(`Validation issues: ${validation.issues.length}`);
3165
+ validation.issues.forEach((issue) => {
3166
+ if (issue.type === "error") {
3167
+ logger.error(`${issue.category}: ${issue.message}`);
3168
+ } else {
3169
+ logger.warn(`${issue.category}: ${issue.message}`);
3170
+ }
3171
+ });
3172
+ }
3173
+ } catch (error) {
3174
+ validation.valid = false;
3175
+ validation.issues.push({
3176
+ type: "error",
3177
+ category: "metadata",
3178
+ message: `Validation failed: ${error instanceof Error ? error.message : String(error)}`
3179
+ });
3180
+ }
3181
+ return validation;
3182
+ }
3183
+ async function getActualDocumentCounts(db) {
3184
+ const counts = {};
3185
+ try {
3186
+ const allDocs = await db.allDocs({ include_docs: true });
3187
+ for (const row of allDocs.rows) {
3188
+ if (row.id.startsWith("_design/")) {
3189
+ counts["_design"] = (counts["_design"] || 0) + 1;
3190
+ continue;
3191
+ }
3192
+ const doc = row.doc;
3193
+ if (doc && doc.docType) {
3194
+ counts[doc.docType] = (counts[doc.docType] || 0) + 1;
3195
+ } else {
3196
+ counts["unknown"] = (counts["unknown"] || 0) + 1;
3197
+ }
3198
+ }
3199
+ } catch (error) {
3200
+ logger.error("Failed to get actual document counts:", error);
3201
+ }
3202
+ return counts;
3203
+ }
3204
+ function compareDocumentCounts(expected, actual, issues) {
3205
+ let countsMatch = true;
3206
+ for (const [docType, expectedCount] of Object.entries(expected)) {
3207
+ const actualCount = actual[docType] || 0;
3208
+ if (actualCount !== expectedCount) {
3209
+ countsMatch = false;
3210
+ issues.push({
3211
+ type: "error",
3212
+ category: "documents",
3213
+ message: `Document count mismatch for ${docType}: expected ${expectedCount}, got ${actualCount}`
3214
+ });
3215
+ }
3216
+ }
3217
+ for (const [docType, actualCount] of Object.entries(actual)) {
3218
+ if (!expected[docType] && docType !== "_design") {
3219
+ issues.push({
3220
+ type: "warning",
3221
+ category: "documents",
3222
+ message: `Unexpected document type found: ${docType} (${actualCount} documents)`
3223
+ });
3224
+ }
3225
+ }
3226
+ return countsMatch;
3227
+ }
3228
+ async function validateCourseConfig(db, manifest, issues) {
3229
+ try {
3230
+ const courseConfig = await db.get("CourseConfig");
3231
+ if (!courseConfig) {
3232
+ issues.push({
3233
+ type: "error",
3234
+ category: "course_config",
3235
+ message: "CourseConfig document not found after migration"
3236
+ });
3237
+ return;
3238
+ }
3239
+ if (!courseConfig.courseID) {
3240
+ issues.push({
3241
+ type: "warning",
3242
+ category: "course_config",
3243
+ message: "CourseConfig document missing courseID field"
3244
+ });
3245
+ }
3246
+ if (courseConfig.courseID !== manifest.courseId) {
3247
+ issues.push({
3248
+ type: "warning",
3249
+ category: "course_config",
3250
+ message: `CourseConfig courseID mismatch: expected ${manifest.courseId}, got ${courseConfig.courseID}`
3251
+ });
3252
+ }
3253
+ logger.debug("CourseConfig document validation passed");
3254
+ } catch (error) {
3255
+ if (error.status === 404) {
3256
+ issues.push({
3257
+ type: "error",
3258
+ category: "course_config",
3259
+ message: "CourseConfig document not found in database"
3260
+ });
3261
+ } else {
3262
+ issues.push({
3263
+ type: "error",
3264
+ category: "course_config",
3265
+ message: `Failed to validate CourseConfig document: ${error instanceof Error ? error.message : String(error)}`
3266
+ });
3267
+ }
3268
+ }
3269
+ }
3270
+ async function validateViews(db, manifest, issues) {
3271
+ let viewsValid = true;
3272
+ try {
3273
+ for (const designDoc of manifest.designDocs) {
3274
+ try {
3275
+ const doc = await db.get(designDoc._id);
3276
+ if (!doc) {
3277
+ viewsValid = false;
3278
+ issues.push({
3279
+ type: "error",
3280
+ category: "views",
3281
+ message: `Design document not found: ${designDoc._id}`
3282
+ });
3283
+ continue;
3284
+ }
3285
+ for (const viewName of Object.keys(designDoc.views)) {
3286
+ try {
3287
+ const viewPath = `${designDoc._id}/${viewName}`;
3288
+ await db.query(viewPath, { limit: 1 });
3289
+ } catch (viewError) {
3290
+ viewsValid = false;
3291
+ issues.push({
3292
+ type: "error",
3293
+ category: "views",
3294
+ message: `View not accessible: ${designDoc._id}/${viewName} - ${viewError}`
3295
+ });
3296
+ }
3297
+ }
3298
+ } catch (error) {
3299
+ viewsValid = false;
3300
+ issues.push({
3301
+ type: "error",
3302
+ category: "views",
3303
+ message: `Failed to validate design document ${designDoc._id}: ${error}`
3304
+ });
3305
+ }
3306
+ }
3307
+ } catch (error) {
3308
+ viewsValid = false;
3309
+ issues.push({
3310
+ type: "error",
3311
+ category: "views",
3312
+ message: `View validation failed: ${error instanceof Error ? error.message : String(error)}`
3313
+ });
3314
+ }
3315
+ return viewsValid;
3316
+ }
3317
+ async function validateAttachmentIntegrity(db, issues) {
3318
+ let attachmentsValid = true;
3319
+ try {
3320
+ const allDocs = await db.allDocs({
3321
+ include_docs: true,
3322
+ limit: 10
3323
+ // Sample first 10 documents for performance
3324
+ });
3325
+ let attachmentCount = 0;
3326
+ let validAttachments = 0;
3327
+ for (const row of allDocs.rows) {
3328
+ const doc = row.doc;
3329
+ if (doc && doc._attachments) {
3330
+ for (const [attachmentName, _attachmentMeta] of Object.entries(doc._attachments)) {
3331
+ attachmentCount++;
3332
+ try {
3333
+ const attachment = await db.getAttachment(doc._id, attachmentName);
3334
+ if (attachment) {
3335
+ validAttachments++;
3336
+ }
3337
+ } catch (attachmentError) {
3338
+ attachmentsValid = false;
3339
+ issues.push({
3340
+ type: "error",
3341
+ category: "attachments",
3342
+ message: `Attachment not accessible: ${doc._id}/${attachmentName} - ${attachmentError}`
3343
+ });
3344
+ }
3345
+ }
3346
+ }
3347
+ }
3348
+ if (attachmentCount === 0) {
3349
+ issues.push({
3350
+ type: "warning",
3351
+ category: "attachments",
3352
+ message: "No attachments found in sampled documents"
3353
+ });
3354
+ } else {
3355
+ logger.info(`Validated ${validAttachments}/${attachmentCount} sampled attachments`);
3356
+ }
3357
+ } catch (error) {
3358
+ attachmentsValid = false;
3359
+ issues.push({
3360
+ type: "error",
3361
+ category: "attachments",
3362
+ message: `Attachment validation failed: ${error instanceof Error ? error.message : String(error)}`
3363
+ });
3364
+ }
3365
+ return attachmentsValid;
3366
+ }
3367
+ var nodeFS;
3368
+ var init_validation = __esm({
3369
+ "src/util/migrator/validation.ts"() {
3370
+ "use strict";
3371
+ init_logger();
3372
+ init_FileSystemAdapter();
3373
+ nodeFS = null;
3374
+ try {
3375
+ if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
3376
+ nodeFS = eval("require")("fs");
3377
+ nodeFS.promises = nodeFS.promises || eval("require")("fs").promises;
3378
+ }
3379
+ } catch {
3380
+ }
3381
+ }
3382
+ });
3383
+
3384
+ // src/util/migrator/StaticToCouchDBMigrator.ts
3385
+ var nodeFS2, nodePath, StaticToCouchDBMigrator;
3386
+ var init_StaticToCouchDBMigrator = __esm({
3387
+ "src/util/migrator/StaticToCouchDBMigrator.ts"() {
3388
+ "use strict";
3389
+ init_logger();
3390
+ init_types4();
3391
+ init_validation();
3392
+ init_FileSystemAdapter();
3393
+ nodeFS2 = null;
3394
+ nodePath = null;
3395
+ try {
3396
+ if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
3397
+ nodeFS2 = eval("require")("fs");
3398
+ nodePath = eval("require")("path");
3399
+ nodeFS2.promises = nodeFS2.promises || eval("require")("fs").promises;
3400
+ }
3401
+ } catch {
3402
+ }
3403
+ StaticToCouchDBMigrator = class {
3404
+ options;
3405
+ progressCallback;
3406
+ fs;
3407
+ constructor(options = {}, fileSystemAdapter) {
3408
+ this.options = {
3409
+ ...DEFAULT_MIGRATION_OPTIONS,
3410
+ ...options
3411
+ };
3412
+ this.fs = fileSystemAdapter;
3413
+ }
3414
+ /**
3415
+ * Set a progress callback to receive updates during migration
3416
+ */
3417
+ setProgressCallback(callback) {
3418
+ this.progressCallback = callback;
3419
+ }
3420
+ /**
3421
+ * Migrate a static course to CouchDB
3422
+ */
3423
+ async migrateCourse(staticPath, targetDB) {
3424
+ const startTime = Date.now();
3425
+ const result = {
3426
+ success: false,
3427
+ documentsRestored: 0,
3428
+ attachmentsRestored: 0,
3429
+ designDocsRestored: 0,
3430
+ courseConfigRestored: 0,
3431
+ errors: [],
3432
+ warnings: [],
3433
+ migrationTime: 0
3434
+ };
3435
+ try {
3436
+ logger.info(`Starting migration from ${staticPath} to CouchDB`);
3437
+ this.reportProgress("manifest", 0, 1, "Validating static course...");
3438
+ const validation = await validateStaticCourse(staticPath, this.fs);
3439
+ if (!validation.valid) {
3440
+ result.errors.push(...validation.errors);
3441
+ throw new Error(`Static course validation failed: ${validation.errors.join(", ")}`);
3442
+ }
3443
+ result.warnings.push(...validation.warnings);
3444
+ this.reportProgress("manifest", 1, 1, "Loading course manifest...");
3445
+ const manifest = await this.loadManifest(staticPath);
3446
+ logger.info(`Loaded manifest for course: ${manifest.courseId} (${manifest.courseName})`);
3447
+ this.reportProgress(
3448
+ "design_docs",
3449
+ 0,
3450
+ manifest.designDocs.length,
3451
+ "Restoring design documents..."
3452
+ );
3453
+ const designDocResults = await this.restoreDesignDocuments(manifest.designDocs, targetDB);
3454
+ result.designDocsRestored = designDocResults.restored;
3455
+ result.errors.push(...designDocResults.errors);
3456
+ result.warnings.push(...designDocResults.warnings);
3457
+ this.reportProgress("course_config", 0, 1, "Restoring CourseConfig document...");
3458
+ const courseConfigResults = await this.restoreCourseConfig(manifest, targetDB);
3459
+ result.courseConfigRestored = courseConfigResults.restored;
3460
+ result.errors.push(...courseConfigResults.errors);
3461
+ result.warnings.push(...courseConfigResults.warnings);
3462
+ this.reportProgress("course_config", 1, 1, "CourseConfig document restored");
3463
+ const expectedCounts = this.calculateExpectedCounts(manifest);
3464
+ this.reportProgress(
3465
+ "documents",
3466
+ 0,
3467
+ manifest.documentCount,
3468
+ "Aggregating documents from chunks..."
3469
+ );
3470
+ const documents = await this.aggregateDocuments(staticPath, manifest);
3471
+ const filteredDocuments = documents.filter((doc) => doc._id !== "CourseConfig");
3472
+ if (documents.length !== filteredDocuments.length) {
3473
+ result.warnings.push(
3474
+ `Filtered out ${documents.length - filteredDocuments.length} CourseConfig document(s) from chunks to prevent conflicts`
3475
+ );
3476
+ }
3477
+ this.reportProgress(
3478
+ "documents",
3479
+ filteredDocuments.length,
3480
+ manifest.documentCount,
3481
+ "Uploading documents to CouchDB..."
3482
+ );
3483
+ const docResults = await this.uploadDocuments(filteredDocuments, targetDB);
3484
+ result.documentsRestored = docResults.restored;
3485
+ result.errors.push(...docResults.errors);
3486
+ result.warnings.push(...docResults.warnings);
3487
+ const docsWithAttachments = documents.filter(
3488
+ (doc) => doc._attachments && Object.keys(doc._attachments).length > 0
3489
+ );
3490
+ this.reportProgress("attachments", 0, docsWithAttachments.length, "Uploading attachments...");
3491
+ const attachmentResults = await this.uploadAttachments(
3492
+ staticPath,
3493
+ docsWithAttachments,
3494
+ targetDB
3495
+ );
3496
+ result.attachmentsRestored = attachmentResults.restored;
3497
+ result.errors.push(...attachmentResults.errors);
3498
+ result.warnings.push(...attachmentResults.warnings);
3499
+ if (this.options.validateRoundTrip) {
3500
+ this.reportProgress("validation", 0, 1, "Validating migration...");
3501
+ const validationResult = await validateMigration(targetDB, expectedCounts, manifest);
3502
+ if (!validationResult.valid) {
3503
+ result.warnings.push("Migration validation found issues");
3504
+ validationResult.issues.forEach((issue) => {
3505
+ if (issue.type === "error") {
3506
+ result.errors.push(`Validation: ${issue.message}`);
3507
+ } else {
3508
+ result.warnings.push(`Validation: ${issue.message}`);
3509
+ }
3510
+ });
3511
+ }
3512
+ this.reportProgress("validation", 1, 1, "Migration validation completed");
3513
+ }
3514
+ result.success = result.errors.length === 0;
3515
+ result.migrationTime = Date.now() - startTime;
3516
+ logger.info(`Migration completed in ${result.migrationTime}ms`);
3517
+ logger.info(`Documents restored: ${result.documentsRestored}`);
3518
+ logger.info(`Attachments restored: ${result.attachmentsRestored}`);
3519
+ logger.info(`Design docs restored: ${result.designDocsRestored}`);
3520
+ logger.info(`CourseConfig restored: ${result.courseConfigRestored}`);
3521
+ if (result.errors.length > 0) {
3522
+ logger.error(`Migration completed with ${result.errors.length} errors`);
3523
+ }
3524
+ if (result.warnings.length > 0) {
3525
+ logger.warn(`Migration completed with ${result.warnings.length} warnings`);
3526
+ }
3527
+ } catch (error) {
3528
+ result.success = false;
3529
+ result.migrationTime = Date.now() - startTime;
3530
+ const errorMessage = error instanceof Error ? error.message : String(error);
3531
+ result.errors.push(`Migration failed: ${errorMessage}`);
3532
+ logger.error("Migration failed:", error);
3533
+ if (this.options.cleanupOnFailure) {
3534
+ try {
3535
+ await this.cleanupFailedMigration(targetDB);
3536
+ } catch (cleanupError) {
3537
+ logger.error("Failed to cleanup after migration failure:", cleanupError);
3538
+ result.warnings.push("Failed to cleanup after migration failure");
3539
+ }
3540
+ }
3541
+ }
3542
+ return result;
3543
+ }
3544
+ /**
3545
+ * Load and parse the manifest file
3546
+ */
3547
+ async loadManifest(staticPath) {
3548
+ try {
3549
+ let manifestContent;
3550
+ let manifestPath;
3551
+ if (this.fs) {
3552
+ manifestPath = this.fs.joinPath(staticPath, "manifest.json");
3553
+ manifestContent = await this.fs.readFile(manifestPath);
3554
+ } else {
3555
+ manifestPath = nodeFS2 && nodePath ? nodePath.join(staticPath, "manifest.json") : `${staticPath}/manifest.json`;
3556
+ if (nodeFS2 && this.isLocalPath(staticPath)) {
3557
+ manifestContent = await nodeFS2.promises.readFile(manifestPath, "utf8");
3558
+ } else {
3559
+ const response = await fetch(manifestPath);
3560
+ if (!response.ok) {
3561
+ throw new Error(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
3562
+ }
3563
+ manifestContent = await response.text();
3564
+ }
3565
+ }
3566
+ const manifest = JSON.parse(manifestContent);
3567
+ if (!manifest.version || !manifest.courseId || !manifest.chunks) {
3568
+ throw new Error("Invalid manifest structure");
3569
+ }
3570
+ return manifest;
3571
+ } catch (error) {
3572
+ const errorMessage = error instanceof FileSystemError ? error.message : `Failed to load manifest: ${error instanceof Error ? error.message : String(error)}`;
3573
+ throw new Error(errorMessage);
3574
+ }
3575
+ }
3576
+ /**
3577
+ * Restore design documents to CouchDB
3578
+ */
3579
+ async restoreDesignDocuments(designDocs, db) {
3580
+ const result = { restored: 0, errors: [], warnings: [] };
3581
+ for (let i = 0; i < designDocs.length; i++) {
3582
+ const designDoc = designDocs[i];
3583
+ this.reportProgress("design_docs", i, designDocs.length, `Restoring ${designDoc._id}...`);
3584
+ try {
3585
+ let existingDoc;
3586
+ try {
3587
+ existingDoc = await db.get(designDoc._id);
3588
+ } catch {
3589
+ }
3590
+ const docToInsert = {
3591
+ _id: designDoc._id,
3592
+ views: designDoc.views
3593
+ };
3594
+ if (existingDoc) {
3595
+ docToInsert._rev = existingDoc._rev;
3596
+ logger.debug(`Updating existing design document: ${designDoc._id}`);
3597
+ } else {
3598
+ logger.debug(`Creating new design document: ${designDoc._id}`);
3599
+ }
3600
+ await db.put(docToInsert);
3601
+ result.restored++;
3602
+ } catch (error) {
3603
+ const errorMessage = `Failed to restore design document ${designDoc._id}: ${error instanceof Error ? error.message : String(error)}`;
3604
+ result.errors.push(errorMessage);
3605
+ logger.error(errorMessage);
3606
+ }
3607
+ }
3608
+ this.reportProgress(
3609
+ "design_docs",
3610
+ designDocs.length,
3611
+ designDocs.length,
3612
+ `Restored ${result.restored} design documents`
3613
+ );
3614
+ return result;
3615
+ }
3616
+ /**
3617
+ * Aggregate documents from all chunks
3618
+ */
3619
+ async aggregateDocuments(staticPath, manifest) {
3620
+ const allDocuments = [];
3621
+ const documentMap = /* @__PURE__ */ new Map();
3622
+ for (let i = 0; i < manifest.chunks.length; i++) {
3623
+ const chunk = manifest.chunks[i];
3624
+ this.reportProgress(
3625
+ "documents",
3626
+ allDocuments.length,
3627
+ manifest.documentCount,
3628
+ `Loading chunk ${chunk.id}...`
3629
+ );
3630
+ try {
3631
+ const documents = await this.loadChunk(staticPath, chunk);
3632
+ for (const doc of documents) {
3633
+ if (!doc._id) {
3634
+ logger.warn(`Document without _id found in chunk ${chunk.id}, skipping`);
3635
+ continue;
3636
+ }
3637
+ if (documentMap.has(doc._id)) {
3638
+ logger.warn(`Duplicate document ID found: ${doc._id}, using latest version`);
3639
+ }
3640
+ documentMap.set(doc._id, doc);
3641
+ }
3642
+ } catch (error) {
3643
+ throw new Error(
3644
+ `Failed to load chunk ${chunk.id}: ${error instanceof Error ? error.message : String(error)}`
3645
+ );
3646
+ }
3647
+ }
3648
+ allDocuments.push(...documentMap.values());
3649
+ logger.info(
3650
+ `Aggregated ${allDocuments.length} unique documents from ${manifest.chunks.length} chunks`
3651
+ );
3652
+ return allDocuments;
3653
+ }
3654
+ /**
3655
+ * Load documents from a single chunk file
3656
+ */
3657
+ async loadChunk(staticPath, chunk) {
3658
+ try {
3659
+ let chunkContent;
3660
+ let chunkPath;
3661
+ if (this.fs) {
3662
+ chunkPath = this.fs.joinPath(staticPath, chunk.path);
3663
+ chunkContent = await this.fs.readFile(chunkPath);
3664
+ } else {
3665
+ chunkPath = nodeFS2 && nodePath ? nodePath.join(staticPath, chunk.path) : `${staticPath}/${chunk.path}`;
3666
+ if (nodeFS2 && this.isLocalPath(staticPath)) {
3667
+ chunkContent = await nodeFS2.promises.readFile(chunkPath, "utf8");
3668
+ } else {
3669
+ const response = await fetch(chunkPath);
3670
+ if (!response.ok) {
3671
+ throw new Error(`Failed to fetch chunk: ${response.status} ${response.statusText}`);
3672
+ }
3673
+ chunkContent = await response.text();
3674
+ }
3675
+ }
3676
+ const documents = JSON.parse(chunkContent);
3677
+ if (!Array.isArray(documents)) {
3678
+ throw new Error("Chunk file does not contain an array of documents");
3679
+ }
3680
+ return documents;
3681
+ } catch (error) {
3682
+ const errorMessage = error instanceof FileSystemError ? error.message : `Failed to load chunk: ${error instanceof Error ? error.message : String(error)}`;
3683
+ throw new Error(errorMessage);
3684
+ }
3685
+ }
3686
+ /**
3687
+ * Upload documents to CouchDB in batches
3688
+ */
3689
+ async uploadDocuments(documents, db) {
3690
+ const result = { restored: 0, errors: [], warnings: [] };
3691
+ const batchSize = this.options.chunkBatchSize;
3692
+ for (let i = 0; i < documents.length; i += batchSize) {
3693
+ const batch = documents.slice(i, i + batchSize);
3694
+ this.reportProgress(
3695
+ "documents",
3696
+ i,
3697
+ documents.length,
3698
+ `Uploading batch ${Math.floor(i / batchSize) + 1}...`
3699
+ );
3700
+ try {
3701
+ const docsToInsert = batch.map((doc) => {
3702
+ const cleanDoc = { ...doc };
3703
+ delete cleanDoc._rev;
3704
+ delete cleanDoc._attachments;
3705
+ return cleanDoc;
3706
+ });
3707
+ const bulkResult = await db.bulkDocs(docsToInsert);
3708
+ for (let j = 0; j < bulkResult.length; j++) {
3709
+ const docResult = bulkResult[j];
3710
+ const originalDoc = batch[j];
3711
+ if ("error" in docResult) {
3712
+ const errorMessage = `Failed to upload document ${originalDoc._id}: ${docResult.error} - ${docResult.reason}`;
3713
+ result.errors.push(errorMessage);
3714
+ logger.error(errorMessage);
3715
+ } else {
3716
+ result.restored++;
3717
+ }
3718
+ }
3719
+ } catch (error) {
3720
+ let errorMessage;
3721
+ if (error instanceof Error) {
3722
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
3723
+ } else if (error && typeof error === "object" && "message" in error) {
3724
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
3725
+ } else {
3726
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${JSON.stringify(error)}`;
3727
+ }
3728
+ result.errors.push(errorMessage);
3729
+ logger.error(errorMessage);
3730
+ }
3731
+ }
3732
+ this.reportProgress(
3733
+ "documents",
3734
+ documents.length,
3735
+ documents.length,
3736
+ `Uploaded ${result.restored} documents`
3737
+ );
3738
+ return result;
3739
+ }
3740
+ /**
3741
+ * Upload attachments from filesystem to CouchDB
3742
+ */
3743
+ async uploadAttachments(staticPath, documents, db) {
3744
+ const result = { restored: 0, errors: [], warnings: [] };
3745
+ let processedDocs = 0;
3746
+ for (const doc of documents) {
3747
+ this.reportProgress(
3748
+ "attachments",
3749
+ processedDocs,
3750
+ documents.length,
3751
+ `Processing attachments for ${doc._id}...`
3752
+ );
3753
+ processedDocs++;
3754
+ if (!doc._attachments) {
3755
+ continue;
3756
+ }
3757
+ for (const [attachmentName, attachmentMeta] of Object.entries(doc._attachments)) {
3758
+ try {
3759
+ const uploadResult = await this.uploadSingleAttachment(
3760
+ staticPath,
3761
+ doc._id,
3762
+ attachmentName,
3763
+ attachmentMeta,
3764
+ db
3765
+ );
3766
+ if (uploadResult.success) {
3767
+ result.restored++;
3768
+ } else {
3769
+ result.errors.push(uploadResult.error || "Unknown attachment upload error");
3770
+ }
3771
+ } catch (error) {
3772
+ const errorMessage = `Failed to upload attachment ${doc._id}/${attachmentName}: ${error instanceof Error ? error.message : String(error)}`;
3773
+ result.errors.push(errorMessage);
3774
+ logger.error(errorMessage);
3775
+ }
3776
+ }
3777
+ }
3778
+ this.reportProgress(
3779
+ "attachments",
3780
+ documents.length,
3781
+ documents.length,
3782
+ `Uploaded ${result.restored} attachments`
3783
+ );
3784
+ return result;
3785
+ }
3786
+ /**
3787
+ * Upload a single attachment file
3788
+ */
3789
+ async uploadSingleAttachment(staticPath, docId, attachmentName, attachmentMeta, db) {
3790
+ const result = {
3791
+ success: false,
3792
+ attachmentName,
3793
+ docId
3794
+ };
3795
+ try {
3796
+ if (!attachmentMeta.path) {
3797
+ result.error = "Attachment metadata missing file path";
3798
+ return result;
3799
+ }
3800
+ let attachmentData;
3801
+ let attachmentPath;
3802
+ if (this.fs) {
3803
+ attachmentPath = this.fs.joinPath(staticPath, attachmentMeta.path);
3804
+ attachmentData = await this.fs.readBinary(attachmentPath);
3805
+ } else {
3806
+ attachmentPath = nodeFS2 && nodePath ? nodePath.join(staticPath, attachmentMeta.path) : `${staticPath}/${attachmentMeta.path}`;
3807
+ if (nodeFS2 && this.isLocalPath(staticPath)) {
3808
+ attachmentData = await nodeFS2.promises.readFile(attachmentPath);
3809
+ } else {
3810
+ const response = await fetch(attachmentPath);
3811
+ if (!response.ok) {
3812
+ result.error = `Failed to fetch attachment: ${response.status} ${response.statusText}`;
3813
+ return result;
3814
+ }
3815
+ attachmentData = await response.arrayBuffer();
3816
+ }
3817
+ }
3818
+ const doc = await db.get(docId);
3819
+ await db.putAttachment(
3820
+ docId,
3821
+ attachmentName,
3822
+ doc._rev,
3823
+ attachmentData,
3824
+ // PouchDB accepts both ArrayBuffer and Buffer
3825
+ attachmentMeta.content_type
3826
+ );
3827
+ result.success = true;
3828
+ } catch (error) {
3829
+ result.error = error instanceof Error ? error.message : String(error);
3830
+ }
3831
+ return result;
3832
+ }
3833
+ /**
3834
+ * Restore CourseConfig document from manifest
3835
+ */
3836
+ async restoreCourseConfig(manifest, targetDB) {
3837
+ const results = {
3838
+ restored: 0,
3839
+ errors: [],
3840
+ warnings: []
3841
+ };
3842
+ try {
3843
+ if (!manifest.courseConfig) {
3844
+ results.warnings.push(
3845
+ "No courseConfig found in manifest, skipping CourseConfig document creation"
3846
+ );
3847
+ return results;
3848
+ }
3849
+ const courseConfigDoc = {
3850
+ _id: "CourseConfig",
3851
+ ...manifest.courseConfig,
3852
+ courseID: manifest.courseId
3853
+ };
3854
+ delete courseConfigDoc._rev;
3855
+ await targetDB.put(courseConfigDoc);
3856
+ results.restored = 1;
3857
+ logger.info(`CourseConfig document created for course: ${manifest.courseId}`);
3858
+ } catch (error) {
3859
+ const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
3860
+ results.errors.push(`Failed to restore CourseConfig: ${errorMessage}`);
3861
+ logger.error("CourseConfig restoration failed:", error);
3862
+ }
3863
+ return results;
3864
+ }
3865
+ /**
3866
+ * Calculate expected document counts from manifest
3867
+ */
3868
+ calculateExpectedCounts(manifest) {
3869
+ const counts = {};
3870
+ for (const chunk of manifest.chunks) {
3871
+ counts[chunk.docType] = (counts[chunk.docType] || 0) + chunk.documentCount;
3872
+ }
3873
+ if (manifest.designDocs.length > 0) {
3874
+ counts["_design"] = manifest.designDocs.length;
3875
+ }
3876
+ return counts;
3877
+ }
3878
+ /**
3879
+ * Clean up database after failed migration
3880
+ */
3881
+ async cleanupFailedMigration(db) {
3882
+ logger.info("Cleaning up failed migration...");
3883
+ try {
3884
+ const allDocs = await db.allDocs();
3885
+ const docsToDelete = allDocs.rows.map((row) => ({
3886
+ _id: row.id,
3887
+ _rev: row.value.rev,
3888
+ _deleted: true
3889
+ }));
3890
+ if (docsToDelete.length > 0) {
3891
+ await db.bulkDocs(docsToDelete);
3892
+ logger.info(`Cleaned up ${docsToDelete.length} documents from failed migration`);
3893
+ }
3894
+ } catch (error) {
3895
+ logger.error("Failed to cleanup documents:", error);
3896
+ throw error;
3897
+ }
3898
+ }
3899
+ /**
3900
+ * Report progress to callback if available
3901
+ */
3902
+ reportProgress(phase, current, total, message) {
3903
+ if (this.progressCallback) {
3904
+ this.progressCallback({
3905
+ phase,
3906
+ current,
3907
+ total,
3908
+ message
3909
+ });
3910
+ }
3911
+ }
3912
+ /**
3913
+ * Check if a path is a local file path (vs URL)
3914
+ */
3915
+ isLocalPath(path2) {
3916
+ return !path2.startsWith("http://") && !path2.startsWith("https://");
3917
+ }
3918
+ };
3919
+ }
3920
+ });
3921
+
3922
+ // src/util/migrator/index.ts
3923
+ var init_migrator = __esm({
3924
+ "src/util/migrator/index.ts"() {
3925
+ "use strict";
3926
+ init_StaticToCouchDBMigrator();
3927
+ init_validation();
3928
+ init_FileSystemAdapter();
3929
+ }
3930
+ });
3931
+
3932
+ // src/util/index.ts
3933
+ var init_util2 = __esm({
3934
+ "src/util/index.ts"() {
3935
+ "use strict";
3936
+ init_Loggable();
3937
+ init_packer();
3938
+ init_migrator();
3939
+ init_dataDirectory();
3940
+ }
3941
+ });
3942
+
3943
+ // src/study/SourceMixer.ts
3944
+ var init_SourceMixer = __esm({
3945
+ "src/study/SourceMixer.ts"() {
3946
+ "use strict";
3947
+ }
3948
+ });
3949
+
3950
+ // src/study/MixerDebugger.ts
3951
+ function printMixerSummary(run) {
3952
+ console.group(`\u{1F3A8} Mixer Run: ${run.mixerType}`);
3953
+ logger.info(`Run ID: ${run.runId}`);
3954
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
3955
+ logger.info(
3956
+ `Config: limit=${run.requestedLimit}${run.quotaPerSource ? `, quota/source=${run.quotaPerSource}` : ""}`
3957
+ );
3958
+ console.group(`\u{1F4E5} Input: ${run.sourceSummaries.length} sources`);
3959
+ for (const src of run.sourceSummaries) {
3960
+ logger.info(
3961
+ ` ${src.sourceName || src.sourceId}: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`
3962
+ );
3963
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}], avg: ${src.avgScore.toFixed(2)}`);
3964
+ }
3965
+ console.groupEnd();
3966
+ console.group(`\u{1F4E4} Output: ${run.finalCount} cards selected (${run.reviewsSelected} reviews, ${run.newSelected} new)`);
3967
+ for (const breakdown of run.sourceBreakdowns) {
3968
+ const name = breakdown.sourceName || breakdown.sourceId;
3969
+ logger.info(
3970
+ ` ${name}: ${breakdown.totalSelected} selected (${breakdown.reviewsSelected} reviews, ${breakdown.newSelected} new) - ${breakdown.selectionRate.toFixed(1)}% selection rate`
3971
+ );
3972
+ }
3973
+ console.groupEnd();
3974
+ console.groupEnd();
3975
+ }
3976
+ function mountMixerDebugger() {
3977
+ if (typeof window === "undefined") return;
3978
+ const win = window;
3979
+ win.skuilder = win.skuilder || {};
3980
+ win.skuilder.mixer = mixerDebugAPI;
3981
+ }
3982
+ var runHistory2, mixerDebugAPI;
3983
+ var init_MixerDebugger = __esm({
3984
+ "src/study/MixerDebugger.ts"() {
3985
+ "use strict";
3986
+ init_logger();
3987
+ init_navigators();
3988
+ runHistory2 = [];
3989
+ mixerDebugAPI = {
3990
+ /**
3991
+ * Get raw run history for programmatic access.
3992
+ */
3993
+ get runs() {
3994
+ return [...runHistory2];
3995
+ },
3996
+ /**
3997
+ * Show summary of a specific mixer run.
3998
+ */
3999
+ showRun(idOrIndex = 0) {
4000
+ if (runHistory2.length === 0) {
4001
+ logger.info("[Mixer Debug] No runs captured yet.");
4002
+ return;
4003
+ }
4004
+ let run;
4005
+ if (typeof idOrIndex === "number") {
4006
+ run = runHistory2[idOrIndex];
4007
+ if (!run) {
4008
+ logger.info(`[Mixer Debug] No run found at index ${idOrIndex}. History length: ${runHistory2.length}`);
4009
+ return;
4010
+ }
4011
+ } else {
4012
+ run = runHistory2.find((r) => r.runId.endsWith(idOrIndex));
4013
+ if (!run) {
4014
+ logger.info(`[Mixer Debug] No run found matching ID '${idOrIndex}'.`);
4015
+ return;
4016
+ }
4017
+ }
4018
+ printMixerSummary(run);
4019
+ },
4020
+ /**
4021
+ * Show summary of the last mixer run.
4022
+ */
4023
+ showLastMix() {
4024
+ this.showRun(0);
4025
+ },
4026
+ /**
4027
+ * Explain source balance in the last run.
4028
+ */
4029
+ explainSourceBalance() {
4030
+ if (runHistory2.length === 0) {
4031
+ logger.info("[Mixer Debug] No runs captured yet.");
4032
+ return;
4033
+ }
4034
+ const run = runHistory2[0];
4035
+ console.group("\u2696\uFE0F Source Balance Analysis");
4036
+ logger.info(`Mixer: ${run.mixerType}`);
4037
+ logger.info(`Requested limit: ${run.requestedLimit}`);
4038
+ if (run.quotaPerSource) {
4039
+ logger.info(`Quota per source: ${run.quotaPerSource}`);
4040
+ }
4041
+ console.group("Input Distribution:");
4042
+ for (const src of run.sourceSummaries) {
4043
+ const name = src.sourceName || src.sourceId;
4044
+ logger.info(`${name}:`);
4045
+ logger.info(` Provided: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`);
4046
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}]`);
4047
+ }
4048
+ console.groupEnd();
4049
+ console.group("Selection Results:");
4050
+ for (const breakdown of run.sourceBreakdowns) {
4051
+ const name = breakdown.sourceName || breakdown.sourceId;
4052
+ logger.info(`${name}:`);
4053
+ logger.info(
4054
+ ` Selected: ${breakdown.totalSelected}/${breakdown.reviewsProvided + breakdown.newProvided} (${breakdown.selectionRate.toFixed(1)}%)`
4055
+ );
4056
+ logger.info(` Reviews: ${breakdown.reviewsSelected}/${breakdown.reviewsProvided}`);
4057
+ logger.info(` New: ${breakdown.newSelected}/${breakdown.newProvided}`);
4058
+ if (breakdown.reviewsProvided > 0 && breakdown.reviewsSelected === 0) {
4059
+ logger.info(` \u26A0\uFE0F Had reviews but none selected!`);
4060
+ }
4061
+ if (breakdown.totalSelected === 0 && breakdown.reviewsProvided + breakdown.newProvided > 0) {
4062
+ logger.info(` \u26A0\uFE0F Had cards but none selected!`);
4063
+ }
4064
+ }
4065
+ console.groupEnd();
4066
+ const selectionRates = run.sourceBreakdowns.map((b) => b.selectionRate);
4067
+ const avgRate = selectionRates.reduce((a, b) => a + b, 0) / selectionRates.length;
4068
+ const maxDeviation = Math.max(...selectionRates.map((r) => Math.abs(r - avgRate)));
4069
+ if (maxDeviation > 20) {
4070
+ logger.info(`
4071
+ \u26A0\uFE0F Significant imbalance detected (max deviation: ${maxDeviation.toFixed(1)}%)`);
4072
+ logger.info("Possible causes:");
4073
+ logger.info(" - Score range differences between sources");
4074
+ logger.info(" - One source has much better quality cards");
4075
+ logger.info(" - Different card availability (reviews vs new)");
4076
+ }
4077
+ console.groupEnd();
4078
+ },
4079
+ /**
4080
+ * Compare score distributions across sources.
4081
+ */
4082
+ compareScores() {
4083
+ if (runHistory2.length === 0) {
4084
+ logger.info("[Mixer Debug] No runs captured yet.");
4085
+ return;
4086
+ }
4087
+ const run = runHistory2[0];
4088
+ console.group("\u{1F4CA} Score Distribution Comparison");
4089
+ console.table(
4090
+ run.sourceSummaries.map((src) => ({
4091
+ source: src.sourceName || src.sourceId,
4092
+ cards: src.totalCards,
4093
+ min: src.bottomScore.toFixed(3),
4094
+ max: src.topScore.toFixed(3),
4095
+ avg: src.avgScore.toFixed(3),
4096
+ range: (src.topScore - src.bottomScore).toFixed(3)
4097
+ }))
4098
+ );
4099
+ const ranges = run.sourceSummaries.map((s) => s.topScore - s.bottomScore);
4100
+ const avgScores = run.sourceSummaries.map((s) => s.avgScore);
4101
+ const rangeDiff = Math.max(...ranges) - Math.min(...ranges);
4102
+ const avgDiff = Math.max(...avgScores) - Math.min(...avgScores);
4103
+ if (rangeDiff > 0.3 || avgDiff > 0.2) {
4104
+ logger.info("\n\u26A0\uFE0F Significant score distribution differences detected");
4105
+ logger.info(
4106
+ "This may cause one source to dominate selection if using global sorting (not quota-based)"
4107
+ );
4108
+ }
4109
+ console.groupEnd();
4110
+ },
4111
+ /**
4112
+ * Show detailed information for a specific card.
4113
+ */
4114
+ showCard(cardId) {
4115
+ for (const run of runHistory2) {
4116
+ const card = run.cards.find((c) => c.cardId === cardId);
4117
+ if (card) {
4118
+ const source = run.sourceSummaries.find((s) => s.sourceIndex === card.sourceIndex);
4119
+ console.group(`\u{1F3B4} Card: ${cardId}`);
4120
+ logger.info(`Course: ${card.courseId}`);
4121
+ logger.info(`Source: ${source?.sourceName || source?.sourceId || "unknown"}`);
4122
+ logger.info(`Origin: ${card.origin}`);
4123
+ logger.info(`Score: ${card.score.toFixed(3)}`);
4124
+ if (card.rankInSource) {
4125
+ logger.info(`Rank in source: #${card.rankInSource}`);
4126
+ }
4127
+ if (card.rankInMix) {
4128
+ logger.info(`Rank in mixed results: #${card.rankInMix}`);
4129
+ }
4130
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
4131
+ if (!card.selected && card.rankInSource) {
4132
+ logger.info("\nWhy not selected:");
4133
+ if (run.quotaPerSource && card.rankInSource > run.quotaPerSource) {
4134
+ logger.info(` - Ranked #${card.rankInSource} in source, but quota was ${run.quotaPerSource}`);
4135
+ }
4136
+ logger.info(" - Check score compared to selected cards using .showRun()");
4137
+ }
4138
+ console.groupEnd();
4139
+ return;
4140
+ }
4141
+ }
4142
+ logger.info(`[Mixer Debug] Card '${cardId}' not found in recent runs.`);
4143
+ },
4144
+ /**
4145
+ * Show all runs in compact format.
4146
+ */
4147
+ listRuns() {
4148
+ if (runHistory2.length === 0) {
4149
+ logger.info("[Mixer Debug] No runs captured yet.");
4150
+ return;
4151
+ }
4152
+ console.table(
4153
+ runHistory2.map((r) => ({
4154
+ id: r.runId.slice(-8),
4155
+ time: r.timestamp.toLocaleTimeString(),
4156
+ mixer: r.mixerType,
4157
+ sources: r.sourceSummaries.length,
4158
+ selected: r.finalCount,
4159
+ reviews: r.reviewsSelected,
4160
+ new: r.newSelected
4161
+ }))
4162
+ );
4163
+ },
2267
4164
  /**
2268
- * Legacy getWeightedCards - now throws as filters should not be used as generators.
2269
- *
2270
- * Use transform() via Pipeline instead.
4165
+ * Export run history as JSON for bug reports.
2271
4166
  */
2272
- async getWeightedCards(_limit) {
2273
- throw new Error(
2274
- "RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2275
- );
4167
+ export() {
4168
+ const json = JSON.stringify(runHistory2, null, 2);
4169
+ logger.info("[Mixer Debug] Run history exported. Copy the returned string or use:");
4170
+ logger.info(" copy(window.skuilder.mixer.export())");
4171
+ return json;
4172
+ },
4173
+ /**
4174
+ * Clear run history.
4175
+ */
4176
+ clear() {
4177
+ runHistory2.length = 0;
4178
+ logger.info("[Mixer Debug] Run history cleared.");
4179
+ },
4180
+ /**
4181
+ * Show help.
4182
+ */
4183
+ help() {
4184
+ logger.info(`
4185
+ \u{1F3A8} Mixer Debug API
4186
+
4187
+ Commands:
4188
+ .showLastMix() Show summary of most recent mixer run
4189
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
4190
+ .explainSourceBalance() Analyze source balance and selection patterns
4191
+ .compareScores() Compare score distributions across sources
4192
+ .showCard(cardId) Show mixer decisions for a specific card
4193
+ .listRuns() List all captured runs in table format
4194
+ .export() Export run history as JSON for bug reports
4195
+ .clear() Clear run history
4196
+ .runs Access raw run history array
4197
+ .help() Show this help message
4198
+
4199
+ Example:
4200
+ window.skuilder.mixer.showLastMix()
4201
+ window.skuilder.mixer.explainSourceBalance()
4202
+ window.skuilder.mixer.compareScores()
4203
+ `);
2276
4204
  }
2277
4205
  };
4206
+ mountMixerDebugger();
2278
4207
  }
2279
4208
  });
2280
4209
 
2281
- // src/core/navigators/filters/types.ts
2282
- var types_exports2 = {};
2283
- var init_types2 = __esm({
2284
- "src/core/navigators/filters/types.ts"() {
2285
- "use strict";
4210
+ // src/study/SessionDebugger.ts
4211
+ function showCurrentQueue() {
4212
+ if (!activeSession) {
4213
+ logger.info("[Session Debug] No active session.");
4214
+ return;
2286
4215
  }
2287
- });
2288
-
2289
- // src/core/navigators/filters/userGoalStub.ts
2290
- var userGoalStub_exports = {};
2291
- __export(userGoalStub_exports, {
2292
- USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
2293
- });
2294
- var USER_GOAL_NAVIGATOR_STUB;
2295
- var init_userGoalStub = __esm({
2296
- "src/core/navigators/filters/userGoalStub.ts"() {
2297
- "use strict";
2298
- USER_GOAL_NAVIGATOR_STUB = true;
4216
+ const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
4217
+ console.group("\u{1F4CA} Current Queue State");
4218
+ logger.info(`Review Queue: ${latest.reviewQLength} cards`);
4219
+ if (latest.reviewQNext3 && latest.reviewQNext3.length > 0) {
4220
+ logger.info(` Next: ${latest.reviewQNext3.join(", ")}`);
2299
4221
  }
2300
- });
2301
-
2302
- // import("./filters/**/*") in src/core/navigators/index.ts
2303
- var globImport_filters;
2304
- var init_2 = __esm({
2305
- 'import("./filters/**/*") in src/core/navigators/index.ts'() {
2306
- globImport_filters = __glob({
2307
- "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
2308
- "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
2309
- "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
2310
- "./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
2311
- "./filters/inferredPreferenceStub.ts": () => Promise.resolve().then(() => (init_inferredPreferenceStub(), inferredPreferenceStub_exports)),
2312
- "./filters/interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
2313
- "./filters/relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
2314
- "./filters/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
2315
- "./filters/userGoalStub.ts": () => Promise.resolve().then(() => (init_userGoalStub(), userGoalStub_exports)),
2316
- "./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports))
2317
- });
4222
+ logger.info(`New Queue: ${latest.newQLength} cards`);
4223
+ if (latest.newQNext3 && latest.newQNext3.length > 0) {
4224
+ logger.info(` Next: ${latest.newQNext3.join(", ")}`);
2318
4225
  }
2319
- });
2320
-
2321
- // src/core/orchestration/gradient.ts
2322
- var init_gradient = __esm({
2323
- "src/core/orchestration/gradient.ts"() {
2324
- "use strict";
2325
- init_logger();
4226
+ logger.info(`Failed Queue: ${latest.failedQLength} cards`);
4227
+ console.groupEnd();
4228
+ }
4229
+ function showPresentationHistory(sessionIndex = 0) {
4230
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
4231
+ if (!session) {
4232
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
4233
+ return;
2326
4234
  }
2327
- });
2328
-
2329
- // src/core/orchestration/learning.ts
2330
- var init_learning = __esm({
2331
- "src/core/orchestration/learning.ts"() {
2332
- "use strict";
2333
- init_contentNavigationStrategy();
2334
- init_types_legacy();
2335
- init_logger();
4235
+ console.group(`\u{1F4DC} Session History: ${session.sessionId}`);
4236
+ logger.info(`Started: ${session.startTime.toLocaleTimeString()}`);
4237
+ if (session.endTime) {
4238
+ logger.info(`Ended: ${session.endTime.toLocaleTimeString()}`);
2336
4239
  }
2337
- });
2338
-
2339
- // src/core/orchestration/signal.ts
2340
- var init_signal = __esm({
2341
- "src/core/orchestration/signal.ts"() {
2342
- "use strict";
4240
+ logger.info(`Cards presented: ${session.presentations.length}`);
4241
+ if (session.presentations.length > 0) {
4242
+ console.table(
4243
+ session.presentations.map((p) => ({
4244
+ "#": p.sequenceNumber,
4245
+ course: p.courseName || p.courseId.slice(0, 8),
4246
+ origin: p.origin,
4247
+ queue: p.queueSource,
4248
+ score: p.score?.toFixed(3) || "-",
4249
+ time: p.timestamp.toLocaleTimeString()
4250
+ }))
4251
+ );
2343
4252
  }
2344
- });
2345
-
2346
- // src/core/orchestration/recording.ts
2347
- var init_recording = __esm({
2348
- "src/core/orchestration/recording.ts"() {
2349
- "use strict";
2350
- init_signal();
2351
- init_types_legacy();
2352
- init_logger();
4253
+ console.groupEnd();
4254
+ }
4255
+ function showInterleaving(sessionIndex = 0) {
4256
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
4257
+ if (!session) {
4258
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
4259
+ return;
2353
4260
  }
2354
- });
2355
-
2356
- // src/core/orchestration/index.ts
2357
- function fnv1a(str) {
2358
- let hash = 2166136261;
2359
- for (let i = 0; i < str.length; i++) {
2360
- hash ^= str.charCodeAt(i);
2361
- hash = Math.imul(hash, 16777619);
4261
+ console.group("\u{1F500} Interleaving Analysis");
4262
+ const courseCounts = /* @__PURE__ */ new Map();
4263
+ const courseOrigins = /* @__PURE__ */ new Map();
4264
+ session.presentations.forEach((p) => {
4265
+ const name = p.courseName || p.courseId;
4266
+ courseCounts.set(name, (courseCounts.get(name) || 0) + 1);
4267
+ if (!courseOrigins.has(name)) {
4268
+ courseOrigins.set(name, { review: 0, new: 0, failed: 0 });
4269
+ }
4270
+ const origins = courseOrigins.get(name);
4271
+ origins[p.origin]++;
4272
+ });
4273
+ logger.info("Course distribution:");
4274
+ console.table(
4275
+ Array.from(courseCounts.entries()).map(([course, count]) => {
4276
+ const origins = courseOrigins.get(course);
4277
+ return {
4278
+ course,
4279
+ total: count,
4280
+ reviews: origins.review,
4281
+ new: origins.new,
4282
+ failed: origins.failed,
4283
+ percentage: (count / session.presentations.length * 100).toFixed(1) + "%"
4284
+ };
4285
+ })
4286
+ );
4287
+ if (session.presentations.length > 0) {
4288
+ logger.info("\nPresentation sequence (first 20):");
4289
+ const sequence = session.presentations.slice(0, 20).map((p, idx) => `${idx + 1}. ${p.courseName || p.courseId.slice(0, 8)} (${p.origin})`).join("\n");
4290
+ logger.info(sequence);
2362
4291
  }
2363
- return hash >>> 0;
2364
- }
2365
- function computeDeviation(userId, strategyId, salt) {
2366
- const input = `${userId}:${strategyId}:${salt}`;
2367
- const hash = fnv1a(input);
2368
- const normalized = hash / 4294967296;
2369
- return normalized * 2 - 1;
2370
- }
2371
- function computeSpread(confidence) {
2372
- const clampedConfidence = Math.max(0, Math.min(1, confidence));
2373
- return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
4292
+ let maxCluster = 0;
4293
+ let currentCluster = 1;
4294
+ let currentCourse = session.presentations[0]?.courseId;
4295
+ for (let i = 1; i < session.presentations.length; i++) {
4296
+ if (session.presentations[i].courseId === currentCourse) {
4297
+ currentCluster++;
4298
+ maxCluster = Math.max(maxCluster, currentCluster);
4299
+ } else {
4300
+ currentCourse = session.presentations[i].courseId;
4301
+ currentCluster = 1;
4302
+ }
4303
+ }
4304
+ if (maxCluster > 3) {
4305
+ logger.info(`
4306
+ \u26A0\uFE0F Detected clustering: max ${maxCluster} cards from same course in a row`);
4307
+ logger.info("This suggests cards are sorted by score rather than round-robin by course.");
4308
+ }
4309
+ console.groupEnd();
2374
4310
  }
2375
- function computeEffectiveWeight(learnable, userId, strategyId, salt) {
2376
- const deviation = computeDeviation(userId, strategyId, salt);
2377
- const spread = computeSpread(learnable.confidence);
2378
- const adjustment = deviation * spread * learnable.weight;
2379
- const effective = learnable.weight + adjustment;
2380
- return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
4311
+ function mountSessionDebugger() {
4312
+ if (typeof window === "undefined") return;
4313
+ const win = window;
4314
+ win.skuilder = win.skuilder || {};
4315
+ win.skuilder.session = sessionDebugAPI;
2381
4316
  }
2382
- async function createOrchestrationContext(user, course) {
2383
- let courseConfig;
2384
- try {
2385
- courseConfig = await course.getCourseConfig();
2386
- } catch (e) {
2387
- logger.error(`[Orchestration] Failed to load course config: ${e}`);
2388
- courseConfig = {
2389
- name: "Unknown",
2390
- description: "",
2391
- public: false,
2392
- deleted: false,
2393
- creator: "",
2394
- admins: [],
2395
- moderators: [],
2396
- dataShapes: [],
2397
- questionTypes: [],
2398
- orchestration: { salt: "default" }
4317
+ var activeSession, sessionHistory, sessionDebugAPI;
4318
+ var init_SessionDebugger = __esm({
4319
+ "src/study/SessionDebugger.ts"() {
4320
+ "use strict";
4321
+ init_logger();
4322
+ activeSession = null;
4323
+ sessionHistory = [];
4324
+ sessionDebugAPI = {
4325
+ /**
4326
+ * Get raw session history for programmatic access.
4327
+ */
4328
+ get sessions() {
4329
+ return [...sessionHistory];
4330
+ },
4331
+ /**
4332
+ * Get active session if any.
4333
+ */
4334
+ get active() {
4335
+ return activeSession;
4336
+ },
4337
+ /**
4338
+ * Show current queue state.
4339
+ */
4340
+ showQueue() {
4341
+ showCurrentQueue();
4342
+ },
4343
+ /**
4344
+ * Show presentation history for current or past session.
4345
+ */
4346
+ showHistory(sessionIndex = 0) {
4347
+ showPresentationHistory(sessionIndex);
4348
+ },
4349
+ /**
4350
+ * Analyze course interleaving pattern.
4351
+ */
4352
+ showInterleaving(sessionIndex = 0) {
4353
+ showInterleaving(sessionIndex);
4354
+ },
4355
+ /**
4356
+ * List all tracked sessions.
4357
+ */
4358
+ listSessions() {
4359
+ if (activeSession) {
4360
+ logger.info(`Active session: ${activeSession.sessionId} (${activeSession.presentations.length} cards presented)`);
4361
+ }
4362
+ if (sessionHistory.length === 0) {
4363
+ logger.info("[Session Debug] No completed sessions in history.");
4364
+ return;
4365
+ }
4366
+ console.table(
4367
+ sessionHistory.map((s, idx) => ({
4368
+ index: idx,
4369
+ id: s.sessionId.slice(-8),
4370
+ started: s.startTime.toLocaleTimeString(),
4371
+ ended: s.endTime?.toLocaleTimeString() || "incomplete",
4372
+ cards: s.presentations.length
4373
+ }))
4374
+ );
4375
+ },
4376
+ /**
4377
+ * Export session history as JSON for bug reports.
4378
+ */
4379
+ export() {
4380
+ const data = {
4381
+ active: activeSession,
4382
+ history: sessionHistory
4383
+ };
4384
+ const json = JSON.stringify(data, null, 2);
4385
+ logger.info("[Session Debug] Session data exported. Copy the returned string or use:");
4386
+ logger.info(" copy(window.skuilder.session.export())");
4387
+ return json;
4388
+ },
4389
+ /**
4390
+ * Clear session history.
4391
+ */
4392
+ clear() {
4393
+ sessionHistory.length = 0;
4394
+ logger.info("[Session Debug] Session history cleared.");
4395
+ },
4396
+ /**
4397
+ * Show help.
4398
+ */
4399
+ help() {
4400
+ logger.info(`
4401
+ \u{1F3AF} Session Debug API
4402
+
4403
+ Commands:
4404
+ .showQueue() Show current queue state (active session only)
4405
+ .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
4406
+ .showInterleaving(index?) Analyze course interleaving pattern
4407
+ .listSessions() List all tracked sessions
4408
+ .export() Export session data as JSON for bug reports
4409
+ .clear() Clear session history
4410
+ .sessions Access raw session history array
4411
+ .active Access active session (if any)
4412
+ .help() Show this help message
4413
+
4414
+ Example:
4415
+ window.skuilder.session.showHistory()
4416
+ window.skuilder.session.showInterleaving()
4417
+ window.skuilder.session.showQueue()
4418
+ `);
4419
+ }
2399
4420
  };
4421
+ mountSessionDebugger();
2400
4422
  }
2401
- const userId = user.getUsername();
2402
- const salt = courseConfig.orchestration?.salt || "default_salt";
2403
- return {
2404
- user,
2405
- course,
2406
- userId,
2407
- courseConfig,
2408
- getEffectiveWeight(strategyId, learnable) {
2409
- return computeEffectiveWeight(learnable, userId, strategyId, salt);
2410
- },
2411
- getDeviation(strategyId) {
2412
- return computeDeviation(userId, strategyId, salt);
2413
- }
2414
- };
2415
- }
2416
- var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
2417
- var init_orchestration = __esm({
2418
- "src/core/orchestration/index.ts"() {
4423
+ });
4424
+
4425
+ // src/study/SessionController.ts
4426
+ var init_SessionController = __esm({
4427
+ "src/study/SessionController.ts"() {
2419
4428
  "use strict";
2420
- init_logger();
2421
- init_gradient();
2422
- init_learning();
2423
- init_signal();
4429
+ init_SrsService();
4430
+ init_EloService();
4431
+ init_ResponseProcessor();
4432
+ init_CardHydrationService();
4433
+ init_ItemQueue();
4434
+ init_couch();
2424
4435
  init_recording();
2425
- MIN_SPREAD = 0.1;
2426
- MAX_SPREAD = 0.5;
2427
- MIN_WEIGHT = 0.1;
2428
- MAX_WEIGHT = 3;
4436
+ init_util2();
4437
+ init_navigators();
4438
+ init_SourceMixer();
4439
+ init_MixerDebugger();
4440
+ init_SessionDebugger();
4441
+ init_logger();
2429
4442
  }
2430
4443
  });
2431
4444
 
@@ -2434,7 +4447,7 @@ var Pipeline_exports = {};
2434
4447
  __export(Pipeline_exports, {
2435
4448
  Pipeline: () => Pipeline
2436
4449
  });
2437
- import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
4450
+ import { toCourseElo as toCourseElo7 } from "@vue-skuilder/common";
2438
4451
  function globToRegex(pattern) {
2439
4452
  const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
2440
4453
  const withWildcards = escaped.replace(/\*/g, ".*");
@@ -2518,6 +4531,7 @@ var init_Pipeline = __esm({
2518
4531
  init_logger();
2519
4532
  init_orchestration();
2520
4533
  init_PipelineDebugger();
4534
+ init_SessionController();
2521
4535
  VERBOSE_RESULTS = true;
2522
4536
  Pipeline = class extends ContentNavigator {
2523
4537
  generator;
@@ -2677,8 +4691,9 @@ var init_Pipeline = __esm({
2677
4691
  generatorSummaries,
2678
4692
  generatedCount,
2679
4693
  filterImpacts,
2680
- allCardsBeforeFiltering,
2681
- result
4694
+ cards,
4695
+ result,
4696
+ context.userElo
2682
4697
  );
2683
4698
  captureRun(report);
2684
4699
  } catch (e) {
@@ -2839,7 +4854,7 @@ var init_Pipeline = __esm({
2839
4854
  let userElo = 1e3;
2840
4855
  try {
2841
4856
  const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
2842
- const courseElo = toCourseElo5(courseReg.elo);
4857
+ const courseElo = toCourseElo7(courseReg.elo);
2843
4858
  userElo = courseElo.global.score;
2844
4859
  } catch (e) {
2845
4860
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
@@ -2892,6 +4907,34 @@ var init_Pipeline = __esm({
2892
4907
  return [...new Set(ids)];
2893
4908
  }
2894
4909
  // ---------------------------------------------------------------------------
4910
+ // Tag ELO diagnostic
4911
+ // ---------------------------------------------------------------------------
4912
+ /**
4913
+ * Get the user's per-tag ELO data for specified tags (or all tags).
4914
+ * Useful for diagnosing why hierarchy gates are open/closed.
4915
+ */
4916
+ async getTagEloStatus(tagFilter) {
4917
+ const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
4918
+ const courseElo = toCourseElo7(courseReg.elo);
4919
+ const result = {};
4920
+ if (!tagFilter) {
4921
+ for (const [tag, data] of Object.entries(courseElo.tags)) {
4922
+ result[tag] = { score: data.score, count: data.count };
4923
+ }
4924
+ } else {
4925
+ const patterns = Array.isArray(tagFilter) ? tagFilter : [tagFilter];
4926
+ for (const pattern of patterns) {
4927
+ const regex = globToRegex(pattern);
4928
+ for (const [tag, data] of Object.entries(courseElo.tags)) {
4929
+ if (regex.test(tag)) {
4930
+ result[tag] = { score: data.score, count: data.count };
4931
+ }
4932
+ }
4933
+ }
4934
+ }
4935
+ return result;
4936
+ }
4937
+ // ---------------------------------------------------------------------------
2895
4938
  // Card-space diagnostic
2896
4939
  // ---------------------------------------------------------------------------
2897
4940
  /**
@@ -3474,7 +5517,7 @@ import {
3474
5517
  EloToNumber,
3475
5518
  Status,
3476
5519
  blankCourseElo as blankCourseElo2,
3477
- toCourseElo as toCourseElo6
5520
+ toCourseElo as toCourseElo8
3478
5521
  } from "@vue-skuilder/common";
3479
5522
  var init_courseDB = __esm({
3480
5523
  "src/impl/couch/courseDB.ts"() {
@@ -3493,7 +5536,7 @@ var init_courseDB = __esm({
3493
5536
  });
3494
5537
 
3495
5538
  // src/impl/couch/classroomDB.ts
3496
- import moment4 from "moment";
5539
+ import moment6 from "moment";
3497
5540
  var init_classroomDB2 = __esm({
3498
5541
  "src/impl/couch/classroomDB.ts"() {
3499
5542
  "use strict";
@@ -3555,7 +5598,7 @@ var init_CouchDBSyncStrategy = __esm({
3555
5598
 
3556
5599
  // src/impl/couch/index.ts
3557
5600
  import fetch3 from "cross-fetch";
3558
- import moment5 from "moment";
5601
+ import moment7 from "moment";
3559
5602
  import process2 from "process";
3560
5603
  function createPouchDBConfig() {
3561
5604
  const hasExplicitCredentials = ENV.COUCHDB_USERNAME && ENV.COUCHDB_PASSWORD;
@@ -3608,7 +5651,7 @@ var init_couch = __esm({
3608
5651
 
3609
5652
  // src/impl/common/BaseUserDB.ts
3610
5653
  import { Status as Status3 } from "@vue-skuilder/common";
3611
- import moment6 from "moment";
5654
+ import moment8 from "moment";
3612
5655
  function accomodateGuest() {
3613
5656
  logger.log("[funnel] accomodateGuest() called");
3614
5657
  if (typeof localStorage === "undefined") {
@@ -4051,7 +6094,7 @@ Currently logged-in as ${this._username}.`
4051
6094
  );
4052
6095
  return reviews.rows.filter((r) => {
4053
6096
  if (r.id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */])) {
4054
- const date = moment6.utc(
6097
+ const date = moment8.utc(
4055
6098
  r.id.substr(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */].length),
4056
6099
  REVIEW_TIME_FORMAT
4057
6100
  );
@@ -4064,11 +6107,11 @@ Currently logged-in as ${this._username}.`
4064
6107
  }).map((r) => r.doc);
4065
6108
  }
4066
6109
  async getReviewsForcast(daysCount) {
4067
- const time = moment6.utc().add(daysCount, "days");
6110
+ const time = moment8.utc().add(daysCount, "days");
4068
6111
  return this.getReviewstoDate(time);
4069
6112
  }
4070
6113
  async getPendingReviews(course_id) {
4071
- const now = moment6.utc();
6114
+ const now = moment8.utc();
4072
6115
  return this.getReviewstoDate(now, course_id);
4073
6116
  }
4074
6117
  async getScheduledReviewCount(course_id) {
@@ -4355,7 +6398,7 @@ Currently logged-in as ${this._username}.`
4355
6398
  */
4356
6399
  async putCardRecord(record) {
4357
6400
  const cardHistoryID = getCardHistoryID(record.courseID, record.cardID);
4358
- record.timeStamp = moment6.utc(record.timeStamp).toString();
6401
+ record.timeStamp = moment8.utc(record.timeStamp).toString();
4359
6402
  try {
4360
6403
  const cardHistory = await this.update(
4361
6404
  cardHistoryID,
@@ -4371,7 +6414,7 @@ Currently logged-in as ${this._username}.`
4371
6414
  const ret = {
4372
6415
  ...record2
4373
6416
  };
4374
- ret.timeStamp = moment6.utc(record2.timeStamp);
6417
+ ret.timeStamp = moment8.utc(record2.timeStamp);
4375
6418
  return ret;
4376
6419
  });
4377
6420
  return cardHistory;
@@ -4786,7 +6829,7 @@ var init_cardProcessor = __esm({
4786
6829
  });
4787
6830
 
4788
6831
  // src/core/bulkImport/types.ts
4789
- var init_types3 = __esm({
6832
+ var init_types5 = __esm({
4790
6833
  "src/core/bulkImport/types.ts"() {
4791
6834
  "use strict";
4792
6835
  }
@@ -4797,7 +6840,7 @@ var init_bulkImport = __esm({
4797
6840
  "src/core/bulkImport/index.ts"() {
4798
6841
  "use strict";
4799
6842
  init_cardProcessor();
4800
- init_types3();
6843
+ init_types5();
4801
6844
  }
4802
6845
  });
4803
6846
 
@@ -5149,7 +7192,7 @@ var init_core = __esm({
5149
7192
  });
5150
7193
 
5151
7194
  // src/impl/static/StaticDataUnpacker.ts
5152
- var pathUtils, nodeFS, StaticDataUnpacker;
7195
+ var pathUtils, nodeFS3, StaticDataUnpacker;
5153
7196
  var init_StaticDataUnpacker = __esm({
5154
7197
  "src/impl/static/StaticDataUnpacker.ts"() {
5155
7198
  "use strict";
@@ -5166,10 +7209,10 @@ var init_StaticDataUnpacker = __esm({
5166
7209
  return false;
5167
7210
  }
5168
7211
  };
5169
- nodeFS = null;
7212
+ nodeFS3 = null;
5170
7213
  try {
5171
7214
  if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
5172
- nodeFS = eval("require")("fs");
7215
+ nodeFS3 = eval("require")("fs");
5173
7216
  }
5174
7217
  } catch {
5175
7218
  }
@@ -5356,8 +7399,8 @@ var init_StaticDataUnpacker = __esm({
5356
7399
  const chunkPath = `${this.basePath}/${chunk.path}`;
5357
7400
  logger.debug(`Loading chunk from ${chunkPath}`);
5358
7401
  let documents;
5359
- if (this.isLocalPath(chunkPath) && nodeFS) {
5360
- const fileContent = await nodeFS.promises.readFile(chunkPath, "utf8");
7402
+ if (this.isLocalPath(chunkPath) && nodeFS3) {
7403
+ const fileContent = await nodeFS3.promises.readFile(chunkPath, "utf8");
5361
7404
  documents = JSON.parse(fileContent);
5362
7405
  } else {
5363
7406
  const response = await fetch(chunkPath);
@@ -5395,8 +7438,8 @@ var init_StaticDataUnpacker = __esm({
5395
7438
  const indexPath = `${this.basePath}/${indexMeta.path}`;
5396
7439
  logger.debug(`Loading index from ${indexPath}`);
5397
7440
  let indexData;
5398
- if (this.isLocalPath(indexPath) && nodeFS) {
5399
- const fileContent = await nodeFS.promises.readFile(indexPath, "utf8");
7441
+ if (this.isLocalPath(indexPath) && nodeFS3) {
7442
+ const fileContent = await nodeFS3.promises.readFile(indexPath, "utf8");
5400
7443
  indexData = JSON.parse(fileContent);
5401
7444
  } else {
5402
7445
  const response = await fetch(indexPath);
@@ -5471,8 +7514,8 @@ var init_StaticDataUnpacker = __esm({
5471
7514
  return null;
5472
7515
  }
5473
7516
  try {
5474
- if (this.isLocalPath(attachmentPath) && nodeFS) {
5475
- const buffer = await nodeFS.promises.readFile(attachmentPath);
7517
+ if (this.isLocalPath(attachmentPath) && nodeFS3) {
7518
+ const buffer = await nodeFS3.promises.readFile(attachmentPath);
5476
7519
  return buffer;
5477
7520
  } else {
5478
7521
  const response = await fetch(attachmentPath);