@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.js CHANGED
@@ -878,8 +878,12 @@ __export(PipelineDebugger_exports, {
878
878
  buildRunReport: () => buildRunReport,
879
879
  captureRun: () => captureRun,
880
880
  mountPipelineDebugger: () => mountPipelineDebugger,
881
- pipelineDebugAPI: () => pipelineDebugAPI
881
+ pipelineDebugAPI: () => pipelineDebugAPI,
882
+ registerPipelineForDebug: () => registerPipelineForDebug
882
883
  });
884
+ function registerPipelineForDebug(pipeline) {
885
+ _activePipeline = pipeline;
886
+ }
883
887
  function getOrigin(card) {
884
888
  const firstEntry = card.provenance[0];
885
889
  if (!firstEntry) return "unknown";
@@ -907,6 +911,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
907
911
  origin: getOrigin(card),
908
912
  finalScore: card.score,
909
913
  provenance: card.provenance,
914
+ tags: card.tags,
910
915
  selected: selectedIds.has(card.cardId)
911
916
  }));
912
917
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
@@ -962,11 +967,13 @@ function mountPipelineDebugger() {
962
967
  win.skuilder = win.skuilder || {};
963
968
  win.skuilder.pipeline = pipelineDebugAPI;
964
969
  }
965
- var MAX_RUNS, runHistory, pipelineDebugAPI;
970
+ var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
966
971
  var init_PipelineDebugger = __esm({
967
972
  "src/core/navigators/PipelineDebugger.ts"() {
968
973
  "use strict";
974
+ init_navigators();
969
975
  init_logger();
976
+ _activePipeline = null;
970
977
  MAX_RUNS = 10;
971
978
  runHistory = [];
972
979
  pipelineDebugAPI = {
@@ -1108,6 +1115,81 @@ var init_PipelineDebugger = __esm({
1108
1115
  runHistory.length = 0;
1109
1116
  logger.info("[Pipeline Debug] Run history cleared.");
1110
1117
  },
1118
+ /**
1119
+ * Show the navigator registry: all registered classes and their roles.
1120
+ *
1121
+ * Useful for verifying that consumer-defined navigators were registered
1122
+ * before pipeline assembly.
1123
+ */
1124
+ showRegistry() {
1125
+ const names = getRegisteredNavigatorNames();
1126
+ if (names.length === 0) {
1127
+ logger.info("[Pipeline Debug] Navigator registry is empty.");
1128
+ return;
1129
+ }
1130
+ console.group("\u{1F4E6} Navigator Registry");
1131
+ console.table(
1132
+ names.map((name) => {
1133
+ const registryRole = getRegisteredNavigatorRole(name);
1134
+ const builtinRole = NavigatorRoles[name];
1135
+ const effectiveRole = builtinRole || registryRole || "\u26A0\uFE0F NONE";
1136
+ const source = builtinRole ? "built-in" : registryRole ? "consumer" : "unclassified";
1137
+ return {
1138
+ name,
1139
+ role: effectiveRole,
1140
+ source,
1141
+ isGenerator: isGenerator(name),
1142
+ isFilter: isFilter(name)
1143
+ };
1144
+ })
1145
+ );
1146
+ console.groupEnd();
1147
+ },
1148
+ /**
1149
+ * Show strategy documents from the last pipeline run and how they mapped
1150
+ * to the registry.
1151
+ *
1152
+ * If no runs are captured yet, falls back to showing just the registry.
1153
+ */
1154
+ showStrategies() {
1155
+ this.showRegistry();
1156
+ if (runHistory.length === 0) {
1157
+ logger.info("[Pipeline Debug] No pipeline runs captured yet \u2014 cannot show strategy doc mapping.");
1158
+ return;
1159
+ }
1160
+ const run = runHistory[0];
1161
+ console.group("\u{1F50C} Pipeline Strategy Mapping (last run)");
1162
+ logger.info(`Generator: ${run.generatorName}`);
1163
+ if (run.generators && run.generators.length > 0) {
1164
+ for (const g of run.generators) {
1165
+ logger.info(` \u{1F4E5} ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
1166
+ }
1167
+ }
1168
+ if (run.filters.length > 0) {
1169
+ logger.info("Filters:");
1170
+ for (const f of run.filters) {
1171
+ logger.info(` \u{1F538} ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
1172
+ }
1173
+ } else {
1174
+ logger.info("Filters: (none)");
1175
+ }
1176
+ console.groupEnd();
1177
+ },
1178
+ /**
1179
+ * Scan the full card space through the filter chain for the current user.
1180
+ *
1181
+ * Reports how many cards are well-indicated and how many are new.
1182
+ * Use this to understand how the search space grows during onboarding.
1183
+ *
1184
+ * @param threshold - Score threshold for "well indicated" (default 0.10)
1185
+ */
1186
+ async diagnoseCardSpace(threshold) {
1187
+ if (!_activePipeline) {
1188
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
1189
+ return null;
1190
+ }
1191
+ return _activePipeline.diagnoseCardSpace({ threshold });
1192
+ },
1111
1193
  /**
1112
1194
  * Show help.
1113
1195
  */
@@ -1120,6 +1202,9 @@ Commands:
1120
1202
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
1121
1203
  .showCard(cardId) Show provenance trail for a specific card
1122
1204
  .explainReviews() Analyze why reviews were/weren't selected
1205
+ .diagnoseCardSpace() Scan full card space through filters (async)
1206
+ .showRegistry() Show navigator registry (classes + roles)
1207
+ .showStrategies() Show registry + strategy mapping from last run
1123
1208
  .listRuns() List all captured runs in table format
1124
1209
  .export() Export run history as JSON for bug reports
1125
1210
  .clear() Clear run history
@@ -1129,7 +1214,7 @@ Commands:
1129
1214
  Example:
1130
1215
  window.skuilder.pipeline.showLastRun()
1131
1216
  window.skuilder.pipeline.showRun(1)
1132
- window.skuilder.pipeline.showCard('abc123')
1217
+ await window.skuilder.pipeline.diagnoseCardSpace()
1133
1218
  `);
1134
1219
  }
1135
1220
  };
@@ -1424,6 +1509,69 @@ var init_generators = __esm({
1424
1509
  }
1425
1510
  });
1426
1511
 
1512
+ // src/core/navigators/generators/prescribed.ts
1513
+ var prescribed_exports = {};
1514
+ __export(prescribed_exports, {
1515
+ default: () => PrescribedCardsGenerator
1516
+ });
1517
+ var PrescribedCardsGenerator;
1518
+ var init_prescribed = __esm({
1519
+ "src/core/navigators/generators/prescribed.ts"() {
1520
+ "use strict";
1521
+ init_navigators();
1522
+ init_logger();
1523
+ PrescribedCardsGenerator = class extends ContentNavigator {
1524
+ name;
1525
+ config;
1526
+ constructor(user, course, strategyData) {
1527
+ super(user, course, strategyData);
1528
+ this.name = strategyData.name || "Prescribed Cards";
1529
+ try {
1530
+ const parsed = JSON.parse(strategyData.serializedData);
1531
+ this.config = { cardIds: parsed.cardIds || [] };
1532
+ } catch {
1533
+ this.config = { cardIds: [] };
1534
+ }
1535
+ logger.debug(
1536
+ `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1537
+ );
1538
+ }
1539
+ async getWeightedCards(limit, _context) {
1540
+ if (this.config.cardIds.length === 0) {
1541
+ return [];
1542
+ }
1543
+ const courseId = this.course.getCourseID();
1544
+ const activeCards = await this.user.getActiveCards();
1545
+ const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1546
+ const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1547
+ if (eligibleIds.length === 0) {
1548
+ logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1549
+ return [];
1550
+ }
1551
+ const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1552
+ cardId,
1553
+ courseId,
1554
+ score: 1,
1555
+ provenance: [
1556
+ {
1557
+ strategy: "prescribed",
1558
+ strategyName: this.strategyName || this.name,
1559
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1560
+ action: "generated",
1561
+ score: 1,
1562
+ reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1563
+ }
1564
+ ]
1565
+ }));
1566
+ logger.info(
1567
+ `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1568
+ );
1569
+ return cards;
1570
+ }
1571
+ };
1572
+ }
1573
+ });
1574
+
1427
1575
  // src/core/navigators/generators/srs.ts
1428
1576
  var srs_exports = {};
1429
1577
  __export(srs_exports, {
@@ -1618,6 +1766,7 @@ var init_ = __esm({
1618
1766
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1619
1767
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1620
1768
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1769
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1621
1770
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1622
1771
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1623
1772
  });
@@ -1818,6 +1967,8 @@ var init_hierarchyDefinition = __esm({
1818
1967
  if (userTagElo.count < minCount) return false;
1819
1968
  if (prereq.masteryThreshold?.minElo !== void 0) {
1820
1969
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1970
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1971
+ return true;
1821
1972
  } else {
1822
1973
  return userTagElo.score >= userGlobalElo;
1823
1974
  }
@@ -1893,14 +2044,38 @@ var init_hierarchyDefinition = __esm({
1893
2044
  };
1894
2045
  }
1895
2046
  }
2047
+ /**
2048
+ * Build a map of prereq tag → max configured boost for all *closed* gates.
2049
+ *
2050
+ * When a gate is closed (prereqs unmet), cards carrying that gate's prereq
2051
+ * tags get boosted — steering the pipeline toward content that helps unlock
2052
+ * the gated material. Once the gate opens, the boost disappears.
2053
+ */
2054
+ getPreReqBoosts(unlockedTags, masteredTags) {
2055
+ const boosts = /* @__PURE__ */ new Map();
2056
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
2057
+ if (unlockedTags.has(tagId)) continue;
2058
+ for (const prereq of prereqs) {
2059
+ if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
2060
+ if (masteredTags.has(prereq.tag)) continue;
2061
+ const existing = boosts.get(prereq.tag) ?? 1;
2062
+ boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
2063
+ }
2064
+ }
2065
+ return boosts;
2066
+ }
1896
2067
  /**
1897
2068
  * CardFilter.transform implementation.
1898
2069
  *
1899
- * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
2070
+ * Two effects:
2071
+ * 1. Cards with locked tags receive score * 0.05 (gating penalty)
2072
+ * 2. Cards carrying prereq tags of closed gates receive a configured
2073
+ * boost (preReqBoost), steering toward content that unlocks gates
1900
2074
  */
1901
2075
  async transform(cards, context) {
1902
2076
  const masteredTags = await this.getMasteredTags(context);
1903
2077
  const unlockedTags = this.getUnlockedTags(masteredTags);
2078
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1904
2079
  const gated = [];
1905
2080
  for (const card of cards) {
1906
2081
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1909,9 +2084,27 @@ var init_hierarchyDefinition = __esm({
1909
2084
  unlockedTags,
1910
2085
  masteredTags
1911
2086
  );
1912
- const LOCKED_PENALTY = 0.01;
1913
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1914
- const action = isUnlocked ? "passed" : "penalized";
2087
+ const LOCKED_PENALTY = 0.02;
2088
+ let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
2089
+ let action = isUnlocked ? "passed" : "penalized";
2090
+ let finalReason = reason;
2091
+ if (isUnlocked && preReqBoosts.size > 0) {
2092
+ const cardTags = card.tags ?? [];
2093
+ let maxBoost = 1;
2094
+ const boostedPrereqs = [];
2095
+ for (const tag of cardTags) {
2096
+ const boost = preReqBoosts.get(tag);
2097
+ if (boost && boost > maxBoost) {
2098
+ maxBoost = boost;
2099
+ boostedPrereqs.push(tag);
2100
+ }
2101
+ }
2102
+ if (maxBoost > 1) {
2103
+ finalScore *= maxBoost;
2104
+ action = "boosted";
2105
+ finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
2106
+ }
2107
+ }
1915
2108
  gated.push({
1916
2109
  ...card,
1917
2110
  score: finalScore,
@@ -1923,7 +2116,7 @@ var init_hierarchyDefinition = __esm({
1923
2116
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1924
2117
  action,
1925
2118
  score: finalScore,
1926
- reason
2119
+ reason: finalReason
1927
2120
  }
1928
2121
  ]
1929
2122
  });
@@ -2857,6 +3050,18 @@ var Pipeline_exports = {};
2857
3050
  __export(Pipeline_exports, {
2858
3051
  Pipeline: () => Pipeline
2859
3052
  });
3053
+ function globToRegex(pattern) {
3054
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
3055
+ const withWildcards = escaped.replace(/\*/g, ".*");
3056
+ return new RegExp(`^${withWildcards}$`);
3057
+ }
3058
+ function globMatch(value, pattern) {
3059
+ if (!pattern.includes("*")) return value === pattern;
3060
+ return globToRegex(pattern).test(value);
3061
+ }
3062
+ function cardMatchesTagPattern(card, pattern) {
3063
+ return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
3064
+ }
2860
3065
  function logPipelineConfig(generator, filters) {
2861
3066
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2862
3067
  logger.info(
@@ -2891,6 +3096,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2891
3096
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2892
3097
  );
2893
3098
  }
3099
+ function logResultCards(cards) {
3100
+ if (!VERBOSE_RESULTS || cards.length === 0) return;
3101
+ logger.info(`[Pipeline] Results (${cards.length} cards):`);
3102
+ for (let i = 0; i < cards.length; i++) {
3103
+ const c = cards[i];
3104
+ const tags = c.tags?.slice(0, 3).join(", ") || "";
3105
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
3106
+ const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
3107
+ return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
3108
+ }).join(" | ");
3109
+ logger.info(
3110
+ `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
3111
+ );
3112
+ }
3113
+ }
2894
3114
  function logCardProvenance(cards, maxCards = 3) {
2895
3115
  const cardsToLog = cards.slice(0, maxCards);
2896
3116
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2905,7 +3125,7 @@ function logCardProvenance(cards, maxCards = 3) {
2905
3125
  }
2906
3126
  }
