@vue-skuilder/db 0.1.32-a → 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 (51) 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 +2279 -227
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/core/index.mjs +2256 -200
  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 +18 -3
  10. package/dist/impl/couch/index.d.ts +18 -3
  11. package/dist/impl/couch/index.js +2323 -224
  12. package/dist/impl/couch/index.js.map +1 -1
  13. package/dist/impl/couch/index.mjs +2311 -208
  14. package/dist/impl/couch/index.mjs.map +1 -1
  15. package/dist/impl/static/index.d.cts +5 -4
  16. package/dist/impl/static/index.d.ts +5 -4
  17. package/dist/impl/static/index.js +2283 -231
  18. package/dist/impl/static/index.js.map +1 -1
  19. package/dist/impl/static/index.mjs +2268 -212
  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 -381
  24. package/dist/index.d.ts +9 -381
  25. package/dist/index.js +9626 -8815
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +9559 -8748
  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 +51 -25
  39. package/src/core/navigators/PipelineDebugger.ts +49 -1
  40. package/src/core/navigators/filters/hierarchyDefinition.ts +92 -5
  41. package/src/core/navigators/filters/relativePriority.ts +7 -1
  42. package/src/core/navigators/generators/prescribed.ts +618 -43
  43. package/src/core/navigators/index.ts +2 -1
  44. package/src/impl/couch/CourseSyncService.ts +72 -4
  45. package/src/impl/couch/courseDB.ts +11 -0
  46. package/src/impl/static/courseDB.ts +13 -0
  47. package/src/study/SessionController.ts +276 -24
  48. package/src/study/services/EloService.ts +22 -3
  49. package/src/study/services/ResponseProcessor.ts +7 -3
  50. package/dist/dataLayerProvider-BKmVoyJR.d.ts +0 -67
  51. package/dist/dataLayerProvider-BQdfJuBN.d.cts +0 -67
@@ -727,13 +727,20 @@ function captureRun(report) {
727
727
  runHistory.pop();
728
728
  }
729
729
  }
