@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
@@ -702,8 +702,12 @@ __export(PipelineDebugger_exports, {
702
702
  buildRunReport: () => buildRunReport,
703
703
  captureRun: () => captureRun,
704
704
  mountPipelineDebugger: () => mountPipelineDebugger,
705
- pipelineDebugAPI: () => pipelineDebugAPI
705
+ pipelineDebugAPI: () => pipelineDebugAPI,
706
+ registerPipelineForDebug: () => registerPipelineForDebug
706
707
  });
708
+ function registerPipelineForDebug(pipeline) {
709
+ _activePipeline = pipeline;
710
+ }
707
711
  function getOrigin(card) {
708
712
  const firstEntry = card.provenance[0];
709
713
  if (!firstEntry) return "unknown";
@@ -731,6 +735,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
731
735
  origin: getOrigin(card),
732
736
  finalScore: card.score,
733
737
  provenance: card.provenance,
738
+ tags: card.tags,
734
739
  selected: selectedIds.has(card.cardId)
735
740
  }));
736
741
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
@@ -786,11 +791,13 @@ function mountPipelineDebugger() {
786
791
  win.skuilder = win.skuilder || {};
787
792
  win.skuilder.pipeline = pipelineDebugAPI;
788
793
  }
789
- var MAX_RUNS, runHistory, pipelineDebugAPI;
794
+ var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
790
795
  var init_PipelineDebugger = __esm({
791
796
  "src/core/navigators/PipelineDebugger.ts"() {
792
797
  "use strict";
798
+ init_navigators();
793
799
  init_logger();
800
+ _activePipeline = null;
794
801
  MAX_RUNS = 10;
795
802
  runHistory = [];
796
803
  pipelineDebugAPI = {
@@ -932,6 +939,81 @@ var init_PipelineDebugger = __esm({
932
939
  runHistory.length = 0;
933
940
  logger.info("[Pipeline Debug] Run history cleared.");
934
941
  },
942
+ /**
943
+ * Show the navigator registry: all registered classes and their roles.
944
+ *
945
+ * Useful for verifying that consumer-defined navigators were registered
946
+ * before pipeline assembly.
947
+ */
948
+ showRegistry() {
949
+ const names = getRegisteredNavigatorNames();
950
+ if (names.length === 0) {
951
+ logger.info("[Pipeline Debug] Navigator registry is empty.");
952
+ return;
953
+ }
954
+ console.group("\u{1F4E6} Navigator Registry");
955
+ console.table(
956
+ names.map((name) => {
957
+ const registryRole = getRegisteredNavigatorRole(name);
958
+ const builtinRole = NavigatorRoles[name];
959
+ const effectiveRole = builtinRole || registryRole || "\u26A0\uFE0F NONE";
960
+ const source = builtinRole ? "built-in" : registryRole ? "consumer" : "unclassified";
961
+ return {
962
+ name,
963
+ role: effectiveRole,
964
+ source,
965
+ isGenerator: isGenerator(name),
966
+ isFilter: isFilter(name)
967
+ };
968
+ })
969
+ );
970
+ console.groupEnd();
971
+ },
972
+ /**
973
+ * Show strategy documents from the last pipeline run and how they mapped
974
+ * to the registry.
975
+ *
976
+ * If no runs are captured yet, falls back to showing just the registry.
977
+ */
978
+ showStrategies() {
979
+ this.showRegistry();
980
+ if (runHistory.length === 0) {
981
+ logger.info("[Pipeline Debug] No pipeline runs captured yet \u2014 cannot show strategy doc mapping.");
982
+ return;
983
+ }
984
+ const run = runHistory[0];
985
+ console.group("\u{1F50C} Pipeline Strategy Mapping (last run)");
986
+ logger.info(`Generator: ${run.generatorName}`);
987
+ if (run.generators && run.generators.length > 0) {
988
+ for (const g of run.generators) {
989
+ logger.info(` \u{1F4E5} ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
990
+ }
991
+ }
992
+ if (run.filters.length > 0) {
993
+ logger.info("Filters:");
994
+ for (const f of run.filters) {
995
+ logger.info(` \u{1F538} ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
996
+ }
997
+ } else {
998
+ logger.info("Filters: (none)");
999
+ }
1000
+ console.groupEnd();
1001
+ },
1002
+ /**
1003
+ * Scan the full card space through the filter chain for the current user.
1004
+ *
1005
+ * Reports how many cards are well-indicated and how many are new.
1006
+ * Use this to understand how the search space grows during onboarding.
1007
+ *
1008
+ * @param threshold - Score threshold for "well indicated" (default 0.10)
1009
+ */
1010
+ async diagnoseCardSpace(threshold) {
1011
+ if (!_activePipeline) {
1012
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
1013
+ return null;
1014
+ }
1015
+ return _activePipeline.diagnoseCardSpace({ threshold });
1016
+ },
935
1017
  /**
936
1018
  * Show help.
937
1019
  */
@@ -944,6 +1026,9 @@ Commands:
944
1026
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
945
1027
  .showCard(cardId) Show provenance trail for a specific card
946
1028
  .explainReviews() Analyze why reviews were/weren't selected
1029
+ .diagnoseCardSpace() Scan full card space through filters (async)
1030
+ .showRegistry() Show navigator registry (classes + roles)
1031
+ .showStrategies() Show registry + strategy mapping from last run
947
1032
  .listRuns() List all captured runs in table format
948
1033
  .export() Export run history as JSON for bug reports
949
1034
  .clear() Clear run history
@@ -953,7 +1038,7 @@ Commands:
953
1038
  Example:
954
1039
  window.skuilder.pipeline.showLastRun()
955
1040
  window.skuilder.pipeline.showRun(1)
956
- window.skuilder.pipeline.showCard('abc123')
1041
+ await window.skuilder.pipeline.diagnoseCardSpace()
957
1042
  `);
958
1043
  }
959
1044
  };
@@ -1248,6 +1333,69 @@ var init_generators = __esm({
1248
1333
  }
1249
1334
  });
1250
1335
 
1336
+ // src/core/navigators/generators/prescribed.ts
1337
+ var prescribed_exports = {};
1338
+ __export(prescribed_exports, {
1339
+ default: () => PrescribedCardsGenerator
1340
+ });
1341
+ var PrescribedCardsGenerator;
1342
+ var init_prescribed = __esm({
1343
+ "src/core/navigators/generators/prescribed.ts"() {
1344
+ "use strict";
1345
+ init_navigators();
1346
+ init_logger();
1347
+ PrescribedCardsGenerator = class extends ContentNavigator {
1348
+ name;
1349
+ config;
1350
+ constructor(user, course, strategyData) {
1351
+ super(user, course, strategyData);
1352
+ this.name = strategyData.name || "Prescribed Cards";
1353
+ try {
1354
+ const parsed = JSON.parse(strategyData.serializedData);
1355
+ this.config = { cardIds: parsed.cardIds || [] };
1356
+ } catch {
1357
+ this.config = { cardIds: [] };
1358
+ }
1359
+ logger.debug(
1360
+ `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1361
+ );
1362
+ }
1363
+ async getWeightedCards(limit, _context) {
1364
+ if (this.config.cardIds.length === 0) {
1365
+ return [];
1366
+ }
1367
+ const courseId = this.course.getCourseID();
1368
+ const activeCards = await this.user.getActiveCards();
1369
+ const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1370
+ const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1371
+ if (eligibleIds.length === 0) {
1372
+ logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1373
+ return [];
1374
+ }
1375
+ const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1376
+ cardId,
1377
+ courseId,
1378
+ score: 1,
1379
+ provenance: [
1380
+ {
1381
+ strategy: "prescribed",
1382
+ strategyName: this.strategyName || this.name,
1383
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1384
+ action: "generated",
1385
+ score: 1,
1386
+ reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1387
+ }
1388
+ ]
1389
+ }));
1390
+ logger.info(
1391
+ `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1392
+ );
1393
+ return cards;
1394
+ }
1395
+ };
1396
+ }
1397
+ });
1398
+
1251
1399
  // src/core/navigators/generators/srs.ts
1252
1400
  var srs_exports = {};
1253
1401
  __export(srs_exports, {
@@ -1442,6 +1590,7 @@ var init_ = __esm({
1442
1590
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1443
1591
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1444
1592
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1593
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1445
1594
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1446
1595
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1447
1596
  });
@@ -1642,6 +1791,8 @@ var init_hierarchyDefinition = __esm({
1642
1791
  if (userTagElo.count < minCount) return false;
1643
1792
  if (prereq.masteryThreshold?.minElo !== void 0) {
1644
1793
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1794
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1795
+ return true;
1645
1796
  } else {
1646
1797
  return userTagElo.score >= userGlobalElo;
1647
1798
  }
@@ -1717,14 +1868,38 @@ var init_hierarchyDefinition = __esm({
1717
1868
  };
1718
1869
  }
1719
1870
  }
1871
+ /**
1872
+ * Build a map of prereq tag → max configured boost for all *closed* gates.
1873
+ *
1874
+ * When a gate is closed (prereqs unmet), cards carrying that gate's prereq
1875
+ * tags get boosted — steering the pipeline toward content that helps unlock
1876
+ * the gated material. Once the gate opens, the boost disappears.
1877
+ */
1878
+ getPreReqBoosts(unlockedTags, masteredTags) {
1879
+ const boosts = /* @__PURE__ */ new Map();
1880
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
1881
+ if (unlockedTags.has(tagId)) continue;
1882
+ for (const prereq of prereqs) {
1883
+ if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
1884
+ if (masteredTags.has(prereq.tag)) continue;
1885
+ const existing = boosts.get(prereq.tag) ?? 1;
1886
+ boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
1887
+ }
1888
+ }
1889
+ return boosts;
1890
+ }
1720
1891
  /**
1721
1892
  * CardFilter.transform implementation.
1722
1893
  *
1723
- * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
1894
+ * Two effects:
1895
+ * 1. Cards with locked tags receive score * 0.05 (gating penalty)
1896
+ * 2. Cards carrying prereq tags of closed gates receive a configured
1897
+ * boost (preReqBoost), steering toward content that unlocks gates
1724
1898
  */
1725
1899
  async transform(cards, context) {
1726
1900
  const masteredTags = await this.getMasteredTags(context);
1727
1901
  const unlockedTags = this.getUnlockedTags(masteredTags);
1902
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1728
1903
  const gated = [];
1729
1904
  for (const card of cards) {
1730
1905
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1733,9 +1908,27 @@ var init_hierarchyDefinition = __esm({
1733
1908
  unlockedTags,
1734
1909
  masteredTags
1735
1910
  );
1736
- const LOCKED_PENALTY = 0.01;
1737
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1738
- const action = isUnlocked ? "passed" : "penalized";
1911
+ const LOCKED_PENALTY = 0.02;
1912
+ let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1913
+ let action = isUnlocked ? "passed" : "penalized";
1914
+ let finalReason = reason;
1915
+ if (isUnlocked && preReqBoosts.size > 0) {
1916
+ const cardTags = card.tags ?? [];
1917
+ let maxBoost = 1;
1918
+ const boostedPrereqs = [];
1919
+ for (const tag of cardTags) {
1920
+ const boost = preReqBoosts.get(tag);
1921
+ if (boost && boost > maxBoost) {
1922
+ maxBoost = boost;
1923
+ boostedPrereqs.push(tag);
1924
+ }
1925
+ }
1926
+ if (maxBoost > 1) {
1927
+ finalScore *= maxBoost;
1928
+ action = "boosted";
1929
+ finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
1930
+ }
1931
+ }
1739
1932
  gated.push({
1740
1933
  ...card,
1741
1934
  score: finalScore,
@@ -1747,7 +1940,7 @@ var init_hierarchyDefinition = __esm({
1747
1940
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1748
1941
  action,
1749
1942
  score: finalScore,
1750
- reason
1943
+ reason: finalReason
1751
1944
  }
1752
1945
  ]
1753
1946
  });
@@ -2682,6 +2875,18 @@ __export(Pipeline_exports, {
2682
2875
  Pipeline: () => Pipeline
2683
2876
  });
2684
2877
  import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
2878
+ function globToRegex(pattern) {
2879
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
2880
+ const withWildcards = escaped.replace(/\*/g, ".*");
2881
+ return new RegExp(`^${withWildcards}$`);
2882
+ }
2883
+ function globMatch(value, pattern) {
2884
+ if (!pattern.includes("*")) return value === pattern;
2885
+ return globToRegex(pattern).test(value);
2886
+ }
2887
+ function cardMatchesTagPattern(card, pattern) {
2888
+ return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
2889
+ }
2685
2890
  function logPipelineConfig(generator, filters) {
2686
2891
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2687
2892
  logger.info(
@@ -2716,6 +2921,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2716
2921
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2717
2922
  );
2718
2923
  }
2924
+ function logResultCards(cards) {
2925
+ if (!VERBOSE_RESULTS || cards.length === 0) return;
2926
+ logger.info(`[Pipeline] Results (${cards.length} cards):`);
2927
+ for (let i = 0; i < cards.length; i++) {
2928
+ const c = cards[i];
2929
+ const tags = c.tags?.slice(0, 3).join(", ") || "";
2930
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
2931
+ const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
2932
+ return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
2933
+ }).join(" | ");
2934
+ logger.info(
2935
+ `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
2936
+ );
2937
+ }
2938
+ }
2719
2939
  function logCardProvenance(cards, maxCards = 3) {
2720
2940
  const cardsToLog = cards.slice(0, maxCards);
2721
2941
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2730,7 +2950,7 @@ function logCardProvenance(cards, maxCards = 3) {
2730
2950
  }
2731
2951
  }
