@vue-skuilder/db 0.1.31-a → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/{contentSource-BmnmvH8C.d.ts → contentSource-Bdwkvqa8.d.ts} +35 -4
  2. package/dist/{contentSource-DfBbaLA-.d.cts → contentSource-DF1nUbPQ.d.cts} +35 -4
  3. package/dist/core/index.d.cts +48 -3
  4. package/dist/core/index.d.ts +48 -3
  5. package/dist/core/index.js +587 -56
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +586 -56
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BeRXVMs5.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
  10. package/dist/{dataLayerProvider-CG9GfaAY.d.ts → dataLayerProvider-BQdfJuBN.d.cts} +20 -1
  11. package/dist/impl/couch/index.d.cts +156 -4
  12. package/dist/impl/couch/index.d.ts +156 -4
  13. package/dist/impl/couch/index.js +805 -47
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +804 -47
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +3 -2
  18. package/dist/impl/static/index.d.ts +3 -2
  19. package/dist/impl/static/index.js +542 -37
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +542 -37
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index.d.cts +64 -3
  24. package/dist/index.d.ts +64 -3
  25. package/dist/index.js +1040 -90
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +1030 -81
  28. package/dist/index.mjs.map +1 -1
  29. package/docs/navigators-architecture.md +64 -5
  30. package/package.json +3 -3
  31. package/src/core/interfaces/contentSource.ts +6 -0
  32. package/src/core/interfaces/courseDB.ts +6 -0
  33. package/src/core/interfaces/dataLayerProvider.ts +20 -0
  34. package/src/core/navigators/Pipeline.ts +414 -9
  35. package/src/core/navigators/PipelineAssembler.ts +23 -18
  36. package/src/core/navigators/PipelineDebugger.ts +115 -1
  37. package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
  38. package/src/core/navigators/generators/prescribed.ts +95 -0
  39. package/src/core/navigators/index.ts +55 -10
  40. package/src/impl/common/BaseUserDB.ts +4 -1
  41. package/src/impl/couch/CourseSyncService.ts +356 -0
  42. package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
  43. package/src/impl/couch/courseDB.ts +60 -13
  44. package/src/impl/couch/index.ts +1 -0
  45. package/src/impl/static/courseDB.ts +5 -0
  46. package/src/study/ItemQueue.ts +42 -0
  47. package/src/study/SessionController.ts +195 -22
  48. package/src/study/SpacedRepetition.ts +7 -2
  49. package/tests/core/navigators/Pipeline.test.ts +1 -1
  50. package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
package/dist/index.mjs CHANGED
@@ -855,8 +855,12 @@ __export(PipelineDebugger_exports, {
855
855
  buildRunReport: () => buildRunReport,
856
856
  captureRun: () => captureRun,
857
857
  mountPipelineDebugger: () => mountPipelineDebugger,
858
- pipelineDebugAPI: () => pipelineDebugAPI
858
+ pipelineDebugAPI: () => pipelineDebugAPI,
859
+ registerPipelineForDebug: () => registerPipelineForDebug
859
860
  });
861
+ function registerPipelineForDebug(pipeline) {
862
+ _activePipeline = pipeline;
863
+ }
860
864
  function getOrigin(card) {
861
865
  const firstEntry = card.provenance[0];
862
866
  if (!firstEntry) return "unknown";
@@ -884,6 +888,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
884
888
  origin: getOrigin(card),
885
889
  finalScore: card.score,
886
890
  provenance: card.provenance,
891
+ tags: card.tags,
887
892
  selected: selectedIds.has(card.cardId)
888
893
  }));
889
894
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
@@ -939,11 +944,13 @@ function mountPipelineDebugger() {
939
944
  win.skuilder = win.skuilder || {};
940
945
  win.skuilder.pipeline = pipelineDebugAPI;
941
946
  }