2907
3127
  }
2908
- var import_common8, Pipeline;
3128
+ var import_common8, VERBOSE_RESULTS, Pipeline;
2909
3129
  var init_Pipeline = __esm({
2910
3130
  "src/core/navigators/Pipeline.ts"() {
2911
3131
  "use strict";
@@ -2914,9 +3134,31 @@ var init_Pipeline = __esm({
2914
3134
  init_logger();
2915
3135
  init_orchestration();
2916
3136
  init_PipelineDebugger();
3137
+ VERBOSE_RESULTS = true;
2917
3138
  Pipeline = class extends ContentNavigator {
2918
3139
  generator;
2919
3140
  filters;
3141
+ /**
3142
+ * Cached orchestration context. Course config and salt don't change within
3143
+ * a page load, so we build the orchestration context once and reuse it on
3144
+ * subsequent getWeightedCards() calls (e.g. mid-session replans).
3145
+ *
3146
+ * This eliminates a remote getCourseConfig() round trip per pipeline run.
3147
+ */
3148
+ _cachedOrchestration = null;
3149
+ /**
3150
+ * Persistent tag cache. Maps cardId → tag names.
3151
+ *
3152
+ * Tags are static within a session (they're set at card generation time),
3153
+ * so we cache them across pipeline runs. On replans, many of the same cards
3154
+ * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
3155
+ */
3156
+ _tagCache = /* @__PURE__ */ new Map();
3157
+ /**
3158
+ * One-shot replan hints. Applied after the filter chain on the next
3159
+ * getWeightedCards() call, then cleared.
3160
+ */
3161
+ _ephemeralHints = null;
2920
3162
  /**
2921
3163
  * Create a new pipeline.
2922
3164
  *
@@ -2937,6 +3179,17 @@ var init_Pipeline = __esm({
2937
3179
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2938
3180
  });
2939
3181
  logPipelineConfig(generator, filters);
3182
+ registerPipelineForDebug(this);
3183
+ }
3184
+ /**
3185
+ * Set one-shot hints for the next pipeline run.
3186
+ * Consumed after one getWeightedCards() call, then cleared.
3187
+ *
3188
+ * Overrides ContentNavigator.setEphemeralHints() no-op.
3189
+ */
3190
+ setEphemeralHints(hints) {
3191
+ this._ephemeralHints = hints;
3192
+ logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
2940
3193
  }
2941
3194
  /**
2942
3195
  * Get weighted cards by running generator and applying filters.
@@ -2953,13 +3206,15 @@ var init_Pipeline = __esm({
2953
3206
  * @returns Cards sorted by score descending
2954
3207
  */
2955
3208
  async getWeightedCards(limit) {
3209
+ const t0 = performance.now();
2956
3210
  const context = await this.buildContext();
2957
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2958
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
3211
+ const tContext = performance.now();
3212
+ const fetchLimit = 500;
2959
3213
  logger.debug(
2960
3214
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2961
3215
  );
2962
3216
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
3217
+ const tGenerate = performance.now();
2963
3218
  const generatedCount = cards.length;
2964
3219
  let generatorSummaries;
2965
3220
  if (this.generator.generators) {
@@ -2988,6 +3243,7 @@ var init_Pipeline = __esm({
2988
3243
  }
2989
3244
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2990
3245
  cards = await this.hydrateTags(cards);
3246
+ const tHydrate = performance.now();
2991
3247
  const allCardsBeforeFiltering = [...cards];
2992
3248
  const filterImpacts = [];
2993
3249
  for (const filter of this.filters) {
@@ -3006,8 +3262,17 @@ var init_Pipeline = __esm({
3006
3262
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
3007
3263
  }
3008
3264
  cards = cards.filter((c) => c.score > 0);
3265
+ const hints = this._ephemeralHints;
3266
+ if (hints) {
3267
+ this._ephemeralHints = null;
3268
+ cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
3269
+ }
3009
3270
  cards.sort((a, b) => b.score - a.score);
3271
+ const tFilter = performance.now();
3010
3272
  const result = cards.slice(0, limit);
3273
+ logger.info(
3274
+ `[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)})`
3275
+ );
3011
3276
  const topScores = result.slice(0, 3).map((c) => c.score);
3012
3277
  logExecutionSummary(
3013
3278
  this.generator.name,
@@ -3017,6 +3282,7 @@ var init_Pipeline = __esm({
3017
3282
  topScores,
3018
3283
  filterImpacts
3019
3284
  );
3285
+ logResultCards(result);
3020
3286
  logCardProvenance(result, 3);
3021
3287
  try {
3022
3288
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -3043,6 +3309,10 @@ var init_Pipeline = __esm({
3043
3309
  * to the WeightedCard objects. Filters can then use card.tags instead of
3044
3310
  * making individual getAppliedTags() calls.
3045
3311
  *
3312
+ * Uses a persistent tag cache across pipeline runs — tags are static within
3313
+ * a session, so cards seen in a prior run (e.g. before a replan) don't
3314
+ * require a second DB query.
3315
+ *
3046
3316
  * @param cards - Cards to hydrate
3047
3317
  * @returns Cards with tags populated
3048
3318
  */
@@ -3050,14 +3320,128 @@ var init_Pipeline = __esm({
3050
3320
  if (cards.length === 0) {
3051
3321
  return cards;
3052
3322
  }
3053
- const cardIds = cards.map((c) => c.cardId);
3054
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
3323
+ const uncachedIds = [];
3324
+ for (const card of cards) {
3325
+ if (!this._tagCache.has(card.cardId)) {
3326
+ uncachedIds.push(card.cardId);
3327
+ }
3328
+ }
3329
+ if (uncachedIds.length > 0) {
3330
+ const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
3331
+ for (const [cardId, tags] of freshTags) {
3332
+ this._tagCache.set(cardId, tags);
3333
+ }
3334
+ }
3335
+ const tagsByCard = /* @__PURE__ */ new Map();
3336
+ for (const card of cards) {
3337
+ tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
3338
+ }
3055
3339
  logTagHydration(cards, tagsByCard);
3056
3340
  return cards.map((card) => ({
3057
3341
  ...card,
3058
- tags: tagsByCard.get(card.cardId) ?? []
3342
+ tags: this._tagCache.get(card.cardId) ?? []
3059
3343
  }));
3060
3344
  }
3345
+ // ---------------------------------------------------------------------------
3346
+ // Ephemeral hints application
3347
+ // ---------------------------------------------------------------------------
3348
+ /**
3349
+ * Apply one-shot replan hints to the post-filter card set.
3350
+ *
3351
+ * Order of operations:
3352
+ * 1. Exclude (remove unwanted cards)
3353
+ * 2. Boost (multiply scores)
3354
+ * 3. Require (inject must-have cards from the full pre-filter pool)
3355
+ *
3356
+ * @param cards - Post-filter cards (score > 0)
3357
+ * @param hints - The ephemeral hints to apply
3358
+ * @param allCards - Full pre-filter card pool (for require injection)
3359
+ */
3360
+ applyHints(cards, hints, allCards) {
3361
+ const beforeCount = cards.length;
3362
+ if (hints.excludeCards?.length) {
3363
+ cards = cards.filter(
3364
+ (c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
3365
+ );
3366
+ }
3367
+ if (hints.excludeTags?.length) {
3368
+ cards = cards.filter(
3369
+ (c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
3370
+ );
3371
+ }
3372
+ if (hints.boostTags) {
3373
+ for (const [pattern, factor] of Object.entries(hints.boostTags)) {
3374
+ for (const card of cards) {
3375
+ if (cardMatchesTagPattern(card, pattern)) {
3376
+ card.score *= factor;
3377
+ card.provenance.push({
3378
+ strategy: "ephemeralHint",
3379
+ strategyId: "ephemeral-hint",
3380
+ strategyName: "Replan Hint",
3381
+ action: "boosted",
3382
+ score: card.score,
3383
+ reason: `boostTag ${pattern} \xD7${factor}`
3384
+ });
3385
+ }
3386
+ }
3387
+ }
3388
+ }
3389
+ if (hints.boostCards) {
3390
+ for (const [pattern, factor] of Object.entries(hints.boostCards)) {
3391
+ for (const card of cards) {
3392
+ if (globMatch(card.cardId, pattern)) {
3393
+ card.score *= factor;
3394
+ card.provenance.push({
3395
+ strategy: "ephemeralHint",
3396
+ strategyId: "ephemeral-hint",
3397
+ strategyName: "Replan Hint",
3398
+ action: "boosted",
3399
+ score: card.score,
3400
+ reason: `boostCard ${pattern} \xD7${factor}`
3401
+ });
3402
+ }
3403
+ }
3404
+ }
3405
+ }
3406
+ const cardIds = new Set(cards.map((c) => c.cardId));
3407
+ const inject = (card, reason) => {
3408
+ if (!cardIds.has(card.cardId)) {
3409
+ const floorScore = Math.max(card.score, 1);
3410
+ cards.push({
3411
+ ...card,
3412
+ score: floorScore,
3413
+ provenance: [
3414
+ ...card.provenance,
3415
+ {
3416
+ strategy: "ephemeralHint",
3417
+ strategyId: "ephemeral-hint",
3418
+ strategyName: "Replan Hint",
3419
+ action: "boosted",
3420
+ score: floorScore,
3421
+ reason
3422
+ }
3423
+ ]
3424
+ });
3425
+ cardIds.add(card.cardId);
3426
+ }
3427
+ };
3428
+ if (hints.requireCards?.length) {
3429
+ for (const pattern of hints.requireCards) {
3430
+ for (const card of allCards) {
3431
+ if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
3432
+ }
3433
+ }
3434
+ }
3435
+ if (hints.requireTags?.length) {
3436
+ for (const pattern of hints.requireTags) {
3437
+ for (const card of allCards) {
3438
+ if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
3439
+ }
3440
+ }
3441
+ }
3442
+ logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
3443
+ return cards;
3444
+ }
3061
3445
  /**
3062
3446
  * Build shared context for generator and filters.
3063
3447
  *
@@ -3075,7 +3459,10 @@ var init_Pipeline = __esm({
3075
3459
  } catch (e) {
3076
3460
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
3077
3461
  }
3078
- const orchestration = await createOrchestrationContext(this.user, this.course);
3462
+ if (!this._cachedOrchestration) {
3463
+ this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
3464
+ }
3465
+ const orchestration = this._cachedOrchestration;
3079
3466
  return {
3080
3467
  user: this.user,
3081
3468
  course: this.course,
@@ -3119,6 +3506,87 @@ var init_Pipeline = __esm({
3119
3506
  }
3120
3507
  return [...new Set(ids)];
3121
3508
  }
3509
+ // ---------------------------------------------------------------------------
3510
+ // Card-space diagnostic
3511
+ // ---------------------------------------------------------------------------
3512
+ /**
3513
+ * Scan every card in the course through the filter chain and report
3514
+ * how many are "well indicated" (score >= threshold) for the current user.
3515
+ *
3516
+ * Also reports how many well-indicated cards the user has NOT yet encountered.
3517
+ *
3518
+ * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
3519
+ */
3520
+ async diagnoseCardSpace(opts) {
3521
+ const THRESHOLD = opts?.threshold ?? 0.1;
3522
+ const t0 = performance.now();
3523
+ const allCardIds = await this.course.getAllCardIds();
3524
+ let cards = allCardIds.map((cardId) => ({
3525
+ cardId,
3526
+ courseId: this.course.getCourseID(),
3527
+ score: 1,
3528
+ provenance: []
3529
+ }));
3530
+ cards = await this.hydrateTags(cards);
3531
+ const context = await this.buildContext();
3532
+ const filterBreakdown = [];
3533
+ for (const filter of this.filters) {
3534
+ cards = await filter.transform(cards, context);
3535
+ const wi = cards.filter((c) => c.score >= THRESHOLD).length;
3536
+ filterBreakdown.push({ name: filter.name, wellIndicated: wi });
3537
+ }
3538
+ const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
3539
+ const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
3540
+ let encounteredIds;
3541
+ try {
3542
+ const courseId = this.course.getCourseID();
3543
+ const seenCards = await this.user.getSeenCards(courseId);
3544
+ encounteredIds = new Set(seenCards);
3545
+ } catch {
3546
+ encounteredIds = /* @__PURE__ */ new Set();
3547
+ }
3548
+ const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
3549
+ const byType = /* @__PURE__ */ new Map();
3550
+ for (const card of cards) {
3551
+ const type = card.cardId.split("-")[1] || "unknown";
3552
+ if (!byType.has(type)) {
3553
+ byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
3554
+ }
3555
+ const entry = byType.get(type);
3556
+ entry.total++;
3557
+ if (card.score >= THRESHOLD) {
3558
+ entry.wellIndicated++;
3559
+ if (!encounteredIds.has(card.cardId)) entry.new++;
3560
+ }
3561
+ }
3562
+ const elapsed = performance.now() - t0;
3563
+ const result = {
3564
+ totalCards: allCardIds.length,
3565
+ threshold: THRESHOLD,
3566
+ wellIndicated: wellIndicatedIds.size,
3567
+ encountered: encounteredIds.size,
3568
+ wellIndicatedNew: wellIndicatedNew.length,
3569
+ byType: Object.fromEntries(byType),
3570
+ filterBreakdown,
3571
+ elapsedMs: Math.round(elapsed)
3572
+ };
3573
+ logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
3574
+ logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
3575
+ logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
3576
+ logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
3577
+ logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
3578
+ logger.info(`[Pipeline:diagnose] By type:`);
3579
+ for (const [type, counts] of byType) {
3580
+ logger.info(
3581
+ `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
3582
+ );
3583
+ }
3584
+ logger.info(`[Pipeline:diagnose] After each filter:`);
3585
+ for (const fb of filterBreakdown) {
3586
+ logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
3587
+ }
3588
+ return result;
3589
+ }
3122
3590
  };
3123
3591
  }
3124
3592
  });
@@ -3223,23 +3691,25 @@ var init_PipelineAssembler = __esm({
3223
3691
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
3224
3692
  }
3225
3693
  }
3694
+ const courseId = course.getCourseID();
3695
+ const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
3696
+ const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
3697
+ if (!hasElo) {
3698
+ logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
3699
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3700
+ }
3701
+ if (!hasSrs) {
3702
+ logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
3703
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
3704
+ }
3226
3705
  if (generatorStrategies.length === 0) {
3227
- if (filterStrategies.length > 0) {
3228
- logger.debug(
3229
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
3230
- );
3231
- const courseId = course.getCourseID();
3232
- generatorStrategies.push(createDefaultEloStrategy(courseId));
3233
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
3234
- } else {
3235
- warnings.push("No generator strategy found");
3236
- return {
3237
- pipeline: null,
3238
- generatorStrategies: [],
3239
- filterStrategies: [],
3240
- warnings
3241
- };
3242
- }
3706
+ warnings.push("No generator strategy found");
3707
+ return {
3708
+ pipeline: null,
3709
+ generatorStrategies: [],
3710
+ filterStrategies: [],
3711
+ warnings
3712
+ };
3243
3713
  }
3244
3714
  let generator;
3245
3715
  if (generatorStrategies.length === 1) {
@@ -3317,6 +3787,7 @@ var init_3 = __esm({
3317
3787
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
3318
3788
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
3319
3789
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3790
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
3320
3791
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
3321
3792
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
3322
3793
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -3334,6 +3805,7 @@ __export(navigators_exports, {
3334
3805
  getCardOrigin: () => getCardOrigin,
3335
3806
  getRegisteredNavigator: () => getRegisteredNavigator,
3336
3807
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
3808
+ getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
3337
3809
  hasRegisteredNavigator: () => hasRegisteredNavigator,
3338
3810
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
3339
3811
  isFilter: () => isFilter,
@@ -3342,16 +3814,19 @@ __export(navigators_exports, {
3342
3814
  pipelineDebugAPI: () => pipelineDebugAPI,
3343
3815
  registerNavigator: () => registerNavigator
3344
3816
  });
3345
- function registerNavigator(implementingClass, constructor) {
3346
- navigatorRegistry.set(implementingClass, constructor);
3347
- logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
3817
+ function registerNavigator(implementingClass, constructor, role) {
3818
+ navigatorRegistry.set(implementingClass, { constructor, role });
3819
+ logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ""}`);
3348
3820
  }
3349
3821
  function getRegisteredNavigator(implementingClass) {
3350
- return navigatorRegistry.get(implementingClass);
3822
+ return navigatorRegistry.get(implementingClass)?.constructor;
3351
3823
  }
3352
3824
  function hasRegisteredNavigator(implementingClass) {
3353
3825
  return navigatorRegistry.has(implementingClass);
3354
3826
  }
3827
+ function getRegisteredNavigatorRole(implementingClass) {
3828
+ return navigatorRegistry.get(implementingClass)?.role;
3829
+ }
3355
3830
  function getRegisteredNavigatorNames() {
3356
3831
  return Array.from(navigatorRegistry.keys());
3357
3832
  }
@@ -3361,8 +3836,10 @@ async function initializeNavigatorRegistry() {
3361
3836
  Promise.resolve().then(() => (init_elo(), elo_exports)),
3362
3837
  Promise.resolve().then(() => (init_srs(), srs_exports))
3363
3838
  ]);
3839
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
3364
3840
  registerNavigator("elo", eloModule.default);
3365
3841
  registerNavigator("srs", srsModule.default);
3842
+ registerNavigator("prescribed", prescribedModule.default);
3366
3843
  const [
3367
3844
  hierarchyModule,
3368
3845
  interferenceModule,
@@ -3397,10 +3874,12 @@ function getCardOrigin(card) {
3397
3874
  return "new";
3398
3875
  }
3399
3876
  function isGenerator(impl) {
3400
- return NavigatorRoles[impl] === "generator" /* GENERATOR */;
3877
+ if (NavigatorRoles[impl] === "generator" /* GENERATOR */) return true;
3878
+ return getRegisteredNavigatorRole(impl) === "generator" /* GENERATOR */;
3401
3879
  }
3402
3880
  function isFilter(impl) {
3403
- return NavigatorRoles[impl] === "filter" /* FILTER */;
3881
+ if (NavigatorRoles[impl] === "filter" /* FILTER */) return true;
3882
+ return getRegisteredNavigatorRole(impl) === "filter" /* FILTER */;
3404
3883
  }
3405
3884
  var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
3406
3885
  var init_navigators = __esm({
@@ -3415,6 +3894,7 @@ var init_navigators = __esm({
3415
3894
  Navigators = /* @__PURE__ */ ((Navigators2) => {
3416
3895
  Navigators2["ELO"] = "elo";
3417
3896
  Navigators2["SRS"] = "srs";
3897
+ Navigators2["PRESCRIBED"] = "prescribed";
3418
3898
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
3419
3899
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
3420
3900
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -3429,6 +3909,7 @@ var init_navigators = __esm({
3429
3909
  NavigatorRoles = {
3430
3910
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
3431
3911
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3912
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
3432
3913
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
3433
3914
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
3434
3915
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -3593,6 +4074,12 @@ var init_navigators = __esm({
3593
4074
  async getWeightedCards(_limit) {
3594
4075
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
3595
4076
  }
4077
+ /**
4078
+ * Set ephemeral hints for the next pipeline run.
4079
+ * No-op for non-Pipeline navigators. Pipeline overrides this.
4080
+ */
4081
+ setEphemeralHints(_hints) {
4082
+ }
3596
4083
  };
3597
4084
  }
3598
4085
  });
@@ -3737,15 +4224,42 @@ var init_courseDB = __esm({
3737
4224
  // private log(msg: string): void {
3738
4225
  // log(`CourseLog: ${this.id}\n ${msg}`);
3739
4226
  // }
4227
+ /**
4228
+ * Primary database handle used for all **read** operations (queries, gets).
4229
+ *
4230
+ * When local sync is active, this points to the local PouchDB replica for
4231
+ * fast, network-free reads. Otherwise it points to the remote CouchDB.
4232
+ */
3740
4233
  db;
4234
+ /**
4235
+ * Remote database handle used for all **write** operations.
4236
+ *
4237
+ * Always points to the remote CouchDB so that writes (ELO updates, tag
4238
+ * mutations, admin operations) aggregate on the server. The local replica
4239
+ * is a read-only snapshot that refreshes on the next page load.
4240
+ *
4241
+ * When local sync is NOT active, this is the same instance as `this.db`.
4242
+ */
4243
+ remoteDB;
3741
4244
  id;
3742
4245
  _getCurrentUser;
3743
4246
  updateQueue;
3744
- constructor(id, userLookup) {
4247
+ /**
4248
+ * @param id - Course ID
4249
+ * @param userLookup - Async function returning the current user DB
4250
+ * @param localDB - Optional local PouchDB replica for reads. When provided,
4251
+ * `this.db` uses the local replica and `this.remoteDB` stays remote.
4252
+ * The UpdateQueue reads from remote and writes to remote (local `_rev`
4253
+ * values may be stale, so read-modify-write cycles must go through
4254
+ * the remote DB to avoid conflicts).
4255
+ */
4256
+ constructor(id, userLookup, localDB) {
3745
4257
  this.id = id;
3746
- this.db = getCourseDB2(this.id);
4258
+ const remote = getCourseDB2(this.id);
4259
+ this.remoteDB = remote;
4260
+ this.db = localDB ?? remote;
3747
4261
  this._getCurrentUser = userLookup;
3748
- this.updateQueue = new UpdateQueue(this.db);
4262
+ this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
3749
4263
  }
3750
4264
  getCourseID() {
3751
4265
  return this.id;
@@ -3833,7 +4347,7 @@ var init_courseDB = __esm({
3833
4347
  };
3834
4348
  }
3835
4349
  async removeCard(id) {
3836
- const doc = await this.db.get(id);
4350
+ const doc = await this.remoteDB.get(id);
3837
4351
  if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
3838
4352
  throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
3839
4353
  }
@@ -3854,7 +4368,7 @@ var init_courseDB = __esm({
3854
4368
  } catch (error) {
3855
4369
  logger.error(`Error removing card ${id} from tags: ${error}`);
3856
4370
  }
3857
- return this.db.remove(doc);
4371
+ return this.remoteDB.remove(doc);
3858
4372
  }
3859
4373
  async getCardDisplayableDataIDs(id) {
3860
4374
  logger.debug(id.join(", "));
@@ -3956,8 +4470,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3956
4470
  if (cardIds.length === 0) {
3957
4471
  return /* @__PURE__ */ new Map();
3958
4472
  }
3959
- const db = getCourseDB2(this.id);
3960
- const result = await db.query("getTags", {
4473
+ const result = await this.db.query("getTags", {
3961
4474
  keys: cardIds,
3962
4475
  include_docs: false
3963
4476
  });
@@ -3974,6 +4487,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3974
4487
  }
3975
4488
  return tagsByCard;
3976
4489
  }
4490
+ async getAllCardIds() {
4491
+ const result = await this.db.allDocs({
4492
+ startkey: "CARD-",
4493
+ endkey: "CARD-\uFFF0",
4494
+ include_docs: false
4495
+ });
4496
+ return result.rows.map((row) => row.id);
4497
+ }
3977
4498
  async addTagToCard(cardId, tagId, updateELO) {
3978
4499
  return await addTagToCard(
3979
4500
  this.id,
@@ -4040,10 +4561,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4040
4561
  }
4041
4562
  }
4042
4563
  async getCourseDoc(id, options) {
4043
- return await getCourseDoc(this.id, id, options);
4564
+ return await this.db.get(id, options);
4044
4565
  }
4045
4566
  async getCourseDocs(ids, options = {}) {
4046
- return await getCourseDocs(this.id, ids, options);
4567
+ return await this.db.allDocs({
4568
+ ...options,
4569
+ keys: ids
4570
+ });
4047
4571
  }
4048
4572
  ////////////////////////////////////
4049
4573
  // NavigationStrategyManager implementation
@@ -4077,7 +4601,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
4077
4601
  }
4078
4602
  async addNavigationStrategy(data) {
4079
4603
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
4080
- return this.db.put(data).then(() => {
4604
+ return this.remoteDB.put(data).then(() => {
4081
4605
  });
4082
4606
  }
4083
4607
  updateNavigationStrategy(id, data) {
@@ -4634,6 +5158,234 @@ var init_adminDB2 = __esm({
4634
5158
  }
4635
5159
  });
4636
5160
 
5161
+ // src/impl/couch/CourseSyncService.ts
5162
+ var CourseSyncService;
5163
+ var init_CourseSyncService = __esm({
5164
+ "src/impl/couch/CourseSyncService.ts"() {
5165
+ "use strict";
5166
+ init_pouchdb_setup();
5167
+ init_couch();
5168
+ init_logger();
5169
+ CourseSyncService = class _CourseSyncService {
5170
+ static instance = null;
5171
+ entries = /* @__PURE__ */ new Map();
5172
+ constructor() {
5173
+ }
5174
+ static getInstance() {
5175
+ if (!_CourseSyncService.instance) {
5176
+ _CourseSyncService.instance = new _CourseSyncService();
5177
+ }
5178
+ return _CourseSyncService.instance;
5179
+ }
5180
+ /**
5181
+ * Reset the singleton (for testing).
5182
+ */
5183
+ static resetInstance() {
5184
+ if (_CourseSyncService.instance) {
5185
+ for (const [, entry] of _CourseSyncService.instance.entries) {
5186
+ if (entry.localDB) {
5187
+ entry.localDB.close().catch(() => {
5188
+ });
5189
+ }
5190
+ }
5191
+ _CourseSyncService.instance.entries.clear();
5192
+ }
5193
+ _CourseSyncService.instance = null;
5194
+ }
5195
+ // --------------------------------------------------------------------------
5196
+ // Public API
5197
+ // --------------------------------------------------------------------------
5198
+ /**
5199
+ * Ensure a course's local replica is synced.
5200
+ *
5201
+ * On first call for a course:
5202
+ * 1. Fetches CourseConfig from remote to check localSync.enabled
5203
+ * 2. If enabled, performs one-shot replication remote → local
5204
+ * 3. Pre-warms PouchDB view indices (elo, getTags)
5205
+ *
5206
+ * On subsequent calls: returns immediately if already synced, or awaits
5207
+ * the in-flight sync if one is in progress.
5208
+ *
5209
+ * Safe to call multiple times — concurrent calls coalesce to one sync.
5210
+ *
5211
+ * @param courseId - The course to sync
5212
+ * @param forceEnabled - Skip the CourseConfig check and sync regardless.
5213
+ * Useful when the caller already knows local sync is desired (e.g.,
5214
+ * LettersPractice hardcodes this).
5215
+ */
5216
+ async ensureSynced(courseId, forceEnabled) {
5217
+ const existing = this.entries.get(courseId);
5218
+ if (existing?.status.state === "ready") {
5219
+ return;
5220
+ }
5221
+ if (existing?.status.state === "disabled") {
5222
+ return;
5223
+ }
5224
+ if (existing?.readyPromise) {
5225
+ return existing.readyPromise;
5226
+ }
5227
+ const entry = {
5228
+ localDB: null,
5229
+ status: { state: "not-started" },
5230
+ readyPromise: null
5231
+ };
5232
+ this.entries.set(courseId, entry);
5233
+ entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
5234
+ return entry.readyPromise;
5235
+ }
5236
+ /**
5237
+ * Get the local PouchDB for a course, or null if not available.
5238
+ *
5239
+ * Returns null when:
5240
+ * - Local sync is not enabled for this course
5241
+ * - Sync has not been triggered yet
5242
+ * - Sync is still in progress
5243
+ * - Sync failed
5244
+ */
5245
+ getLocalDB(courseId) {
5246
+ const entry = this.entries.get(courseId);
5247
+ if (entry?.status.state === "ready" && entry.localDB) {
5248
+ return entry.localDB;
5249
+ }
5250
+ return null;
5251
+ }
5252
+ /**
5253
+ * Check whether a course has a ready local replica.
5254
+ */
5255
+ isReady(courseId) {
5256
+ return this.entries.get(courseId)?.status.state === "ready";
5257
+ }
5258
+ /**
5259
+ * Get detailed sync status for a course.
5260
+ */
5261
+ getStatus(courseId) {
5262
+ return this.entries.get(courseId)?.status ?? { state: "not-started" };
5263
+ }
5264
+ // --------------------------------------------------------------------------
5265
+ // Internal
5266
+ // --------------------------------------------------------------------------
5267
+ async performSync(courseId, entry, forceEnabled) {
5268
+ try {
5269
+ if (!forceEnabled) {
5270
+ entry.status = { state: "checking-config" };
5271
+ const enabled = await this.checkLocalSyncEnabled(courseId);
5272
+ if (!enabled) {
5273
+ entry.status = { state: "disabled" };
5274
+ entry.readyPromise = null;
5275
+ logger.debug(
5276
+ `[CourseSyncService] Local sync disabled for course ${courseId}`
5277
+ );
5278
+ return;
5279
+ }
5280
+ }
5281
+ entry.status = { state: "syncing" };
5282
+ const localDBName = this.localDBName(courseId);
5283
+ const localDB = new pouchdb_setup_default(localDBName);
5284
+ entry.localDB = localDB;
5285
+ const remoteDB = this.getRemoteDB(courseId);
5286
+ const syncStart = Date.now();
5287
+ logger.info(
5288
+ `[CourseSyncService] Starting one-shot replication for course ${courseId}`
5289
+ );
5290
+ const result = await this.replicate(remoteDB, localDB);
5291
+ const syncTimeMs = Date.now() - syncStart;
5292
+ logger.info(
5293
+ `[CourseSyncService] Replication complete for course ${courseId}: ${result.docs_written} docs in ${syncTimeMs}ms`
5294
+ );
5295
+ entry.status = { state: "warming-views" };
5296
+ const warmStart = Date.now();
5297
+ await this.warmViewIndices(localDB);
5298
+ const viewWarmTimeMs = Date.now() - warmStart;
5299
+ logger.info(
5300
+ `[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
5301
+ );
5302
+ entry.status = {
5303
+ state: "ready",
5304
+ docsReplicated: result.docs_written,
5305
+ syncTimeMs,
5306
+ viewWarmTimeMs
5307
+ };
5308
+ } catch (e) {
5309
+ const errorMsg = e instanceof Error ? e.message : String(e);
5310
+ logger.error(
5311
+ `[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
5312
+ );
5313
+ entry.status = { state: "error", error: errorMsg };
5314
+ entry.readyPromise = null;
5315
+ if (entry.localDB) {
5316
+ try {
5317
+ await entry.localDB.destroy();
5318
+ } catch {
5319
+ }
5320
+ entry.localDB = null;
5321
+ }
5322
+ }
5323
+ }
5324
+ /**
5325
+ * Check CourseConfig.localSync.enabled on the remote DB.
5326
+ */
5327
+ async checkLocalSyncEnabled(courseId) {
5328
+ try {
5329
+ const remoteDB = this.getRemoteDB(courseId);
5330
+ const config = await remoteDB.get("CourseConfig");
5331
+ return config.localSync?.enabled === true;
5332
+ } catch (e) {
5333
+ logger.warn(
5334
+ `[CourseSyncService] Could not read CourseConfig for ${courseId}, assuming local sync disabled: ${e}`
5335
+ );
5336
+ return false;
5337
+ }
5338
+ }
5339
+ /**
5340
+ * One-shot replication from remote to local.
5341
+ */
5342
+ replicate(source, target) {
5343
+ return new Promise((resolve, reject) => {
5344
+ void pouchdb_setup_default.replicate(source, target, {
5345
+ // One-shot, not live. Local is a read-only snapshot.
5346
+ }).on("complete", (info) => {
5347
+ resolve(info);
5348
+ }).on("error", (err) => {
5349
+ reject(err);
5350
+ });
5351
+ });
5352
+ }
5353
+ /**
5354
+ * Pre-warm PouchDB view indices by running a minimal query against each
5355
+ * design doc. This forces PouchDB to build the MapReduce index now
5356
+ * (during a loading phase) rather than on first pipeline query.
5357
+ */
5358
+ async warmViewIndices(localDB) {
5359
+ const viewsToWarm = ["elo", "getTags"];
5360
+ for (const viewName of viewsToWarm) {
5361
+ try {
5362
+ await localDB.query(viewName, { limit: 1 });
5363
+ logger.debug(
5364
+ `[CourseSyncService] Warmed view index: ${viewName}`
5365
+ );
5366
+ } catch (e) {
5367
+ logger.debug(
5368
+ `[CourseSyncService] Could not warm view ${viewName}: ${e}`
5369
+ );
5370
+ }
5371
+ }
5372
+ }
5373
+ /**
5374
+ * Get a remote PouchDB handle for a course.
5375
+ */
5376
+ getRemoteDB(courseId) {
5377
+ return getCourseDB2(courseId);
5378
+ }
5379
+ /**
5380
+ * Local DB naming convention.
5381
+ */
5382
+ localDBName(courseId) {
5383
+ return `coursedb-local-${courseId}`;
5384
+ }
5385
+ };
5386
+ }
5387
+ });
5388
+
4637
5389
  // src/impl/couch/auth.ts
4638
5390
  async function getCurrentSession() {
4639
5391
  try {
@@ -4932,15 +5684,6 @@ function getCourseDB2(courseID) {
4932
5684
  createPouchDBConfig()
4933
5685
  );
4934
5686
  }
4935
- function getCourseDocs(courseID, docIDs, options = {}) {
4936
- return getCourseDB2(courseID).allDocs({
4937
- ...options,
4938
- keys: docIDs
4939
- });
4940
- }
4941
- function getCourseDoc(courseID, docID, options = {}) {
4942
- return getCourseDB2(courseID).get(docID, options);
4943
- }
4944
5687
  function filterAllDocsByPrefix2(db, prefix, opts) {
4945
5688
  const options = {
4946
5689
  startkey: prefix,
@@ -4974,6 +5717,7 @@ var init_couch = __esm({
4974
5717
  init_classroomDB2();
4975
5718
  init_courseAPI();
4976
5719
  init_courseDB();
5720
+ init_CourseSyncService();
4977
5721
  init_CouchDBSyncStrategy();
4978
5722
  isBrowser = typeof window !== "undefined";
4979
5723
  if (isBrowser) {
@@ -5274,6 +6018,9 @@ Currently logged-in as ${this._username}.`
5274
6018
  const id = row.id;
5275
6019
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
5276
6020
  id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
6021
+ id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
6022
+ id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
6023
+ id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
5277
6024
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
5278
6025
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
5279
6026
  id === _BaseUser.DOC_IDS.CONFIG;
@@ -6070,6 +6817,7 @@ var init_PouchDataLayerProvider = __esm({
6070
6817
  init_adminDB2();
6071
6818
  init_classroomDB2();
6072
6819
  init_courseDB();
6820
+ init_CourseSyncService();
6073
6821
  init_common();
6074
6822
  init_CouchDBSyncStrategy();
6075
6823
  CouchDataLayerProvider = class {
@@ -6109,7 +6857,22 @@ var init_PouchDataLayerProvider = __esm({
6109
6857
  return this.userDB;
6110
6858
  }
6111
6859
  getCourseDB(courseId) {
6112
- return new CourseDB(courseId, async () => this.getUserDB());
6860
+ const localDB = CourseSyncService.getInstance().getLocalDB(courseId);
6861
+ return new CourseDB(courseId, async () => this.getUserDB(), localDB ?? void 0);
6862
+ }
6863
+ /**
6864
+ * Trigger local sync for a course. Call during app initialization or
6865
+ * pre-session loading for courses that opt in via CourseConfig.localSync.
6866
+ *
6867
+ * Safe to call multiple times — concurrent calls coalesce. Returns when
6868
+ * sync is complete (or immediately if already synced / disabled).
6869
+ *
6870
+ * @param courseId - The course to sync locally
6871
+ * @param forceEnabled - Skip CourseConfig check and sync regardless.
6872
+ * Use when the caller already knows local sync is desired.
6873
+ */
6874
+ async ensureCourseSynced(courseId, forceEnabled) {
6875
+ return CourseSyncService.getInstance().ensureSynced(courseId, forceEnabled);
6113
6876
  }
6114
6877
  getCoursesDB() {
6115
6878
  return new CoursesDB(this._courseIDs);
@@ -6737,6 +7500,10 @@ var init_courseDB2 = __esm({
6737
7500
  }
6738
7501
  return tagsByCard;
6739
7502
  }
7503
+ async getAllCardIds() {
7504
+ const tagsIndex = await this.unpacker.getTagsIndex();
7505
+ return Object.keys(tagsIndex.byCard);
7506
+ }
6740
7507
  async addTagToCard(_cardId, _tagId) {
6741
7508
  throw new Error("Cannot modify tags in static mode");
6742
7509
  }
@@ -7999,6 +8766,7 @@ __export(index_exports, {
7999
8766
  getDefaultLearnableWeight: () => getDefaultLearnableWeight,
8000
8767
  getRegisteredNavigator: () => getRegisteredNavigator,
8001
8768
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
8769
+ getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
8002
8770
  getStudySource: () => getStudySource,
8003
8771
  hasRegisteredNavigator: () => hasRegisteredNavigator,
8004
8772
  importParsedCards: () => importParsedCards,
@@ -8361,6 +9129,7 @@ init_couch();
8361
9129
  // src/study/SpacedRepetition.ts
8362
9130
  init_util();
8363
9131
  var import_moment7 = __toESM(require("moment"), 1);
9132
+ var import_common22 = require("@vue-skuilder/common");
8364
9133
  init_logger();
8365
9134
  var duration = import_moment7.default.duration;
8366
9135
  function newInterval(user, cardHistory) {
@@ -8376,12 +9145,16 @@ function newQuestionInterval(user, cardHistory) {
8376
9145
  const lastInterval = lastSuccessfulInterval(records);
8377
9146
  if (lastInterval > cardHistory.bestInterval) {
8378
9147
  cardHistory.bestInterval = lastInterval;
8379
- void user.update(cardHistory._id, {
9148
+ user.update(cardHistory._id, {
8380
9149
  bestInterval: lastInterval
9150
+ }).catch((e) => {
9151
+ logger.warn(`[SpacedRepetition] Failed to update bestInterval for ${cardHistory._id}: ${e?.message ?? e}`);
8381
9152
  });
8382
9153
  }
8383
9154
  if (currentAttempt.isCorrect) {
8384
- const skill = Math.min(1, Math.max(0, currentAttempt.performance));
9155
+ const rawPerf = currentAttempt.performance;
9156
+ const numericPerf = (0, import_common22.isTaggedPerformance)(rawPerf) ? rawPerf._global : rawPerf;
9157
+ const skill = Math.min(1, Math.max(0, numericPerf));
8385
9158
  logger.debug(`Demontrated skill: ${skill}`);
8386
9159
  const interval = lastInterval * (0.75 + skill);
8387
9160
  cardHistory.lapses = getLapses(cardHistory.records);
@@ -8472,7 +9245,7 @@ var SrsService = class {
8472
9245
  };
8473
9246
 
8474
9247
  // src/study/services/EloService.ts
8475
- var import_common22 = require("@vue-skuilder/common");
9248
+ var import_common23 = require("@vue-skuilder/common");
8476
9249
  init_logger();
8477
9250
  var EloService = class {
8478
9251
  dataLayer;
@@ -8495,12 +9268,12 @@ var EloService = class {
8495
9268
  logger.warn(`k value interpretation not currently implemented`);
8496
9269
  }
8497
9270
  const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
8498
- const userElo = (0, import_common22.toCourseElo)(
9271
+ const userElo = (0, import_common23.toCourseElo)(
8499
9272
  userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
8500
9273
  );
8501
9274
  const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
8502
9275
  if (cardElo && userElo) {
8503
- const eloUpdate = (0, import_common22.adjustCourseScores)(userElo, cardElo, userScore);
9276
+ const eloUpdate = (0, import_common23.adjustCourseScores)(userElo, cardElo, userScore);
8504
9277
  userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
8505
9278
  const results = await Promise.allSettled([
8506
9279
  this.user.updateUserElo(course_id, eloUpdate.userElo),
@@ -8546,12 +9319,12 @@ var EloService = class {
8546
9319
  */
8547
9320
  async updateUserAndCardEloPerTag(taggedPerformance, course_id, card_id, userCourseRegDoc, currentCard) {
8548
9321
  const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
8549
- const userElo = (0, import_common22.toCourseElo)(
9322
+ const userElo = (0, import_common23.toCourseElo)(
8550
9323
  userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
8551
9324
  );
8552
9325
  const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
8553
9326
  if (cardElo && userElo) {
8554
- const eloUpdate = (0, import_common22.adjustCourseScoresPerTag)(userElo, cardElo, taggedPerformance);
9327
+ const eloUpdate = (0, import_common23.adjustCourseScoresPerTag)(userElo, cardElo, taggedPerformance);
8555
9328
  userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
8556
9329
  const results = await Promise.allSettled([
8557
9330
  this.user.updateUserElo(course_id, eloUpdate.userElo),
@@ -8591,7 +9364,7 @@ var EloService = class {
8591
9364
  // src/study/services/ResponseProcessor.ts
8592
9365
  init_core();
8593
9366
  init_logger();
8594
- var import_common23 = require("@vue-skuilder/common");
9367
+ var import_common24 = require("@vue-skuilder/common");
8595
9368
  var ResponseProcessor = class {
8596
9369
  srsService;
8597
9370
  eloService;
@@ -8612,7 +9385,7 @@ var ResponseProcessor = class {
8612
9385
  taggedPerformance: null
8613
9386
  };
8614
9387
  }
8615
- if ((0, import_common23.isTaggedPerformance)(performance2)) {
9388
+ if ((0, import_common24.isTaggedPerformance)(performance2)) {
8616
9389
  return {
8617
9390
  globalScore: performance2._global,
8618
9391
  taggedPerformance: performance2
@@ -8820,7 +9593,7 @@ var ResponseProcessor = class {
8820
9593
  };
8821
9594
 
8822
9595
  // src/study/services/CardHydrationService.ts
8823
- var import_common24 = require("@vue-skuilder/common");
9596
+ var import_common25 = require("@vue-skuilder/common");
8824
9597
  init_logger();
8825
9598
  function parseAudioURIs(data) {
8826
9599
  if (typeof data !== "string") return [];
@@ -8955,8 +9728,8 @@ var CardHydrationService = class {
8955
9728
  try {
8956
9729
  const courseDB = this.getCourseDB(item.courseID);
8957
9730
  const cardData = await courseDB.getCourseDoc(item.cardID);
8958
- if (!(0, import_common24.isCourseElo)(cardData.elo)) {
8959
- cardData.elo = (0, import_common24.toCourseElo)(cardData.elo);
9731
+ if (!(0, import_common25.isCourseElo)(cardData.elo)) {
9732
+ cardData.elo = (0, import_common25.toCourseElo)(cardData.elo);
8960
9733
  }
8961
9734
  const view = this.getViewComponent(cardData.id_view);
8962
9735
  const dataDocs = await Promise.all(
@@ -8980,7 +9753,7 @@ var CardHydrationService = class {
8980
9753
  );
8981
9754
  await Promise.allSettled(uniqueAudioUrls.map(prefetchAudio));
8982
9755
  }
8983
- const data = dataDocs.map(import_common24.displayableDataToViewData).reverse();
9756
+ const data = dataDocs.map(import_common25.displayableDataToViewData).reverse();
8984
9757
  this.hydratedCards.set(item.cardID, {
8985
9758
  item,
8986
9759
  view,
@@ -9033,6 +9806,46 @@ var ItemQueue = class {
9033
9806
  return null;
9034
9807
  }
9035
9808
  }
9809
+ /**
9810
+ * Atomically replace all queue contents with new items.
9811
+ *
9812
+ * Used by mid-session replanning to swap the queue without a window where
9813
+ * it's empty (avoiding dead-air if nextCard() is called concurrently).
9814
+ *
9815
+ * Preserves dequeueCount (cumulative across the session).
9816
+ * Resets seenCardIds to match the new contents — cards from the old queue
9817
+ * that don't appear in the new set can be re-added in future replans.
9818
+ */
9819
+ replaceAll(items, cardIdExtractor) {
9820
+ this.q = [];
9821
+ this.seenCardIds = [];
9822
+ for (const item of items) {
9823
+ const cardId = cardIdExtractor(item);
9824
+ if (!this.seenCardIds.includes(cardId)) {
9825
+ this.seenCardIds.push(cardId);
9826
+ this.q.push(item);
9827
+ }
9828
+ }
9829
+ }
9830
+ /**
9831
+ * Merge new items into the front of the queue, skipping duplicates.
9832
+ * Used by additive replans to inject high-quality candidates without
9833
+ * discarding the existing queue contents.
9834
+ */
9835
+ mergeToFront(items, cardIdExtractor) {
9836
+ let added = 0;
9837
+ const toInsert = [];
9838
+ for (const item of items) {
9839
+ const cardId = cardIdExtractor(item);
9840
+ if (!this.seenCardIds.includes(cardId)) {
9841
+ this.seenCardIds.push(cardId);
9842
+ toInsert.push(item);
9843
+ added++;
9844
+ }
9845
+ }
9846
+ this.q.unshift(...toInsert);
9847
+ return added;
9848
+ }
9036
9849
  get toString() {
9037
9850
  return `${typeof this.q[0]}:
9038
9851
  ` + this.q.map((i) => ` ${i.courseID}+${i.cardID}: ${i.status}`).join("\n");
@@ -11116,7 +11929,7 @@ mountSessionDebugger();
11116
11929
 
11117
11930
  // src/study/SessionController.ts
11118
11931
  init_logger();
11119
- var SessionController = class extends Loggable {
11932
+ var SessionController = class _SessionController extends Loggable {
11120
11933
  _className = "SessionController";
11121
11934
  services;
11122
11935
  srsService;
@@ -11137,6 +11950,18 @@ var SessionController = class extends Loggable {
11137
11950
  newQ = new ItemQueue();
11138
11951
  failedQ = new ItemQueue();
11139
11952
  // END Session card stores
11953
+ /**
11954
+ * Promise tracking a currently in-progress replan, or null if idle.
11955
+ * Used by nextCard() to await completion before drawing from queues.
11956
+ */
11957
+ _replanPromise = null;
11958
+ /**
11959
+ * Number of well-indicated new cards remaining before the queue
11960
+ * degrades to poorly-indicated content. Decremented on each newQ
11961
+ * draw; when it hits 0, a replan is triggered automatically
11962
+ * (user state has changed from completing good cards).
11963
+ */
11964
+ _wellIndicatedRemaining = 0;
11140
11965
  startTime;
11141
11966
  endTime;
11142
11967
  _secondsRemaining;
@@ -11230,13 +12055,83 @@ var SessionController = class extends Loggable {
11230
12055
  "[SessionController] All content sources must implement getWeightedCards()."
11231
12056
  );
11232
12057
  }
11233
- await this.getWeightedContent();
12058
+ const wellIndicated = await this.getWeightedContent();
12059
+ this._wellIndicatedRemaining = wellIndicated;
12060
+ if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
12061
+ this.log(
12062
+ `[Init] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards in initial load`
12063
+ );
12064
+ }
11234
12065
  await this.hydrationService.ensureHydratedCards();
11235
12066
  startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
11236
12067
  this._intervalHandle = setInterval(() => {
11237
12068
  this.tick();
11238
12069
  }, 1e3);
11239
12070
  }
12071
+ /**
12072
+ * Request a mid-session replan. Re-runs the pipeline with current user state
12073
+ * and atomically replaces the newQ contents. Safe to call at any time during
12074
+ * a session — if called while a replan is already in progress, returns the
12075
+ * existing replan promise (no duplicate work).
12076
+ *
12077
+ * Does NOT affect reviewQ or failedQ.
12078
+ *
12079
+ * If nextCard() is called while a replan is in flight, it will automatically
12080
+ * await the replan before drawing from queues, ensuring the user always sees
12081
+ * cards scored against their latest state.
12082
+ *
12083
+ * Typical trigger: application-level code (e.g. after a GPC intro completion)
12084
+ * calls this to ensure newly-unlocked content appears in the session.
12085
+ */
12086
+ async requestReplan(hints) {
12087
+ if (this._replanPromise) {
12088
+ this.log("Replan already in progress, awaiting existing replan");
12089
+ return this._replanPromise;
12090
+ }
12091
+ if (hints) {
12092
+ for (const source of this.sources) {
12093
+ this.log(`[Hints] source type=${source.constructor.name}, hasMethod=${typeof source.setEphemeralHints}`);
12094
+ source.setEphemeralHints?.(hints);
12095
+ }
12096
+ }
12097
+ this.log(`Mid-session replan requested${hints ? ` (hints: ${JSON.stringify(hints)})` : ""}`);
12098
+ this._replanPromise = this._executeReplan();
12099
+ try {
12100
+ await this._replanPromise;
12101
+ } finally {
12102
+ this._replanPromise = null;
12103
+ }
12104
+ }
12105
+ /** Minimum well-indicated cards before an additive retry is attempted */
12106
+ static MIN_WELL_INDICATED = 5;
12107
+ /**
12108
+ * Score threshold for considering a card "well-indicated."
12109
+ * Cards below this score are treated as fallback filler — present only
12110
+ * because no strategy hard-removed them, but likely penalized by one
12111
+ * or more filters. Strategy-agnostic: the SessionController doesn't
12112
+ * know or care which strategy assigned the score.
12113
+ */
12114
+ static WELL_INDICATED_SCORE = 0.1;
12115
+ /**
12116
+ * Internal replan execution. Runs the pipeline, builds a new newQ,
12117
+ * atomically swaps it in, and triggers hydration for the new contents.
12118
+ *
12119
+ * If the initial replan produces fewer than MIN_WELL_INDICATED cards that
12120
+ * pass all hierarchy filters, one additive retry is attempted — merging
12121
+ * any new high-quality candidates into the front of the queue.
12122
+ */
12123
+ async _executeReplan() {
12124
+ const wellIndicated = await this.getWeightedContent({ replan: true });
12125
+ this._wellIndicatedRemaining = wellIndicated;
12126
+ if (wellIndicated >= 0 && wellIndicated < _SessionController.MIN_WELL_INDICATED) {
12127
+ this.log(
12128
+ `[Replan] Only ${wellIndicated}/${_SessionController.MIN_WELL_INDICATED} well-indicated cards after replan`
12129
+ );
12130
+ }
12131
+ await this.hydrationService.ensureHydratedCards();
12132
+ this.log(`Replan complete: newQ now has ${this.newQ.length} cards`);
12133
+ snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
12134
+ }
11240
12135
  addTime(seconds) {
11241
12136
  this.endTime = new Date(this.endTime.valueOf() + 1e3 * seconds);
11242
12137
  }
@@ -11292,6 +12187,9 @@ var SessionController = class extends Loggable {
11292
12187
  hydratedCache: {
11293
12188
  count: this.hydrationService.hydratedCount,
11294
12189
  cardIds: this.hydrationService.getHydratedCardIds()
12190
+ },
12191
+ replan: {
12192
+ inProgress: this._replanPromise !== null
11295
12193
  }
11296
12194
  };
11297
12195
  }
@@ -11304,7 +12202,20 @@ var SessionController = class extends Loggable {
11304
12202
  * 3. Uses SourceMixer to balance content across sources
11305
12203
  * 4. Populates review and new card queues with mixed results
11306
12204
  */
11307
- async getWeightedContent() {
12205
+ /**
12206
+ * Fetch weighted content from all sources and populate session queues.
12207
+ *
12208
+ * @param options.replan - If true, this is a mid-session replan rather than
12209
+ * initial session setup. Skips review queue population (avoiding duplicates),
12210
+ * atomically replaces newQ contents, and treats empty results as non-fatal.
12211
+ * @param options.additive - If true (replan only), merge new high-quality
12212
+ * candidates into the front of the existing newQ instead of replacing it.
12213
+ * @returns Number of "well-indicated" cards (passed all hierarchy filters)
12214
+ * in the new content. Returns -1 if no content was loaded.
12215
+ */
12216
+ async getWeightedContent(options) {
12217
+ const replan = options?.replan ?? false;
12218
+ const additive = options?.additive ?? false;
11308
12219
  const limit = 20;
11309
12220
  const batches = [];
11310
12221
  for (let i = 0; i < this.sources.length; i++) {
@@ -11323,6 +12234,10 @@ var SessionController = class extends Loggable {
11323
12234
  }
11324
12235
  }
11325
12236
  if (batches.length === 0) {
12237
+ if (replan) {
12238
+ this.log("Replan: no content from any source, keeping existing newQ");
12239
+ return -1;
12240
+ }
11326
12241
  throw new Error(
11327
12242
  `Cannot start session: failed to load content from all ${this.sources.length} source(s). Check logs for details.`
11328
12243
  );
@@ -11334,10 +12249,12 @@ var SessionController = class extends Loggable {
11334
12249
  });
11335
12250
  await Promise.all(
11336
12251
  sourceIds.map(async (id) => {
11337
- try {
11338
- const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
11339
- this.courseNameCache.set(id, config.name);
11340
- } catch {
12252
+ if (!this.courseNameCache.has(id)) {
12253
+ try {
12254
+ const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
12255
+ this.courseNameCache.set(id, config.name);
12256
+ } catch {
12257
+ }
11341
12258
  }
11342
12259
  })
11343
12260
  );
@@ -11355,20 +12272,26 @@ var SessionController = class extends Loggable {
11355
12272
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
11356
12273
  const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
11357
12274
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
11358
- let report = "Mixed content session created with:\n";
11359
- for (const w of reviewWeighted) {
11360
- const reviewItem = {
11361
- cardID: w.cardId,
11362
- courseID: w.courseId,
11363
- contentSourceType: "course",
11364
- contentSourceID: w.courseId,
11365
- reviewID: w.reviewID,
11366
- status: "review"
11367
- };
11368
- this.reviewQ.add(reviewItem, reviewItem.cardID);
11369
- report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
12275
+ let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
12276
+ if (!replan) {
12277
+ for (const w of reviewWeighted) {
12278
+ const reviewItem = {
12279
+ cardID: w.cardId,
12280
+ courseID: w.courseId,
12281
+ contentSourceType: "course",
12282
+ contentSourceID: w.courseId,
12283
+ reviewID: w.reviewID,
12284
+ status: "review"
12285
+ };
12286
+ this.reviewQ.add(reviewItem, reviewItem.cardID);
12287
+ report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
11370
12288
  `;
12289
+ }
11371
12290
  }
12291
+ const wellIndicated = newWeighted.filter(
12292
+ (w) => w.score >= _SessionController.WELL_INDICATED_SCORE
12293
+ ).length;
12294
+ const newItems = [];
11372
12295
  for (const w of newWeighted) {
11373
12296
  const newItem = {
11374
12297
  cardID: w.cardId,
@@ -11377,11 +12300,23 @@ var SessionController = class extends Loggable {
11377
12300
  contentSourceID: w.courseId,
11378
12301
  status: "new"
11379
12302
  };
11380
- this.newQ.add(newItem, newItem.cardID);
12303
+ newItems.push(newItem);
11381
12304
  report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
11382
12305
  `;
11383
12306
  }
12307
+ if (additive) {
12308
+ const added = this.newQ.mergeToFront(newItems, (item) => item.cardID);
12309
+ report += `Additive merge: ${added} new cards added to front of newQ
12310
+ `;
12311
+ } else if (replan) {
12312
+ this.newQ.replaceAll(newItems, (item) => item.cardID);
12313
+ } else {
12314
+ for (const item of newItems) {
12315
+ this.newQ.add(item, item.cardID);
12316
+ }
12317
+ }
11384
12318
  this.log(report);
12319
+ return wellIndicated;
11385
12320
  }
11386
12321
  /**
11387
12322
  * Returns items that should be pre-hydrated.
@@ -11458,6 +12393,17 @@ var SessionController = class extends Loggable {
11458
12393
  }
11459
12394
  async nextCard(action = "dismiss-success") {
11460
12395
  this.dismissCurrentCard(action);
12396
+ if (this._replanPromise) {
12397
+ this.log("nextCard: awaiting in-flight replan before drawing");
12398
+ await this._replanPromise;
12399
+ }
12400
+ const REPLAN_BUFFER = 3;
12401
+ if (this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
12402
+ this.log(
12403
+ `[AutoReplan] ${this._wellIndicatedRemaining} well-indicated cards remaining (newQ: ${this.newQ.length}). Triggering background replan.`
12404
+ );
12405
+ void this.requestReplan();
12406
+ }
11461
12407
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
11462
12408
  this._currentCard = null;
11463
12409
  endSessionTracking();
@@ -11571,6 +12517,9 @@ var SessionController = class extends Loggable {
11571
12517
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
11572
12518
  } else if (this.newQ.peek(0)?.cardID === item.cardID) {
11573
12519
  this.newQ.dequeue((queueItem) => queueItem.cardID);
12520
+ if (this._wellIndicatedRemaining > 0) {
12521
+ this._wellIndicatedRemaining--;
12522
+ }
11574
12523
  } else if (this.failedQ.peek(0)?.cardID === item.cardID) {
11575
12524
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
11576
12525
  }
@@ -11666,6 +12615,7 @@ init_factory();
11666
12615
  getDefaultLearnableWeight,
11667
12616
  getRegisteredNavigator,
11668
12617
  getRegisteredNavigatorNames,
12618
+ getRegisteredNavigatorRole,
11669
12619
  getStudySource,
11670
12620
  hasRegisteredNavigator,
11671
12621
  importParsedCards,