2732
2952
  }
2733
- var Pipeline;
2953
+ var VERBOSE_RESULTS, Pipeline;
2734
2954
  var init_Pipeline = __esm({
2735
2955
  "src/core/navigators/Pipeline.ts"() {
2736
2956
  "use strict";
@@ -2738,9 +2958,31 @@ var init_Pipeline = __esm({
2738
2958
  init_logger();
2739
2959
  init_orchestration();
2740
2960
  init_PipelineDebugger();
2961
+ VERBOSE_RESULTS = true;
2741
2962
  Pipeline = class extends ContentNavigator {
2742
2963
  generator;
2743
2964
  filters;
2965
+ /**
2966
+ * Cached orchestration context. Course config and salt don't change within
2967
+ * a page load, so we build the orchestration context once and reuse it on
2968
+ * subsequent getWeightedCards() calls (e.g. mid-session replans).
2969
+ *
2970
+ * This eliminates a remote getCourseConfig() round trip per pipeline run.
2971
+ */
2972
+ _cachedOrchestration = null;
2973
+ /**
2974
+ * Persistent tag cache. Maps cardId → tag names.
2975
+ *
2976
+ * Tags are static within a session (they're set at card generation time),
2977
+ * so we cache them across pipeline runs. On replans, many of the same cards
2978
+ * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
2979
+ */
2980
+ _tagCache = /* @__PURE__ */ new Map();
2981
+ /**
2982
+ * One-shot replan hints. Applied after the filter chain on the next
2983
+ * getWeightedCards() call, then cleared.
2984
+ */
2985
+ _ephemeralHints = null;
2744
2986
  /**
2745
2987
  * Create a new pipeline.
2746
2988
  *
@@ -2761,6 +3003,17 @@ var init_Pipeline = __esm({
2761
3003
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2762
3004
  });
2763
3005
  logPipelineConfig(generator, filters);
3006
+ registerPipelineForDebug(this);
3007
+ }
3008
+ /**
3009
+ * Set one-shot hints for the next pipeline run.
3010
+ * Consumed after one getWeightedCards() call, then cleared.
3011
+ *
3012
+ * Overrides ContentNavigator.setEphemeralHints() no-op.
3013
+ */
3014
+ setEphemeralHints(hints) {
3015
+ this._ephemeralHints = hints;
3016
+ logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
2764
3017
  }
2765
3018
  /**
2766
3019
  * Get weighted cards by running generator and applying filters.
@@ -2777,13 +3030,15 @@ var init_Pipeline = __esm({
2777
3030
  * @returns Cards sorted by score descending
2778
3031
  */
2779
3032
  async getWeightedCards(limit) {
3033
+ const t0 = performance.now();
2780
3034
  const context = await this.buildContext();
2781
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2782
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
3035
+ const tContext = performance.now();
3036
+ const fetchLimit = 500;
2783
3037
  logger.debug(
2784
3038
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2785
3039
  );
2786
3040
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
3041
+ const tGenerate = performance.now();
2787
3042
  const generatedCount = cards.length;
2788
3043
  let generatorSummaries;
2789
3044
  if (this.generator.generators) {
@@ -2812,6 +3067,7 @@ var init_Pipeline = __esm({
2812
3067
  }
2813
3068
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2814
3069
  cards = await this.hydrateTags(cards);
3070
+ const tHydrate = performance.now();
2815
3071
  const allCardsBeforeFiltering = [...cards];
2816
3072
  const filterImpacts = [];
2817
3073
  for (const filter of this.filters) {
@@ -2830,8 +3086,17 @@ var init_Pipeline = __esm({
2830
3086
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2831
3087
  }
2832
3088
  cards = cards.filter((c) => c.score > 0);
3089
+ const hints = this._ephemeralHints;
3090
+ if (hints) {
3091
+ this._ephemeralHints = null;
3092
+ cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
3093
+ }
2833
3094
  cards.sort((a, b) => b.score - a.score);
3095
+ const tFilter = performance.now();
2834
3096
  const result = cards.slice(0, limit);
3097
+ logger.info(
3098
+ `[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)})`
3099
+ );
2835
3100
  const topScores = result.slice(0, 3).map((c) => c.score);
2836
3101
  logExecutionSummary(
2837
3102
  this.generator.name,
@@ -2841,6 +3106,7 @@ var init_Pipeline = __esm({
2841
3106
  topScores,
2842
3107
  filterImpacts
2843
3108
  );
3109
+ logResultCards(result);
2844
3110
  logCardProvenance(result, 3);
2845
3111
  try {
2846
3112
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -2867,6 +3133,10 @@ var init_Pipeline = __esm({
2867
3133
  * to the WeightedCard objects. Filters can then use card.tags instead of
2868
3134
  * making individual getAppliedTags() calls.
2869
3135
  *
3136
+ * Uses a persistent tag cache across pipeline runs — tags are static within
3137
+ * a session, so cards seen in a prior run (e.g. before a replan) don't
3138
+ * require a second DB query.
3139
+ *
2870
3140
  * @param cards - Cards to hydrate
2871
3141
  * @returns Cards with tags populated
2872
3142
  */
@@ -2874,14 +3144,128 @@ var init_Pipeline = __esm({
2874
3144
  if (cards.length === 0) {
2875
3145
  return cards;
2876
3146
  }
2877
- const cardIds = cards.map((c) => c.cardId);
2878
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
3147
+ const uncachedIds = [];
3148
+ for (const card of cards) {
3149
+ if (!this._tagCache.has(card.cardId)) {
3150
+ uncachedIds.push(card.cardId);
3151
+ }
3152
+ }
3153
+ if (uncachedIds.length > 0) {
3154
+ const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
3155
+ for (const [cardId, tags] of freshTags) {
3156
+ this._tagCache.set(cardId, tags);
3157
+ }
3158
+ }
3159
+ const tagsByCard = /* @__PURE__ */ new Map();
3160
+ for (const card of cards) {
3161
+ tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
3162
+ }
2879
3163
  logTagHydration(cards, tagsByCard);
2880
3164
  return cards.map((card) => ({
2881
3165
  ...card,
2882
- tags: tagsByCard.get(card.cardId) ?? []
3166
+ tags: this._tagCache.get(card.cardId) ?? []
2883
3167
  }));
2884
3168
  }
3169
+ // ---------------------------------------------------------------------------
3170
+ // Ephemeral hints application
3171
+ // ---------------------------------------------------------------------------
3172
+ /**
3173
+ * Apply one-shot replan hints to the post-filter card set.
3174
+ *
3175
+ * Order of operations:
3176
+ * 1. Exclude (remove unwanted cards)
3177
+ * 2. Boost (multiply scores)
3178
+ * 3. Require (inject must-have cards from the full pre-filter pool)
3179
+ *
3180
+ * @param cards - Post-filter cards (score > 0)
3181
+ * @param hints - The ephemeral hints to apply
3182
+ * @param allCards - Full pre-filter card pool (for require injection)
3183
+ */
3184
+ applyHints(cards, hints, allCards) {
3185
+ const beforeCount = cards.length;
3186
+ if (hints.excludeCards?.length) {
3187
+ cards = cards.filter(
3188
+ (c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
3189
+ );
3190
+ }
3191
+ if (hints.excludeTags?.length) {
3192
+ cards = cards.filter(
3193
+ (c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
3194
+ );
3195
+ }
3196
+ if (hints.boostTags) {
3197
+ for (const [pattern, factor] of Object.entries(hints.boostTags)) {
3198
+ for (const card of cards) {
3199
+ if (cardMatchesTagPattern(card, pattern)) {
3200
+ card.score *= factor;
3201
+ card.provenance.push({
3202
+ strategy: "ephemeralHint",
3203
+ strategyId: "ephemeral-hint",
3204
+ strategyName: "Replan Hint",
3205
+ action: "boosted",
3206
+ score: card.score,
3207
+ reason: `boostTag ${pattern} \xD7${factor}`
3208
+ });
3209
+ }
3210
+ }
3211
+ }
3212
+ }
3213
+ if (hints.boostCards) {
3214
+ for (const [pattern, factor] of Object.entries(hints.boostCards)) {
3215
+ for (const card of cards) {
3216
+ if (globMatch(card.cardId, pattern)) {
3217
+ card.score *= factor;
3218
+ card.provenance.push({
3219
+ strategy: "ephemeralHint",
3220
+ strategyId: "ephemeral-hint",
3221
+ strategyName: "Replan Hint",
3222
+ action: "boosted",
3223
+ score: card.score,
3224
+ reason: `boostCard ${pattern} \xD7${factor}`
3225
+ });
3226
+ }
3227
+ }
3228
+ }
3229
+ }
3230
+ const cardIds = new Set(cards.map((c) => c.cardId));
3231
+ const inject = (card, reason) => {
3232
+ if (!cardIds.has(card.cardId)) {
3233
+ const floorScore = Math.max(card.score, 1);
3234
+ cards.push({
3235
+ ...card,
3236
+ score: floorScore,
3237
+ provenance: [
3238
+ ...card.provenance,
3239
+ {
3240
+ strategy: "ephemeralHint",
3241
+ strategyId: "ephemeral-hint",
3242
+ strategyName: "Replan Hint",
3243
+ action: "boosted",
3244
+ score: floorScore,
3245
+ reason
3246
+ }
3247
+ ]
3248
+ });
3249
+ cardIds.add(card.cardId);
3250
+ }
3251
+ };
3252
+ if (hints.requireCards?.length) {
3253
+ for (const pattern of hints.requireCards) {
3254
+ for (const card of allCards) {
3255
+ if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
3256
+ }
3257
+ }
3258
+ }
3259
+ if (hints.requireTags?.length) {
3260
+ for (const pattern of hints.requireTags) {
3261
+ for (const card of allCards) {
3262
+ if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
3263
+ }
3264
+ }
3265
+ }
3266
+ logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
3267
+ return cards;
3268
+ }
2885
3269
  /**
2886
3270
  * Build shared context for generator and filters.
2887
3271
  *
@@ -2899,7 +3283,10 @@ var init_Pipeline = __esm({
2899
3283
  } catch (e) {
2900
3284
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
2901
3285
  }
2902
- const orchestration = await createOrchestrationContext(this.user, this.course);
3286
+ if (!this._cachedOrchestration) {
3287
+ this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
3288
+ }
3289
+ const orchestration = this._cachedOrchestration;
2903
3290
  return {
2904
3291
  user: this.user,
2905
3292
  course: this.course,
@@ -2943,6 +3330,87 @@ var init_Pipeline = __esm({
2943
3330
  }
2944
3331
  return [...new Set(ids)];
2945
3332
  }
3333
+ // ---------------------------------------------------------------------------
3334
+ // Card-space diagnostic
3335
+ // ---------------------------------------------------------------------------
3336
+ /**
3337
+ * Scan every card in the course through the filter chain and report
3338
+ * how many are "well indicated" (score >= threshold) for the current user.
3339
+ *
3340
+ * Also reports how many well-indicated cards the user has NOT yet encountered.
3341
+ *
3342
+ * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
3343
+ */
3344
+ async diagnoseCardSpace(opts) {
3345
+ const THRESHOLD = opts?.threshold ?? 0.1;
3346
+ const t0 = performance.now();
3347
+ const allCardIds = await this.course.getAllCardIds();
3348
+ let cards = allCardIds.map((cardId) => ({
3349
+ cardId,
3350
+ courseId: this.course.getCourseID(),
3351
+ score: 1,
3352
+ provenance: []
3353
+ }));
3354
+ cards = await this.hydrateTags(cards);
3355
+ const context = await this.buildContext();
3356
+ const filterBreakdown = [];
3357
+ for (const filter of this.filters) {
3358
+ cards = await filter.transform(cards, context);
3359
+ const wi = cards.filter((c) => c.score >= THRESHOLD).length;
3360
+ filterBreakdown.push({ name: filter.name, wellIndicated: wi });
3361
+ }
3362
+ const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
3363
+ const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
3364
+ let encounteredIds;
3365
+ try {
3366
+ const courseId = this.course.getCourseID();
3367
+ const seenCards = await this.user.getSeenCards(courseId);
3368
+ encounteredIds = new Set(seenCards);
3369
+ } catch {
3370
+ encounteredIds = /* @__PURE__ */ new Set();
3371
+ }
3372
+ const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
3373
+ const byType = /* @__PURE__ */ new Map();
3374
+ for (const card of cards) {
3375
+ const type = card.cardId.split("-")[1] || "unknown";
3376
+ if (!byType.has(type)) {
3377
+ byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
3378
+ }
3379
+ const entry = byType.get(type);
3380
+ entry.total++;
3381
+ if (card.score >= THRESHOLD) {
3382
+ entry.wellIndicated++;
3383
+ if (!encounteredIds.has(card.cardId)) entry.new++;
3384
+ }
3385
+ }
3386
+ const elapsed = performance.now() - t0;
3387
+ const result = {
3388
+ totalCards: allCardIds.length,
3389
+ threshold: THRESHOLD,
3390
+ wellIndicated: wellIndicatedIds.size,
3391
+ encountered: encounteredIds.size,
3392
+ wellIndicatedNew: wellIndicatedNew.length,
3393
+ byType: Object.fromEntries(byType),
3394
+ filterBreakdown,
3395
+ elapsedMs: Math.round(elapsed)
3396
+ };
3397
+ logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
3398
+ logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
3399
+ logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
3400
+ logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
3401
+ logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
3402
+ logger.info(`[Pipeline:diagnose] By type:`);
3403
+ for (const [type, counts] of byType) {
3404
+ logger.info(
3405
+ `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
3406
+ );
3407
+ }
3408
+ logger.info(`[Pipeline:diagnose] After each filter:`);
3409
+ for (const fb of filterBreakdown) {
3410
+ logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
3411
+ }
3412
+ return result;
3413
+ }
2946
3414
  };
2947
3415
  }
2948
3416
  });
@@ -3047,23 +3515,25 @@ var init_PipelineAssembler = __esm({
3047
3515
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
3048
3516
  }
3049
3517
  }
3518
+ const courseId = course.getCourseID();
3519
+ const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
3520
+ const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
3521
+ if (!hasElo) {
3522
+ logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
3523
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3524
+ }
3525
+ if (!hasSrs) {
3526
+ logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
3527
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
3528
+ }
3050
3529
  if (generatorStrategies.length === 0) {
3051
- if (filterStrategies.length > 0) {
3052
- logger.debug(
3053
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
3054
- );
3055
- const courseId = course.getCourseID();
3056
- generatorStrategies.push(createDefaultEloStrategy(courseId));
3057
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
3058
- } else {
3059
- warnings.push("No generator strategy found");
3060
- return {
3061
- pipeline: null,
3062
- generatorStrategies: [],
3063
- filterStrategies: [],
3064
- warnings
3065
- };
3066
- }
3530
+ warnings.push("No generator strategy found");
3531
+ return {
3532
+ pipeline: null,
3533
+ generatorStrategies: [],
3534
+ filterStrategies: [],
3535
+ warnings
3536
+ };
3067
3537
  }
3068
3538
  let generator;
3069
3539
  if (generatorStrategies.length === 1) {
@@ -3141,6 +3611,7 @@ var init_3 = __esm({
3141
3611
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
3142
3612
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
3143
3613
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3614
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
3144
3615
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
3145
3616
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
3146
3617
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -3158,6 +3629,7 @@ __export(navigators_exports, {
3158
3629
  getCardOrigin: () => getCardOrigin,
3159
3630
  getRegisteredNavigator: () => getRegisteredNavigator,
3160
3631
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
3632
+ getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
3161
3633
  hasRegisteredNavigator: () => hasRegisteredNavigator,
3162
3634
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
3163
3635
  isFilter: () => isFilter,
@@ -3166,16 +3638,19 @@ __export(navigators_exports, {
3166
3638
  pipelineDebugAPI: () => pipelineDebugAPI,
3167
3639
  registerNavigator: () => registerNavigator
3168
3640
  });
3169
- function registerNavigator(implementingClass, constructor) {
3170
- navigatorRegistry.set(implementingClass, constructor);
3171
- logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
3641
+ function registerNavigator(implementingClass, constructor, role) {
3642
+ navigatorRegistry.set(implementingClass, { constructor, role });
3643
+ logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ""}`);
3172
3644
  }
3173
3645
  function getRegisteredNavigator(implementingClass) {
3174
- return navigatorRegistry.get(implementingClass);
3646
+ return navigatorRegistry.get(implementingClass)?.constructor;
3175
3647
  }
3176
3648
  function hasRegisteredNavigator(implementingClass) {
3177
3649
  return navigatorRegistry.has(implementingClass);
3178
3650
  }
3651
+ function getRegisteredNavigatorRole(implementingClass) {
3652
+ return navigatorRegistry.get(implementingClass)?.role;
3653
+ }
3179
3654
  function getRegisteredNavigatorNames() {
3180
3655
  return Array.from(navigatorRegistry.keys());
3181
3656
  }
@@ -3185,8 +3660,10 @@ async function initializeNavigatorRegistry() {
3185
3660
  Promise.resolve().then(() => (init_elo(), elo_exports)),
3186
3661
  Promise.resolve().then(() => (init_srs(), srs_exports))
3187
3662
  ]);
3663
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
3188
3664
  registerNavigator("elo", eloModule.default);
3189
3665
  registerNavigator("srs", srsModule.default);
3666
+ registerNavigator("prescribed", prescribedModule.default);
3190
3667
  const [
3191
3668
  hierarchyModule,
3192
3669
  interferenceModule,
@@ -3221,10 +3698,12 @@ function getCardOrigin(card) {
3221
3698
  return "new";
3222
3699
  }
3223
3700
  function isGenerator(impl) {
3224
- return NavigatorRoles[impl] === "generator" /* GENERATOR */;
3701
+ if (NavigatorRoles[impl] === "generator" /* GENERATOR */) return true;
3702
+ return getRegisteredNavigatorRole(impl) === "generator" /* GENERATOR */;
3225
3703
  }
3226
3704
  function isFilter(impl) {
3227
- return NavigatorRoles[impl] === "filter" /* FILTER */;
3705
+ if (NavigatorRoles[impl] === "filter" /* FILTER */) return true;
3706
+ return getRegisteredNavigatorRole(impl) === "filter" /* FILTER */;
3228
3707
  }
3229
3708
  var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
3230
3709
  var init_navigators = __esm({
@@ -3239,6 +3718,7 @@ var init_navigators = __esm({
3239
3718
  Navigators = /* @__PURE__ */ ((Navigators2) => {
3240
3719
  Navigators2["ELO"] = "elo";
3241
3720
  Navigators2["SRS"] = "srs";
3721
+ Navigators2["PRESCRIBED"] = "prescribed";
3242
3722
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
3243
3723
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
3244
3724
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -3253,6 +3733,7 @@ var init_navigators = __esm({
3253
3733
  NavigatorRoles = {
3254
3734
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
3255
3735
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3736
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
3256
3737
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
3257
3738
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
3258
3739
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -3417,6 +3898,12 @@ var init_navigators = __esm({
3417
3898
  async getWeightedCards(_limit) {
3418
3899
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
3419
3900
  }
3901
+ /**
3902
+ * Set ephemeral hints for the next pipeline run.
3903
+ * No-op for non-Pipeline navigators. Pipeline overrides this.
3904
+ */
3905
+ setEphemeralHints(_hints) {
3906
+ }
3420
3907
  };
3421
3908
  }
3422
3909
  });
@@ -3521,15 +4008,42 @@ var init_courseDB = __esm({
3521
4008
  // private log(msg: string): void {
3522
4009
  // log(`CourseLog: ${this.id}\n ${msg}`);
3523
4010
  // }
4011
+ /**
4012
+ * Primary database handle used for all **read** operations (queries, gets).
4013
+ *
4014
+ * When local sync is active, this points to the local PouchDB replica for
4015
+ * fast, network-free reads. Otherwise it points to the remote CouchDB.
4016
+ */
3524
4017
  db;
4018
+ /**
4019
+ * Remote database handle used for all **write** operations.
4020
+ *
4021
+ * Always points to the remote CouchDB so that writes (ELO updates, tag
4022
+ * mutations, admin operations) aggregate on the server. The local replica
4023
+ * is a read-only snapshot that refreshes on the next page load.
4024
+ *
4025
+ * When local sync is NOT active, this is the same instance as `this.db`.
4026
+ */
4027
+ remoteDB;
3525
4028
  id;
3526
4029
  _getCurrentUser;
3527
4030
  updateQueue;
3528
- constructor(id, userLookup) {
4031
+ /**
4032
+ * @param id - Course ID
4033
+ * @param userLookup - Async function returning the current user DB
4034
+ * @param localDB - Optional local PouchDB replica for reads. When provided,
4035
+ * `this.db` uses the local replica and `this.remoteDB` stays remote.
4036
+ * The UpdateQueue reads from remote and writes to remote (local `_rev`
4037
+ * values may be stale, so read-modify-write cycles must go through
4038
+ * the remote DB to avoid conflicts).
4039
+ */
4040
+ constructor(id, userLookup, localDB) {
3529
4041
  this.id = id;
3530
- this.db = getCourseDB2(this.id);
4042
+ const remote = getCourseDB2(this.id);
4043
+ this.remoteDB = remote;
4044
+ this.db = localDB ?? remote;
3531
4045
  this._getCurrentUser = userLookup;
3532
- this.updateQueue = new UpdateQueue(this.db);
4046
+ this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
3533
4047
  }
3534
4048
  getCourseID() {
3535
4049
  return this.id;
@@ -3617,7 +4131,7 @@ var init_courseDB = __esm({
3617
4131
  };
3618
4132
  }
3619
4133
  async removeCard(id) {
3620
- const doc = await this.db.get(id);
4134
+ const doc = await this.remoteDB.get(id);
3621
4135
  if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
3622
4136
  throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
3623
4137
  }
@@ -3638,7 +4152,7 @@ var init_courseDB = __esm({
3638
4152
  } catch (error) {
3639
4153
  logger.error(`Error removing card ${id} from tags: ${error}`);
3640
4154
  }
3641
- return this.db.remove(doc);
4155
+ return this.remoteDB.remove(doc);
3642
4156
  }
3643
4157
  async getCardDisplayableDataIDs(id) {
3644
4158
  logger.debug(id.join(", "));
@@ -3740,8 +4254,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3740
4254
  if (cardIds.length === 0) {
3741
4255
  return /* @__PURE__ */ new Map();
3742
4256
  }
3743
- const db = getCourseDB2(this.id);
3744
- const result = await db.query("getTags", {
4257
+ const result = await this.db.query("getTags", {
3745
4258
  keys: cardIds,
3746
4259
  include_docs: false
3747
4260
  });
@@ -3758,6 +4271,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3758
4271
  }
3759
4272
  return tagsByCard;
3760
4273
  }
4274
+ async getAllCardIds() {
4275
+ const result = await this.db.allDocs({
4276
+ startkey: "CARD-",
4277
+ endkey: "CARD-\uFFF0",
4278
+ include_docs: false
4279
+ });
4280
+ return result.rows.map((row) => row.id);
4281
+ }
3761
4282
  async addTagToCard(cardId, tagId, updateELO) {
3762
4283
  return await addTagToCard(
3763
4284
  this.id,
@@ -3824,10 +4345,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3824
4345
  }
3825
4346
  }
3826
4347
  async getCourseDoc(id, options) {
3827
- return await getCourseDoc(this.id, id, options);
4348
+ return await this.db.get(id, options);
3828
4349
  }
3829
4350
  async getCourseDocs(ids, options = {}) {
3830
- return await getCourseDocs(this.id, ids, options);
4351
+ return await this.db.allDocs({
4352
+ ...options,
4353
+ keys: ids
4354
+ });
3831
4355
  }
3832
4356
  ////////////////////////////////////
3833
4357
  // NavigationStrategyManager implementation
@@ -3861,7 +4385,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3861
4385
  }
3862
4386
  async addNavigationStrategy(data) {
3863
4387
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
3864
- return this.db.put(data).then(() => {
4388
+ return this.remoteDB.put(data).then(() => {
3865
4389
  });
3866
4390
  }
3867
4391
  updateNavigationStrategy(id, data) {
@@ -4277,6 +4801,16 @@ var init_adminDB2 = __esm({
4277
4801
  }
4278
4802
  });
4279
4803
 
4804
+ // src/impl/couch/CourseSyncService.ts
4805
+ var init_CourseSyncService = __esm({
4806
+ "src/impl/couch/CourseSyncService.ts"() {
4807
+ "use strict";
4808
+ init_pouchdb_setup();
4809
+ init_couch();
4810
+ init_logger();
4811
+ }
4812
+ });
4813
+
4280
4814
  // src/impl/couch/auth.ts
4281
4815
  import fetch from "cross-fetch";
4282
4816
  var init_auth = __esm({
@@ -4331,15 +4865,6 @@ function getCourseDB2(courseID) {
4331
4865
  createPouchDBConfig()
4332
4866
  );
4333
4867
  }
4334
- function getCourseDocs(courseID, docIDs, options = {}) {
4335
- return getCourseDB2(courseID).allDocs({
4336
- ...options,
4337
- keys: docIDs
4338
- });
4339
- }
4340
- function getCourseDoc(courseID, docID, options = {}) {
4341
- return getCourseDB2(courseID).get(docID, options);
4342
- }
4343
4868
  function filterAllDocsByPrefix2(db, prefix, opts) {
4344
4869
  const options = {
4345
4870
  startkey: prefix,
@@ -4370,6 +4895,7 @@ var init_couch = __esm({
4370
4895
  init_classroomDB2();
4371
4896
  init_courseAPI();
4372
4897
  init_courseDB();
4898
+ init_CourseSyncService();
4373
4899
  init_CouchDBSyncStrategy();
4374
4900
  isBrowser = typeof window !== "undefined";
4375
4901
  if (isBrowser) {
@@ -4589,6 +5115,9 @@ Currently logged-in as ${this._username}.`
4589
5115
  const id = row.id;
4590
5116
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
4591
5117
  id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
5118
+ id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
5119
+ id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
5120
+ id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
4592
5121
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
4593
5122
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
4594
5123
  id === _BaseUser.DOC_IDS.CONFIG;
@@ -6153,6 +6682,7 @@ export {
6153
6682
  getDefaultLearnableWeight,
6154
6683
  getRegisteredNavigator,
6155
6684
  getRegisteredNavigatorNames,
6685
+ getRegisteredNavigatorRole,
6156
6686
  getStudySource,
6157
6687
  hasRegisteredNavigator,
6158
6688
  importParsedCards,