942
- var MAX_RUNS, runHistory, pipelineDebugAPI;
947
+ var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
943
948
  var init_PipelineDebugger = __esm({
944
949
  "src/core/navigators/PipelineDebugger.ts"() {
945
950
  "use strict";
951
+ init_navigators();
946
952
  init_logger();
953
+ _activePipeline = null;
947
954
  MAX_RUNS = 10;
948
955
  runHistory = [];
949
956
  pipelineDebugAPI = {
@@ -1085,6 +1092,81 @@ var init_PipelineDebugger = __esm({
1085
1092
  runHistory.length = 0;
1086
1093
  logger.info("[Pipeline Debug] Run history cleared.");
1087
1094
  },
1095
+ /**
1096
+ * Show the navigator registry: all registered classes and their roles.
1097
+ *
1098
+ * Useful for verifying that consumer-defined navigators were registered
1099
+ * before pipeline assembly.
1100
+ */
1101
+ showRegistry() {
1102
+ const names = getRegisteredNavigatorNames();
1103
+ if (names.length === 0) {
1104
+ logger.info("[Pipeline Debug] Navigator registry is empty.");
1105
+ return;
1106
+ }
1107
+ console.group("\u{1F4E6} Navigator Registry");
1108
+ console.table(
1109
+ names.map((name) => {
1110
+ const registryRole = getRegisteredNavigatorRole(name);
1111
+ const builtinRole = NavigatorRoles[name];
1112
+ const effectiveRole = builtinRole || registryRole || "\u26A0\uFE0F NONE";
1113
+ const source = builtinRole ? "built-in" : registryRole ? "consumer" : "unclassified";
1114
+ return {
1115
+ name,
1116
+ role: effectiveRole,
1117
+ source,
1118
+ isGenerator: isGenerator(name),
1119
+ isFilter: isFilter(name)
1120
+ };
1121
+ })
1122
+ );
1123
+ console.groupEnd();
1124
+ },
1125
+ /**
1126
+ * Show strategy documents from the last pipeline run and how they mapped
1127
+ * to the registry.
1128
+ *
1129
+ * If no runs are captured yet, falls back to showing just the registry.
1130
+ */
1131
+ showStrategies() {
1132
+ this.showRegistry();
1133
+ if (runHistory.length === 0) {
1134
+ logger.info("[Pipeline Debug] No pipeline runs captured yet \u2014 cannot show strategy doc mapping.");
1135
+ return;
1136
+ }
1137
+ const run = runHistory[0];
1138
+ console.group("\u{1F50C} Pipeline Strategy Mapping (last run)");
1139
+ logger.info(`Generator: ${run.generatorName}`);
1140
+ if (run.generators && run.generators.length > 0) {
1141
+ for (const g of run.generators) {
1142
+ logger.info(` \u{1F4E5} ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
1143
+ }
1144
+ }
1145
+ if (run.filters.length > 0) {
1146
+ logger.info("Filters:");
1147
+ for (const f of run.filters) {
1148
+ logger.info(` \u{1F538} ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
1149
+ }
1150
+ } else {
1151
+ logger.info("Filters: (none)");
1152
+ }
1153
+ console.groupEnd();
1154
+ },
1155
+ /**
1156
+ * Scan the full card space through the filter chain for the current user.
1157
+ *
1158
+ * Reports how many cards are well-indicated and how many are new.
1159
+ * Use this to understand how the search space grows during onboarding.
1160
+ *
1161
+ * @param threshold - Score threshold for "well indicated" (default 0.10)
1162
+ */
1163
+ async diagnoseCardSpace(threshold) {
1164
+ if (!_activePipeline) {
1165
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
1166
+ return null;
1167
+ }
1168
+ return _activePipeline.diagnoseCardSpace({ threshold });
1169
+ },
1088
1170
  /**
1089
1171
  * Show help.
1090
1172
  */
@@ -1097,6 +1179,9 @@ Commands:
1097
1179
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
1098
1180
  .showCard(cardId) Show provenance trail for a specific card
1099
1181
  .explainReviews() Analyze why reviews were/weren't selected
1182
+ .diagnoseCardSpace() Scan full card space through filters (async)
1183
+ .showRegistry() Show navigator registry (classes + roles)
1184
+ .showStrategies() Show registry + strategy mapping from last run
1100
1185
  .listRuns() List all captured runs in table format
1101
1186
  .export() Export run history as JSON for bug reports
1102
1187
  .clear() Clear run history
@@ -1106,7 +1191,7 @@ Commands:
1106
1191
  Example:
1107
1192
  window.skuilder.pipeline.showLastRun()
1108
1193
  window.skuilder.pipeline.showRun(1)
1109
- window.skuilder.pipeline.showCard('abc123')
1194
+ await window.skuilder.pipeline.diagnoseCardSpace()
1110
1195
  `);
1111
1196
  }
1112
1197
  };
@@ -1401,6 +1486,69 @@ var init_generators = __esm({
1401
1486
  }
1402
1487
  });
1403
1488
 
1489
+ // src/core/navigators/generators/prescribed.ts
1490
+ var prescribed_exports = {};
1491
+ __export(prescribed_exports, {
1492
+ default: () => PrescribedCardsGenerator
1493
+ });
1494
+ var PrescribedCardsGenerator;
1495
+ var init_prescribed = __esm({
1496
+ "src/core/navigators/generators/prescribed.ts"() {
1497
+ "use strict";
1498
+ init_navigators();
1499
+ init_logger();
1500
+ PrescribedCardsGenerator = class extends ContentNavigator {
1501
+ name;
1502
+ config;
1503
+ constructor(user, course, strategyData) {
1504
+ super(user, course, strategyData);
1505
+ this.name = strategyData.name || "Prescribed Cards";
1506
+ try {
1507
+ const parsed = JSON.parse(strategyData.serializedData);
1508
+ this.config = { cardIds: parsed.cardIds || [] };
1509
+ } catch {
1510
+ this.config = { cardIds: [] };
1511
+ }
1512
+ logger.debug(
1513
+ `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1514
+ );
1515
+ }
1516
+ async getWeightedCards(limit, _context) {
1517
+ if (this.config.cardIds.length === 0) {
1518
+ return [];
1519
+ }
1520
+ const courseId = this.course.getCourseID();
1521
+ const activeCards = await this.user.getActiveCards();
1522
+ const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1523
+ const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1524
+ if (eligibleIds.length === 0) {
1525
+ logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1526
+ return [];
1527
+ }
1528
+ const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1529
+ cardId,
1530
+ courseId,
1531
+ score: 1,
1532
+ provenance: [
1533
+ {
1534
+ strategy: "prescribed",
1535
+ strategyName: this.strategyName || this.name,
1536
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1537
+ action: "generated",
1538
+ score: 1,
1539
+ reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1540
+ }
1541
+ ]
1542
+ }));
1543
+ logger.info(
1544
+ `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1545
+ );
1546
+ return cards;
1547
+ }
1548
+ };
1549
+ }
1550
+ });
1551
+
1404
1552
  // src/core/navigators/generators/srs.ts
1405
1553
  var srs_exports = {};
1406
1554
  __export(srs_exports, {
@@ -1595,6 +1743,7 @@ var init_ = __esm({
1595
1743
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1596
1744
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1597
1745
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1746
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1598
1747
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1599
1748
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1600
1749
  });
@@ -1795,6 +1944,8 @@ var init_hierarchyDefinition = __esm({
1795
1944
  if (userTagElo.count < minCount) return false;
1796
1945
  if (prereq.masteryThreshold?.minElo !== void 0) {
1797
1946
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1947
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1948
+ return true;
1798
1949
  } else {
1799
1950
  return userTagElo.score >= userGlobalElo;
1800
1951
  }
@@ -1870,14 +2021,38 @@ var init_hierarchyDefinition = __esm({
1870
2021
  };
1871
2022
  }
1872
2023
  }
2024
+ /**
2025
+ * Build a map of prereq tag → max configured boost for all *closed* gates.
2026
+ *
2027
+ * When a gate is closed (prereqs unmet), cards carrying that gate's prereq
2028
+ * tags get boosted — steering the pipeline toward content that helps unlock
2029
+ * the gated material. Once the gate opens, the boost disappears.
2030
+ */
2031
+ getPreReqBoosts(unlockedTags, masteredTags) {
2032
+ const boosts = /* @__PURE__ */ new Map();
2033
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
2034
+ if (unlockedTags.has(tagId)) continue;
2035
+ for (const prereq of prereqs) {
2036
+ if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
2037
+ if (masteredTags.has(prereq.tag)) continue;
2038
+ const existing = boosts.get(prereq.tag) ?? 1;
2039
+ boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
2040
+ }
2041
+ }
2042
+ return boosts;
2043
+ }
1873
2044
  /**
1874
2045
  * CardFilter.transform implementation.
1875
2046
  *
1876
- * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
2047
+ * Two effects:
2048
+ * 1. Cards with locked tags receive score * 0.05 (gating penalty)
2049
+ * 2. Cards carrying prereq tags of closed gates receive a configured
2050
+ * boost (preReqBoost), steering toward content that unlocks gates
1877
2051
  */
1878
2052
  async transform(cards, context) {
1879
2053
  const masteredTags = await this.getMasteredTags(context);
1880
2054
  const unlockedTags = this.getUnlockedTags(masteredTags);
2055
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1881
2056
  const gated = [];
1882
2057
  for (const card of cards) {
1883
2058
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1886,9 +2061,27 @@ var init_hierarchyDefinition = __esm({
1886
2061
  unlockedTags,
1887
2062
  masteredTags
1888
2063
  );
1889
- const LOCKED_PENALTY = 0.01;
1890
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1891
- const action = isUnlocked ? "passed" : "penalized";
2064
+ const LOCKED_PENALTY = 0.02;
2065
+ let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
2066
+ let action = isUnlocked ? "passed" : "penalized";
2067
+ let finalReason = reason;
2068
+ if (isUnlocked && preReqBoosts.size > 0) {
2069
+ const cardTags = card.tags ?? [];
2070
+ let maxBoost = 1;
2071
+ const boostedPrereqs = [];
2072
+ for (const tag of cardTags) {
2073
+ const boost = preReqBoosts.get(tag);
2074
+ if (boost && boost > maxBoost) {
2075
+ maxBoost = boost;
2076
+ boostedPrereqs.push(tag);
2077
+ }
2078
+ }
2079
+ if (maxBoost > 1) {
2080
+ finalScore *= maxBoost;
2081
+ action = "boosted";
2082
+ finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
2083
+ }
2084
+ }
1892
2085
  gated.push({
1893
2086
  ...card,
1894
2087
  score: finalScore,
@@ -1900,7 +2093,7 @@ var init_hierarchyDefinition = __esm({
1900
2093
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1901
2094
  action,
1902
2095
  score: finalScore,
1903
- reason
2096
+ reason: finalReason
1904
2097
  }
1905
2098
  ]
1906
2099
  });
@@ -2835,6 +3028,18 @@ __export(Pipeline_exports, {
2835
3028
  Pipeline: () => Pipeline
2836
3029
  });
2837
3030
  import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
3031
+ function globToRegex(pattern) {
3032
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
3033
+ const withWildcards = escaped.replace(/\*/g, ".*");
3034
+ return new RegExp(`^${withWildcards}$`);
3035
+ }
3036
+ function globMatch(value, pattern) {
3037
+ if (!pattern.includes("*")) return value === pattern;
3038
+ return globToRegex(pattern).test(value);
3039
+ }
3040
+ function cardMatchesTagPattern(card, pattern) {
3041
+ return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
3042
+ }
2838
3043
  function logPipelineConfig(generator, filters) {
2839
3044
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2840
3045
  logger.info(
@@ -2869,6 +3074,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2869
3074
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2870
3075
  );
2871
3076
  }
3077
+ function logResultCards(cards) {
3078
+ if (!VERBOSE_RESULTS || cards.length === 0) return;
3079
+ logger.info(`[Pipeline] Results (${cards.length} cards):`);
3080
+ for (let i = 0; i < cards.length; i++) {
3081
+ const c = cards[i];
3082
+ const tags = c.tags?.slice(0, 3).join(", ") || "";
3083
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
3084
+ const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
3085
+ return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
3086
+ }).join(" | ");
3087
+ logger.info(
3088
+ `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
3089
+ );
3090
+ }
3091
+ }
2872
3092
  function logCardProvenance(cards, maxCards = 3) {
2873
3093
  const cardsToLog = cards.slice(0, maxCards);
2874
3094
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2883,7 +3103,7 @@ function logCardProvenance(cards, maxCards = 3) {
2883
3103
  }
2884
3104
  }
2885
3105
  }