730
- function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards) {
730
+ function parseCardElo(provenance) {
731
+ const eloEntry = provenance.find((p) => p.strategy === "elo");
732
+ if (!eloEntry?.reason) return void 0;
733
+ const match = eloEntry.reason.match(/card:\s*(\d+)/);
734
+ return match ? parseInt(match[1], 10) : void 0;
735
+ }
736
+ function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards, userElo) {
731
737
  const selectedIds = new Set(selectedCards.map((c) => c.cardId));
732
738
  const cards = allCards.map((card) => ({
733
739
  cardId: card.cardId,
734
740
  courseId: card.courseId,
735
741
  origin: getOrigin(card),
736
742
  finalScore: card.score,
743
+ cardElo: parseCardElo(card.provenance),
737
744
  provenance: card.provenance,
738
745
  tags: card.tags,
739
746
  selected: selectedIds.has(card.cardId)
@@ -743,6 +750,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
743
750
  return {
744
751
  courseId,
745
752
  courseName,
753
+ userElo,
746
754
  generatorName,
747
755
  generators,
748
756
  generatedCount,
@@ -763,6 +771,7 @@ function printRunSummary(run) {
763
771
  console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
764
772
  logger.info(`Run ID: ${run.runId}`);
765
773
  logger.info(`Time: ${run.timestamp.toISOString()}`);
774
+ logger.info(`User ELO: ${run.userElo ?? "unknown"}`);
766
775
  logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
767
776
  if (run.generators && run.generators.length > 0) {
768
777
  console.group("Generator breakdown:");
@@ -849,8 +858,12 @@ var init_PipelineDebugger = __esm({
849
858
  console.group(`\u{1F3B4} Card: ${cardId}`);
850
859
  logger.info(`Course: ${card.courseId}`);
851
860
  logger.info(`Origin: ${card.origin}`);
861
+ logger.info(`Card ELO: ${card.cardElo ?? "unknown"} | User ELO: ${run.userElo ?? "unknown"}`);
852
862
  logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
853
863
  logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
864
+ if (card.tags && card.tags.length > 0) {
865
+ logger.info(`Tags (${card.tags.length}): ${card.tags.join(", ")}`);
866
+ }
854
867
  logger.info("Provenance:");
855
868
  logger.info(formatProvenance(card.provenance));
856
869
  console.groupEnd();
@@ -1014,6 +1027,27 @@ var init_PipelineDebugger = __esm({
1014
1027
  }
1015
1028
  return _activePipeline.diagnoseCardSpace({ threshold });
1016
1029
  },
1030
+ /**
1031
+ * Show user's per-tag ELO data. Useful for diagnosing hierarchy gate status.
1032
+ *
1033
+ * @param tagFilter - Optional glob pattern(s) to filter tags.
1034
+ * Examples: 'gpc:expose:*', 'gpc:intro:t-T', ['gpc:expose:t-*', 'gpc:intro:t-*']
1035
+ */
1036
+ async showTagElo(tagFilter) {
1037
+ if (!_activePipeline) {
1038
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
1039
+ return;
1040
+ }
1041
+ const status = await _activePipeline.getTagEloStatus(tagFilter);
1042
+ const entries = Object.entries(status).sort(([a], [b]) => a.localeCompare(b));
1043
+ if (entries.length === 0) {
1044
+ logger.info(`[Pipeline Debug] No tag ELO data found${tagFilter ? ` for pattern: ${tagFilter}` : ""}.`);
1045
+ return;
1046
+ }
1047
+ console.table(
1048
+ Object.fromEntries(entries.map(([tag, data]) => [tag, { score: Math.round(data.score), count: data.count }]))
1049
+ );
1050
+ },
1017
1051
  /**
1018
1052
  * Show help.
1019
1053
  */
@@ -1025,6 +1059,7 @@ Commands:
1025
1059
  .showLastRun() Show summary of most recent pipeline run
1026
1060
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
1027
1061
  .showCard(cardId) Show provenance trail for a specific card
1062
+ .showTagElo(pattern) Show user's tag ELO data (async). E.g. 'gpc:expose:*'
1028
1063
  .explainReviews() Analyze why reviews were/weren't selected
1029
1064
  .diagnoseCardSpace() Scan full card space through filters (async)
1030
1065
  .showRegistry() Show navigator registry (classes + roles)
@@ -1338,60 +1373,423 @@ var prescribed_exports = {};
1338
1373
  __export(prescribed_exports, {
1339
1374
  default: () => PrescribedCardsGenerator
1340
1375
  });
1341
- var PrescribedCardsGenerator;
1376
+ function dedupe(arr) {
1377
+ return [...new Set(arr)];
1378
+ }
1379
+ function isoNow() {
1380
+ return (/* @__PURE__ */ new Date()).toISOString();
1381
+ }
1382
+ function clamp(value, min, max) {
1383
+ return Math.max(min, Math.min(max, value));
1384
+ }
1385
+ function matchesTagPattern(tag, pattern) {
1386
+ if (pattern === "*") return true;
1387
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
1388
+ const re = new RegExp(`^${escaped}$`);
1389
+ return re.test(tag);
1390
+ }
1391
+ function pickTopByScore(cards, limit) {
1392
+ return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1393
+ }
1394
+ 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;
1342
1395
  var init_prescribed = __esm({
1343
1396
  "src/core/navigators/generators/prescribed.ts"() {
1344
1397
  "use strict";
1345
1398
  init_navigators();
1346
1399
  init_logger();
1400
+ DEFAULT_FRESHNESS_WINDOW = 3;
1401
+ DEFAULT_MAX_DIRECT_PER_RUN = 3;
1402
+ DEFAULT_MAX_SUPPORT_PER_RUN = 3;
1403
+ DEFAULT_HIERARCHY_DEPTH = 2;
1404
+ DEFAULT_MIN_COUNT = 3;
1405
+ BASE_TARGET_SCORE = 1;
1406
+ BASE_SUPPORT_SCORE = 0.8;
1407
+ MAX_TARGET_MULTIPLIER = 8;
1408
+ MAX_SUPPORT_MULTIPLIER = 4;
1409
+ LOCKED_TAG_PREFIXES = ["concept:"];
1410
+ LESSON_GATE_PENALTY_TAG_HINT = "concept:";
1347
1411
  PrescribedCardsGenerator = class extends ContentNavigator {
1348
1412
  name;
1349
1413
  config;
1350
1414
  constructor(user, course, strategyData) {
1351
1415
  super(user, course, strategyData);
1352
1416
  this.name = strategyData.name || "Prescribed Cards";
1353
- try {
1354
- const parsed = JSON.parse(strategyData.serializedData);
1355
- this.config = { cardIds: parsed.cardIds || [] };
1356
- } catch {
1357
- this.config = { cardIds: [] };
1358
- }
1417
+ this.config = this.parseConfig(strategyData.serializedData);
1359
1418
  logger.debug(
1360
- `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1419
+ `[Prescribed] Initialized with ${this.config.groups.length} groups and ${this.config.groups.reduce((n, g) => n + g.targetCardIds.length, 0)} targets`
1361
1420
  );
1362
1421
  }
1363
- async getWeightedCards(limit, _context) {
1364
- if (this.config.cardIds.length === 0) {
1422
+ get strategyKey() {
1423
+ return "PrescribedProgress";
1424
+ }
1425
+ async getWeightedCards(limit, context) {
1426
+ if (this.config.groups.length === 0 || limit <= 0) {
1365
1427
  return [];
1366
1428
  }
1367
1429
  const courseId = this.course.getCourseID();
1368
1430
  const activeCards = await this.user.getActiveCards();
1369
1431
  const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1370
- const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1371
- if (eligibleIds.length === 0) {
1372
- logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1432
+ const seenCards = await this.user.getSeenCards(courseId).catch(() => []);
1433
+ const seenIds = new Set(seenCards);
1434
+ const progress = await this.getStrategyState() ?? {
1435
+ updatedAt: isoNow(),
1436
+ groups: {}
1437
+ };
1438
+ const hierarchyConfigs = await this.loadHierarchyConfigs();
1439
+ const courseReg = await this.user.getCourseRegDoc(courseId).catch(() => null);
1440
+ const userGlobalElo = typeof courseReg?.elo === "number" ? courseReg.elo : courseReg?.elo?.global?.score ?? context?.userElo ?? 1e3;
1441
+ const userTagElo = typeof courseReg?.elo === "number" ? {} : courseReg?.elo?.tags ?? {};
1442
+ const allTargetIds = dedupe(this.config.groups.flatMap((g) => g.targetCardIds));
1443
+ const allSupportIds = dedupe(this.config.groups.flatMap((g) => g.supportCardIds ?? []));
1444
+ const allRelevantIds = dedupe([...allTargetIds, ...allSupportIds]);
1445
+ const tagsByCard = allRelevantIds.length > 0 ? await this.course.getAppliedTagsBatch(allRelevantIds) : /* @__PURE__ */ new Map();
1446
+ const nextState = {
1447
+ updatedAt: isoNow(),
1448
+ groups: {}
1449
+ };
1450
+ const emitted = [];
1451
+ const emittedIds = /* @__PURE__ */ new Set();
1452
+ for (const group of this.config.groups) {
1453
+ const runtime = this.buildGroupRuntimeState({
1454
+ group,
1455
+ priorState: progress.groups[group.id],
1456
+ activeIds,
1457
+ seenIds,
1458
+ tagsByCard,
1459
+ hierarchyConfigs,
1460
+ userTagElo,
1461
+ userGlobalElo
1462
+ });
1463
+ nextState.groups[group.id] = this.buildNextGroupState(runtime, progress.groups[group.id]);
1464
+ const directCards = this.buildDirectTargetCards(
1465
+ runtime,
1466
+ courseId,
1467
+ emittedIds
1468
+ );
1469
+ const supportCards = this.buildSupportCards(
1470
+ runtime,
1471
+ courseId,
1472
+ emittedIds
1473
+ );
1474
+ emitted.push(...directCards, ...supportCards);
1475
+ }
1476
+ if (emitted.length === 0) {
1477
+ logger.debug("[Prescribed] No prescribed targets/support emitted this run");
1478
+ await this.putStrategyState(nextState).catch((e) => {
1479
+ logger.debug(`[Prescribed] Failed to persist empty-state update: ${e}`);
1480
+ });
1373
1481
  return [];
1374
1482
  }
1375
- const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1376
- cardId,
1377
- courseId,
1378
- score: 1,
1379
- provenance: [
1380
- {
1381
- strategy: "prescribed",
1382
- strategyName: this.strategyName || this.name,
1383
- strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1384
- action: "generated",
1385
- score: 1,
1386
- reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1483
+ const finalCards = pickTopByScore(emitted, limit);
1484
+ const surfacedByGroup = /* @__PURE__ */ new Map();
1485
+ for (const card of finalCards) {
1486
+ const prov = card.provenance[0];
1487
+ const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
1488
+ const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
1489
+ if (!groupId) continue;
1490
+ if (!surfacedByGroup.has(groupId)) {
1491
+ surfacedByGroup.set(groupId, { targetIds: [], supportIds: [] });
1492
+ }
1493
+ surfacedByGroup.get(groupId)[mode].push(card.cardId);
1494
+ }
1495
+ for (const group of this.config.groups) {
1496
+ const groupState = nextState.groups[group.id];
1497
+ const surfaced = surfacedByGroup.get(group.id);
1498
+ if (surfaced && (surfaced.targetIds.length > 0 || surfaced.supportIds.length > 0)) {
1499
+ groupState.lastSurfacedAt = isoNow();
1500
+ groupState.sessionsSinceSurfaced = 0;
1501
+ if (surfaced.supportIds.length > 0) {
1502
+ groupState.lastSupportAt = isoNow();
1387
1503
  }
1388
- ]
1389
- }));
1504
+ }
1505
+ }
1506
+ await this.putStrategyState(nextState).catch((e) => {
1507
+ logger.debug(`[Prescribed] Failed to persist prescribed progress: ${e}`);
1508
+ });
1390
1509
  logger.info(
1391
- `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1510
+ `[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)`
1392
1511
  );
1512
+ return finalCards;
1513
+ }
1514
+ parseConfig(serializedData) {
1515
+ try {
1516
+ const parsed = JSON.parse(serializedData);
1517
+ const groupsRaw = Array.isArray(parsed.groups) ? parsed.groups : [];
1518
+ const groups = groupsRaw.map((raw, i) => ({
1519
+ id: typeof raw.id === "string" && raw.id.trim().length > 0 ? raw.id : `group-${i + 1}`,
1520
+ targetCardIds: dedupe(Array.isArray(raw.targetCardIds) ? raw.targetCardIds.filter((v) => typeof v === "string") : []),
1521
+ supportCardIds: dedupe(Array.isArray(raw.supportCardIds) ? raw.supportCardIds.filter((v) => typeof v === "string") : []),
1522
+ supportTagPatterns: dedupe(Array.isArray(raw.supportTagPatterns) ? raw.supportTagPatterns.filter((v) => typeof v === "string") : []),
1523
+ freshnessWindowSessions: typeof raw.freshnessWindowSessions === "number" ? raw.freshnessWindowSessions : DEFAULT_FRESHNESS_WINDOW,
1524
+ maxDirectTargetsPerRun: typeof raw.maxDirectTargetsPerRun === "number" ? raw.maxDirectTargetsPerRun : DEFAULT_MAX_DIRECT_PER_RUN,
1525
+ maxSupportCardsPerRun: typeof raw.maxSupportCardsPerRun === "number" ? raw.maxSupportCardsPerRun : DEFAULT_MAX_SUPPORT_PER_RUN,
1526
+ hierarchyWalk: {
1527
+ enabled: raw.hierarchyWalk?.enabled !== false,
1528
+ maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
1529
+ },
1530
+ retireOnEncounter: raw.retireOnEncounter !== false
1531
+ })).filter((g) => g.targetCardIds.length > 0);
1532
+ return { groups };
1533
+ } catch {
1534
+ return { groups: [] };
1535
+ }
1536
+ }
1537
+ async loadHierarchyConfigs() {
1538
+ try {
1539
+ const strategies = await this.course.getNavigationStrategies();
1540
+ return strategies.filter((s) => s.implementingClass === "hierarchyDefinition").map((s) => {
1541
+ try {
1542
+ const parsed = JSON.parse(s.serializedData);
1543
+ return {
1544
+ prerequisites: parsed.prerequisites || {}
1545
+ };
1546
+ } catch {
1547
+ return { prerequisites: {} };
1548
+ }
1549
+ });
1550
+ } catch (e) {
1551
+ logger.debug(`[Prescribed] Failed to load hierarchy configs: ${e}`);
1552
+ return [];
1553
+ }
1554
+ }
1555
+ buildGroupRuntimeState(args) {
1556
+ const {
1557
+ group,
1558
+ priorState,
1559
+ activeIds,
1560
+ seenIds,
1561
+ tagsByCard,
1562
+ hierarchyConfigs,
1563
+ userTagElo,
1564
+ userGlobalElo
1565
+ } = args;
1566
+ const encounteredTargets = /* @__PURE__ */ new Set();
1567
+ for (const cardId of group.targetCardIds) {
1568
+ if (activeIds.has(cardId) || seenIds.has(cardId)) {
1569
+ encounteredTargets.add(cardId);
1570
+ }
1571
+ }
1572
+ if (priorState?.encounteredCardIds?.length) {
1573
+ for (const cardId of priorState.encounteredCardIds) {
1574
+ encounteredTargets.add(cardId);
1575
+ }
1576
+ }
1577
+ const pendingTargets = group.targetCardIds.filter((id) => !encounteredTargets.has(id));
1578
+ const targetTags = /* @__PURE__ */ new Map();
1579
+ for (const cardId of pendingTargets) {
1580
+ targetTags.set(cardId, tagsByCard.get(cardId) ?? []);
1581
+ }
1582
+ const blockedTargets = [];
1583
+ const surfaceableTargets = [];
1584
+ const supportTags = /* @__PURE__ */ new Set();
1585
+ for (const cardId of pendingTargets) {
1586
+ const tags = targetTags.get(cardId) ?? [];
1587
+ const resolution = this.resolveBlockedSupportTags(
1588
+ tags,
1589
+ hierarchyConfigs,
1590
+ userTagElo,
1591
+ userGlobalElo,
1592
+ group.hierarchyWalk?.enabled !== false,
1593
+ group.hierarchyWalk?.maxDepth ?? DEFAULT_HIERARCHY_DEPTH
1594
+ );
1595
+ if (resolution.blocked) {
1596
+ blockedTargets.push(cardId);
1597
+ resolution.supportTags.forEach((t) => supportTags.add(t));
1598
+ } else {
1599
+ surfaceableTargets.push(cardId);
1600
+ }
1601
+ }
1602
+ const supportCandidates = dedupe([
1603
+ ...group.supportCardIds ?? [],
1604
+ ...this.findSupportCardsByTags(
1605
+ group,
1606
+ tagsByCard,
1607
+ [...supportTags]
1608
+ )
1609
+ ]).filter((id) => !activeIds.has(id) && !seenIds.has(id));
1610
+ const sessionsSinceSurfaced = priorState?.sessionsSinceSurfaced ?? 0;
1611
+ const freshnessWindow = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
1612
+ const staleSessions = Math.max(0, sessionsSinceSurfaced - freshnessWindow);
1613
+ const pressureMultiplier = pendingTargets.length === 0 ? 1 : clamp(1 + staleSessions * 0.75 + Math.min(2, pendingTargets.length * 0.1), 1, MAX_TARGET_MULTIPLIER);
1614
+ const supportMultiplier = blockedTargets.length === 0 ? 1 : clamp(1 + staleSessions * 0.5 + Math.min(1.5, blockedTargets.length * 0.15), 1, MAX_SUPPORT_MULTIPLIER);
1615
+ return {
1616
+ group,
1617
+ encounteredTargets,
1618
+ pendingTargets,
1619
+ blockedTargets,
1620
+ surfaceableTargets,
1621
+ targetTags,
1622
+ supportCandidates,
1623
+ supportTags: [...supportTags],
1624
+ pressureMultiplier,
1625
+ supportMultiplier
1626
+ };
1627
+ }
1628
+ buildNextGroupState(runtime, prior) {
1629
+ const carriedSessions = prior?.sessionsSinceSurfaced ?? 0;
1630
+ const surfacedThisRun = false;
1631
+ return {
1632
+ encounteredCardIds: [...runtime.encounteredTargets].sort(),
1633
+ lastSurfacedAt: prior?.lastSurfacedAt ?? null,
1634
+ sessionsSinceSurfaced: surfacedThisRun ? 0 : carriedSessions + 1,
1635
+ lastSupportAt: prior?.lastSupportAt ?? null,
1636
+ blockedTargetIds: [...runtime.blockedTargets].sort(),
1637
+ lastResolvedSupportTags: [...runtime.supportTags].sort()
1638
+ };
1639
+ }
1640
+ buildDirectTargetCards(runtime, courseId, emittedIds) {
1641
+ const maxDirect = runtime.group.maxDirectTargetsPerRun ?? DEFAULT_MAX_DIRECT_PER_RUN;
1642
+ const directIds = runtime.surfaceableTargets.filter((id) => !emittedIds.has(id)).slice(0, maxDirect);
1643
+ const cards = [];
1644
+ for (const cardId of directIds) {
1645
+ emittedIds.add(cardId);
1646
+ cards.push({
1647
+ cardId,
1648
+ courseId,
1649
+ score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
1650
+ provenance: [
1651
+ {
1652
+ strategy: "prescribed",
1653
+ strategyName: this.strategyName || this.name,
1654
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1655
+ action: "generated",
1656
+ score: BASE_TARGET_SCORE * runtime.pressureMultiplier,
1657
+ reason: `mode=target;group=${runtime.group.id};pending=${runtime.pendingTargets.length};surfaceable=${runtime.surfaceableTargets.length};blocked=${runtime.blockedTargets.length};multiplier=${runtime.pressureMultiplier.toFixed(2)}`
1658
+ }
1659
+ ]
1660
+ });
1661
+ }
1662
+ return cards;
1663
+ }
1664
+ buildSupportCards(runtime, courseId, emittedIds) {
1665
+ if (runtime.blockedTargets.length === 0 || runtime.supportCandidates.length === 0) {
1666
+ return [];
1667
+ }
1668
+ const maxSupport = runtime.group.maxSupportCardsPerRun ?? DEFAULT_MAX_SUPPORT_PER_RUN;
1669
+ const supportIds = runtime.supportCandidates.filter((id) => !emittedIds.has(id)).slice(0, maxSupport);
1670
+ const cards = [];
1671
+ for (const cardId of supportIds) {
1672
+ emittedIds.add(cardId);
1673
+ cards.push({
1674
+ cardId,
1675
+ courseId,
1676
+ score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
1677
+ provenance: [
1678
+ {
1679
+ strategy: "prescribed",
1680
+ strategyName: this.strategyName || this.name,
1681
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1682
+ action: "generated",
1683
+ score: BASE_SUPPORT_SCORE * runtime.supportMultiplier,
1684
+ reason: `mode=support;group=${runtime.group.id};blocked=${runtime.blockedTargets.length};supportTags=${runtime.supportTags.join("|") || "none"};multiplier=${runtime.supportMultiplier.toFixed(2)}`
1685
+ }
1686
+ ]
1687
+ });
1688
+ }
1393
1689
  return cards;
1394
1690
  }
1691
+ findSupportCardsByTags(group, tagsByCard, supportTags) {
1692
+ if (supportTags.length === 0) {
1693
+ return [];
1694
+ }
1695
+ const explicitSupportIds = group.supportCardIds ?? [];
1696
+ const explicitPatterns = group.supportTagPatterns ?? [];
1697
+ if (explicitSupportIds.length === 0 && explicitPatterns.length === 0) {
1698
+ return [];
1699
+ }
1700
+ const candidates = /* @__PURE__ */ new Set();
1701
+ for (const cardId of explicitSupportIds) {
1702
+ const cardTags = tagsByCard.get(cardId) ?? [];
1703
+ const matchesResolved = supportTags.some((supportTag) => cardTags.includes(supportTag));
1704
+ const matchesPattern = explicitPatterns.some(
1705
+ (pattern) => cardTags.some((tag) => matchesTagPattern(tag, pattern))
1706
+ );
1707
+ if (matchesResolved || matchesPattern) {
1708
+ candidates.add(cardId);
1709
+ }
1710
+ }
1711
+ return [...candidates];
1712
+ }
1713
+ resolveBlockedSupportTags(targetTags, hierarchyConfigs, userTagElo, userGlobalElo, hierarchyWalkEnabled, maxDepth) {
1714
+ if (!hierarchyWalkEnabled || targetTags.length === 0 || hierarchyConfigs.length === 0) {
1715
+ return {
1716
+ blocked: false,
1717
+ supportTags: []
1718
+ };
1719
+ }
1720
+ const supportTags = /* @__PURE__ */ new Set();
1721
+ let blocked = false;
1722
+ for (const targetTag of targetTags) {
1723
+ for (const hierarchy of hierarchyConfigs) {
1724
+ const prereqs = hierarchy.prerequisites[targetTag];
1725
+ if (!prereqs || prereqs.length === 0) continue;
1726
+ const unmet = prereqs.filter(
1727
+ (pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
1728
+ );
1729
+ if (unmet.length === 0) {
1730
+ continue;
1731
+ }
1732
+ blocked = true;
1733
+ for (const prereq of unmet) {
1734
+ this.collectSupportTagsRecursive(
1735
+ prereq.tag,
1736
+ hierarchyConfigs,
1737
+ userTagElo,
1738
+ userGlobalElo,
1739
+ maxDepth,
1740
+ /* @__PURE__ */ new Set(),
1741
+ supportTags
1742
+ );
1743
+ }
1744
+ }
1745
+ }
1746
+ return { blocked, supportTags: [...supportTags] };
1747
+ }
1748
+ collectSupportTagsRecursive(tag, hierarchyConfigs, userTagElo, userGlobalElo, depth, visited, out) {
1749
+ if (depth < 0 || visited.has(tag)) return;
1750
+ if (this.isHardGatedTag(tag)) return;
1751
+ visited.add(tag);
1752
+ let walkedFurther = false;
1753
+ for (const hierarchy of hierarchyConfigs) {
1754
+ const prereqs = hierarchy.prerequisites[tag];
1755
+ if (!prereqs || prereqs.length === 0) continue;
1756
+ const unmet = prereqs.filter(
1757
+ (pr) => !this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo)
1758
+ );
1759
+ if (unmet.length > 0 && depth > 0) {
1760
+ walkedFurther = true;
1761
+ for (const prereq of unmet) {
1762
+ this.collectSupportTagsRecursive(
1763
+ prereq.tag,
1764
+ hierarchyConfigs,
1765
+ userTagElo,
1766
+ userGlobalElo,
1767
+ depth - 1,
1768
+ visited,
1769
+ out
1770
+ );
1771
+ }
1772
+ }
1773
+ }
1774
+ if (!walkedFurther) {
1775
+ out.add(tag);
1776
+ }
1777
+ }
1778
+ isHardGatedTag(tag) {
1779
+ return LOCKED_TAG_PREFIXES.some((prefix) => tag.startsWith(prefix)) && tag.startsWith(LESSON_GATE_PENALTY_TAG_HINT);
1780
+ }
1781
+ isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1782
+ if (!userTagElo) return false;
1783
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
1784
+ if (userTagElo.count < minCount) return false;
1785
+ if (prereq.masteryThreshold?.minElo !== void 0) {
1786
+ return userTagElo.score >= prereq.masteryThreshold.minElo;
1787
+ }
1788
+ if (prereq.masteryThreshold?.minCount !== void 0) {
1789
+ return true;
1790
+ }
1791
+ return userTagElo.score >= userGlobalElo;
1792
+ }
1395
1793
  };
1396
1794
  }
1397
1795
  });
@@ -1755,12 +2153,13 @@ __export(hierarchyDefinition_exports, {
1755
2153
  default: () => HierarchyDefinitionNavigator
1756
2154
  });
1757
2155
  import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
1758
- var DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
2156
+ var DEFAULT_MIN_COUNT2, HierarchyDefinitionNavigator;
1759
2157
  var init_hierarchyDefinition = __esm({
1760
2158
  "src/core/navigators/filters/hierarchyDefinition.ts"() {
1761
2159
  "use strict";
1762
2160
  init_navigators();
1763
- DEFAULT_MIN_COUNT = 3;
2161
+ init_logger();
2162
+ DEFAULT_MIN_COUNT2 = 3;
1764
2163
  HierarchyDefinitionNavigator = class extends ContentNavigator {
1765
2164
  config;
1766
2165
  /** Human-readable name for CardFilter interface */
@@ -1787,7 +2186,7 @@ var init_hierarchyDefinition = __esm({
1787
2186
  */
1788
2187
  isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1789
2188
  if (!userTagElo) return false;
1790
- const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
2189
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
1791
2190
  if (userTagElo.count < minCount) return false;
1792
2191
  if (prereq.masteryThreshold?.minElo !== void 0) {
1793
2192
  return userTagElo.score >= prereq.masteryThreshold.minElo;
@@ -1888,18 +2287,55 @@ var init_hierarchyDefinition = __esm({
1888
2287
  }
1889
2288
  return boosts;
1890
2289
  }
2290
+ /**
2291
+ * Build a map of gated tag → max configured targetBoost for all *open* gates.
2292
+ *
2293
+ * When a gate opens (prereqs met), cards carrying the gated tag get boosted —
2294
+ * ensuring newly-unlocked content surfaces promptly. The boost is a static
2295
+ * multiplier; natural ELO/SRS deprioritization after interaction handles decay.
2296
+ */
2297
+ getTargetBoosts(unlockedTags) {
2298
+ const boosts = /* @__PURE__ */ new Map();
2299
+ const configKeys = Object.keys(this.config.prerequisites);
2300
+ const unlockedArr = [...unlockedTags];
2301
+ logger.info(
2302
+ `[HierarchyDefinition:targetBoost:trace] ${this.name} | configKeys=${configKeys.length}, unlocked=${unlockedArr.length} (${unlockedArr.slice(0, 5).join(", ")}${unlockedArr.length > 5 ? "..." : ""})`
2303
+ );
2304
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
2305
+ if (!unlockedTags.has(tagId)) continue;
2306
+ logger.info(
2307
+ `[HierarchyDefinition:targetBoost:trace] UNLOCKED ${tagId}: ${prereqs.length} prereqs, raw=${JSON.stringify(prereqs.map((p) => ({ tag: p.tag, tb: p.targetBoost })))}`
2308
+ );
2309
+ for (const prereq of prereqs) {
2310
+ if (!prereq.targetBoost || prereq.targetBoost <= 1) continue;
2311
+ const existing = boosts.get(tagId) ?? 1;
2312
+ boosts.set(tagId, Math.max(existing, prereq.targetBoost));
2313
+ }
2314
+ }
2315
+ if (boosts.size > 0) {
2316
+ logger.info(
2317
+ `[HierarchyDefinition] targetBoosts active: ${[...boosts.entries()].map(([t, b]) => `${t}=\xD7${b}`).join(", ")}`
2318
+ );
2319
+ } else {
2320
+ logger.info(
2321
+ `[HierarchyDefinition:targetBoost:trace] no targetBoosts found despite ${unlockedArr.length} unlocked tags`
2322
+ );
2323
+ }
2324
+ return boosts;
2325
+ }
1891
2326
  /**
1892
2327
  * CardFilter.transform implementation.
1893
2328
  *
1894
- * Two effects:
1895
- * 1. Cards with locked tags receive score * 0.05 (gating penalty)
1896
- * 2. Cards carrying prereq tags of closed gates receive a configured
1897
- * boost (preReqBoost), steering toward content that unlocks gates
2329
+ * Three effects:
2330
+ * 1. Cards with locked tags receive score * 0.02 (gating penalty)
2331
+ * 2. Cards carrying prereq tags of closed gates receive preReqBoost
2332
+ * 3. Cards carrying gated tags of open gates receive targetBoost
1898
2333
  */
1899
2334
  async transform(cards, context) {
1900
2335
  const masteredTags = await this.getMasteredTags(context);
1901
2336
  const unlockedTags = this.getUnlockedTags(masteredTags);
1902
2337
  const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
2338
+ const targetBoosts = this.getTargetBoosts(unlockedTags);
1903
2339
  const gated = [];
1904
2340
  for (const card of cards) {
1905
2341
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1927,6 +2363,29 @@ var init_hierarchyDefinition = __esm({
1927
2363
  finalScore *= maxBoost;
1928
2364
  action = "boosted";
1929
2365
  finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
2366
+ logger.info(
2367
+ `[HierarchyDefinition] preReqBoost \xD7${maxBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedPrereqs.join(", ")}] (score: ${card.score.toFixed(3)} \u2192 ${finalScore.toFixed(3)})`
2368
+ );
2369
+ }
2370
+ }
2371
+ if (isUnlocked && targetBoosts.size > 0) {
2372
+ const cardTags = card.tags ?? [];
2373
+ let maxTargetBoost = 1;
2374
+ const boostedTargets = [];
2375
+ for (const tag of cardTags) {
2376
+ const boost = targetBoosts.get(tag);
2377
+ if (boost && boost > maxTargetBoost) {
2378
+ maxTargetBoost = boost;
2379
+ boostedTargets.push(tag);
2380
+ }
2381
+ }
2382
+ if (maxTargetBoost > 1) {
2383
+ finalScore *= maxTargetBoost;
2384
+ action = "boosted";
2385
+ finalReason = `${finalReason} | targetBoost \xD7${maxTargetBoost.toFixed(2)} for ${boostedTargets.join(", ")}`;
2386
+ logger.info(
2387
+ `[HierarchyDefinition] targetBoost \xD7${maxTargetBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedTargets.join(", ")}] (score: ${card.score.toFixed(3)} \u2192 ${finalScore.toFixed(3)})`
2388
+ );
1930
2389
  }
1931
2390
  }
1932
2391
  gated.push({
@@ -2114,12 +2573,12 @@ __export(interferenceMitigator_exports, {
2114
2573
  default: () => InterferenceMitigatorNavigator
2115
2574
  });
2116
2575
  import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
2117
- var DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
2576
+ var DEFAULT_MIN_COUNT3, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
2118
2577
  var init_interferenceMitigator = __esm({
2119
2578
  "src/core/navigators/filters/interferenceMitigator.ts"() {
2120
2579
  "use strict";
2121
2580
  init_navigators();
2122
- DEFAULT_MIN_COUNT2 = 10;
2581
+ DEFAULT_MIN_COUNT3 = 10;
2123
2582
  DEFAULT_MIN_ELAPSED_DAYS = 3;
2124
2583
  DEFAULT_INTERFERENCE_DECAY = 0.8;
2125
2584
  InterferenceMitigatorNavigator = class extends ContentNavigator {
@@ -2144,7 +2603,7 @@ var init_interferenceMitigator = __esm({
2144
2603
  return {
2145
2604
  interferenceSets: sets,
2146
2605
  maturityThreshold: {
2147
- minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
2606
+ minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT3,
2148
2607
  minElo: parsed.maturityThreshold?.minElo,
2149
2608
  minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
2150
2609
  },
@@ -2154,7 +2613,7 @@ var init_interferenceMitigator = __esm({
2154
2613
  return {
2155
2614
  interferenceSets: [],
2156
2615
  maturityThreshold: {
2157
- minCount: DEFAULT_MIN_COUNT2,
2616
+ minCount: DEFAULT_MIN_COUNT3,
2158
2617
  minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
2159
2618
  },
2160
2619
  defaultDecay: DEFAULT_INTERFERENCE_DECAY
@@ -2201,7 +2660,7 @@ var init_interferenceMitigator = __esm({
2201
2660
  try {
2202
2661
  const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
2203
2662
  const userElo = toCourseElo4(courseReg.elo);
2204
- const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
2663
+ const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT3;
2205
2664
  const minElo = this.config.maturityThreshold?.minElo;
2206
2665
  const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
2207
2666
  const minCountForElapsed = minElapsedDays * 2;
@@ -2436,7 +2895,7 @@ var init_relativePriority = __esm({
2436
2895
  const cardTags = card.tags ?? [];
2437
2896
  const priority = this.computeCardPriority(cardTags);
2438
2897
  const boostFactor = this.computeBoostFactor(priority);
2439
- const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
2898
+ const finalScore = Math.max(0, card.score * boostFactor);
2440
2899
  const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
2441
2900
  const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
2442
2901
  return {
@@ -2712,160 +3171,1718 @@ var init_learning = __esm({
2712
3171
  }
2713
3172
  });
2714
3173
 
2715
- // src/core/orchestration/signal.ts
2716
- function computeOutcomeSignal(records, config = {}) {
2717
- if (!records || records.length === 0) {
2718
- return null;
3174
+ // src/core/orchestration/signal.ts
3175
+ function computeOutcomeSignal(records, config = {}) {
3176
+ if (!records || records.length === 0) {
3177
+ return null;
3178
+ }
3179
+ const target = config.targetAccuracy ?? 0.85;
3180
+ const tolerance = config.tolerance ?? 0.05;
3181
+ let correct = 0;
3182
+ for (const r of records) {
3183
+ if (r.isCorrect) correct++;
3184
+ }
3185
+ const accuracy = correct / records.length;
3186
+ return scoreAccuracyInZone(accuracy, target, tolerance);
3187
+ }
3188
+ function scoreAccuracyInZone(accuracy, target, tolerance) {
3189
+ const dist = Math.abs(accuracy - target);
3190
+ if (dist <= tolerance) {
3191
+ return 1;
3192
+ }
3193
+ const excess = dist - tolerance;
3194
+ const slope = 2.5;
3195
+ return Math.max(0, 1 - excess * slope);
3196
+ }
3197
+ var init_signal = __esm({
3198
+ "src/core/orchestration/signal.ts"() {
3199
+ "use strict";
3200
+ }
3201
+ });
3202
+
3203
+ // src/core/orchestration/recording.ts
3204
+ async function recordUserOutcome(context, periodStart, periodEnd, records, activeStrategyIds, eloStart = 0, eloEnd = 0, config) {
3205
+ const { user, course, userId } = context;
3206
+ const courseId = course.getCourseID();
3207
+ const outcomeValue = computeOutcomeSignal(records, config);
3208
+ if (outcomeValue === null) {
3209
+ logger.debug(
3210
+ `[Orchestration] No outcome signal computed for ${userId} (insufficient data). Skipping record.`
3211
+ );
3212
+ return;
3213
+ }
3214
+ const deviations = {};
3215
+ for (const strategyId of activeStrategyIds) {
3216
+ deviations[strategyId] = context.getDeviation(strategyId);
3217
+ }
3218
+ const id = `USER_OUTCOME::${courseId}::${userId}::${periodEnd}`;
3219
+ const record = {
3220
+ _id: id,
3221
+ docType: "USER_OUTCOME" /* USER_OUTCOME */,
3222
+ courseId,
3223
+ userId,
3224
+ periodStart,
3225
+ periodEnd,
3226
+ outcomeValue,
3227
+ deviations,
3228
+ metadata: {
3229
+ sessionsCount: 1,
3230
+ // Assumes recording is triggered per-session currently
3231
+ cardsSeen: records.length,
3232
+ eloStart,
3233
+ eloEnd,
3234
+ signalType: "accuracy_in_zone"
3235
+ }
3236
+ };
3237
+ try {
3238
+ await user.putUserOutcome(record);
3239
+ logger.debug(
3240
+ `[Orchestration] Recorded outcome ${outcomeValue.toFixed(3)} for ${userId} (doc: ${id})`
3241
+ );
3242
+ } catch (e) {
3243
+ logger.error(`[Orchestration] Failed to record outcome: ${e}`);
3244
+ }
3245
+ }
3246
+ var init_recording = __esm({
3247
+ "src/core/orchestration/recording.ts"() {
3248
+ "use strict";
3249
+ init_signal();
3250
+ init_types_legacy();
3251
+ init_logger();
3252
+ }
3253
+ });
3254
+
3255
+ // src/core/orchestration/index.ts
3256
+ function fnv1a(str) {
3257
+ let hash = 2166136261;
3258
+ for (let i = 0; i < str.length; i++) {
3259
+ hash ^= str.charCodeAt(i);
3260
+ hash = Math.imul(hash, 16777619);
3261
+ }
3262
+ return hash >>> 0;
3263
+ }
3264
+ function computeDeviation(userId, strategyId, salt) {
3265
+ const input = `${userId}:${strategyId}:${salt}`;
3266
+ const hash = fnv1a(input);
3267
+ const normalized = hash / 4294967296;
3268
+ return normalized * 2 - 1;
3269
+ }
3270
+ function computeSpread(confidence) {
3271
+ const clampedConfidence = Math.max(0, Math.min(1, confidence));
3272
+ return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
3273
+ }
3274
+ function computeEffectiveWeight(learnable, userId, strategyId, salt) {
3275
+ const deviation = computeDeviation(userId, strategyId, salt);
3276
+ const spread = computeSpread(learnable.confidence);
3277
+ const adjustment = deviation * spread * learnable.weight;
3278
+ const effective = learnable.weight + adjustment;
3279
+ return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
3280
+ }
3281
+ async function createOrchestrationContext(user, course) {
3282
+ let courseConfig;
3283
+ try {
3284
+ courseConfig = await course.getCourseConfig();
3285
+ } catch (e) {
3286
+ logger.error(`[Orchestration] Failed to load course config: ${e}`);
3287
+ courseConfig = {
3288
+ name: "Unknown",
3289
+ description: "",
3290
+ public: false,
3291
+ deleted: false,
3292
+ creator: "",
3293
+ admins: [],
3294
+ moderators: [],
3295
+ dataShapes: [],
3296
+ questionTypes: [],
3297
+ orchestration: { salt: "default" }
3298
+ };
3299
+ }
3300
+ const userId = user.getUsername();
3301
+ const salt = courseConfig.orchestration?.salt || "default_salt";
3302
+ return {
3303
+ user,
3304
+ course,
3305
+ userId,
3306
+ courseConfig,
3307
+ getEffectiveWeight(strategyId, learnable) {
3308
+ return computeEffectiveWeight(learnable, userId, strategyId, salt);
3309
+ },
3310
+ getDeviation(strategyId) {
3311
+ return computeDeviation(userId, strategyId, salt);
3312
+ }
3313
+ };
3314
+ }
3315
+ var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
3316
+ var init_orchestration = __esm({
3317
+ "src/core/orchestration/index.ts"() {
3318
+ "use strict";
3319
+ init_logger();
3320
+ init_gradient();
3321
+ init_learning();
3322
+ init_signal();
3323
+ init_recording();
3324
+ MIN_SPREAD = 0.1;
3325
+ MAX_SPREAD = 0.5;
3326
+ MIN_WEIGHT = 0.1;
3327
+ MAX_WEIGHT = 3;
3328
+ }
3329
+ });
3330
+
3331
+ // src/study/SpacedRepetition.ts
3332
+ import moment4 from "moment";
3333
+ import { isTaggedPerformance } from "@vue-skuilder/common";
3334
+ var duration;
3335
+ var init_SpacedRepetition = __esm({
3336
+ "src/study/SpacedRepetition.ts"() {
3337
+ "use strict";
3338
+ init_util();
3339
+ init_logger();
3340
+ duration = moment4.duration;
3341
+ }
3342
+ });
3343
+
3344
+ // src/study/services/SrsService.ts
3345
+ import moment5 from "moment";
3346
+ var init_SrsService = __esm({
3347
+ "src/study/services/SrsService.ts"() {
3348
+ "use strict";
3349
+ init_couch();
3350
+ init_SpacedRepetition();
3351
+ init_logger();
3352
+ }
3353
+ });
3354
+
3355
+ // src/study/services/EloService.ts
3356
+ import {
3357
+ adjustCourseScores,
3358
+ adjustCourseScoresPerTag,
3359
+ toCourseElo as toCourseElo5
3360
+ } from "@vue-skuilder/common";
3361
+ var init_EloService = __esm({
3362
+ "src/study/services/EloService.ts"() {
3363
+ "use strict";
3364
+ init_logger();
3365
+ }
3366
+ });
3367
+
3368
+ // src/study/services/ResponseProcessor.ts
3369
+ import { isTaggedPerformance as isTaggedPerformance2 } from "@vue-skuilder/common";
3370
+ var init_ResponseProcessor = __esm({
3371
+ "src/study/services/ResponseProcessor.ts"() {
3372
+ "use strict";
3373
+ init_core();
3374
+ init_logger();
3375
+ }
3376
+ });
3377
+
3378
+ // src/study/services/CardHydrationService.ts
3379
+ import {
3380
+ displayableDataToViewData,
3381
+ isCourseElo,
3382
+ toCourseElo as toCourseElo6
3383
+ } from "@vue-skuilder/common";
3384
+ var init_CardHydrationService = __esm({
3385
+ "src/study/services/CardHydrationService.ts"() {
3386
+ "use strict";
3387
+ init_logger();
3388
+ }
3389
+ });
3390
+
3391
+ // src/study/ItemQueue.ts
3392
+ var init_ItemQueue = __esm({
3393
+ "src/study/ItemQueue.ts"() {
3394
+ "use strict";
3395
+ }
3396
+ });
3397
+
3398
+ // src/util/packer/types.ts
3399
+ var init_types3 = __esm({
3400
+ "src/util/packer/types.ts"() {
3401
+ "use strict";
3402
+ }
3403
+ });
3404
+
3405
+ // src/util/packer/CouchDBToStaticPacker.ts
3406
+ var init_CouchDBToStaticPacker = __esm({
3407
+ "src/util/packer/CouchDBToStaticPacker.ts"() {
3408
+ "use strict";
3409
+ init_types_legacy();
3410
+ init_logger();
3411
+ }
3412
+ });
3413
+
3414
+ // src/util/packer/index.ts
3415
+ var init_packer = __esm({
3416
+ "src/util/packer/index.ts"() {
3417
+ "use strict";
3418
+ init_types3();
3419
+ init_CouchDBToStaticPacker();
3420
+ }
3421
+ });
3422
+
3423
+ // src/util/migrator/types.ts
3424
+ var DEFAULT_MIGRATION_OPTIONS;
3425
+ var init_types4 = __esm({
3426
+ "src/util/migrator/types.ts"() {
3427
+ "use strict";
3428
+ DEFAULT_MIGRATION_OPTIONS = {
3429
+ chunkBatchSize: 100,
3430
+ validateRoundTrip: false,
3431
+ cleanupOnFailure: true,
3432
+ timeout: 3e5
3433
+ // 5 minutes
3434
+ };
3435
+ }
3436
+ });
3437
+
3438
+ // src/util/migrator/FileSystemAdapter.ts
3439
+ var FileSystemError;
3440
+ var init_FileSystemAdapter = __esm({
3441
+ "src/util/migrator/FileSystemAdapter.ts"() {
3442
+ "use strict";
3443
+ FileSystemError = class extends Error {
3444
+ constructor(message, operation, filePath, cause) {
3445
+ super(message);
3446
+ this.operation = operation;
3447
+ this.filePath = filePath;
3448
+ this.cause = cause;
3449
+ this.name = "FileSystemError";
3450
+ }
3451
+ };
3452
+ }
3453
+ });
3454
+
3455
+ // src/util/migrator/validation.ts
3456
+ async function validateStaticCourse(staticPath, fs) {
3457
+ const validation = {
3458
+ valid: true,
3459
+ manifestExists: false,
3460
+ chunksExist: false,
3461
+ attachmentsExist: false,
3462
+ errors: [],
3463
+ warnings: []
3464
+ };
3465
+ try {
3466
+ if (fs) {
3467
+ const stats = await fs.stat(staticPath);
3468
+ if (!stats.isDirectory()) {
3469
+ validation.errors.push(`Path is not a directory: ${staticPath}`);
3470
+ validation.valid = false;
3471
+ return validation;
3472
+ }
3473
+ } else if (!nodeFS) {
3474
+ validation.errors.push("File system access not available - validation skipped");
3475
+ validation.valid = false;
3476
+ return validation;
3477
+ } else {
3478
+ const stats = await nodeFS.promises.stat(staticPath);
3479
+ if (!stats.isDirectory()) {
3480
+ validation.errors.push(`Path is not a directory: ${staticPath}`);
3481
+ validation.valid = false;
3482
+ return validation;
3483
+ }
3484
+ }
3485
+ let manifestPath = `${staticPath}/manifest.json`;
3486
+ try {
3487
+ if (fs) {
3488
+ manifestPath = fs.joinPath(staticPath, "manifest.json");
3489
+ if (await fs.exists(manifestPath)) {
3490
+ validation.manifestExists = true;
3491
+ const manifestContent = await fs.readFile(manifestPath);
3492
+ const manifest = JSON.parse(manifestContent);
3493
+ validation.courseId = manifest.courseId;
3494
+ validation.courseName = manifest.courseName;
3495
+ if (!manifest.version || !manifest.courseId || !manifest.chunks || !Array.isArray(manifest.chunks)) {
3496
+ validation.errors.push("Invalid manifest structure");
3497
+ validation.valid = false;
3498
+ }
3499
+ } else {
3500
+ validation.errors.push(`Manifest not found: ${manifestPath}`);
3501
+ validation.valid = false;
3502
+ }
3503
+ } else {
3504
+ manifestPath = `${staticPath}/manifest.json`;
3505
+ await nodeFS.promises.access(manifestPath);
3506
+ validation.manifestExists = true;
3507
+ const manifestContent = await nodeFS.promises.readFile(manifestPath, "utf8");
3508
+ const manifest = JSON.parse(manifestContent);
3509
+ validation.courseId = manifest.courseId;
3510
+ validation.courseName = manifest.courseName;
3511
+ if (!manifest.version || !manifest.courseId || !manifest.chunks || !Array.isArray(manifest.chunks)) {
3512
+ validation.errors.push("Invalid manifest structure");
3513
+ validation.valid = false;
3514
+ }
3515
+ }
3516
+ } catch (error) {
3517
+ const errorMessage = error instanceof FileSystemError ? error.message : `Manifest not found or invalid: ${manifestPath}`;
3518
+ validation.errors.push(errorMessage);
3519
+ validation.valid = false;
3520
+ }
3521
+ let chunksPath = `${staticPath}/chunks`;
3522
+ try {
3523
+ if (fs) {
3524
+ chunksPath = fs.joinPath(staticPath, "chunks");
3525
+ if (await fs.exists(chunksPath)) {
3526
+ const chunksStats = await fs.stat(chunksPath);
3527
+ if (chunksStats.isDirectory()) {
3528
+ validation.chunksExist = true;
3529
+ } else {
3530
+ validation.errors.push(`Chunks path is not a directory: ${chunksPath}`);
3531
+ validation.valid = false;
3532
+ }
3533
+ } else {
3534
+ validation.errors.push(`Chunks directory not found: ${chunksPath}`);
3535
+ validation.valid = false;
3536
+ }
3537
+ } else {
3538
+ chunksPath = `${staticPath}/chunks`;
3539
+ const chunksStats = await nodeFS.promises.stat(chunksPath);
3540
+ if (chunksStats.isDirectory()) {
3541
+ validation.chunksExist = true;
3542
+ } else {
3543
+ validation.errors.push(`Chunks path is not a directory: ${chunksPath}`);
3544
+ validation.valid = false;
3545
+ }
3546
+ }
3547
+ } catch (error) {
3548
+ const errorMessage = error instanceof FileSystemError ? error.message : `Chunks directory not found: ${chunksPath}`;
3549
+ validation.errors.push(errorMessage);
3550
+ validation.valid = false;
3551
+ }
3552
+ let attachmentsPath;
3553
+ try {
3554
+ if (fs) {
3555
+ attachmentsPath = fs.joinPath(staticPath, "attachments");
3556
+ if (await fs.exists(attachmentsPath)) {
3557
+ const attachmentsStats = await fs.stat(attachmentsPath);
3558
+ if (attachmentsStats.isDirectory()) {
3559
+ validation.attachmentsExist = true;
3560
+ }
3561
+ } else {
3562
+ validation.warnings.push(
3563
+ `Attachments directory not found: ${attachmentsPath} (this is OK if course has no attachments)`
3564
+ );
3565
+ }
3566
+ } else {
3567
+ attachmentsPath = `${staticPath}/attachments`;
3568
+ const attachmentsStats = await nodeFS.promises.stat(attachmentsPath);
3569
+ if (attachmentsStats.isDirectory()) {
3570
+ validation.attachmentsExist = true;
3571
+ }
3572
+ }
3573
+ } catch (error) {
3574
+ attachmentsPath = attachmentsPath || `${staticPath}/attachments`;
3575
+ const warningMessage = error instanceof FileSystemError ? error.message : `Attachments directory not found: ${attachmentsPath} (this is OK if course has no attachments)`;
3576
+ validation.warnings.push(warningMessage);
3577
+ }
3578
+ } catch (error) {
3579
+ validation.errors.push(
3580
+ `Failed to validate static course: ${error instanceof Error ? error.message : String(error)}`
3581
+ );
3582
+ validation.valid = false;
3583
+ }
3584
+ return validation;
3585
+ }
3586
+ async function validateMigration(targetDB, expectedCounts, manifest) {
3587
+ const validation = {
3588
+ valid: true,
3589
+ documentCountMatch: false,
3590
+ attachmentIntegrity: false,
3591
+ viewFunctionality: false,
3592
+ issues: []
3593
+ };
3594
+ try {
3595
+ logger.info("Starting migration validation...");
3596
+ const actualCounts = await getActualDocumentCounts(targetDB);
3597
+ validation.documentCountMatch = compareDocumentCounts(
3598
+ expectedCounts,
3599
+ actualCounts,
3600
+ validation.issues
3601
+ );
3602
+ await validateCourseConfig(targetDB, manifest, validation.issues);
3603
+ validation.viewFunctionality = await validateViews(targetDB, manifest, validation.issues);
3604
+ validation.attachmentIntegrity = await validateAttachmentIntegrity(targetDB, validation.issues);
3605
+ validation.valid = validation.documentCountMatch && validation.viewFunctionality && validation.attachmentIntegrity;
3606
+ logger.info(`Migration validation completed. Valid: ${validation.valid}`);
3607
+ if (validation.issues.length > 0) {
3608
+ logger.info(`Validation issues: ${validation.issues.length}`);
3609
+ validation.issues.forEach((issue) => {
3610
+ if (issue.type === "error") {
3611
+ logger.error(`${issue.category}: ${issue.message}`);
3612
+ } else {
3613
+ logger.warn(`${issue.category}: ${issue.message}`);
3614
+ }
3615
+ });
3616
+ }
3617
+ } catch (error) {
3618
+ validation.valid = false;
3619
+ validation.issues.push({
3620
+ type: "error",
3621
+ category: "metadata",
3622
+ message: `Validation failed: ${error instanceof Error ? error.message : String(error)}`
3623
+ });
3624
+ }
3625
+ return validation;
3626
+ }
3627
+ async function getActualDocumentCounts(db) {
3628
+ const counts = {};
3629
+ try {
3630
+ const allDocs = await db.allDocs({ include_docs: true });
3631
+ for (const row of allDocs.rows) {
3632
+ if (row.id.startsWith("_design/")) {
3633
+ counts["_design"] = (counts["_design"] || 0) + 1;
3634
+ continue;
3635
+ }
3636
+ const doc = row.doc;
3637
+ if (doc && doc.docType) {
3638
+ counts[doc.docType] = (counts[doc.docType] || 0) + 1;
3639
+ } else {
3640
+ counts["unknown"] = (counts["unknown"] || 0) + 1;
3641
+ }
3642
+ }
3643
+ } catch (error) {
3644
+ logger.error("Failed to get actual document counts:", error);
3645
+ }
3646
+ return counts;
3647
+ }
3648
+ function compareDocumentCounts(expected, actual, issues) {
3649
+ let countsMatch = true;
3650
+ for (const [docType, expectedCount] of Object.entries(expected)) {
3651
+ const actualCount = actual[docType] || 0;
3652
+ if (actualCount !== expectedCount) {
3653
+ countsMatch = false;
3654
+ issues.push({
3655
+ type: "error",
3656
+ category: "documents",
3657
+ message: `Document count mismatch for ${docType}: expected ${expectedCount}, got ${actualCount}`
3658
+ });
3659
+ }
3660
+ }
3661
+ for (const [docType, actualCount] of Object.entries(actual)) {
3662
+ if (!expected[docType] && docType !== "_design") {
3663
+ issues.push({
3664
+ type: "warning",
3665
+ category: "documents",
3666
+ message: `Unexpected document type found: ${docType} (${actualCount} documents)`
3667
+ });
3668
+ }
3669
+ }
3670
+ return countsMatch;
3671
+ }
3672
+ async function validateCourseConfig(db, manifest, issues) {
3673
+ try {
3674
+ const courseConfig = await db.get("CourseConfig");
3675
+ if (!courseConfig) {
3676
+ issues.push({
3677
+ type: "error",
3678
+ category: "course_config",
3679
+ message: "CourseConfig document not found after migration"
3680
+ });
3681
+ return;
3682
+ }
3683
+ if (!courseConfig.courseID) {
3684
+ issues.push({
3685
+ type: "warning",
3686
+ category: "course_config",
3687
+ message: "CourseConfig document missing courseID field"
3688
+ });
3689
+ }
3690
+ if (courseConfig.courseID !== manifest.courseId) {
3691
+ issues.push({
3692
+ type: "warning",
3693
+ category: "course_config",
3694
+ message: `CourseConfig courseID mismatch: expected ${manifest.courseId}, got ${courseConfig.courseID}`
3695
+ });
3696
+ }
3697
+ logger.debug("CourseConfig document validation passed");
3698
+ } catch (error) {
3699
+ if (error.status === 404) {
3700
+ issues.push({
3701
+ type: "error",
3702
+ category: "course_config",
3703
+ message: "CourseConfig document not found in database"
3704
+ });
3705
+ } else {
3706
+ issues.push({
3707
+ type: "error",
3708
+ category: "course_config",
3709
+ message: `Failed to validate CourseConfig document: ${error instanceof Error ? error.message : String(error)}`
3710
+ });
3711
+ }
3712
+ }
3713
+ }
3714
+ async function validateViews(db, manifest, issues) {
3715
+ let viewsValid = true;
3716
+ try {
3717
+ for (const designDoc of manifest.designDocs) {
3718
+ try {
3719
+ const doc = await db.get(designDoc._id);
3720
+ if (!doc) {
3721
+ viewsValid = false;
3722
+ issues.push({
3723
+ type: "error",
3724
+ category: "views",
3725
+ message: `Design document not found: ${designDoc._id}`
3726
+ });
3727
+ continue;
3728
+ }
3729
+ for (const viewName of Object.keys(designDoc.views)) {
3730
+ try {
3731
+ const viewPath = `${designDoc._id}/${viewName}`;
3732
+ await db.query(viewPath, { limit: 1 });
3733
+ } catch (viewError) {
3734
+ viewsValid = false;
3735
+ issues.push({
3736
+ type: "error",
3737
+ category: "views",
3738
+ message: `View not accessible: ${designDoc._id}/${viewName} - ${viewError}`
3739
+ });
3740
+ }
3741
+ }
3742
+ } catch (error) {
3743
+ viewsValid = false;
3744
+ issues.push({
3745
+ type: "error",
3746
+ category: "views",
3747
+ message: `Failed to validate design document ${designDoc._id}: ${error}`
3748
+ });
3749
+ }
3750
+ }
3751
+ } catch (error) {
3752
+ viewsValid = false;
3753
+ issues.push({
3754
+ type: "error",
3755
+ category: "views",
3756
+ message: `View validation failed: ${error instanceof Error ? error.message : String(error)}`
3757
+ });
3758
+ }
3759
+ return viewsValid;
3760
+ }
3761
+ async function validateAttachmentIntegrity(db, issues) {
3762
+ let attachmentsValid = true;
3763
+ try {
3764
+ const allDocs = await db.allDocs({
3765
+ include_docs: true,
3766
+ limit: 10
3767
+ // Sample first 10 documents for performance
3768
+ });
3769
+ let attachmentCount = 0;
3770
+ let validAttachments = 0;
3771
+ for (const row of allDocs.rows) {
3772
+ const doc = row.doc;
3773
+ if (doc && doc._attachments) {
3774
+ for (const [attachmentName, _attachmentMeta] of Object.entries(doc._attachments)) {
3775
+ attachmentCount++;
3776
+ try {
3777
+ const attachment = await db.getAttachment(doc._id, attachmentName);
3778
+ if (attachment) {
3779
+ validAttachments++;
3780
+ }
3781
+ } catch (attachmentError) {
3782
+ attachmentsValid = false;
3783
+ issues.push({
3784
+ type: "error",
3785
+ category: "attachments",
3786
+ message: `Attachment not accessible: ${doc._id}/${attachmentName} - ${attachmentError}`
3787
+ });
3788
+ }
3789
+ }
3790
+ }
3791
+ }
3792
+ if (attachmentCount === 0) {
3793
+ issues.push({
3794
+ type: "warning",
3795
+ category: "attachments",
3796
+ message: "No attachments found in sampled documents"
3797
+ });
3798
+ } else {
3799
+ logger.info(`Validated ${validAttachments}/${attachmentCount} sampled attachments`);
3800
+ }
3801
+ } catch (error) {
3802
+ attachmentsValid = false;
3803
+ issues.push({
3804
+ type: "error",
3805
+ category: "attachments",
3806
+ message: `Attachment validation failed: ${error instanceof Error ? error.message : String(error)}`
3807
+ });
3808
+ }
3809
+ return attachmentsValid;
3810
+ }
3811
+ var nodeFS;
3812
+ var init_validation = __esm({
3813
+ "src/util/migrator/validation.ts"() {
3814
+ "use strict";
3815
+ init_logger();
3816
+ init_FileSystemAdapter();
3817
+ nodeFS = null;
3818
+ try {
3819
+ if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
3820
+ nodeFS = eval("require")("fs");
3821
+ nodeFS.promises = nodeFS.promises || eval("require")("fs").promises;
3822
+ }
3823
+ } catch {
3824
+ }
3825
+ }
3826
+ });
3827
+
3828
+ // src/util/migrator/StaticToCouchDBMigrator.ts
3829
+ var nodeFS2, nodePath, StaticToCouchDBMigrator;
3830
+ var init_StaticToCouchDBMigrator = __esm({
3831
+ "src/util/migrator/StaticToCouchDBMigrator.ts"() {
3832
+ "use strict";
3833
+ init_logger();
3834
+ init_types4();
3835
+ init_validation();
3836
+ init_FileSystemAdapter();
3837
+ nodeFS2 = null;
3838
+ nodePath = null;
3839
+ try {
3840
+ if (typeof window === "undefined" && typeof process !== "undefined" && process.versions?.node) {
3841
+ nodeFS2 = eval("require")("fs");
3842
+ nodePath = eval("require")("path");
3843
+ nodeFS2.promises = nodeFS2.promises || eval("require")("fs").promises;
3844
+ }
3845
+ } catch {
3846
+ }
3847
+ StaticToCouchDBMigrator = class {
3848
+ options;
3849
+ progressCallback;
3850
+ fs;
3851
+ constructor(options = {}, fileSystemAdapter) {
3852
+ this.options = {
3853
+ ...DEFAULT_MIGRATION_OPTIONS,
3854
+ ...options
3855
+ };
3856
+ this.fs = fileSystemAdapter;
3857
+ }
3858
+ /**
3859
+ * Set a progress callback to receive updates during migration
3860
+ */
3861
+ setProgressCallback(callback) {
3862
+ this.progressCallback = callback;
3863
+ }
3864
+ /**
3865
+ * Migrate a static course to CouchDB
3866
+ */
3867
+ async migrateCourse(staticPath, targetDB) {
3868
+ const startTime = Date.now();
3869
+ const result = {
3870
+ success: false,
3871
+ documentsRestored: 0,
3872
+ attachmentsRestored: 0,
3873
+ designDocsRestored: 0,
3874
+ courseConfigRestored: 0,
3875
+ errors: [],
3876
+ warnings: [],
3877
+ migrationTime: 0
3878
+ };
3879
+ try {
3880
+ logger.info(`Starting migration from ${staticPath} to CouchDB`);
3881
+ this.reportProgress("manifest", 0, 1, "Validating static course...");
3882
+ const validation = await validateStaticCourse(staticPath, this.fs);
3883
+ if (!validation.valid) {
3884
+ result.errors.push(...validation.errors);
3885
+ throw new Error(`Static course validation failed: ${validation.errors.join(", ")}`);
3886
+ }
3887
+ result.warnings.push(...validation.warnings);
3888
+ this.reportProgress("manifest", 1, 1, "Loading course manifest...");
3889
+ const manifest = await this.loadManifest(staticPath);
3890
+ logger.info(`Loaded manifest for course: ${manifest.courseId} (${manifest.courseName})`);
3891
+ this.reportProgress(
3892
+ "design_docs",
3893
+ 0,
3894
+ manifest.designDocs.length,
3895
+ "Restoring design documents..."
3896
+ );
3897
+ const designDocResults = await this.restoreDesignDocuments(manifest.designDocs, targetDB);
3898
+ result.designDocsRestored = designDocResults.restored;
3899
+ result.errors.push(...designDocResults.errors);
3900
+ result.warnings.push(...designDocResults.warnings);
3901
+ this.reportProgress("course_config", 0, 1, "Restoring CourseConfig document...");
3902
+ const courseConfigResults = await this.restoreCourseConfig(manifest, targetDB);
3903
+ result.courseConfigRestored = courseConfigResults.restored;
3904
+ result.errors.push(...courseConfigResults.errors);
3905
+ result.warnings.push(...courseConfigResults.warnings);
3906
+ this.reportProgress("course_config", 1, 1, "CourseConfig document restored");
3907
+ const expectedCounts = this.calculateExpectedCounts(manifest);
3908
+ this.reportProgress(
3909
+ "documents",
3910
+ 0,
3911
+ manifest.documentCount,
3912
+ "Aggregating documents from chunks..."
3913
+ );
3914
+ const documents = await this.aggregateDocuments(staticPath, manifest);
3915
+ const filteredDocuments = documents.filter((doc) => doc._id !== "CourseConfig");
3916
+ if (documents.length !== filteredDocuments.length) {
3917
+ result.warnings.push(
3918
+ `Filtered out ${documents.length - filteredDocuments.length} CourseConfig document(s) from chunks to prevent conflicts`
3919
+ );
3920
+ }
3921
+ this.reportProgress(
3922
+ "documents",
3923
+ filteredDocuments.length,
3924
+ manifest.documentCount,
3925
+ "Uploading documents to CouchDB..."
3926
+ );
3927
+ const docResults = await this.uploadDocuments(filteredDocuments, targetDB);
3928
+ result.documentsRestored = docResults.restored;
3929
+ result.errors.push(...docResults.errors);
3930
+ result.warnings.push(...docResults.warnings);
3931
+ const docsWithAttachments = documents.filter(
3932
+ (doc) => doc._attachments && Object.keys(doc._attachments).length > 0
3933
+ );
3934
+ this.reportProgress("attachments", 0, docsWithAttachments.length, "Uploading attachments...");
3935
+ const attachmentResults = await this.uploadAttachments(
3936
+ staticPath,
3937
+ docsWithAttachments,
3938
+ targetDB
3939
+ );
3940
+ result.attachmentsRestored = attachmentResults.restored;
3941
+ result.errors.push(...attachmentResults.errors);
3942
+ result.warnings.push(...attachmentResults.warnings);
3943
+ if (this.options.validateRoundTrip) {
3944
+ this.reportProgress("validation", 0, 1, "Validating migration...");
3945
+ const validationResult = await validateMigration(targetDB, expectedCounts, manifest);
3946
+ if (!validationResult.valid) {
3947
+ result.warnings.push("Migration validation found issues");
3948
+ validationResult.issues.forEach((issue) => {
3949
+ if (issue.type === "error") {
3950
+ result.errors.push(`Validation: ${issue.message}`);
3951
+ } else {
3952
+ result.warnings.push(`Validation: ${issue.message}`);
3953
+ }
3954
+ });
3955
+ }
3956
+ this.reportProgress("validation", 1, 1, "Migration validation completed");
3957
+ }
3958
+ result.success = result.errors.length === 0;
3959
+ result.migrationTime = Date.now() - startTime;
3960
+ logger.info(`Migration completed in ${result.migrationTime}ms`);
3961
+ logger.info(`Documents restored: ${result.documentsRestored}`);
3962
+ logger.info(`Attachments restored: ${result.attachmentsRestored}`);
3963
+ logger.info(`Design docs restored: ${result.designDocsRestored}`);
3964
+ logger.info(`CourseConfig restored: ${result.courseConfigRestored}`);
3965
+ if (result.errors.length > 0) {
3966
+ logger.error(`Migration completed with ${result.errors.length} errors`);
3967
+ }
3968
+ if (result.warnings.length > 0) {
3969
+ logger.warn(`Migration completed with ${result.warnings.length} warnings`);
3970
+ }
3971
+ } catch (error) {
3972
+ result.success = false;
3973
+ result.migrationTime = Date.now() - startTime;
3974
+ const errorMessage = error instanceof Error ? error.message : String(error);
3975
+ result.errors.push(`Migration failed: ${errorMessage}`);
3976
+ logger.error("Migration failed:", error);
3977
+ if (this.options.cleanupOnFailure) {
3978
+ try {
3979
+ await this.cleanupFailedMigration(targetDB);
3980
+ } catch (cleanupError) {
3981
+ logger.error("Failed to cleanup after migration failure:", cleanupError);
3982
+ result.warnings.push("Failed to cleanup after migration failure");
3983
+ }
3984
+ }
3985
+ }
3986
+ return result;
3987
+ }
3988
+ /**
3989
+ * Load and parse the manifest file
3990
+ */
3991
+ async loadManifest(staticPath) {
3992
+ try {
3993
+ let manifestContent;
3994
+ let manifestPath;
3995
+ if (this.fs) {
3996
+ manifestPath = this.fs.joinPath(staticPath, "manifest.json");
3997
+ manifestContent = await this.fs.readFile(manifestPath);
3998
+ } else {
3999
+ manifestPath = nodeFS2 && nodePath ? nodePath.join(staticPath, "manifest.json") : `${staticPath}/manifest.json`;
4000
+ if (nodeFS2 && this.isLocalPath(staticPath)) {
4001
+ manifestContent = await nodeFS2.promises.readFile(manifestPath, "utf8");
4002
+ } else {
4003
+ const response = await fetch(manifestPath);
4004
+ if (!response.ok) {
4005
+ throw new Error(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
4006
+ }
4007
+ manifestContent = await response.text();
4008
+ }
4009
+ }
4010
+ const manifest = JSON.parse(manifestContent);
4011
+ if (!manifest.version || !manifest.courseId || !manifest.chunks) {
4012
+ throw new Error("Invalid manifest structure");
4013
+ }
4014
+ return manifest;
4015
+ } catch (error) {
4016
+ const errorMessage = error instanceof FileSystemError ? error.message : `Failed to load manifest: ${error instanceof Error ? error.message : String(error)}`;
4017
+ throw new Error(errorMessage);
4018
+ }
4019
+ }
4020
+ /**
4021
+ * Restore design documents to CouchDB
4022
+ */
4023
+ async restoreDesignDocuments(designDocs, db) {
4024
+ const result = { restored: 0, errors: [], warnings: [] };
4025
+ for (let i = 0; i < designDocs.length; i++) {
4026
+ const designDoc = designDocs[i];
4027
+ this.reportProgress("design_docs", i, designDocs.length, `Restoring ${designDoc._id}...`);
4028
+ try {
4029
+ let existingDoc;
4030
+ try {
4031
+ existingDoc = await db.get(designDoc._id);
4032
+ } catch {
4033
+ }
4034
+ const docToInsert = {
4035
+ _id: designDoc._id,
4036
+ views: designDoc.views
4037
+ };
4038
+ if (existingDoc) {
4039
+ docToInsert._rev = existingDoc._rev;
4040
+ logger.debug(`Updating existing design document: ${designDoc._id}`);
4041
+ } else {
4042
+ logger.debug(`Creating new design document: ${designDoc._id}`);
4043
+ }
4044
+ await db.put(docToInsert);
4045
+ result.restored++;
4046
+ } catch (error) {
4047
+ const errorMessage = `Failed to restore design document ${designDoc._id}: ${error instanceof Error ? error.message : String(error)}`;
4048
+ result.errors.push(errorMessage);
4049
+ logger.error(errorMessage);
4050
+ }
4051
+ }
4052
+ this.reportProgress(
4053
+ "design_docs",
4054
+ designDocs.length,
4055
+ designDocs.length,
4056
+ `Restored ${result.restored} design documents`
4057
+ );
4058
+ return result;
4059
+ }
4060
+ /**
4061
+ * Aggregate documents from all chunks
4062
+ */
4063
+ async aggregateDocuments(staticPath, manifest) {
4064
+ const allDocuments = [];
4065
+ const documentMap = /* @__PURE__ */ new Map();
4066
+ for (let i = 0; i < manifest.chunks.length; i++) {
4067
+ const chunk = manifest.chunks[i];
4068
+ this.reportProgress(
4069
+ "documents",
4070
+ allDocuments.length,
4071
+ manifest.documentCount,
4072
+ `Loading chunk ${chunk.id}...`
4073
+ );
4074
+ try {
4075
+ const documents = await this.loadChunk(staticPath, chunk);
4076
+ for (const doc of documents) {
4077
+ if (!doc._id) {
4078
+ logger.warn(`Document without _id found in chunk ${chunk.id}, skipping`);
4079
+ continue;
4080
+ }
4081
+ if (documentMap.has(doc._id)) {
4082
+ logger.warn(`Duplicate document ID found: ${doc._id}, using latest version`);
4083
+ }
4084
+ documentMap.set(doc._id, doc);
4085
+ }
4086
+ } catch (error) {
4087
+ throw new Error(
4088
+ `Failed to load chunk ${chunk.id}: ${error instanceof Error ? error.message : String(error)}`
4089
+ );
4090
+ }
4091
+ }
4092
+ allDocuments.push(...documentMap.values());
4093
+ logger.info(
4094
+ `Aggregated ${allDocuments.length} unique documents from ${manifest.chunks.length} chunks`
4095
+ );
4096
+ return allDocuments;
4097
+ }
4098
+ /**
4099
+ * Load documents from a single chunk file
4100
+ */
4101
+ async loadChunk(staticPath, chunk) {
4102
+ try {
4103
+ let chunkContent;
4104
+ let chunkPath;
4105
+ if (this.fs) {
4106
+ chunkPath = this.fs.joinPath(staticPath, chunk.path);
4107
+ chunkContent = await this.fs.readFile(chunkPath);
4108
+ } else {
4109
+ chunkPath = nodeFS2 && nodePath ? nodePath.join(staticPath, chunk.path) : `${staticPath}/${chunk.path}`;
4110
+ if (nodeFS2 && this.isLocalPath(staticPath)) {
4111
+ chunkContent = await nodeFS2.promises.readFile(chunkPath, "utf8");
4112
+ } else {
4113
+ const response = await fetch(chunkPath);
4114
+ if (!response.ok) {
4115
+ throw new Error(`Failed to fetch chunk: ${response.status} ${response.statusText}`);
4116
+ }
4117
+ chunkContent = await response.text();
4118
+ }
4119
+ }
4120
+ const documents = JSON.parse(chunkContent);
4121
+ if (!Array.isArray(documents)) {
4122
+ throw new Error("Chunk file does not contain an array of documents");
4123
+ }
4124
+ return documents;
4125
+ } catch (error) {
4126
+ const errorMessage = error instanceof FileSystemError ? error.message : `Failed to load chunk: ${error instanceof Error ? error.message : String(error)}`;
4127
+ throw new Error(errorMessage);
4128
+ }
4129
+ }
4130
+ /**
4131
+ * Upload documents to CouchDB in batches
4132
+ */
4133
+ async uploadDocuments(documents, db) {
4134
+ const result = { restored: 0, errors: [], warnings: [] };
4135
+ const batchSize = this.options.chunkBatchSize;
4136
+ for (let i = 0; i < documents.length; i += batchSize) {
4137
+ const batch = documents.slice(i, i + batchSize);
4138
+ this.reportProgress(
4139
+ "documents",
4140
+ i,
4141
+ documents.length,
4142
+ `Uploading batch ${Math.floor(i / batchSize) + 1}...`
4143
+ );
4144
+ try {
4145
+ const docsToInsert = batch.map((doc) => {
4146
+ const cleanDoc = { ...doc };
4147
+ delete cleanDoc._rev;
4148
+ delete cleanDoc._attachments;
4149
+ return cleanDoc;
4150
+ });
4151
+ const bulkResult = await db.bulkDocs(docsToInsert);
4152
+ for (let j = 0; j < bulkResult.length; j++) {
4153
+ const docResult = bulkResult[j];
4154
+ const originalDoc = batch[j];
4155
+ if ("error" in docResult) {
4156
+ const errorMessage = `Failed to upload document ${originalDoc._id}: ${docResult.error} - ${docResult.reason}`;
4157
+ result.errors.push(errorMessage);
4158
+ logger.error(errorMessage);
4159
+ } else {
4160
+ result.restored++;
4161
+ }
4162
+ }
4163
+ } catch (error) {
4164
+ let errorMessage;
4165
+ if (error instanceof Error) {
4166
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
4167
+ } else if (error && typeof error === "object" && "message" in error) {
4168
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
4169
+ } else {
4170
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${JSON.stringify(error)}`;
4171
+ }
4172
+ result.errors.push(errorMessage);
4173
+ logger.error(errorMessage);
4174
+ }
4175
+ }
4176
+ this.reportProgress(
4177
+ "documents",
4178
+ documents.length,
4179
+ documents.length,
4180
+ `Uploaded ${result.restored} documents`
4181
+ );
4182
+ return result;
4183
+ }
4184
+ /**
4185
+ * Upload attachments from filesystem to CouchDB
4186
+ */
4187
+ async uploadAttachments(staticPath, documents, db) {
4188
+ const result = { restored: 0, errors: [], warnings: [] };
4189
+ let processedDocs = 0;
4190
+ for (const doc of documents) {
4191
+ this.reportProgress(
4192
+ "attachments",
4193
+ processedDocs,
4194
+ documents.length,
4195
+ `Processing attachments for ${doc._id}...`
4196
+ );
4197
+ processedDocs++;
4198
+ if (!doc._attachments) {
4199
+ continue;
4200
+ }
4201
+ for (const [attachmentName, attachmentMeta] of Object.entries(doc._attachments)) {
4202
+ try {
4203
+ const uploadResult = await this.uploadSingleAttachment(
4204
+ staticPath,
4205
+ doc._id,
4206
+ attachmentName,
4207
+ attachmentMeta,
4208
+ db
4209
+ );
4210
+ if (uploadResult.success) {
4211
+ result.restored++;
4212
+ } else {
4213
+ result.errors.push(uploadResult.error || "Unknown attachment upload error");
4214
+ }
4215
+ } catch (error) {
4216
+ const errorMessage = `Failed to upload attachment ${doc._id}/${attachmentName}: ${error instanceof Error ? error.message : String(error)}`;
4217
+ result.errors.push(errorMessage);
4218
+ logger.error(errorMessage);
4219
+ }
4220
+ }
4221
+ }
4222
+ this.reportProgress(
4223
+ "attachments",
4224
+ documents.length,
4225
+ documents.length,
4226
+ `Uploaded ${result.restored} attachments`
4227
+ );
4228
+ return result;
4229
+ }
4230
+ /**
4231
+ * Upload a single attachment file
4232
+ */
4233
+ async uploadSingleAttachment(staticPath, docId, attachmentName, attachmentMeta, db) {
4234
+ const result = {
4235
+ success: false,
4236
+ attachmentName,
4237
+ docId
4238
+ };
4239
+ try {
4240
+ if (!attachmentMeta.path) {
4241
+ result.error = "Attachment metadata missing file path";
4242
+ return result;
4243
+ }
4244
+ let attachmentData;
4245
+ let attachmentPath;
4246
+ if (this.fs) {
4247
+ attachmentPath = this.fs.joinPath(staticPath, attachmentMeta.path);
4248
+ attachmentData = await this.fs.readBinary(attachmentPath);
4249
+ } else {
4250
+ attachmentPath = nodeFS2 && nodePath ? nodePath.join(staticPath, attachmentMeta.path) : `${staticPath}/${attachmentMeta.path}`;
4251
+ if (nodeFS2 && this.isLocalPath(staticPath)) {
4252
+ attachmentData = await nodeFS2.promises.readFile(attachmentPath);
4253
+ } else {
4254
+ const response = await fetch(attachmentPath);
4255
+ if (!response.ok) {
4256
+ result.error = `Failed to fetch attachment: ${response.status} ${response.statusText}`;
4257
+ return result;
4258
+ }
4259
+ attachmentData = await response.arrayBuffer();
4260
+ }
4261
+ }
4262
+ const doc = await db.get(docId);
4263
+ await db.putAttachment(
4264
+ docId,
4265
+ attachmentName,
4266
+ doc._rev,
4267
+ attachmentData,
4268
+ // PouchDB accepts both ArrayBuffer and Buffer
4269
+ attachmentMeta.content_type
4270
+ );
4271
+ result.success = true;
4272
+ } catch (error) {
4273
+ result.error = error instanceof Error ? error.message : String(error);
4274
+ }
4275
+ return result;
4276
+ }
4277
+ /**
4278
+ * Restore CourseConfig document from manifest
4279
+ */
4280
+ async restoreCourseConfig(manifest, targetDB) {
4281
+ const results = {
4282
+ restored: 0,
4283
+ errors: [],
4284
+ warnings: []
4285
+ };
4286
+ try {
4287
+ if (!manifest.courseConfig) {
4288
+ results.warnings.push(
4289
+ "No courseConfig found in manifest, skipping CourseConfig document creation"
4290
+ );
4291
+ return results;
4292
+ }
4293
+ const courseConfigDoc = {
4294
+ _id: "CourseConfig",
4295
+ ...manifest.courseConfig,
4296
+ courseID: manifest.courseId
4297
+ };
4298
+ delete courseConfigDoc._rev;
4299
+ await targetDB.put(courseConfigDoc);
4300
+ results.restored = 1;
4301
+ logger.info(`CourseConfig document created for course: ${manifest.courseId}`);
4302
+ } catch (error) {
4303
+ const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
4304
+ results.errors.push(`Failed to restore CourseConfig: ${errorMessage}`);
4305
+ logger.error("CourseConfig restoration failed:", error);
4306
+ }
4307
+ return results;
4308
+ }
4309
+ /**
4310
+ * Calculate expected document counts from manifest
4311
+ */
4312
+ calculateExpectedCounts(manifest) {
4313
+ const counts = {};
4314
+ for (const chunk of manifest.chunks) {
4315
+ counts[chunk.docType] = (counts[chunk.docType] || 0) + chunk.documentCount;
4316
+ }
4317
+ if (manifest.designDocs.length > 0) {
4318
+ counts["_design"] = manifest.designDocs.length;
4319
+ }
4320
+ return counts;
4321
+ }
4322
+ /**
4323
+ * Clean up database after failed migration
4324
+ */
4325
+ async cleanupFailedMigration(db) {
4326
+ logger.info("Cleaning up failed migration...");
4327
+ try {
4328
+ const allDocs = await db.allDocs();
4329
+ const docsToDelete = allDocs.rows.map((row) => ({
4330
+ _id: row.id,
4331
+ _rev: row.value.rev,
4332
+ _deleted: true
4333
+ }));
4334
+ if (docsToDelete.length > 0) {
4335
+ await db.bulkDocs(docsToDelete);
4336
+ logger.info(`Cleaned up ${docsToDelete.length} documents from failed migration`);
4337
+ }
4338
+ } catch (error) {
4339
+ logger.error("Failed to cleanup documents:", error);
4340
+ throw error;
4341
+ }
4342
+ }
4343
+ /**
4344
+ * Report progress to callback if available
4345
+ */
4346
+ reportProgress(phase, current, total, message) {
4347
+ if (this.progressCallback) {
4348
+ this.progressCallback({
4349
+ phase,
4350
+ current,
4351
+ total,
4352
+ message
4353
+ });
4354
+ }
4355
+ }
4356
+ /**
4357
+ * Check if a path is a local file path (vs URL)
4358
+ */
4359
+ isLocalPath(path2) {
4360
+ return !path2.startsWith("http://") && !path2.startsWith("https://");
4361
+ }
4362
+ };
4363
+ }
4364
+ });
4365
+
4366
+ // src/util/migrator/index.ts
4367
+ var init_migrator = __esm({
4368
+ "src/util/migrator/index.ts"() {
4369
+ "use strict";
4370
+ init_StaticToCouchDBMigrator();
4371
+ init_validation();
4372
+ init_FileSystemAdapter();
4373
+ }
4374
+ });
4375
+
4376
+ // src/util/index.ts
4377
+ var init_util2 = __esm({
4378
+ "src/util/index.ts"() {
4379
+ "use strict";
4380
+ init_Loggable();
4381
+ init_packer();
4382
+ init_migrator();
4383
+ init_dataDirectory();
4384
+ }
4385
+ });
4386
+
4387
+ // src/study/SourceMixer.ts
4388
+ var init_SourceMixer = __esm({
4389
+ "src/study/SourceMixer.ts"() {
4390
+ "use strict";
4391
+ }
4392
+ });
4393
+
4394
+ // src/study/MixerDebugger.ts
4395
+ function printMixerSummary(run) {
4396
+ console.group(`\u{1F3A8} Mixer Run: ${run.mixerType}`);
4397
+ logger.info(`Run ID: ${run.runId}`);
4398
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
4399
+ logger.info(
4400
+ `Config: limit=${run.requestedLimit}${run.quotaPerSource ? `, quota/source=${run.quotaPerSource}` : ""}`
4401
+ );
4402
+ console.group(`\u{1F4E5} Input: ${run.sourceSummaries.length} sources`);
4403
+ for (const src of run.sourceSummaries) {
4404
+ logger.info(
4405
+ ` ${src.sourceName || src.sourceId}: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`
4406
+ );
4407
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}], avg: ${src.avgScore.toFixed(2)}`);
4408
+ }
4409
+ console.groupEnd();
4410
+ console.group(`\u{1F4E4} Output: ${run.finalCount} cards selected (${run.reviewsSelected} reviews, ${run.newSelected} new)`);
4411
+ for (const breakdown of run.sourceBreakdowns) {
4412
+ const name = breakdown.sourceName || breakdown.sourceId;
4413
+ logger.info(
4414
+ ` ${name}: ${breakdown.totalSelected} selected (${breakdown.reviewsSelected} reviews, ${breakdown.newSelected} new) - ${breakdown.selectionRate.toFixed(1)}% selection rate`
4415
+ );
4416
+ }
4417
+ console.groupEnd();
4418
+ console.groupEnd();
4419
+ }
4420
+ function mountMixerDebugger() {
4421
+ if (typeof window === "undefined") return;
4422
+ const win = window;
4423
+ win.skuilder = win.skuilder || {};
4424
+ win.skuilder.mixer = mixerDebugAPI;
4425
+ }
4426
+ var runHistory2, mixerDebugAPI;
4427
+ var init_MixerDebugger = __esm({
4428
+ "src/study/MixerDebugger.ts"() {
4429
+ "use strict";
4430
+ init_logger();
4431
+ init_navigators();
4432
+ runHistory2 = [];
4433
+ mixerDebugAPI = {
4434
+ /**
4435
+ * Get raw run history for programmatic access.
4436
+ */
4437
+ get runs() {
4438
+ return [...runHistory2];
4439
+ },
4440
+ /**
4441
+ * Show summary of a specific mixer run.
4442
+ */
4443
+ showRun(idOrIndex = 0) {
4444
+ if (runHistory2.length === 0) {
4445
+ logger.info("[Mixer Debug] No runs captured yet.");
4446
+ return;
4447
+ }
4448
+ let run;
4449
+ if (typeof idOrIndex === "number") {
4450
+ run = runHistory2[idOrIndex];
4451
+ if (!run) {
4452
+ logger.info(`[Mixer Debug] No run found at index ${idOrIndex}. History length: ${runHistory2.length}`);
4453
+ return;
4454
+ }
4455
+ } else {
4456
+ run = runHistory2.find((r) => r.runId.endsWith(idOrIndex));
4457
+ if (!run) {
4458
+ logger.info(`[Mixer Debug] No run found matching ID '${idOrIndex}'.`);
4459
+ return;
4460
+ }
4461
+ }
4462
+ printMixerSummary(run);
4463
+ },
4464
+ /**
4465
+ * Show summary of the last mixer run.
4466
+ */
4467
+ showLastMix() {
4468
+ this.showRun(0);
4469
+ },
4470
+ /**
4471
+ * Explain source balance in the last run.
4472
+ */
4473
+ explainSourceBalance() {
4474
+ if (runHistory2.length === 0) {
4475
+ logger.info("[Mixer Debug] No runs captured yet.");
4476
+ return;
4477
+ }
4478
+ const run = runHistory2[0];
4479
+ console.group("\u2696\uFE0F Source Balance Analysis");
4480
+ logger.info(`Mixer: ${run.mixerType}`);
4481
+ logger.info(`Requested limit: ${run.requestedLimit}`);
4482
+ if (run.quotaPerSource) {
4483
+ logger.info(`Quota per source: ${run.quotaPerSource}`);
4484
+ }
4485
+ console.group("Input Distribution:");
4486
+ for (const src of run.sourceSummaries) {
4487
+ const name = src.sourceName || src.sourceId;
4488
+ logger.info(`${name}:`);
4489
+ logger.info(` Provided: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`);
4490
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}]`);
4491
+ }
4492
+ console.groupEnd();
4493
+ console.group("Selection Results:");
4494
+ for (const breakdown of run.sourceBreakdowns) {
4495
+ const name = breakdown.sourceName || breakdown.sourceId;
4496
+ logger.info(`${name}:`);
4497
+ logger.info(
4498
+ ` Selected: ${breakdown.totalSelected}/${breakdown.reviewsProvided + breakdown.newProvided} (${breakdown.selectionRate.toFixed(1)}%)`
4499
+ );
4500
+ logger.info(` Reviews: ${breakdown.reviewsSelected}/${breakdown.reviewsProvided}`);
4501
+ logger.info(` New: ${breakdown.newSelected}/${breakdown.newProvided}`);
4502
+ if (breakdown.reviewsProvided > 0 && breakdown.reviewsSelected === 0) {
4503
+ logger.info(` \u26A0\uFE0F Had reviews but none selected!`);
4504
+ }
4505
+ if (breakdown.totalSelected === 0 && breakdown.reviewsProvided + breakdown.newProvided > 0) {
4506
+ logger.info(` \u26A0\uFE0F Had cards but none selected!`);
4507
+ }
4508
+ }
4509
+ console.groupEnd();
4510
+ const selectionRates = run.sourceBreakdowns.map((b) => b.selectionRate);
4511
+ const avgRate = selectionRates.reduce((a, b) => a + b, 0) / selectionRates.length;
4512
+ const maxDeviation = Math.max(...selectionRates.map((r) => Math.abs(r - avgRate)));
4513
+ if (maxDeviation > 20) {
4514
+ logger.info(`
4515
+ \u26A0\uFE0F Significant imbalance detected (max deviation: ${maxDeviation.toFixed(1)}%)`);
4516
+ logger.info("Possible causes:");
4517
+ logger.info(" - Score range differences between sources");
4518
+ logger.info(" - One source has much better quality cards");
4519
+ logger.info(" - Different card availability (reviews vs new)");
4520
+ }
4521
+ console.groupEnd();
4522
+ },
4523
+ /**
4524
+ * Compare score distributions across sources.
4525
+ */
4526
+ compareScores() {
4527
+ if (runHistory2.length === 0) {
4528
+ logger.info("[Mixer Debug] No runs captured yet.");
4529
+ return;
4530
+ }
4531
+ const run = runHistory2[0];
4532
+ console.group("\u{1F4CA} Score Distribution Comparison");
4533
+ console.table(
4534
+ run.sourceSummaries.map((src) => ({
4535
+ source: src.sourceName || src.sourceId,
4536
+ cards: src.totalCards,
4537
+ min: src.bottomScore.toFixed(3),
4538
+ max: src.topScore.toFixed(3),
4539
+ avg: src.avgScore.toFixed(3),
4540
+ range: (src.topScore - src.bottomScore).toFixed(3)
4541
+ }))
4542
+ );
4543
+ const ranges = run.sourceSummaries.map((s) => s.topScore - s.bottomScore);
4544
+ const avgScores = run.sourceSummaries.map((s) => s.avgScore);
4545
+ const rangeDiff = Math.max(...ranges) - Math.min(...ranges);
4546
+ const avgDiff = Math.max(...avgScores) - Math.min(...avgScores);
4547
+ if (rangeDiff > 0.3 || avgDiff > 0.2) {
4548
+ logger.info("\n\u26A0\uFE0F Significant score distribution differences detected");
4549
+ logger.info(
4550
+ "This may cause one source to dominate selection if using global sorting (not quota-based)"
4551
+ );
4552
+ }
4553
+ console.groupEnd();
4554
+ },
4555
+ /**
4556
+ * Show detailed information for a specific card.
4557
+ */
4558
+ showCard(cardId) {
4559
+ for (const run of runHistory2) {
4560
+ const card = run.cards.find((c) => c.cardId === cardId);
4561
+ if (card) {
4562
+ const source = run.sourceSummaries.find((s) => s.sourceIndex === card.sourceIndex);
4563
+ console.group(`\u{1F3B4} Card: ${cardId}`);
4564
+ logger.info(`Course: ${card.courseId}`);
4565
+ logger.info(`Source: ${source?.sourceName || source?.sourceId || "unknown"}`);
4566
+ logger.info(`Origin: ${card.origin}`);
4567
+ logger.info(`Score: ${card.score.toFixed(3)}`);
4568
+ if (card.rankInSource) {
4569
+ logger.info(`Rank in source: #${card.rankInSource}`);
4570
+ }
4571
+ if (card.rankInMix) {
4572
+ logger.info(`Rank in mixed results: #${card.rankInMix}`);
4573
+ }
4574
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
4575
+ if (!card.selected && card.rankInSource) {
4576
+ logger.info("\nWhy not selected:");
4577
+ if (run.quotaPerSource && card.rankInSource > run.quotaPerSource) {
4578
+ logger.info(` - Ranked #${card.rankInSource} in source, but quota was ${run.quotaPerSource}`);
4579
+ }
4580
+ logger.info(" - Check score compared to selected cards using .showRun()");
4581
+ }
4582
+ console.groupEnd();
4583
+ return;
4584
+ }
4585
+ }
4586
+ logger.info(`[Mixer Debug] Card '${cardId}' not found in recent runs.`);
4587
+ },
4588
+ /**
4589
+ * Show all runs in compact format.
4590
+ */
4591
+ listRuns() {
4592
+ if (runHistory2.length === 0) {
4593
+ logger.info("[Mixer Debug] No runs captured yet.");
4594
+ return;
4595
+ }
4596
+ console.table(
4597
+ runHistory2.map((r) => ({
4598
+ id: r.runId.slice(-8),
4599
+ time: r.timestamp.toLocaleTimeString(),
4600
+ mixer: r.mixerType,
4601
+ sources: r.sourceSummaries.length,
4602
+ selected: r.finalCount,
4603
+ reviews: r.reviewsSelected,
4604
+ new: r.newSelected
4605
+ }))
4606
+ );
4607
+ },
4608
+ /**
4609
+ * Export run history as JSON for bug reports.
4610
+ */
4611
+ export() {
4612
+ const json = JSON.stringify(runHistory2, null, 2);
4613
+ logger.info("[Mixer Debug] Run history exported. Copy the returned string or use:");
4614
+ logger.info(" copy(window.skuilder.mixer.export())");
4615
+ return json;
4616
+ },
4617
+ /**
4618
+ * Clear run history.
4619
+ */
4620
+ clear() {
4621
+ runHistory2.length = 0;
4622
+ logger.info("[Mixer Debug] Run history cleared.");
4623
+ },
4624
+ /**
4625
+ * Show help.
4626
+ */
4627
+ help() {
4628
+ logger.info(`
4629
+ \u{1F3A8} Mixer Debug API
4630
+
4631
+ Commands:
4632
+ .showLastMix() Show summary of most recent mixer run
4633
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
4634
+ .explainSourceBalance() Analyze source balance and selection patterns
4635
+ .compareScores() Compare score distributions across sources
4636
+ .showCard(cardId) Show mixer decisions for a specific card
4637
+ .listRuns() List all captured runs in table format
4638
+ .export() Export run history as JSON for bug reports
4639
+ .clear() Clear run history
4640
+ .runs Access raw run history array
4641
+ .help() Show this help message
4642
+
4643
+ Example:
4644
+ window.skuilder.mixer.showLastMix()
4645
+ window.skuilder.mixer.explainSourceBalance()
4646
+ window.skuilder.mixer.compareScores()
4647
+ `);
4648
+ }
4649
+ };
4650
+ mountMixerDebugger();
4651
+ }
4652
+ });
4653
+
4654
+ // src/study/SessionDebugger.ts
4655
+ function showCurrentQueue() {
4656
+ if (!activeSession) {
4657
+ logger.info("[Session Debug] No active session.");
4658
+ return;
2719
4659
  }
2720
- const target = config.targetAccuracy ?? 0.85;
2721
- const tolerance = config.tolerance ?? 0.05;
2722
- let correct = 0;
2723
- for (const r of records) {
2724
- if (r.isCorrect) correct++;
4660
+ const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
4661
+ console.group("\u{1F4CA} Current Queue State");
4662
+ logger.info(`Review Queue: ${latest.reviewQLength} cards`);
4663
+ if (latest.reviewQNext3 && latest.reviewQNext3.length > 0) {
4664
+ logger.info(` Next: ${latest.reviewQNext3.join(", ")}`);
2725
4665
  }
2726
- const accuracy = correct / records.length;
2727
- return scoreAccuracyInZone(accuracy, target, tolerance);
2728
- }
2729
- function scoreAccuracyInZone(accuracy, target, tolerance) {
2730
- const dist = Math.abs(accuracy - target);
2731
- if (dist <= tolerance) {
2732
- return 1;
4666
+ logger.info(`New Queue: ${latest.newQLength} cards`);
4667
+ if (latest.newQNext3 && latest.newQNext3.length > 0) {
4668
+ logger.info(` Next: ${latest.newQNext3.join(", ")}`);
2733
4669
  }
2734
- const excess = dist - tolerance;
2735
- const slope = 2.5;
2736
- return Math.max(0, 1 - excess * slope);
4670
+ logger.info(`Failed Queue: ${latest.failedQLength} cards`);
4671
+ console.groupEnd();
2737
4672
  }
2738
- var init_signal = __esm({
2739
- "src/core/orchestration/signal.ts"() {
2740
- "use strict";
4673
+ function showPresentationHistory(sessionIndex = 0) {
4674
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
4675
+ if (!session) {
4676
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
4677
+ return;
2741
4678
  }
2742
- });
2743
-
2744
- // src/core/orchestration/recording.ts
2745
- async function recordUserOutcome(context, periodStart, periodEnd, records, activeStrategyIds, eloStart = 0, eloEnd = 0, config) {
2746
- const { user, course, userId } = context;
2747
- const courseId = course.getCourseID();
2748
- const outcomeValue = computeOutcomeSignal(records, config);
2749
- if (outcomeValue === null) {
2750
- logger.debug(
2751
- `[Orchestration] No outcome signal computed for ${userId} (insufficient data). Skipping record.`
4679
+ console.group(`\u{1F4DC} Session History: ${session.sessionId}`);
4680
+ logger.info(`Started: ${session.startTime.toLocaleTimeString()}`);
4681
+ if (session.endTime) {
4682
+ logger.info(`Ended: ${session.endTime.toLocaleTimeString()}`);
4683
+ }
4684
+ logger.info(`Cards presented: ${session.presentations.length}`);
4685
+ if (session.presentations.length > 0) {
4686
+ console.table(
4687
+ session.presentations.map((p) => ({
4688
+ "#": p.sequenceNumber,
4689
+ course: p.courseName || p.courseId.slice(0, 8),
4690
+ origin: p.origin,
4691
+ queue: p.queueSource,
4692
+ score: p.score?.toFixed(3) || "-",
4693
+ time: p.timestamp.toLocaleTimeString()
4694
+ }))
2752
4695
  );
4696
+ }
4697
+ console.groupEnd();
4698
+ }
4699
+ function showInterleaving(sessionIndex = 0) {
4700
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
4701
+ if (!session) {
4702
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
2753
4703
  return;
2754
4704
  }
2755
- const deviations = {};
2756
- for (const strategyId of activeStrategyIds) {
2757
- deviations[strategyId] = context.getDeviation(strategyId);
4705
+ console.group("\u{1F500} Interleaving Analysis");
4706
+ const courseCounts = /* @__PURE__ */ new Map();
4707
+ const courseOrigins = /* @__PURE__ */ new Map();
4708
+ session.presentations.forEach((p) => {
4709
+ const name = p.courseName || p.courseId;
4710
+ courseCounts.set(name, (courseCounts.get(name) || 0) + 1);
4711
+ if (!courseOrigins.has(name)) {
4712
+ courseOrigins.set(name, { review: 0, new: 0, failed: 0 });
4713
+ }
4714
+ const origins = courseOrigins.get(name);
4715
+ origins[p.origin]++;
4716
+ });
4717
+ logger.info("Course distribution:");
4718
+ console.table(
4719
+ Array.from(courseCounts.entries()).map(([course, count]) => {
4720
+ const origins = courseOrigins.get(course);
4721
+ return {
4722
+ course,
4723
+ total: count,
4724
+ reviews: origins.review,
4725
+ new: origins.new,
4726
+ failed: origins.failed,
4727
+ percentage: (count / session.presentations.length * 100).toFixed(1) + "%"
4728
+ };
4729
+ })
4730
+ );
4731
+ if (session.presentations.length > 0) {
4732
+ logger.info("\nPresentation sequence (first 20):");
4733
+ const sequence = session.presentations.slice(0, 20).map((p, idx) => `${idx + 1}. ${p.courseName || p.courseId.slice(0, 8)} (${p.origin})`).join("\n");
4734
+ logger.info(sequence);
2758
4735
  }
2759
- const id = `USER_OUTCOME::${courseId}::${userId}::${periodEnd}`;
2760
- const record = {
2761
- _id: id,
2762
- docType: "USER_OUTCOME" /* USER_OUTCOME */,
2763
- courseId,
2764
- userId,
2765
- periodStart,
2766
- periodEnd,
2767
- outcomeValue,
2768
- deviations,
2769
- metadata: {
2770
- sessionsCount: 1,
2771
- // Assumes recording is triggered per-session currently
2772
- cardsSeen: records.length,
2773
- eloStart,
2774
- eloEnd,
2775
- signalType: "accuracy_in_zone"
4736
+ let maxCluster = 0;
4737
+ let currentCluster = 1;
4738
+ let currentCourse = session.presentations[0]?.courseId;
4739
+ for (let i = 1; i < session.presentations.length; i++) {
4740
+ if (session.presentations[i].courseId === currentCourse) {
4741
+ currentCluster++;
4742
+ maxCluster = Math.max(maxCluster, currentCluster);
4743
+ } else {
4744
+ currentCourse = session.presentations[i].courseId;
4745
+ currentCluster = 1;
2776
4746
  }
2777
- };
2778
- try {
2779
- await user.putUserOutcome(record);
2780
- logger.debug(
2781
- `[Orchestration] Recorded outcome ${outcomeValue.toFixed(3)} for ${userId} (doc: ${id})`
2782
- );
2783
- } catch (e) {
2784
- logger.error(`[Orchestration] Failed to record outcome: ${e}`);
2785
4747
  }
4748
+ if (maxCluster > 3) {
4749
+ logger.info(`
4750
+ \u26A0\uFE0F Detected clustering: max ${maxCluster} cards from same course in a row`);
4751
+ logger.info("This suggests cards are sorted by score rather than round-robin by course.");
4752
+ }
4753
+ console.groupEnd();
2786
4754
  }
2787
- var init_recording = __esm({
2788
- "src/core/orchestration/recording.ts"() {
4755
+ function mountSessionDebugger() {
4756
+ if (typeof window === "undefined") return;
4757
+ const win = window;
4758
+ win.skuilder = win.skuilder || {};
4759
+ win.skuilder.session = sessionDebugAPI;
4760
+ }
4761
+ var activeSession, sessionHistory, sessionDebugAPI;
4762
+ var init_SessionDebugger = __esm({
4763
+ "src/study/SessionDebugger.ts"() {
2789
4764
  "use strict";
2790
- init_signal();
2791
- init_types_legacy();
2792
4765
  init_logger();
2793
- }
2794
- });
4766
+ activeSession = null;
4767
+ sessionHistory = [];
4768
+ sessionDebugAPI = {
4769
+ /**
4770
+ * Get raw session history for programmatic access.
4771
+ */
4772
+ get sessions() {
4773
+ return [...sessionHistory];
4774
+ },
4775
+ /**
4776
+ * Get active session if any.
4777
+ */
4778
+ get active() {
4779
+ return activeSession;
4780
+ },
4781
+ /**
4782
+ * Show current queue state.
4783
+ */
4784
+ showQueue() {
4785
+ showCurrentQueue();
4786
+ },
4787
+ /**
4788
+ * Show presentation history for current or past session.
4789
+ */
4790
+ showHistory(sessionIndex = 0) {
4791
+ showPresentationHistory(sessionIndex);
4792
+ },
4793
+ /**
4794
+ * Analyze course interleaving pattern.
4795
+ */
4796
+ showInterleaving(sessionIndex = 0) {
4797
+ showInterleaving(sessionIndex);
4798
+ },
4799
+ /**
4800
+ * List all tracked sessions.
4801
+ */
4802
+ listSessions() {
4803
+ if (activeSession) {
4804
+ logger.info(`Active session: ${activeSession.sessionId} (${activeSession.presentations.length} cards presented)`);
4805
+ }
4806
+ if (sessionHistory.length === 0) {
4807
+ logger.info("[Session Debug] No completed sessions in history.");
4808
+ return;
4809
+ }
4810
+ console.table(
4811
+ sessionHistory.map((s, idx) => ({
4812
+ index: idx,
4813
+ id: s.sessionId.slice(-8),
4814
+ started: s.startTime.toLocaleTimeString(),
4815
+ ended: s.endTime?.toLocaleTimeString() || "incomplete",
4816
+ cards: s.presentations.length
4817
+ }))
4818
+ );
4819
+ },
4820
+ /**
4821
+ * Export session history as JSON for bug reports.
4822
+ */
4823
+ export() {
4824
+ const data = {
4825
+ active: activeSession,
4826
+ history: sessionHistory
4827
+ };
4828
+ const json = JSON.stringify(data, null, 2);
4829
+ logger.info("[Session Debug] Session data exported. Copy the returned string or use:");
4830
+ logger.info(" copy(window.skuilder.session.export())");
4831
+ return json;
4832
+ },
4833
+ /**
4834
+ * Clear session history.
4835
+ */
4836
+ clear() {
4837
+ sessionHistory.length = 0;
4838
+ logger.info("[Session Debug] Session history cleared.");
4839
+ },
4840
+ /**
4841
+ * Show help.
4842
+ */
4843
+ help() {
4844
+ logger.info(`
4845
+ \u{1F3AF} Session Debug API
2795
4846
 
2796
- // src/core/orchestration/index.ts
2797
- function fnv1a(str) {
2798
- let hash = 2166136261;
2799
- for (let i = 0; i < str.length; i++) {
2800
- hash ^= str.charCodeAt(i);
2801
- hash = Math.imul(hash, 16777619);
2802
- }
2803
- return hash >>> 0;
2804
- }
2805
- function computeDeviation(userId, strategyId, salt) {
2806
- const input = `${userId}:${strategyId}:${salt}`;
2807
- const hash = fnv1a(input);
2808
- const normalized = hash / 4294967296;
2809
- return normalized * 2 - 1;
2810
- }
2811
- function computeSpread(confidence) {
2812
- const clampedConfidence = Math.max(0, Math.min(1, confidence));
2813
- return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
2814
- }
2815
- function computeEffectiveWeight(learnable, userId, strategyId, salt) {
2816
- const deviation = computeDeviation(userId, strategyId, salt);
2817
- const spread = computeSpread(learnable.confidence);
2818
- const adjustment = deviation * spread * learnable.weight;
2819
- const effective = learnable.weight + adjustment;
2820
- return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
2821
- }
2822
- async function createOrchestrationContext(user, course) {
2823
- let courseConfig;
2824
- try {
2825
- courseConfig = await course.getCourseConfig();
2826
- } catch (e) {
2827
- logger.error(`[Orchestration] Failed to load course config: ${e}`);
2828
- courseConfig = {
2829
- name: "Unknown",
2830
- description: "",
2831
- public: false,
2832
- deleted: false,
2833
- creator: "",
2834
- admins: [],
2835
- moderators: [],
2836
- dataShapes: [],
2837
- questionTypes: [],
2838
- orchestration: { salt: "default" }
4847
+ Commands:
4848
+ .showQueue() Show current queue state (active session only)
4849
+ .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
4850
+ .showInterleaving(index?) Analyze course interleaving pattern
4851
+ .listSessions() List all tracked sessions
4852
+ .export() Export session data as JSON for bug reports
4853
+ .clear() Clear session history
4854
+ .sessions Access raw session history array
4855
+ .active Access active session (if any)
4856
+ .help() Show this help message
4857
+
4858
+ Example:
4859
+ window.skuilder.session.showHistory()
4860
+ window.skuilder.session.showInterleaving()
4861
+ window.skuilder.session.showQueue()
4862
+ `);
4863
+ }
2839
4864
  };
4865
+ mountSessionDebugger();
2840
4866
  }
2841
- const userId = user.getUsername();
2842
- const salt = courseConfig.orchestration?.salt || "default_salt";
2843
- return {
2844
- user,
2845
- course,
2846
- userId,
2847
- courseConfig,
2848
- getEffectiveWeight(strategyId, learnable) {
2849
- return computeEffectiveWeight(learnable, userId, strategyId, salt);
2850
- },
2851
- getDeviation(strategyId) {
2852
- return computeDeviation(userId, strategyId, salt);
2853
- }
2854
- };
2855
- }
2856
- var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
2857
- var init_orchestration = __esm({
2858
- "src/core/orchestration/index.ts"() {
4867
+ });
4868
+
4869
+ // src/study/SessionController.ts
4870
+ var init_SessionController = __esm({
4871
+ "src/study/SessionController.ts"() {
2859
4872
  "use strict";
2860
- init_logger();
2861
- init_gradient();
2862
- init_learning();
2863
- init_signal();
4873
+ init_SrsService();
4874
+ init_EloService();
4875
+ init_ResponseProcessor();
4876
+ init_CardHydrationService();
4877
+ init_ItemQueue();
4878
+ init_couch();
2864
4879
  init_recording();
2865
- MIN_SPREAD = 0.1;
2866
- MAX_SPREAD = 0.5;
2867
- MIN_WEIGHT = 0.1;
2868
- MAX_WEIGHT = 3;
4880
+ init_util2();
4881
+ init_navigators();
4882
+ init_SourceMixer();
4883
+ init_MixerDebugger();
4884
+ init_SessionDebugger();
4885
+ init_logger();
2869
4886
  }
2870
4887
  });
2871
4888
 
@@ -2874,7 +4891,7 @@ var Pipeline_exports = {};
2874
4891
  __export(Pipeline_exports, {
2875
4892
  Pipeline: () => Pipeline
2876
4893
  });
2877
- import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
4894
+ import { toCourseElo as toCourseElo7 } from "@vue-skuilder/common";
2878
4895
  function globToRegex(pattern) {
2879
4896
  const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
2880
4897
  const withWildcards = escaped.replace(/\*/g, ".*");
@@ -2958,6 +4975,7 @@ var init_Pipeline = __esm({
2958
4975
  init_logger();
2959
4976
  init_orchestration();
2960
4977
  init_PipelineDebugger();
4978
+ init_SessionController();
2961
4979
  VERBOSE_RESULTS = true;
2962
4980
  Pipeline = class extends ContentNavigator {
2963
4981
  generator;
@@ -3117,8 +5135,9 @@ var init_Pipeline = __esm({
3117
5135
  generatorSummaries,
3118
5136
  generatedCount,
3119
5137
  filterImpacts,
3120
- allCardsBeforeFiltering,
3121
- result
5138
+ cards,
5139
+ result,
5140
+ context.userElo
3122
5141
  );
3123
5142
  captureRun(report);
3124
5143
  } catch (e) {
@@ -3201,7 +5220,7 @@ var init_Pipeline = __esm({
3201
5220
  card.provenance.push({
3202
5221
  strategy: "ephemeralHint",
3203
5222
  strategyId: "ephemeral-hint",
3204
- strategyName: "Replan Hint",
5223
+ strategyName: hints._label ? `Replan Hint (${hints._label})` : "Replan Hint",
3205
5224
  action: "boosted",
3206
5225
  score: card.score,
3207
5226
  reason: `boostTag ${pattern} \xD7${factor}`
@@ -3218,7 +5237,7 @@ var init_Pipeline = __esm({
3218
5237
  card.provenance.push({
3219
5238
  strategy: "ephemeralHint",
3220
5239
  strategyId: "ephemeral-hint",
3221
- strategyName: "Replan Hint",
5240
+ strategyName: hints._label ? `Replan Hint (${hints._label})` : "Replan Hint",
3222
5241
  action: "boosted",
3223
5242
  score: card.score,
3224
5243
  reason: `boostCard ${pattern} \xD7${factor}`
@@ -3228,6 +5247,7 @@ var init_Pipeline = __esm({
3228
5247
  }
3229
5248
  }
3230
5249
  const cardIds = new Set(cards.map((c) => c.cardId));
5250
+ const hintLabel = hints._label ? `Replan Hint (${hints._label})` : "Replan Hint";
3231
5251
  const inject = (card, reason) => {
3232
5252
  if (!cardIds.has(card.cardId)) {
3233
5253
  const floorScore = Math.max(card.score, 1);
@@ -3239,7 +5259,7 @@ var init_Pipeline = __esm({
3239
5259
  {
3240
5260
  strategy: "ephemeralHint",
3241
5261
  strategyId: "ephemeral-hint",
3242
- strategyName: "Replan Hint",
5262
+ strategyName: hintLabel,
3243
5263
  action: "boosted",
3244
5264
  score: floorScore,
3245
5265
  reason
@@ -3278,7 +5298,7 @@ var init_Pipeline = __esm({
3278
5298
  let userElo = 1e3;
3279
5299
  try {
3280
5300
  const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
3281
- const courseElo = toCourseElo5(courseReg.elo);
5301
+ const courseElo = toCourseElo7(courseReg.elo);
3282
5302
  userElo = courseElo.global.score;
3283
5303
  } catch (e) {
3284
5304
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
@@ -3331,6 +5351,34 @@ var init_Pipeline = __esm({
3331
5351
  return [...new Set(ids)];
3332
5352
  }
3333
5353
  // ---------------------------------------------------------------------------
5354
+ // Tag ELO diagnostic
5355
+ // ---------------------------------------------------------------------------
5356
+ /**
5357
+ * Get the user's per-tag ELO data for specified tags (or all tags).
5358
+ * Useful for diagnosing why hierarchy gates are open/closed.
5359
+ */
5360
+ async getTagEloStatus(tagFilter) {
5361
+ const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
5362
+ const courseElo = toCourseElo7(courseReg.elo);
5363
+ const result = {};
5364
+ if (!tagFilter) {
5365
+ for (const [tag, data] of Object.entries(courseElo.tags)) {
5366
+ result[tag] = { score: data.score, count: data.count };
5367
+ }
5368
+ } else {
5369
+ const patterns = Array.isArray(tagFilter) ? tagFilter : [tagFilter];
5370
+ for (const pattern of patterns) {
5371
+ const regex = globToRegex(pattern);
5372
+ for (const [tag, data] of Object.entries(courseElo.tags)) {
5373
+ if (regex.test(tag)) {
5374
+ result[tag] = { score: data.score, count: data.count };
5375
+ }
5376
+ }
5377
+ }
5378
+ }
5379
+ return result;
5380
+ }
5381
+ // ---------------------------------------------------------------------------
3334
5382
  // Card-space diagnostic
3335
5383
  // ---------------------------------------------------------------------------
3336
5384
  /**
@@ -3913,7 +5961,7 @@ import {
3913
5961
  EloToNumber,
3914
5962
  Status,
3915
5963
  blankCourseElo as blankCourseElo2,
3916
- toCourseElo as toCourseElo6
5964
+ toCourseElo as toCourseElo8
3917
5965
  } from "@vue-skuilder/common";
3918
5966
  function randIntWeightedTowardZero(n) {
3919
5967
  return Math.floor(Math.random() * Math.random() * Math.random() * n);
@@ -4097,7 +6145,7 @@ var init_courseDB = __esm({
4097
6145
  docs.rows.forEach((r) => {
4098
6146
  if (isSuccessRow(r)) {
4099
6147
  if (r.doc && r.doc.elo) {
4100
- ret.push(toCourseElo6(r.doc.elo));
6148
+ ret.push(toCourseElo8(r.doc.elo));
4101
6149
  } else {
4102
6150
  logger.warn("no elo data for card: " + r.id);
4103
6151
  ret.push(blankCourseElo2());
@@ -4448,10 +6496,18 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4448
6496
  * @param limit - Maximum number of cards to return
4449
6497
  * @returns Cards sorted by score descending
4450
6498
  */
6499
+ _pendingHints = null;
6500
+ setEphemeralHints(hints) {
6501
+ this._pendingHints = hints;
6502
+ }
4451
6503
  async getWeightedCards(limit) {
4452
6504
  const u = await this._getCurrentUser();
4453
6505
  try {
4454
6506
  const navigator = await this.createNavigator(u);
6507
+ if (this._pendingHints) {
6508
+ navigator.setEphemeralHints(this._pendingHints);
6509
+ this._pendingHints = null;
6510
+ }
4455
6511
  return navigator.getWeightedCards(limit);
4456
6512
  } catch (e) {
4457
6513
  logger.error(`[courseDB] Error getting weighted cards: ${e}`);
@@ -4594,7 +6650,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4594
6650
  });
4595
6651
 
4596
6652
  // src/impl/couch/classroomDB.ts
4597
- import moment4 from "moment";
6653
+ import moment6 from "moment";
4598
6654
  var CLASSROOM_CONFIG, ClassroomDBBase, StudentClassroomDB;
4599
6655
  var init_classroomDB2 = __esm({
4600
6656
  "src/impl/couch/classroomDB.ts"() {
@@ -4712,9 +6768,9 @@ var init_classroomDB2 = __esm({
4712
6768
  }
4713
6769
  const activeCards = await this._user.getActiveCards();
4714
6770
  const activeCardIds = new Set(activeCards.map((ac) => ac.cardID));
4715
- const now = moment4.utc();
6771
+ const now = moment6.utc();
4716
6772
  const assigned = await this.getAssignedContent();
4717
- const due = assigned.filter((c) => now.isAfter(moment4.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
6773
+ const due = assigned.filter((c) => now.isAfter(moment6.utc(c.activeOn, REVIEW_TIME_FORMAT2)));
4718
6774
  logger.info(`[StudentClassroomDB] Due content: ${JSON.stringify(due)}`);
4719
6775
  for (const content of due) {
4720
6776
  if (content.type === "course") {
@@ -4812,7 +6868,7 @@ var init_CourseSyncService = __esm({
4812
6868
  });
4813
6869
 
4814
6870
  // src/impl/couch/auth.ts
4815
- import fetch from "cross-fetch";
6871
+ import fetch2 from "cross-fetch";
4816
6872
  var init_auth = __esm({
4817
6873
  "src/impl/couch/auth.ts"() {
4818
6874
  "use strict";
@@ -4837,8 +6893,8 @@ var init_CouchDBSyncStrategy = __esm({
4837
6893
  });
4838
6894
 
4839
6895
  // src/impl/couch/index.ts
4840
- import fetch2 from "cross-fetch";
4841
- import moment5 from "moment";
6896
+ import fetch3 from "cross-fetch";
6897
+ import moment7 from "moment";
4842
6898
  import process2 from "process";
4843
6899
  function createPouchDBConfig() {
4844
6900
  const hasExplicitCredentials = ENV.COUCHDB_USERNAME && ENV.COUCHDB_PASSWORD;
@@ -4915,7 +6971,7 @@ var init_couch = __esm({
4915
6971
 
4916
6972
  // src/impl/common/BaseUserDB.ts
4917
6973
  import { Status as Status3 } from "@vue-skuilder/common";
4918
- import moment6 from "moment";
6974
+ import moment8 from "moment";
4919
6975
  async function getOrCreateClassroomRegistrationsDoc(user) {
4920
6976
  let ret;
4921
6977
  try {
@@ -5277,7 +7333,7 @@ Currently logged-in as ${this._username}.`
5277
7333
  );
5278
7334
  return reviews.rows.filter((r) => {
5279
7335
  if (r.id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */])) {
5280
- const date = moment6.utc(
7336
+ const date = moment8.utc(
5281
7337
  r.id.substr(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */].length),
5282
7338
  REVIEW_TIME_FORMAT
5283
7339
  );
@@ -5290,11 +7346,11 @@ Currently logged-in as ${this._username}.`
5290
7346
  }).map((r) => r.doc);
5291
7347
  }
5292
7348
  async getReviewsForcast(daysCount) {
5293
- const time = moment6.utc().add(daysCount, "days");
7349
+ const time = moment8.utc().add(daysCount, "days");
5294
7350
  return this.getReviewstoDate(time);
5295
7351
  }
5296
7352
  async getPendingReviews(course_id) {
5297
- const now = moment6.utc();
7353
+ const now = moment8.utc();
5298
7354
  return this.getReviewstoDate(now, course_id);
5299
7355
  }
5300
7356
  async getScheduledReviewCount(course_id) {
@@ -5581,7 +7637,7 @@ Currently logged-in as ${this._username}.`
5581
7637
  */
5582
7638
  async putCardRecord(record) {
5583
7639
  const cardHistoryID = getCardHistoryID(record.courseID, record.cardID);
5584
- record.timeStamp = moment6.utc(record.timeStamp).toString();
7640
+ record.timeStamp = moment8.utc(record.timeStamp).toString();
5585
7641
  try {
5586
7642
  const cardHistory = await this.update(
5587
7643
  cardHistoryID,
@@ -5597,7 +7653,7 @@ Currently logged-in as ${this._username}.`
5597
7653
  const ret = {
5598
7654
  ...record2
5599
7655
  };
5600
- ret.timeStamp = moment6.utc(record2.timeStamp);
7656
+ ret.timeStamp = moment8.utc(record2.timeStamp);
5601
7657
  return ret;
5602
7658
  });
5603
7659
  return cardHistory;
@@ -6297,7 +8353,7 @@ var init_cardProcessor = __esm({
6297
8353
  });
6298
8354
 
6299
8355
  // src/core/bulkImport/types.ts
6300
- var init_types3 = __esm({
8356
+ var init_types5 = __esm({
6301
8357
  "src/core/bulkImport/types.ts"() {
6302
8358
  "use strict";
6303
8359
  }
@@ -6308,7 +8364,7 @@ var init_bulkImport = __esm({
6308
8364
  "src/core/bulkImport/index.ts"() {
6309
8365
  "use strict";
6310
8366
  init_cardProcessor();
6311
- init_types3();
8367
+ init_types5();
6312
8368
  }
6313
8369
  });
6314
8370