2886
- var Pipeline;
3106
+ var VERBOSE_RESULTS, Pipeline;
2887
3107
  var init_Pipeline = __esm({
2888
3108
  "src/core/navigators/Pipeline.ts"() {
2889
3109
  "use strict";
@@ -2891,9 +3111,31 @@ var init_Pipeline = __esm({
2891
3111
  init_logger();
2892
3112
  init_orchestration();
2893
3113
  init_PipelineDebugger();
3114
+ VERBOSE_RESULTS = true;
2894
3115
  Pipeline = class extends ContentNavigator {
2895
3116
  generator;
2896
3117
  filters;
3118
+ /**
3119
+ * Cached orchestration context. Course config and salt don't change within
3120
+ * a page load, so we build the orchestration context once and reuse it on
3121
+ * subsequent getWeightedCards() calls (e.g. mid-session replans).
3122
+ *
3123
+ * This eliminates a remote getCourseConfig() round trip per pipeline run.
3124
+ */
3125
+ _cachedOrchestration = null;
3126
+ /**
3127
+ * Persistent tag cache. Maps cardId → tag names.
3128
+ *
3129
+ * Tags are static within a session (they're set at card generation time),
3130
+ * so we cache them across pipeline runs. On replans, many of the same cards
3131
+ * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
3132
+ */
3133
+ _tagCache = /* @__PURE__ */ new Map();
3134
+ /**
3135
+ * One-shot replan hints. Applied after the filter chain on the next
3136
+ * getWeightedCards() call, then cleared.
3137
+ */
3138
+ _ephemeralHints = null;
2897
3139
  /**
2898
3140
  * Create a new pipeline.
2899
3141
  *
@@ -2914,6 +3156,17 @@ var init_Pipeline = __esm({
2914
3156
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2915
3157
  });
2916
3158
  logPipelineConfig(generator, filters);
3159
+ registerPipelineForDebug(this);
3160
+ }
3161
+ /**
3162
+ * Set one-shot hints for the next pipeline run.
3163
+ * Consumed after one getWeightedCards() call, then cleared.
3164
+ *
3165
+ * Overrides ContentNavigator.setEphemeralHints() no-op.
3166
+ */
3167
+ setEphemeralHints(hints) {
3168
+ this._ephemeralHints = hints;
3169
+ logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
2917
3170
  }
2918
3171
  /**
2919
3172
  * Get weighted cards by running generator and applying filters.
@@ -2930,13 +3183,15 @@ var init_Pipeline = __esm({
2930
3183
  * @returns Cards sorted by score descending
2931
3184
  */
2932
3185
  async getWeightedCards(limit) {
3186
+ const t0 = performance.now();
2933
3187
  const context = await this.buildContext();
2934
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2935
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
3188
+ const tContext = performance.now();
3189
+ const fetchLimit = 500;
2936
3190
  logger.debug(
2937
3191
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2938
3192
  );
2939
3193
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
3194
+ const tGenerate = performance.now();
2940
3195
  const generatedCount = cards.length;
2941
3196
  let generatorSummaries;
2942
3197
  if (this.generator.generators) {
@@ -2965,6 +3220,7 @@ var init_Pipeline = __esm({
2965
3220
  }
2966
3221
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2967
3222
  cards = await this.hydrateTags(cards);
3223
+ const tHydrate = performance.now();
2968
3224
  const allCardsBeforeFiltering = [...cards];
2969
3225
  const filterImpacts = [];
2970
3226
  for (const filter of this.filters) {
@@ -2983,8 +3239,17 @@ var init_Pipeline = __esm({
2983
3239
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2984
3240
  }
2985
3241
  cards = cards.filter((c) => c.score > 0);
3242
+ const hints = this._ephemeralHints;
3243
+ if (hints) {
3244
+ this._ephemeralHints = null;
3245
+ cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
3246
+ }
2986
3247
  cards.sort((a, b) => b.score - a.score);
3248
+ const tFilter = performance.now();
2987
3249
  const result = cards.slice(0, limit);
3250
+ logger.info(
3251
+ `[Pipeline:timing] total=${(tFilter - t0).toFixed(0)}ms (context=${(tContext - t0).toFixed(0)} generate=${(tGenerate - tContext).toFixed(0)} hydrate=${(tHydrate - tGenerate).toFixed(0)} filter=${(tFilter - tHydrate).toFixed(0)})`
3252
+ );
2988
3253
  const topScores = result.slice(0, 3).map((c) => c.score);
2989
3254
  logExecutionSummary(
2990
3255
  this.generator.name,
@@ -2994,6 +3259,7 @@ var init_Pipeline = __esm({
2994
3259
  topScores,
2995
3260
  filterImpacts
2996
3261
  );
3262
+ logResultCards(result);
2997
3263
  logCardProvenance(result, 3);
2998
3264
  try {
2999
3265
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -3020,6 +3286,10 @@ var init_Pipeline = __esm({
3020
3286
  * to the WeightedCard objects. Filters can then use card.tags instead of
3021
3287
  * making individual getAppliedTags() calls.
3022
3288
  *
3289
+ * Uses a persistent tag cache across pipeline runs — tags are static within
3290
+ * a session, so cards seen in a prior run (e.g. before a replan) don't
3291
+ * require a second DB query.
3292
+ *
3023
3293
  * @param cards - Cards to hydrate
3024
3294
  * @returns Cards with tags populated
3025
3295
  */
@@ -3027,14 +3297,128 @@ var init_Pipeline = __esm({
3027
3297
  if (cards.length === 0) {
3028
3298
  return cards;
3029
3299
  }
3030
- const cardIds = cards.map((c) => c.cardId);
3031
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
3300
+ const uncachedIds = [];
3301
+ for (const card of cards) {
3302
+ if (!this._tagCache.has(card.cardId)) {
3303
+ uncachedIds.push(card.cardId);
3304
+ }
3305
+ }
3306
+ if (uncachedIds.length > 0) {
3307
+ const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
3308
+ for (const [cardId, tags] of freshTags) {
3309
+ this._tagCache.set(cardId, tags);
3310
+ }
3311
+ }
3312
+ const tagsByCard = /* @__PURE__ */ new Map();
3313
+ for (const card of cards) {
3314
+ tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
3315
+ }
3032
3316
  logTagHydration(cards, tagsByCard);
3033
3317
  return cards.map((card) => ({
3034
3318
  ...card,
3035
- tags: tagsByCard.get(card.cardId) ?? []
3319
+ tags: this._tagCache.get(card.cardId) ?? []
3036
3320
  }));
3037
3321
  }
3322
+ // ---------------------------------------------------------------------------
3323
+ // Ephemeral hints application
3324
+ // ---------------------------------------------------------------------------
3325
+ /**
3326
+ * Apply one-shot replan hints to the post-filter card set.
3327
+ *
3328
+ * Order of operations:
3329
+ * 1. Exclude (remove unwanted cards)
3330
+ * 2. Boost (multiply scores)
3331
+ * 3. Require (inject must-have cards from the full pre-filter pool)
3332
+ *
3333
+ * @param cards - Post-filter cards (score > 0)
3334
+ * @param hints - The ephemeral hints to apply
3335
+ * @param allCards - Full pre-filter card pool (for require injection)
3336
+ */
3337
+ applyHints(cards, hints, allCards) {
3338
+ const beforeCount = cards.length;
3339
+ if (hints.excludeCards?.length) {
3340
+ cards = cards.filter(
3341
+ (c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
3342
+ );
3343
+ }
3344
+ if (hints.excludeTags?.length) {
3345
+ cards = cards.filter(
3346
+ (c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
3347
+ );
3348
+ }
3349
+ if (hints.boostTags) {
3350
+ for (const [pattern, factor] of Object.entries(hints.boostTags)) {
3351
+ for (const card of cards) {
3352
+ if (cardMatchesTagPattern(card, pattern)) {
3353
+ card.score *= factor;
3354
+ card.provenance.push({
3355
+ strategy: "ephemeralHint",
3356
+ strategyId: "ephemeral-hint",
3357
+ strategyName: "Replan Hint",
3358
+ action: "boosted",
3359
+ score: card.score,
3360
+ reason: `boostTag ${pattern} \xD7${factor}`
3361
+ });
3362
+ }
3363
+ }
3364
+ }
3365
+ }
3366
+ if (hints.boostCards) {
3367
+ for (const [pattern, factor] of Object.entries(hints.boostCards)) {
3368
+ for (const card of cards) {
3369
+ if (globMatch(card.cardId, pattern)) {
3370
+ card.score *= factor;
3371
+ card.provenance.push({
3372
+ strategy: "ephemeralHint",
3373
+ strategyId: "ephemeral-hint",
3374
+ strategyName: "Replan Hint",
3375
+ action: "boosted",
3376
+ score: card.score,
3377
+ reason: `boostCard ${pattern} \xD7${factor}`
3378
+ });
3379
+ }
3380
+ }
3381
+ }
3382
+ }
3383
+ const cardIds = new Set(cards.map((c) => c.cardId));
3384
+ const inject = (card, reason) => {
3385
+ if (!cardIds.has(card.cardId)) {
3386
+ const floorScore = Math.max(card.score, 1);
3387
+ cards.push({
3388
+ ...card,
3389
+ score: floorScore,
3390
+ provenance: [
3391
+ ...card.provenance,
3392
+ {
3393
+ strategy: "ephemeralHint",
3394
+ strategyId: "ephemeral-hint",
3395
+ strategyName: "Replan Hint",
3396
+ action: "boosted",
3397
+ score: floorScore,
3398
+ reason
3399
+ }
3400
+ ]
3401
+ });
3402
+ cardIds.add(card.cardId);
3403
+ }
3404
+ };
3405
+ if (hints.requireCards?.length) {
3406
+ for (const pattern of hints.requireCards) {
3407
+ for (const card of allCards) {
3408
+ if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
3409
+ }
3410
+ }
3411
+ }
3412
+ if (hints.requireTags?.length) {
3413
+ for (const pattern of hints.requireTags) {
3414
+ for (const card of allCards) {
3415
+ if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
3416
+ }
3417
+ }
3418
+ }
3419
+ logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
3420
+ return cards;
3421
+ }
3038
3422
  /**
3039
3423
  * Build shared context for generator and filters.
3040
3424
  *
@@ -3052,7 +3436,10 @@ var init_Pipeline = __esm({
3052
3436
  } catch (e) {
3053
3437
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
3054
3438
  }
3055
- const orchestration = await createOrchestrationContext(this.user, this.course);
3439
+ if (!this._cachedOrchestration) {
3440
+ this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
3441
+ }
3442
+ const orchestration = this._cachedOrchestration;
3056
3443
  return {
3057
3444
  user: this.user,
3058
3445
  course: this.course,
@@ -3096,6 +3483,87 @@ var init_Pipeline = __esm({
3096
3483
  }
3097
3484
  return [...new Set(ids)];
3098
3485
  }
3486
+ // ---------------------------------------------------------------------------
3487
+ // Card-space diagnostic
3488
+ // ---------------------------------------------------------------------------
3489
+ /**
3490
+ * Scan every card in the course through the filter chain and report
3491
+ * how many are "well indicated" (score >= threshold) for the current user.
3492
+ *
3493
+ * Also reports how many well-indicated cards the user has NOT yet encountered.
3494
+ *
3495
+ * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
3496
+ */
3497
+ async diagnoseCardSpace(opts) {
3498
+ const THRESHOLD = opts?.threshold ?? 0.1;
3499
+ const t0 = performance.now();
3500
+ const allCardIds = await this.course.getAllCardIds();
3501
+ let cards = allCardIds.map((cardId) => ({
3502
+ cardId,
3503
+ courseId: this.course.getCourseID(),
3504
+ score: 1,
3505
+ provenance: []
3506
+ }));
3507
+ cards = await this.hydrateTags(cards);
3508
+ const context = await this.buildContext();
3509
+ const filterBreakdown = [];
3510
+ for (const filter of this.filters) {
3511
+ cards = await filter.transform(cards, context);
3512
+ const wi = cards.filter((c) => c.score >= THRESHOLD).length;
3513
+ filterBreakdown.push({ name: filter.name, wellIndicated: wi });
3514
+ }
3515
+ const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
3516
+ const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
3517
+ let encounteredIds;
3518
+ try {
3519
+ const courseId = this.course.getCourseID();
3520
+ const seenCards = await this.user.getSeenCards(courseId);
3521
+ encounteredIds = new Set(seenCards);
3522
+ } catch {
3523
+ encounteredIds = /* @__PURE__ */ new Set();
3524
+ }
3525
+ const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
3526
+ const byType = /* @__PURE__ */ new Map();
3527
+ for (const card of cards) {
3528
+ const type = card.cardId.split("-")[1] || "unknown";
3529
+ if (!byType.has(type)) {
3530
+ byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
3531
+ }
3532
+ const entry = byType.get(type);
3533
+ entry.total++;
3534
+ if (card.score >= THRESHOLD) {
3535
+ entry.wellIndicated++;
3536
+ if (!encounteredIds.has(card.cardId)) entry.new++;
3537
+ }
3538
+ }
3539
+ const elapsed = performance.now() - t0;
3540
+ const result = {
3541
+ totalCards: allCardIds.length,
3542
+ threshold: THRESHOLD,
3543
+ wellIndicated: wellIndicatedIds.size,
3544
+ encountered: encounteredIds.size,
3545
+ wellIndicatedNew: wellIndicatedNew.length,
3546
+ byType: Object.fromEntries(byType),
3547
+ filterBreakdown,
3548
+ elapsedMs: Math.round(elapsed)
3549
+ };
3550
+ logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
3551
+ logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
3552
+ logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
3553
+ logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
3554
+ logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
3555
+ logger.info(`[Pipeline:diagnose] By type:`);
3556
+ for (const [type, counts] of byType) {
3557
+ logger.info(
3558
+ `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
3559
+ );
3560
+ }
3561
+ logger.info(`[Pipeline:diagnose] After each filter:`);
3562
+ for (const fb of filterBreakdown) {
3563
+ logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
3564
+ }
3565
+ return result;
3566
+ }
3099
3567
  };
3100
3568
  }
3101
3569
  });
@@ -3200,23 +3668,25 @@ var init_PipelineAssembler = __esm({
3200
3668
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
3201
3669
  }
3202
3670
  }
3671
+ const courseId = course.getCourseID();
3672
+ const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
3673
+ const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
3674
+ if (!hasElo) {
3675
+ logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
3676
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3677
+ }
3678
+ if (!hasSrs) {
3679
+ logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
3680
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
3681
+ }
3203
3682
  if (generatorStrategies.length === 0) {
3204
- if (filterStrategies.length > 0) {
3205
- logger.debug(
3206
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
3207
- );
3208
- const courseId = course.getCourseID();
3209
- generatorStrategies.push(createDefaultEloStrategy(courseId));
3210
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
3211
- } else {
3212
- warnings.push("No generator strategy found");
3213
- return {
3214
- pipeline: null,
3215
- generatorStrategies: [],
3216
- filterStrategies: [],
3217
- warnings
3218
- };
3219
- }
3683
+ warnings.push("No generator strategy found");
3684
+ return {
3685
+ pipeline: null,
3686
+ generatorStrategies: [],
3687
+ filterStrategies: [],
3688
+ warnings
3689
+ };
3220
3690
  }
3221
3691
  let generator;
3222
3692
  if (generatorStrategies.length === 1) {
@@ -3294,6 +3764,7 @@ var init_3 = __esm({
3294
3764
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
3295
3765
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
3296
3766
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3767
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
3297
3768
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
3298
3769
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
3299
3770
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -3311,6 +3782,7 @@ __export(navigators_exports, {
3311
3782
  getCardOrigin: () => getCardOrigin,
3312
3783
  getRegisteredNavigator: () => getRegisteredNavigator,
3313
3784
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
3785
+ getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
3314
3786
  hasRegisteredNavigator: () => hasRegisteredNavigator,
3315
3787
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
3316
3788
  isFilter: () => isFilter,
@@ -3319,16 +3791,19 @@ __export(navigators_exports, {
3319
3791
  pipelineDebugAPI: () => pipelineDebugAPI,
3320
3792
  registerNavigator: () => registerNavigator
3321
3793
  });
3322
- function registerNavigator(implementingClass, constructor) {
3323
- navigatorRegistry.set(implementingClass, constructor);
3324
- logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
3794
+ function registerNavigator(implementingClass, constructor, role) {
3795
+ navigatorRegistry.set(implementingClass, { constructor, role });
3796
+ logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ""}`);
3325
3797
  }
3326
3798
  function getRegisteredNavigator(implementingClass) {
3327
- return navigatorRegistry.get(implementingClass);
3799
+ return navigatorRegistry.get(implementingClass)?.constructor;
3328
3800
  }
3329
3801
  function hasRegisteredNavigator(implementingClass) {
3330
3802
  return navigatorRegistry.has(implementingClass);
3331
3803
  }
3804
+ function getRegisteredNavigatorRole(implementingClass) {
3805
+ return navigatorRegistry.get(implementingClass)?.role;
3806
+ }
3332
3807
  function getRegisteredNavigatorNames() {
3333
3808
  return Array.from(navigatorRegistry.keys());
3334
3809
  }
@@ -3338,8 +3813,10 @@ async function initializeNavigatorRegistry() {
3338
3813
  Promise.resolve().then(() => (init_elo(), elo_exports)),
3339
3814
  Promise.resolve().then(() => (init_srs(), srs_exports))
3340
3815
  ]);
3816
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
3341
3817
  registerNavigator("elo", eloModule.default);
3342
3818
  registerNavigator("srs", srsModule.default);
3819
+ registerNavigator("prescribed", prescribedModule.default);
3343
3820
  const [
3344
3821
  hierarchyModule,
3345
3822
  interferenceModule,
@@ -3374,10 +3851,12 @@ function getCardOrigin(card) {
3374
3851
  return "new";
3375
3852
  }
3376
3853
  function isGenerator(impl) {
3377
- return NavigatorRoles[impl] === "generator" /* GENERATOR */;
3854
+ if (NavigatorRoles[impl] === "generator" /* GENERATOR */) return true;
3855
+ return getRegisteredNavigatorRole(impl) === "generator" /* GENERATOR */;
3378
3856
  }
3379
3857
  function isFilter(impl) {
3380
- return NavigatorRoles[impl] === "filter" /* FILTER */;
3858
+ if (NavigatorRoles[impl] === "filter" /* FILTER */) return true;
3859
+ return getRegisteredNavigatorRole(impl) === "filter" /* FILTER */;
3381
3860
  }
3382
3861
  var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
3383
3862
  var init_navigators = __esm({
@@ -3392,6 +3871,7 @@ var init_navigators = __esm({
3392
3871
  Navigators = /* @__PURE__ */ ((Navigators2) => {
3393
3872
  Navigators2["ELO"] = "elo";
3394
3873
  Navigators2["SRS"] = "srs";
3874
+ Navigators2["PRESCRIBED"] = "prescribed";
3395
3875
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
3396
3876
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
3397
3877
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -3406,6 +3886,7 @@ var init_navigators = __esm({
3406
3886
  NavigatorRoles = {
3407
3887
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
3408
3888
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3889
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
3409
3890
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
3410
3891
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
3411
3892
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -3570,6 +4051,12 @@ var init_navigators = __esm({
3570
4051
  async getWeightedCards(_limit) {
3571
4052
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
3572
4053
  }
4054
+ /**
4055
+ * Set ephemeral hints for the next pipeline run.
4056
+ * No-op for non-Pipeline navigators. Pipeline overrides this.
4057
+ */
4058
+ setEphemeralHints(_hints) {
4059
+ }
3573
4060
  };
3574
4061
  }
3575
4062
  });
@@ -3719,15 +4206,42 @@ var init_courseDB = __esm({
3719
4206
  // private log(msg: string): void {
3720
4207
  // log(`CourseLog: ${this.id}\n ${msg}`);
3721
4208
  // }
4209
+ /**
4210
+ * Primary database handle used for all **read** operations (queries, gets).
4211
+ *
4212
+ * When local sync is active, this points to the local PouchDB replica for
4213
+ * fast, network-free reads. Otherwise it points to the remote CouchDB.
4214
+ */
3722
4215
  db;
4216
+ /**
4217
+ * Remote database handle used for all **write** operations.
4218
+ *
4219
+ * Always points to the remote CouchDB so that writes (ELO updates, tag
4220
+ * mutations, admin operations) aggregate on the server. The local replica
4221
+ * is a read-only snapshot that refreshes on the next page load.
4222
+ *
4223
+ * When local sync is NOT active, this is the same instance as `this.db`.
4224
+ */
4225
+ remoteDB;
3723
4226
  id;
3724
4227
  _getCurrentUser;
3725
4228
  updateQueue;
3726
- constructor(id, userLookup) {
4229
+ /**
4230
+ * @param id - Course ID
4231
+ * @param userLookup - Async function returning the current user DB
4232
+ * @param localDB - Optional local PouchDB replica for reads. When provided,
4233
+ * `this.db` uses the local replica and `this.remoteDB` stays remote.
4234
+ * The UpdateQueue reads from remote and writes to remote (local `_rev`
4235
+ * values may be stale, so read-modify-write cycles must go through
4236
+ * the remote DB to avoid conflicts).
4237
+ */
4238
+ constructor(id, userLookup, localDB) {
3727
4239
  this.id = id;
3728
- this.db = getCourseDB2(this.id);
4240
+ const remote = getCourseDB2(this.id);
4241
+ this.remoteDB = remote;
4242
+ this.db = localDB ?? remote;
3729
4243
  this._getCurrentUser = userLookup;
3730
- this.updateQueue = new UpdateQueue(this.db);
4244
+ this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
3731
4245
  }
3732
4246
  getCourseID() {
3733
4247
  return this.id;
@@ -3815,7 +4329,7 @@ var init_courseDB = __esm({
3815
4329
  };
3816
4330
  }
3817
4331
  async removeCard(id) {
3818
- const doc = await this.db.get(id);
4332
+ const doc = await this.remoteDB.get(id);
3819
4333
  if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
3820
4334
  throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
3821
4335
  }
@@ -3836,7 +4350,7 @@ var init_courseDB = __esm({
3836
4350
  } catch (error) {
3837
4351
  logger.error(`Error removing card ${id} from tags: ${error}`);
3838
4352
  }
3839
- return this.db.remove(doc);
4353
+ return this.remoteDB.remove(doc);
3840
4354
  }
3841
4355
  async getCardDisplayableDataIDs(id) {
3842
4356
  logger.debug(id.join(", "));
@@ -3938,8 +4452,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3938
4452
  if (cardIds.length === 0) {
3939
4453
  return /* @__PURE__ */ new Map();
3940
4454
  }
3941
- const db = getCourseDB2(this.id);
3942
- const result = await db.query("getTags", {
4455
+ const result = await this.db.query("getTags", {
3943
4456
  keys: cardIds,
3944
4457
  include_docs: false
3945
4458
  });
@@ -3956,6 +4469,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3956
4469
  }
3957
4470
  return tagsByCard;
3958
4471
  }
4472
+ async getAllCardIds() {
4473
+ const result = await this.db.allDocs({
4474
+ startkey: "CARD-",
4475
+ endkey: "CARD-\uFFF0",
4476
+ include_docs: false
4477
+ });
4478
+ return result.rows.map((row) => row.id);
4479
+ }
3959
4480
  async addTagToCard(cardId, tagId, updateELO) {
3960
4481
  return await addTagToCard(
3961
4482
  this.id,
@@ -4022,10 +4543,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4022
4543
  }
4023
4544
  }
4024
4545
  async getCourseDoc(id, options) {
4025
- return await getCourseDoc(this.id, id, options);
4546
+ return await this.db.get(id, options);
4026
4547
  }
4027
4548
  async getCourseDocs(ids, options = {}) {
4028
- return await getCourseDocs(this.id, ids, options);
4549
+ return await this.db.allDocs({
4550
+ ...options,
4551
+ keys: ids
4552
+ });
4029
4553
  }
4030
4554
  ////////////////////////////////////
4031
4555
  // NavigationStrategyManager implementation
@@ -4059,7 +4583,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4059
4583
  }
4060
4584
  async addNavigationStrategy(data) {
4061
4585
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
4062
- return this.db.put(data).then(() => {
4586
+ return this.remoteDB.put(data).then(() => {
4063
4587
  });
4064
4588
  }
4065
4589
  updateNavigationStrategy(id, data) {
@@ -4616,6 +5140,234 @@ var init_adminDB2 = __esm({
4616
5140
  }
4617
5141
  });
4618
5142
 
5143
+ // src/impl/couch/CourseSyncService.ts
5144
+ var CourseSyncService;
5145
+ var init_CourseSyncService = __esm({
5146
+ "src/impl/couch/CourseSyncService.ts"() {
5147
+ "use strict";
5148
+ init_pouchdb_setup();
5149
+ init_couch();
5150
+ init_logger();
5151
+ CourseSyncService = class _CourseSyncService {
5152
+ static instance = null;
5153
+ entries = /* @__PURE__ */ new Map();
5154
+ constructor() {
5155
+ }
5156
+ static getInstance() {
5157
+ if (!_CourseSyncService.instance) {
5158
+ _CourseSyncService.instance = new _CourseSyncService();
5159
+ }
5160
+ return _CourseSyncService.instance;
5161
+ }
5162
+ /**
5163
+ * Reset the singleton (for testing).
5164
+ */
5165
+ static resetInstance() {
5166
+ if (_CourseSyncService.instance) {
5167
+ for (const [, entry] of _CourseSyncService.instance.entries) {
5168
+ if (entry.localDB) {
5169
+ entry.localDB.close().catch(() => {
5170
+ });
5171
+ }
5172
+ }
5173
+ _CourseSyncService.instance.entries.clear();
5174
+ }
5175
+ _CourseSyncService.instance = null;
5176
+ }
5177
+ // --------------------------------------------------------------------------
5178
+ // Public API
5179
+ // --------------------------------------------------------------------------
5180
+ /**
5181
+ * Ensure a course's local replica is synced.
5182
+ *
5183
+ * On first call for a course:
5184
+ * 1. Fetches CourseConfig from remote to check localSync.enabled
5185
+ * 2. If enabled, performs one-shot replication remote → local
5186
+ * 3. Pre-warms PouchDB view indices (elo, getTags)
5187
+ *
5188
+ * On subsequent calls: returns immediately if already synced, or awaits
5189
+ * the in-flight sync if one is in progress.
5190
+ *
5191
+ * Safe to call multiple times — concurrent calls coalesce to one sync.
5192
+ *
5193
+ * @param courseId - The course to sync
5194
+ * @param forceEnabled - Skip the CourseConfig check and sync regardless.
5195
+ * Useful when the caller already knows local sync is desired (e.g.,
5196
+ * LettersPractice hardcodes this).
5197
+ */
5198
+ async ensureSynced(courseId, forceEnabled) {
5199
+ const existing = this.entries.get(courseId);
5200
+ if (existing?.status.state === "ready") {
5201
+ return;
5202
+ }
5203
+ if (existing?.status.state === "disabled") {
5204
+ return;
5205
+ }
5206
+ if (existing?.readyPromise) {
5207
+ return existing.readyPromise;
5208
+ }
5209
+ const entry = {
5210
+ localDB: null,
5211
+ status: { state: "not-started" },
5212
+ readyPromise: null
5213
+ };
5214
+ this.entries.set(courseId, entry);
5215
+ entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
5216
+ return entry.readyPromise;
5217
+ }
5218
+ /**
5219
+ * Get the local PouchDB for a course, or null if not available.
5220
+ *
5221
+ * Returns null when:
5222
+ * - Local sync is not enabled for this course
5223
+ * - Sync has not been triggered yet
5224
+ * - Sync is still in progress
5225
+ * - Sync failed
5226
+ */
5227
+ getLocalDB(courseId) {
5228
+ const entry = this.entries.get(courseId);
5229
+ if (entry?.status.state === "ready" && entry.localDB) {
5230
+ return entry.localDB;
5231
+ }
5232
+ return null;
5233
+ }
5234
+ /**
5235
+ * Check whether a course has a ready local replica.
5236
+ */
5237
+ isReady(courseId) {
5238
+ return this.entries.get(courseId)?.status.state === "ready";
5239
+ }
5240
+ /**
5241
+ * Get detailed sync status for a course.
5242
+ */
5243
+ getStatus(courseId) {
5244
+ return this.entries.get(courseId)?.status ?? { state: "not-started" };
5245
+ }
5246
+ // --------------------------------------------------------------------------
5247
+ // Internal
5248
+ // --------------------------------------------------------------------------
5249
+ async performSync(courseId, entry, forceEnabled) {
5250
+ try {
5251
+ if (!forceEnabled) {
5252
+ entry.status = { state: "checking-config" };
5253
+ const enabled = await this.checkLocalSyncEnabled(courseId);
5254
+ if (!enabled) {
5255
+ entry.status = { state: "disabled" };
5256
+ entry.readyPromise = null;
5257
+ logger.debug(
5258
+ `[CourseSyncService] Local sync disabled for course ${courseId}`
5259
+ );
5260
+ return;
5261
+ }
5262
+ }
5263
+ entry.status = { state: "syncing" };
5264
+ const localDBName = this.localDBName(courseId);
5265
+ const localDB = new pouchdb_setup_default(localDBName);
5266
+ entry.localDB = localDB;
5267
+ const remoteDB = this.getRemoteDB(courseId);
5268
+ const syncStart = Date.now();
5269
+ logger.info(
5270
+ `[CourseSyncService] Starting one-shot replication for course ${courseId}`
5271
+ );
5272
+ const result = await this.replicate(remoteDB, localDB);
5273
+ const syncTimeMs = Date.now() - syncStart;
5274
+ logger.info(
5275
+ `[CourseSyncService] Replication complete for course ${courseId}: ${result.docs_written} docs in ${syncTimeMs}ms`
5276
+ );
5277
+ entry.status = { state: "warming-views" };
5278
+ const warmStart = Date.now();
5279
+ await this.warmViewIndices(localDB);
5280
+ const viewWarmTimeMs = Date.now() - warmStart;
5281
+ logger.info(
5282
+ `[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
5283
+ );
5284
+ entry.status = {
5285
+ state: "ready",
5286
+ docsReplicated: result.docs_written,
5287
+ syncTimeMs,
5288
+ viewWarmTimeMs
5289
+ };
5290
+ } catch (e) {
5291
+ const errorMsg = e instanceof Error ? e.message : String(e);
5292
+ logger.error(
5293
+ `[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
5294
+ );
5295
+ entry.status = { state: "error", error: errorMsg };
5296
+ entry.readyPromise = null;
5297
+ if (entry.localDB) {
5298
+ try {
5299
+ await entry.localDB.destroy();
5300
+ } catch {
5301
+ }
5302
+ entry.localDB = null;
5303
+ }
5304
+ }
5305
+ }
5306
+ /**
5307
+ * Check CourseConfig.localSync.enabled on the remote DB.
5308
+ */
5309
+ async checkLocalSyncEnabled(courseId) {
5310
+ try {
5311
+ const remoteDB = this.getRemoteDB(courseId);
5312
+ const config = await remoteDB.get("CourseConfig");
5313
+ return config.localSync?.enabled === true;
5314
+ } catch (e) {
5315
+ logger.warn(
5316
+ `[CourseSyncService] Could not read CourseConfig for ${courseId}, assuming local sync disabled: ${e}`
5317
+ );
5318
+ return false;
5319
+ }
5320
+ }
5321
+ /**
5322
+ * One-shot replication from remote to local.
5323
+ */
5324
+ replicate(source, target) {
5325
+ return new Promise((resolve, reject) => {
5326
+ void pouchdb_setup_default.replicate(source, target, {
5327
+ // One-shot, not live. Local is a read-only snapshot.
5328
+ }).on("complete", (info) => {
5329
+ resolve(info);
5330
+ }).on("error", (err) => {
5331
+ reject(err);
5332
+ });
5333
+ });
5334
+ }
5335
+ /**
5336
+ * Pre-warm PouchDB view indices by running a minimal query against each
5337
+ * design doc. This forces PouchDB to build the MapReduce index now
5338
+ * (during a loading phase) rather than on first pipeline query.
5339
+ */
5340
+ async warmViewIndices(localDB) {
5341
+ const viewsToWarm = ["elo", "getTags"];
5342
+ for (const viewName of viewsToWarm) {
5343
+ try {
5344
+ await localDB.query(viewName, { limit: 1 });
5345
+ logger.debug(
5346
+ `[CourseSyncService] Warmed view index: ${viewName}`
5347
+ );
5348
+ } catch (e) {
5349
+ logger.debug(
5350
+ `[CourseSyncService] Could not warm view ${viewName}: ${e}`
5351
+ );
5352
+ }
5353
+ }
5354
+ }
5355
+ /**
5356
+ * Get a remote PouchDB handle for a course.
5357
+ */
5358
+ getRemoteDB(courseId) {
5359
+ return getCourseDB2(courseId);
5360
+ }
5361
+ /**
5362
+ * Local DB naming convention.
5363
+ */
5364
+ localDBName(courseId) {
5365
+ return `coursedb-local-${courseId}`;
5366
+ }
5367
+ };
5368
+ }
5369
+ });
5370
+
4619
5371
  // src/impl/couch/auth.ts
4620
5372
  import fetch2 from "cross-fetch";
4621
5373
  async function getCurrentSession() {
@@ -4916,15 +5668,6 @@ function getCourseDB2(courseID) {
4916
5668
  createPouchDBConfig()
4917
5669
  );
4918
5670
  }
4919
- function getCourseDocs(courseID, docIDs, options = {}) {
4920
- return getCourseDB2(courseID).allDocs({
4921
- ...options,
4922
- keys: docIDs
4923
- });
4924
- }
4925
- function getCourseDoc(courseID, docID, options = {}) {
4926
- return getCourseDB2(courseID).get(docID, options);
4927
- }
4928
5671
  function filterAllDocsByPrefix2(db, prefix, opts) {
4929
5672
  const options = {
4930
5673
  startkey: prefix,
@@ -4955,6 +5698,7 @@ var init_couch = __esm({
4955
5698
  init_classroomDB2();
4956
5699
  init_courseAPI();
4957
5700
  init_courseDB();
5701
+ init_CourseSyncService();
4958
5702
  init_CouchDBSyncStrategy();
4959
5703
  isBrowser = typeof window !== "undefined";
4960
5704
  if (isBrowser) {
@@ -5255,6 +5999,9 @@ Currently logged-in as ${this._username}.`
5255
5999
  const id = row.id;
5256
6000
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
5257
6001
  id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
6002
+ id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
6003
+ id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
6004
+ id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
5258
6005
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
5259
6006
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
5260
6007
  id === _BaseUser.DOC_IDS.CONFIG;
@@ -6051,6 +6798,7 @@ var init_PouchDataLayerProvider = __esm({
6051
6798
  init_adminDB2();
6052
6799
  init_classroomDB2();
6053
6800
  init_courseDB();
6801
+ init_CourseSyncService();
6054
6802
  init_common();
6055
6803
  init_CouchDBSyncStrategy();
6056
6804
  CouchDataLayerProvider = class {
@@ -6090,7 +6838,22 @@ var init_PouchDataLayerProvider = __esm({
6090
6838
  return this.userDB;
6091
6839
  }
6092
6840
  getCourseDB(courseId) {
6093
- return new CourseDB(courseId, async () => this.getUserDB());
6841
+ const localDB = CourseSyncService.getInstance().getLocalDB(courseId);
6842
+ return new CourseDB(courseId, async () => this.getUserDB(), localDB ?? void 0);
6843
+ }
6844
+ /**
6845
+ * Trigger local sync for a course. Call during app initialization or
6846
+ * pre-session loading for courses that opt in via CourseConfig.localSync.
6847
+ *
6848
+ * Safe to call multiple times — concurrent calls coalesce. Returns when
6849
+ * sync is complete (or immediately if already synced / disabled).
6850
+ *
6851
+ * @param courseId - The course to sync locally
6852
+ * @param forceEnabled - Skip CourseConfig check and sync regardless.
6853
+ * Use when the caller already knows local sync is desired.
6854
+ */
6855
+ async ensureCourseSynced(courseId, forceEnabled) {
6856
+ return CourseSyncService.getInstance().ensureSynced(courseId, forceEnabled);
6094
6857
  }
6095
6858
  getCoursesDB() {
6096
6859
  return new CoursesDB(this._courseIDs);
@@ -6718,6 +7481,10 @@ var init_courseDB2 = __esm({
6718
7481
  }
6719
7482
  return tagsByCard;
6720
7483
  }
7484
+ async getAllCardIds() {
7485
+ const tagsIndex = await this.unpacker.getTagsIndex();
7486
+ return Object.keys(tagsIndex.byCard);
7487
+ }
6721
7488
  async addTagToCard(_cardId, _tagId) {
6722
7489
  throw new Error("Cannot modify tags in static mode");
6723
7490
  }
@@ -8253,6 +9020,7 @@ import moment8 from "moment";
8253
9020
  init_util();
8254
9021
  init_logger();
8255
9022
  import moment7 from "moment";
9023
+ import { isTaggedPerformance } from "@vue-skuilder/common";
8256
9024
  var duration = moment7.duration;
8257
9025
  function newInterval(user, cardHistory) {
8258
9026
  if (areQuestionRecords(cardHistory)) {
@@ -8267,12 +9035,16 @@ function newQuestionInterval(user, cardHistory) {
8267
9035
  const lastInterval = lastSuccessfulInterval(records);
8268
9036
  if (lastInterval > cardHistory.bestInterval) {
8269
9037
  cardHistory.bestInterval = lastInterval;
8270
- void user.update(cardHistory._id, {
9038
+ user.update(cardHistory._id, {
8271
9039
  bestInterval: lastInterval
9040
+ }).catch((e) => {
9041
+ logger.warn(`[SpacedRepetition] Failed to update bestInterval for ${cardHistory._id}: ${e?.message ?? e}`);
8272
9042
  });
8273
9043
  }
8274
9044
  if (currentAttempt.isCorrect) {
8275
- const skill = Math.min(1, Math.max(0, currentAttempt.performance));
9045
+ const rawPerf = currentAttempt.performance;
9046
+ const numericPerf = isTaggedPerformance(rawPerf) ? rawPerf._global : rawPerf;
9047
+ const skill = Math.min(1, Math.max(0, numericPerf));
8276
9048
  logger.debug(`Demontrated skill: ${skill}`);
8277
9049
  const interval = lastInterval * (0.75 + skill);
8278
9050
  cardHistory.lapses = getLapses(cardHistory.records);
@@ -8486,7 +9258,7 @@ var EloService = class {
8486
9258
  // src/study/services/ResponseProcessor.ts
8487
9259
  init_core();
8488
9260
  init_logger();
8489
- import { isTaggedPerformance } from "@vue-skuilder/common";
9261
+ import { isTaggedPerformance as isTaggedPerformance2 } from "@vue-skuilder/common";
8490
9262
  var ResponseProcessor = class {
8491
9263
  srsService;
8492
9264
  eloService;
@@ -8507,7 +9279,7 @@ var ResponseProcessor = class {
8507
9279
  taggedPerformance: null
8508
9280
  };
8509
9281
  }
8510
- if (isTaggedPerformance(performance2)) {
9282
+ if (isTaggedPerformance2(performance2)) {
8511
9283
  return {
8512
9284
  globalScore: performance2._global,
8513
9285
  taggedPerformance: performance2
@@ -8932,6 +9704,46 @@ var ItemQueue = class {
8932
9704
  return null;
8933
9705
  }
8934
9706
  }
9707
+ /**
9708
+ * Atomically replace all queue contents with new items.
9709
+ *
9710
+ * Used by mid-session replanning to swap the queue without a window where
9711
+ * it's empty (avoiding dead-air if nextCard() is called concurrently).
9712
+ *
9713
+ * Preserves dequeueCount (cumulative across the session).
9714
+ * Resets seenCardIds to match the new contents — cards from the old queue
9715
+ * that don't appear in the new set can be re-added in future replans.
9716
+ */
9717
+ replaceAll(items, cardIdExtractor) {
9718
+ this.q = [];
9719
+ this.seenCardIds = [];
9720
+ for (const item of items) {
9721
+ const cardId = cardIdExtractor(item);
9722
+ if (!this.seenCardIds.includes(cardId)) {
9723
+ this.seenCardIds.push(cardId);
9724
+ this.q.push(item);
9725
+ }
9726
+ }
9727
+ }
9728
+ /**
9729
+ * Merge new items into the front of the queue, skipping duplicates.
9730
+ * Used by additive replans to inject high-quality candidates without
9731
+ * discarding the existing queue contents.
9732
+ */
9733
+ mergeToFront(items, cardIdExtractor) {
9734
+ let added = 0;
9735
+ const toInsert = [];
9736
+ for (const item of items) {
9737
+ const cardId = cardIdExtractor(item);
9738
+ if (!this.seenCardIds.includes(cardId)) {
9739
+ this.seenCardIds.push(cardId);
9740
+ toInsert.push(item);
9741
+ added++;
9742
+ }
9743
+ }
9744
+ this.q.unshift(...toInsert);
9745
+ return added;
9746
+ }
8935
9747
  get toString() {
8936
9748
  return `${typeof this.q[0]}:
8937
9749
  ` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
@@ -11015,7 +11827,7 @@ mountSessionDebugger();
11015
11827
 
11016
11828
  // src/study/SessionController.ts
11017
11829
  init_logger();
11018
- var SessionController = class extends Loggable {
11830
+ var SessionController = class _SessionController extends Loggable {
11019
11831
  _className = "SessionController";
11020
11832
  services;
11021
11833
  srsService;
@@ -11036,6 +11848,18 @@ var SessionController = class extends Loggable {
11036
11848
  newQ = new ItemQueue();
11037
11849
  failedQ = new ItemQueue();
11038
11850
  // END Session card stores
11851
+ /**
11852
+ * Promise tracking a currently in-progress replan, or null if idle.
11853
+ * Used by nextCard() to await completion before drawing from queues.
11854
+ */
11855
+ _replanPromise = null;
11856
+ /**
11857
+ * Number of well-indicated new cards remaining before the queue
11858
+ * degrades to poorly-indicated content. Decremented on each newQ
11859
+ * draw; when it hits 0, a replan is triggered automatically
11860
+ * (user state has changed from completing good cards).
11861
+ */
11862
+ _wellIndicatedRemaining = 0;
11039
11863
  startTime;
11040
11864
  endTime;
11041
11865
  _secondsRemaining;
@@ -11129,13 +11953,83 @@ var SessionController = class extends Loggable {
11129
11953
  "[SessionController] All content sources must implement getWeightedCards()."
11130
11954
  );
11131
11955
  }
11132
- await this.getWeightedContent();
11956
+ const wellIndicated = await this.getWeightedContent();
11957
+ this._wellIndicatedRemaining = wellIndicated;
11958
+ if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
11959
+ this.log(
11960
+ `[Init] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards in initial load`
11961
+ );
11962
+ }
11133
11963
  await this.hydrationService.ensureHydratedCards();
11134
11964
  startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
11135
11965
  this._intervalHandle = setInterval(() => {
11136
11966
  this.tick();
11137
11967
  }, 1e3);
11138
11968
  }
11969
+ /**
11970
+ * Request a mid-session replan. Re-runs the pipeline with current user state
11971
+ * and atomically replaces the newQ contents. Safe to call at any time during
11972
+ * a session — if called while a replan is already in progress, returns the
11973
+ * existing replan promise (no duplicate work).
11974
+ *
11975
+ * Does NOT affect reviewQ or failedQ.
11976
+ *
11977
+ * If nextCard() is called while a replan is in flight, it will automatically
11978
+ * await the replan before drawing from queues, ensuring the user always sees
11979
+ * cards scored against their latest state.
11980
+ *
11981
+ * Typical trigger: application-level code (e.g. after a GPC intro completion)
11982
+ * calls this to ensure newly-unlocked content appears in the session.
11983
+ */
11984
+ async requestReplan(hints) {
11985
+ if (this._replanPromise) {
11986
+ this.log("Replan already in progress, awaiting existing replan");
11987
+ return this._replanPromise;
11988
+ }
11989
+ if (hints) {
11990
+ for (const source of this.sources) {
11991
+ this.log(`[Hints] source type=${source.constructor.name}, hasMethod=${typeof source.setEphemeralHints}`);
11992
+ source.setEphemeralHints?.(hints);
11993
+ }
11994
+ }
11995
+ this.log(`Mid-session replan requested${hints ? ` (hints: ${JSON.stringify(hints)})` : ""}`);
11996
+ this._replanPromise = this._executeReplan();
11997
+ try {
11998
+ await this._replanPromise;
11999
+ } finally {
12000
+ this._replanPromise = null;
12001
+ }
12002
+ }
12003
+ /** Minimum well-indicated cards before an additive retry is attempted */
12004
+ static MIN_WELL_INDICATED = 5;
12005
+ /**
12006
+ * Score threshold for considering a card "well-indicated."
12007
+ * Cards below this score are treated as fallback filler — present only
12008
+ * because no strategy hard-removed them, but likely penalized by one
12009
+ * or more filters. Strategy-agnostic: the SessionController doesn't
12010
+ * know or care which strategy assigned the score.
12011
+ */
12012
+ static WELL_INDICATED_SCORE = 0.1;
12013
+ /**
12014
+ * Internal replan execution. Runs the pipeline, builds a new newQ,
12015
+ * atomically swaps it in, and triggers hydration for the new contents.
12016
+ *
12017
+ * If the initial replan produces fewer than MIN_WELL_INDICATED cards that
12018
+ * pass all hierarchy filters, one additive retry is attempted — merging
12019
+ * any new high-quality candidates into the front of the queue.
12020
+ */
12021
+ async _executeReplan() {
12022
+ const wellIndicated = await this.getWeightedContent({ replan: true });
12023
+ this._wellIndicatedRemaining = wellIndicated;
12024
+ if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
12025
+ this.log(
12026
+ `[Replan] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards after replan`
12027
+ );
12028
+ }
12029
+ await this.hydrationService.ensureHydratedCards();
12030
+ this.log(`Replan complete: newQ now has ${this.newQ.length} cards`);
12031
+ snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
12032
+ }
11139
12033
  addTime(seconds) {
11140
12034
  this.endTime = new Date(this.endTime.valueOf() + 1e3 * seconds);
11141
12035
  }
@@ -11191,6 +12085,9 @@ var SessionController = class extends Loggable {
11191
12085
  hydratedCache: {
11192
12086
  count: this.hydrationService.hydratedCount,
11193
12087
  cardIds: this.hydrationService.getHydratedCardIds()
12088
+ },
12089
+ replan: {
12090
+ inProgress: this._replanPromise !== null
11194
12091
  }
11195
12092
  };
11196
12093
  }
@@ -11203,7 +12100,20 @@ var SessionController = class extends Loggable {
11203
12100
  * 3. Uses SourceMixer to balance content across sources
11204
12101
  * 4. Populates review and new card queues with mixed results
11205
12102
  */
11206
- async getWeightedContent() {
12103
+ /**
12104
+ * Fetch weighted content from all sources and populate session queues.
12105
+ *
12106
+ * @param options.replan - If true, this is a mid-session replan rather than
12107
+ * initial session setup. Skips review queue population (avoiding duplicates),
12108
+ * atomically replaces newQ contents, and treats empty results as non-fatal.
12109
+ * @param options.additive - If true (replan only), merge new high-quality
12110
+ * candidates into the front of the existing newQ instead of replacing it.
12111
+ * @returns Number of "well-indicated" cards (passed all hierarchy filters)
12112
+ * in the new content. Returns -1 if no content was loaded.
12113
+ */
12114
+ async getWeightedContent(options) {
12115
+ const replan = options?.replan ?? false;
12116
+ const additive = options?.additive ?? false;
11207
12117
  const limit = 20;
11208
12118
  const batches = [];
11209
12119
  for (let i = 0; i < this.sources.length; i++) {
@@ -11222,6 +12132,10 @@ var SessionController = class extends Loggable {
11222
12132
  }
11223
12133
  }
11224
12134
  if (batches.length === 0) {
12135
+ if (replan) {
12136
+ this.log("Replan: no content from any source, keeping existing newQ");
12137
+ return -1;
12138
+ }
11225
12139
  throw new Error(
11226
12140
  `Cannot start session: failed to load content from all ${this.sources.length} source(s). Check logs for details.`
11227
12141
  );
@@ -11233,10 +12147,12 @@ var SessionController = class extends Loggable {
11233
12147
  });
11234
12148
  await Promise.all(
11235
12149
  sourceIds.map(async (id) => {
11236
- try {
11237
- const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
11238
- this.courseNameCache.set(id, config.name);
11239
- } catch {
12150
+ if (!this.courseNameCache.has(id)) {
12151
+ try {
12152
+ const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
12153
+ this.courseNameCache.set(id, config.name);
12154
+ } catch {
12155
+ }
11240
12156
  }
11241
12157
  })
11242
12158
  );
@@ -11254,20 +12170,26 @@ var SessionController = class extends Loggable {
11254
12170
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
11255
12171
  const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
11256
12172
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
11257
- let report = "Mixed content session created with:\n";
11258
- for (const w of reviewWeighted) {
11259
- const reviewItem = {
11260
- cardID: w.cardId,
11261
- courseID: w.courseId,
11262
- contentSourceType: "course",
11263
- contentSourceID: w.courseId,
11264
- reviewID: w.reviewID,
11265
- status: "review"
11266
- };
11267
- this.reviewQ.add(reviewItem, reviewItem.cardID);
11268
- report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
12173
+ let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
12174
+ if (!replan) {
12175
+ for (const w of reviewWeighted) {
12176
+ const reviewItem = {
12177
+ cardID: w.cardId,
12178
+ courseID: w.courseId,
12179
+ contentSourceType: "course",
12180
+ contentSourceID: w.courseId,
12181
+ reviewID: w.reviewID,
12182
+ status: "review"
12183
+ };
12184
+ this.reviewQ.add(reviewItem, reviewItem.cardID);
12185
+ report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
11269
12186
  `;
12187
+ }
11270
12188
  }
12189
+ const wellIndicated = newWeighted.filter(
12190
+ (w) => w.score >= _SessionController.WELL_INDICATED_SCORE
12191
+ ).length;
12192
+ const newItems = [];
11271
12193
  for (const w of newWeighted) {
11272
12194
  const newItem = {
11273
12195
  cardID: w.cardId,
@@ -11276,11 +12198,23 @@ var SessionController = class extends Loggable {
11276
12198
  contentSourceID: w.courseId,
11277
12199
  status: "new"
11278
12200
  };
11279
- this.newQ.add(newItem, newItem.cardID);
12201
+ newItems.push(newItem);
11280
12202
  report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
11281
12203
  `;
11282
12204
  }
12205
+ if (additive) {
12206
+ const added = this.newQ.mergeToFront(newItems, (item) => item.cardID);
12207
+ report += `Additive merge: ${added} new cards added to front of newQ
12208
+ `;
12209
+ } else if (replan) {
12210
+ this.newQ.replaceAll(newItems, (item) => item.cardID);
12211
+ } else {
12212
+ for (const item of newItems) {
12213
+ this.newQ.add(item, item.cardID);
12214
+ }
12215
+ }
11283
12216
  this.log(report);
12217
+ return wellIndicated;
11284
12218
  }
11285
12219
  /**
11286
12220
  * Returns items that should be pre-hydrated.
@@ -11357,6 +12291,17 @@ var SessionController = class extends Loggable {
11357
12291
  }
11358
12292
  async nextCard(action = "dismiss-success") {
11359
12293
  this.dismissCurrentCard(action);
12294
+ if (this._replanPromise) {
12295
+ this.log("nextCard: awaiting in-flight replan before drawing");
12296
+ await this._replanPromise;
12297
+ }
12298
+ const REPLAN_BUFFER = 3;
12299
+ if (this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
12300
+ this.log(
12301
+ `[AutoReplan] ${this._wellIndicatedRemaining} well-indicated cards remaining (newQ: ${this.newQ.length}). Triggering background replan.`
12302
+ );
12303
+ void this.requestReplan();
12304
+ }
11360
12305
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
11361
12306
  this._currentCard = null;
11362
12307
  endSessionTracking();
@@ -11470,6 +12415,9 @@ var SessionController = class extends Loggable {
11470
12415
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
11471
12416
  } else if (this.newQ.peek(0)?.cardID === item.cardID) {
11472
12417
  this.newQ.dequeue((queueItem) => queueItem.cardID);
12418
+ if (this._wellIndicatedRemaining > 0) {
12419
+ this._wellIndicatedRemaining--;
12420
+ }
11473
12421
  } else if (this.failedQ.peek(0)?.cardID === item.cardID) {
11474
12422
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
11475
12423
  }
@@ -11564,6 +12512,7 @@ export {
11564
12512
  getDefaultLearnableWeight,
11565
12513
  getRegisteredNavigator,
11566
12514
  getRegisteredNavigatorNames,
12515
+ getRegisteredNavigatorRole,
11567
12516
  getStudySource,
11568
12517
  hasRegisteredNavigator,
11569
12518
  importParsedCards,