@vue-skuilder/db 0.1.31-b → 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 (49) hide show
  1. package/dist/{contentSource-ygoFw9oV.d.ts → contentSource-Bdwkvqa8.d.ts} +16 -0
  2. package/dist/{contentSource-B7nXusjk.d.cts → contentSource-DF1nUbPQ.d.cts} +16 -0
  3. package/dist/core/index.d.cts +34 -3
  4. package/dist/core/index.d.ts +34 -3
  5. package/dist/core/index.js +510 -50
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +510 -50
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BW7HvkMt.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
  10. package/dist/{dataLayerProvider-BfXUVDuG.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 +730 -41
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +729 -41
  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 +467 -31
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +467 -31
  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 +948 -72
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +948 -72
  28. package/dist/index.mjs.map +1 -1
  29. package/package.json +3 -3
  30. package/src/core/interfaces/contentSource.ts +6 -0
  31. package/src/core/interfaces/courseDB.ts +6 -0
  32. package/src/core/interfaces/dataLayerProvider.ts +20 -0
  33. package/src/core/navigators/Pipeline.ts +414 -9
  34. package/src/core/navigators/PipelineAssembler.ts +23 -18
  35. package/src/core/navigators/PipelineDebugger.ts +35 -1
  36. package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
  37. package/src/core/navigators/generators/prescribed.ts +95 -0
  38. package/src/core/navigators/index.ts +12 -0
  39. package/src/impl/common/BaseUserDB.ts +4 -1
  40. package/src/impl/couch/CourseSyncService.ts +356 -0
  41. package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
  42. package/src/impl/couch/courseDB.ts +60 -13
  43. package/src/impl/couch/index.ts +1 -0
  44. package/src/impl/static/courseDB.ts +5 -0
  45. package/src/study/ItemQueue.ts +42 -0
  46. package/src/study/SessionController.ts +195 -22
  47. package/src/study/SpacedRepetition.ts +3 -1
  48. package/tests/core/navigators/Pipeline.test.ts +1 -1
  49. package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
@@ -725,8 +725,12 @@ __export(PipelineDebugger_exports, {
725
725
  buildRunReport: () => buildRunReport,
726
726
  captureRun: () => captureRun,
727
727
  mountPipelineDebugger: () => mountPipelineDebugger,
728
- pipelineDebugAPI: () => pipelineDebugAPI
728
+ pipelineDebugAPI: () => pipelineDebugAPI,
729
+ registerPipelineForDebug: () => registerPipelineForDebug
729
730
  });
731
+ function registerPipelineForDebug(pipeline) {
732
+ _activePipeline = pipeline;
733
+ }
730
734
  function getOrigin(card) {
731
735
  const firstEntry = card.provenance[0];
732
736
  if (!firstEntry) return "unknown";
@@ -754,6 +758,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
754
758
  origin: getOrigin(card),
755
759
  finalScore: card.score,
756
760
  provenance: card.provenance,
761
+ tags: card.tags,
757
762
  selected: selectedIds.has(card.cardId)
758
763
  }));
759
764
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
@@ -809,12 +814,13 @@ function mountPipelineDebugger() {
809
814
  win.skuilder = win.skuilder || {};
810
815
  win.skuilder.pipeline = pipelineDebugAPI;
811
816
  }
812
- var MAX_RUNS, runHistory, pipelineDebugAPI;
817
+ var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
813
818
  var init_PipelineDebugger = __esm({
814
819
  "src/core/navigators/PipelineDebugger.ts"() {
815
820
  "use strict";
816
821
  init_navigators();
817
822
  init_logger();
823
+ _activePipeline = null;
818
824
  MAX_RUNS = 10;
819
825
  runHistory = [];
820
826
  pipelineDebugAPI = {
@@ -1016,6 +1022,21 @@ var init_PipelineDebugger = __esm({
1016
1022
  }
1017
1023
  console.groupEnd();
1018
1024
  },
1025
+ /**
1026
+ * Scan the full card space through the filter chain for the current user.
1027
+ *
1028
+ * Reports how many cards are well-indicated and how many are new.
1029
+ * Use this to understand how the search space grows during onboarding.
1030
+ *
1031
+ * @param threshold - Score threshold for "well indicated" (default 0.10)
1032
+ */
1033
+ async diagnoseCardSpace(threshold) {
1034
+ if (!_activePipeline) {
1035
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
1036
+ return null;
1037
+ }
1038
+ return _activePipeline.diagnoseCardSpace({ threshold });
1039
+ },
1019
1040
  /**
1020
1041
  * Show help.
1021
1042
  */
@@ -1028,6 +1049,7 @@ Commands:
1028
1049
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
1029
1050
  .showCard(cardId) Show provenance trail for a specific card
1030
1051
  .explainReviews() Analyze why reviews were/weren't selected
1052
+ .diagnoseCardSpace() Scan full card space through filters (async)
1031
1053
  .showRegistry() Show navigator registry (classes + roles)
1032
1054
  .showStrategies() Show registry + strategy mapping from last run
1033
1055
  .listRuns() List all captured runs in table format
@@ -1039,7 +1061,7 @@ Commands:
1039
1061
  Example:
1040
1062
  window.skuilder.pipeline.showLastRun()
1041
1063
  window.skuilder.pipeline.showRun(1)
1042
- window.skuilder.pipeline.showCard('abc123')
1064
+ await window.skuilder.pipeline.diagnoseCardSpace()
1043
1065
  `);
1044
1066
  }
1045
1067
  };
@@ -1334,6 +1356,69 @@ var init_generators = __esm({
1334
1356
  }
1335
1357
  });
1336
1358
 
1359
+ // src/core/navigators/generators/prescribed.ts
1360
+ var prescribed_exports = {};
1361
+ __export(prescribed_exports, {
1362
+ default: () => PrescribedCardsGenerator
1363
+ });
1364
+ var PrescribedCardsGenerator;
1365
+ var init_prescribed = __esm({
1366
+ "src/core/navigators/generators/prescribed.ts"() {
1367
+ "use strict";
1368
+ init_navigators();
1369
+ init_logger();
1370
+ PrescribedCardsGenerator = class extends ContentNavigator {
1371
+ name;
1372
+ config;
1373
+ constructor(user, course, strategyData) {
1374
+ super(user, course, strategyData);
1375
+ this.name = strategyData.name || "Prescribed Cards";
1376
+ try {
1377
+ const parsed = JSON.parse(strategyData.serializedData);
1378
+ this.config = { cardIds: parsed.cardIds || [] };
1379
+ } catch {
1380
+ this.config = { cardIds: [] };
1381
+ }
1382
+ logger.debug(
1383
+ `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1384
+ );
1385
+ }
1386
+ async getWeightedCards(limit, _context) {
1387
+ if (this.config.cardIds.length === 0) {
1388
+ return [];
1389
+ }
1390
+ const courseId = this.course.getCourseID();
1391
+ const activeCards = await this.user.getActiveCards();
1392
+ const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1393
+ const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1394
+ if (eligibleIds.length === 0) {
1395
+ logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1396
+ return [];
1397
+ }
1398
+ const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1399
+ cardId,
1400
+ courseId,
1401
+ score: 1,
1402
+ provenance: [
1403
+ {
1404
+ strategy: "prescribed",
1405
+ strategyName: this.strategyName || this.name,
1406
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1407
+ action: "generated",
1408
+ score: 1,
1409
+ reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1410
+ }
1411
+ ]
1412
+ }));
1413
+ logger.info(
1414
+ `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1415
+ );
1416
+ return cards;
1417
+ }
1418
+ };
1419
+ }
1420
+ });
1421
+
1337
1422
  // src/core/navigators/generators/srs.ts
1338
1423
  var srs_exports = {};
1339
1424
  __export(srs_exports, {
@@ -1528,6 +1613,7 @@ var init_ = __esm({
1528
1613
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1529
1614
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1530
1615
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1616
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1531
1617
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1532
1618
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1533
1619
  });
@@ -1728,6 +1814,8 @@ var init_hierarchyDefinition = __esm({
1728
1814
  if (userTagElo.count < minCount) return false;
1729
1815
  if (prereq.masteryThreshold?.minElo !== void 0) {
1730
1816
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1817
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1818
+ return true;
1731
1819
  } else {
1732
1820
  return userTagElo.score >= userGlobalElo;
1733
1821
  }
@@ -1803,14 +1891,38 @@ var init_hierarchyDefinition = __esm({
1803
1891
  };
1804
1892
  }
1805
1893
  }
1894
+ /**
1895
+ * Build a map of prereq tag → max configured boost for all *closed* gates.
1896
+ *
1897
+ * When a gate is closed (prereqs unmet), cards carrying that gate's prereq
1898
+ * tags get boosted — steering the pipeline toward content that helps unlock
1899
+ * the gated material. Once the gate opens, the boost disappears.
1900
+ */
1901
+ getPreReqBoosts(unlockedTags, masteredTags) {
1902
+ const boosts = /* @__PURE__ */ new Map();
1903
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
1904
+ if (unlockedTags.has(tagId)) continue;
1905
+ for (const prereq of prereqs) {
1906
+ if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
1907
+ if (masteredTags.has(prereq.tag)) continue;
1908
+ const existing = boosts.get(prereq.tag) ?? 1;
1909
+ boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
1910
+ }
1911
+ }
1912
+ return boosts;
1913
+ }
1806
1914
  /**
1807
1915
  * CardFilter.transform implementation.
1808
1916
  *
1809
- * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
1917
+ * Two effects:
1918
+ * 1. Cards with locked tags receive score * 0.05 (gating penalty)
1919
+ * 2. Cards carrying prereq tags of closed gates receive a configured
1920
+ * boost (preReqBoost), steering toward content that unlocks gates
1810
1921
  */
1811
1922
  async transform(cards, context) {
1812
1923
  const masteredTags = await this.getMasteredTags(context);
1813
1924
  const unlockedTags = this.getUnlockedTags(masteredTags);
1925
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1814
1926
  const gated = [];
1815
1927
  for (const card of cards) {
1816
1928
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1819,9 +1931,27 @@ var init_hierarchyDefinition = __esm({
1819
1931
  unlockedTags,
1820
1932
  masteredTags
1821
1933
  );
1822
- const LOCKED_PENALTY = 0.01;
1823
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1824
- const action = isUnlocked ? "passed" : "penalized";
1934
+ const LOCKED_PENALTY = 0.02;
1935
+ let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1936
+ let action = isUnlocked ? "passed" : "penalized";
1937
+ let finalReason = reason;
1938
+ if (isUnlocked && preReqBoosts.size > 0) {
1939
+ const cardTags = card.tags ?? [];
1940
+ let maxBoost = 1;
1941
+ const boostedPrereqs = [];
1942
+ for (const tag of cardTags) {
1943
+ const boost = preReqBoosts.get(tag);
1944
+ if (boost && boost > maxBoost) {
1945
+ maxBoost = boost;
1946
+ boostedPrereqs.push(tag);
1947
+ }
1948
+ }
1949
+ if (maxBoost > 1) {
1950
+ finalScore *= maxBoost;
1951
+ action = "boosted";
1952
+ finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
1953
+ }
1954
+ }
1825
1955
  gated.push({
1826
1956
  ...card,
1827
1957
  score: finalScore,
@@ -1833,7 +1963,7 @@ var init_hierarchyDefinition = __esm({
1833
1963
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1834
1964
  action,
1835
1965
  score: finalScore,
1836
- reason
1966
+ reason: finalReason
1837
1967
  }
1838
1968
  ]
1839
1969
  });
@@ -2767,6 +2897,18 @@ var Pipeline_exports = {};
2767
2897
  __export(Pipeline_exports, {
2768
2898
  Pipeline: () => Pipeline
2769
2899
  });
2900
+ function globToRegex(pattern) {
2901
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
2902
+ const withWildcards = escaped.replace(/\*/g, ".*");
2903
+ return new RegExp(`^${withWildcards}$`);
2904
+ }
2905
+ function globMatch(value, pattern) {
2906
+ if (!pattern.includes("*")) return value === pattern;
2907
+ return globToRegex(pattern).test(value);
2908
+ }
2909
+ function cardMatchesTagPattern(card, pattern) {
2910
+ return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
2911
+ }
2770
2912
  function logPipelineConfig(generator, filters) {
2771
2913
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2772
2914
  logger.info(
@@ -2801,6 +2943,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2801
2943
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2802
2944
  );
2803
2945
  }
2946
+ function logResultCards(cards) {
2947
+ if (!VERBOSE_RESULTS || cards.length === 0) return;
2948
+ logger.info(`[Pipeline] Results (${cards.length} cards):`);
2949
+ for (let i = 0; i < cards.length; i++) {
2950
+ const c = cards[i];
2951
+ const tags = c.tags?.slice(0, 3).join(", ") || "";
2952
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
2953
+ const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
2954
+ return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
2955
+ }).join(" | ");
2956
+ logger.info(
2957
+ `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
2958
+ );
2959
+ }
2960
+ }
2804
2961
  function logCardProvenance(cards, maxCards = 3) {
2805
2962
  const cardsToLog = cards.slice(0, maxCards);
2806
2963
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2815,7 +2972,7 @@ function logCardProvenance(cards, maxCards = 3) {
2815
2972
  }
2816
2973
  }
2817
2974
  }
2818
- var import_common8, Pipeline;
2975
+ var import_common8, VERBOSE_RESULTS, Pipeline;
2819
2976
  var init_Pipeline = __esm({
2820
2977
  "src/core/navigators/Pipeline.ts"() {
2821
2978
  "use strict";
@@ -2824,9 +2981,31 @@ var init_Pipeline = __esm({
2824
2981
  init_logger();
2825
2982
  init_orchestration();
2826
2983
  init_PipelineDebugger();
2984
+ VERBOSE_RESULTS = true;
2827
2985
  Pipeline = class extends ContentNavigator {
2828
2986
  generator;
2829
2987
  filters;
2988
+ /**
2989
+ * Cached orchestration context. Course config and salt don't change within
2990
+ * a page load, so we build the orchestration context once and reuse it on
2991
+ * subsequent getWeightedCards() calls (e.g. mid-session replans).
2992
+ *
2993
+ * This eliminates a remote getCourseConfig() round trip per pipeline run.
2994
+ */
2995
+ _cachedOrchestration = null;
2996
+ /**
2997
+ * Persistent tag cache. Maps cardId → tag names.
2998
+ *
2999
+ * Tags are static within a session (they're set at card generation time),
3000
+ * so we cache them across pipeline runs. On replans, many of the same cards
3001
+ * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
3002
+ */
3003
+ _tagCache = /* @__PURE__ */ new Map();
3004
+ /**
3005
+ * One-shot replan hints. Applied after the filter chain on the next
3006
+ * getWeightedCards() call, then cleared.
3007
+ */
3008
+ _ephemeralHints = null;
2830
3009
  /**
2831
3010
  * Create a new pipeline.
2832
3011
  *
@@ -2847,6 +3026,17 @@ var init_Pipeline = __esm({
2847
3026
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2848
3027
  });
2849
3028
  logPipelineConfig(generator, filters);
3029
+ registerPipelineForDebug(this);
3030
+ }
3031
+ /**
3032
+ * Set one-shot hints for the next pipeline run.
3033
+ * Consumed after one getWeightedCards() call, then cleared.
3034
+ *
3035
+ * Overrides ContentNavigator.setEphemeralHints() no-op.
3036
+ */
3037
+ setEphemeralHints(hints) {
3038
+ this._ephemeralHints = hints;
3039
+ logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
2850
3040
  }
2851
3041
  /**
2852
3042
  * Get weighted cards by running generator and applying filters.
@@ -2863,13 +3053,15 @@ var init_Pipeline = __esm({
2863
3053
  * @returns Cards sorted by score descending
2864
3054
  */
2865
3055
  async getWeightedCards(limit) {
3056
+ const t0 = performance.now();
2866
3057
  const context = await this.buildContext();
2867
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2868
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
3058
+ const tContext = performance.now();
3059
+ const fetchLimit = 500;
2869
3060
  logger.debug(
2870
3061
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2871
3062
  );
2872
3063
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
3064
+ const tGenerate = performance.now();
2873
3065
  const generatedCount = cards.length;
2874
3066
  let generatorSummaries;
2875
3067
  if (this.generator.generators) {
@@ -2898,6 +3090,7 @@ var init_Pipeline = __esm({
2898
3090
  }
2899
3091
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2900
3092
  cards = await this.hydrateTags(cards);
3093
+ const tHydrate = performance.now();
2901
3094
  const allCardsBeforeFiltering = [...cards];
2902
3095
  const filterImpacts = [];
2903
3096
  for (const filter of this.filters) {
@@ -2916,8 +3109,17 @@ var init_Pipeline = __esm({
2916
3109
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2917
3110
  }
2918
3111
  cards = cards.filter((c) => c.score > 0);
3112
+ const hints = this._ephemeralHints;
3113
+ if (hints) {
3114
+ this._ephemeralHints = null;
3115
+ cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
3116
+ }
2919
3117
  cards.sort((a, b) => b.score - a.score);
3118
+ const tFilter = performance.now();
2920
3119
  const result = cards.slice(0, limit);
3120
+ logger.info(
3121
+ `[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)})`
3122
+ );
2921
3123
  const topScores = result.slice(0, 3).map((c) => c.score);
2922
3124
  logExecutionSummary(
2923
3125
  this.generator.name,
@@ -2927,6 +3129,7 @@ var init_Pipeline = __esm({
2927
3129
  topScores,
2928
3130
  filterImpacts
2929
3131
  );
3132
+ logResultCards(result);
2930
3133
  logCardProvenance(result, 3);
2931
3134
  try {
2932
3135
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -2953,6 +3156,10 @@ var init_Pipeline = __esm({
2953
3156
  * to the WeightedCard objects. Filters can then use card.tags instead of
2954
3157
  * making individual getAppliedTags() calls.
2955
3158
  *
3159
+ * Uses a persistent tag cache across pipeline runs — tags are static within
3160
+ * a session, so cards seen in a prior run (e.g. before a replan) don't
3161
+ * require a second DB query.
3162
+ *
2956
3163
  * @param cards - Cards to hydrate
2957
3164
  * @returns Cards with tags populated
2958
3165
  */
@@ -2960,14 +3167,128 @@ var init_Pipeline = __esm({
2960
3167
  if (cards.length === 0) {
2961
3168
  return cards;
2962
3169
  }
2963
- const cardIds = cards.map((c) => c.cardId);
2964
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
3170
+ const uncachedIds = [];
3171
+ for (const card of cards) {
3172
+ if (!this._tagCache.has(card.cardId)) {
3173
+ uncachedIds.push(card.cardId);
3174
+ }
3175
+ }
3176
+ if (uncachedIds.length > 0) {
3177
+ const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
3178
+ for (const [cardId, tags] of freshTags) {
3179
+ this._tagCache.set(cardId, tags);
3180
+ }
3181
+ }
3182
+ const tagsByCard = /* @__PURE__ */ new Map();
3183
+ for (const card of cards) {
3184
+ tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
3185
+ }
2965
3186
  logTagHydration(cards, tagsByCard);
2966
3187
  return cards.map((card) => ({
2967
3188
  ...card,
2968
- tags: tagsByCard.get(card.cardId) ?? []
3189
+ tags: this._tagCache.get(card.cardId) ?? []
2969
3190
  }));
2970
3191
  }
3192
+ // ---------------------------------------------------------------------------
3193
+ // Ephemeral hints application
3194
+ // ---------------------------------------------------------------------------
3195
+ /**
3196
+ * Apply one-shot replan hints to the post-filter card set.
3197
+ *
3198
+ * Order of operations:
3199
+ * 1. Exclude (remove unwanted cards)
3200
+ * 2. Boost (multiply scores)
3201
+ * 3. Require (inject must-have cards from the full pre-filter pool)
3202
+ *
3203
+ * @param cards - Post-filter cards (score > 0)
3204
+ * @param hints - The ephemeral hints to apply
3205
+ * @param allCards - Full pre-filter card pool (for require injection)
3206
+ */
3207
+ applyHints(cards, hints, allCards) {
3208
+ const beforeCount = cards.length;
3209
+ if (hints.excludeCards?.length) {
3210
+ cards = cards.filter(
3211
+ (c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
3212
+ );
3213
+ }
3214
+ if (hints.excludeTags?.length) {
3215
+ cards = cards.filter(
3216
+ (c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
3217
+ );
3218
+ }
3219
+ if (hints.boostTags) {
3220
+ for (const [pattern, factor] of Object.entries(hints.boostTags)) {
3221
+ for (const card of cards) {
3222
+ if (cardMatchesTagPattern(card, pattern)) {
3223
+ card.score *= factor;
3224
+ card.provenance.push({
3225
+ strategy: "ephemeralHint",
3226
+ strategyId: "ephemeral-hint",
3227
+ strategyName: "Replan Hint",
3228
+ action: "boosted",
3229
+ score: card.score,
3230
+ reason: `boostTag ${pattern} \xD7${factor}`
3231
+ });
3232
+ }
3233
+ }
3234
+ }
3235
+ }
3236
+ if (hints.boostCards) {
3237
+ for (const [pattern, factor] of Object.entries(hints.boostCards)) {
3238
+ for (const card of cards) {
3239
+ if (globMatch(card.cardId, pattern)) {
3240
+ card.score *= factor;
3241
+ card.provenance.push({
3242
+ strategy: "ephemeralHint",
3243
+ strategyId: "ephemeral-hint",
3244
+ strategyName: "Replan Hint",
3245
+ action: "boosted",
3246
+ score: card.score,
3247
+ reason: `boostCard ${pattern} \xD7${factor}`
3248
+ });
3249
+ }
3250
+ }
3251
+ }
3252
+ }
3253
+ const cardIds = new Set(cards.map((c) => c.cardId));
3254
+ const inject = (card, reason) => {
3255
+ if (!cardIds.has(card.cardId)) {
3256
+ const floorScore = Math.max(card.score, 1);
3257
+ cards.push({
3258
+ ...card,
3259
+ score: floorScore,
3260
+ provenance: [
3261
+ ...card.provenance,
3262
+ {
3263
+ strategy: "ephemeralHint",
3264
+ strategyId: "ephemeral-hint",
3265
+ strategyName: "Replan Hint",
3266
+ action: "boosted",
3267
+ score: floorScore,
3268
+ reason
3269
+ }
3270
+ ]
3271
+ });
3272
+ cardIds.add(card.cardId);
3273
+ }
3274
+ };
3275
+ if (hints.requireCards?.length) {
3276
+ for (const pattern of hints.requireCards) {
3277
+ for (const card of allCards) {
3278
+ if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
3279
+ }
3280
+ }
3281
+ }
3282
+ if (hints.requireTags?.length) {
3283
+ for (const pattern of hints.requireTags) {
3284
+ for (const card of allCards) {
3285
+ if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
3286
+ }
3287
+ }
3288
+ }
3289
+ logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
3290
+ return cards;
3291
+ }
2971
3292
  /**
2972
3293
  * Build shared context for generator and filters.
2973
3294
  *
@@ -2985,7 +3306,10 @@ var init_Pipeline = __esm({
2985
3306
  } catch (e) {
2986
3307
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
2987
3308
  }
2988
- const orchestration = await createOrchestrationContext(this.user, this.course);
3309
+ if (!this._cachedOrchestration) {
3310
+ this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
3311
+ }
3312
+ const orchestration = this._cachedOrchestration;
2989
3313
  return {
2990
3314
  user: this.user,
2991
3315
  course: this.course,
@@ -3029,6 +3353,87 @@ var init_Pipeline = __esm({
3029
3353
  }
3030
3354
  return [...new Set(ids)];
3031
3355
  }
3356
+ // ---------------------------------------------------------------------------
3357
+ // Card-space diagnostic
3358
+ // ---------------------------------------------------------------------------
3359
+ /**
3360
+ * Scan every card in the course through the filter chain and report
3361
+ * how many are "well indicated" (score >= threshold) for the current user.
3362
+ *
3363
+ * Also reports how many well-indicated cards the user has NOT yet encountered.
3364
+ *
3365
+ * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
3366
+ */
3367
+ async diagnoseCardSpace(opts) {
3368
+ const THRESHOLD = opts?.threshold ?? 0.1;
3369
+ const t0 = performance.now();
3370
+ const allCardIds = await this.course.getAllCardIds();
3371
+ let cards = allCardIds.map((cardId) => ({
3372
+ cardId,
3373
+ courseId: this.course.getCourseID(),
3374
+ score: 1,
3375
+ provenance: []
3376
+ }));
3377
+ cards = await this.hydrateTags(cards);
3378
+ const context = await this.buildContext();
3379
+ const filterBreakdown = [];
3380
+ for (const filter of this.filters) {
3381
+ cards = await filter.transform(cards, context);
3382
+ const wi = cards.filter((c) => c.score >= THRESHOLD).length;
3383
+ filterBreakdown.push({ name: filter.name, wellIndicated: wi });
3384
+ }
3385
+ const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
3386
+ const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
3387
+ let encounteredIds;
3388
+ try {
3389
+ const courseId = this.course.getCourseID();
3390
+ const seenCards = await this.user.getSeenCards(courseId);
3391
+ encounteredIds = new Set(seenCards);
3392
+ } catch {
3393
+ encounteredIds = /* @__PURE__ */ new Set();
3394
+ }
3395
+ const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
3396
+ const byType = /* @__PURE__ */ new Map();
3397
+ for (const card of cards) {
3398
+ const type = card.cardId.split("-")[1] || "unknown";
3399
+ if (!byType.has(type)) {
3400
+ byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
3401
+ }
3402
+ const entry = byType.get(type);
3403
+ entry.total++;
3404
+ if (card.score >= THRESHOLD) {
3405
+ entry.wellIndicated++;
3406
+ if (!encounteredIds.has(card.cardId)) entry.new++;
3407
+ }
3408
+ }
3409
+ const elapsed = performance.now() - t0;
3410
+ const result = {
3411
+ totalCards: allCardIds.length,
3412
+ threshold: THRESHOLD,
3413
+ wellIndicated: wellIndicatedIds.size,
3414
+ encountered: encounteredIds.size,
3415
+ wellIndicatedNew: wellIndicatedNew.length,
3416
+ byType: Object.fromEntries(byType),
3417
+ filterBreakdown,
3418
+ elapsedMs: Math.round(elapsed)
3419
+ };
3420
+ logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
3421
+ logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
3422
+ logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
3423
+ logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
3424
+ logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
3425
+ logger.info(`[Pipeline:diagnose] By type:`);
3426
+ for (const [type, counts] of byType) {
3427
+ logger.info(
3428
+ `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
3429
+ );
3430
+ }
3431
+ logger.info(`[Pipeline:diagnose] After each filter:`);
3432
+ for (const fb of filterBreakdown) {
3433
+ logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
3434
+ }
3435
+ return result;
3436
+ }
3032
3437
  };
3033
3438
  }
3034
3439
  });
@@ -3133,23 +3538,25 @@ var init_PipelineAssembler = __esm({
3133
3538
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
3134
3539
  }
3135
3540
  }
3541
+ const courseId = course.getCourseID();
3542
+ const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
3543
+ const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
3544
+ if (!hasElo) {
3545
+ logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
3546
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3547
+ }
3548
+ if (!hasSrs) {
3549
+ logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
3550
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
3551
+ }
3136
3552
  if (generatorStrategies.length === 0) {
3137
- if (filterStrategies.length > 0) {
3138
- logger.debug(
3139
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
3140
- );
3141
- const courseId = course.getCourseID();
3142
- generatorStrategies.push(createDefaultEloStrategy(courseId));
3143
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
3144
- } else {
3145
- warnings.push("No generator strategy found");
3146
- return {
3147
- pipeline: null,
3148
- generatorStrategies: [],
3149
- filterStrategies: [],
3150
- warnings
3151
- };
3152
- }
3553
+ warnings.push("No generator strategy found");
3554
+ return {
3555
+ pipeline: null,
3556
+ generatorStrategies: [],
3557
+ filterStrategies: [],
3558
+ warnings
3559
+ };
3153
3560
  }
3154
3561
  let generator;
3155
3562
  if (generatorStrategies.length === 1) {
@@ -3227,6 +3634,7 @@ var init_3 = __esm({
3227
3634
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
3228
3635
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
3229
3636
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3637
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
3230
3638
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
3231
3639
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
3232
3640
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -3275,8 +3683,10 @@ async function initializeNavigatorRegistry() {
3275
3683
  Promise.resolve().then(() => (init_elo(), elo_exports)),
3276
3684
  Promise.resolve().then(() => (init_srs(), srs_exports))
3277
3685
  ]);
3686
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
3278
3687
  registerNavigator("elo", eloModule.default);
3279
3688
  registerNavigator("srs", srsModule.default);
3689
+ registerNavigator("prescribed", prescribedModule.default);
3280
3690
  const [
3281
3691
  hierarchyModule,
3282
3692
  interferenceModule,
@@ -3331,6 +3741,7 @@ var init_navigators = __esm({
3331
3741
  Navigators = /* @__PURE__ */ ((Navigators2) => {
3332
3742
  Navigators2["ELO"] = "elo";
3333
3743
  Navigators2["SRS"] = "srs";
3744
+ Navigators2["PRESCRIBED"] = "prescribed";
3334
3745
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
3335
3746
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
3336
3747
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -3345,6 +3756,7 @@ var init_navigators = __esm({
3345
3756
  NavigatorRoles = {
3346
3757
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
3347
3758
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3759
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
3348
3760
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
3349
3761
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
3350
3762
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -3509,6 +3921,12 @@ var init_navigators = __esm({
3509
3921
  async getWeightedCards(_limit) {
3510
3922
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
3511
3923
  }
3924
+ /**
3925
+ * Set ephemeral hints for the next pipeline run.
3926
+ * No-op for non-Pipeline navigators. Pipeline overrides this.
3927
+ */
3928
+ setEphemeralHints(_hints) {
3929
+ }
3512
3930
  };
3513
3931
  }
3514
3932
  });
@@ -3608,15 +4026,42 @@ var init_courseDB = __esm({
3608
4026
  // private log(msg: string): void {
3609
4027
  // log(`CourseLog: ${this.id}\n ${msg}`);
3610
4028
  // }
4029
+ /**
4030
+ * Primary database handle used for all **read** operations (queries, gets).
4031
+ *
4032
+ * When local sync is active, this points to the local PouchDB replica for
4033
+ * fast, network-free reads. Otherwise it points to the remote CouchDB.
4034
+ */
3611
4035
  db;
4036
+ /**
4037
+ * Remote database handle used for all **write** operations.
4038
+ *
4039
+ * Always points to the remote CouchDB so that writes (ELO updates, tag
4040
+ * mutations, admin operations) aggregate on the server. The local replica
4041
+ * is a read-only snapshot that refreshes on the next page load.
4042
+ *
4043
+ * When local sync is NOT active, this is the same instance as `this.db`.
4044
+ */
4045
+ remoteDB;
3612
4046
  id;
3613
4047
  _getCurrentUser;
3614
4048
  updateQueue;
3615
- constructor(id, userLookup) {
4049
+ /**
4050
+ * @param id - Course ID
4051
+ * @param userLookup - Async function returning the current user DB
4052
+ * @param localDB - Optional local PouchDB replica for reads. When provided,
4053
+ * `this.db` uses the local replica and `this.remoteDB` stays remote.
4054
+ * The UpdateQueue reads from remote and writes to remote (local `_rev`
4055
+ * values may be stale, so read-modify-write cycles must go through
4056
+ * the remote DB to avoid conflicts).
4057
+ */
4058
+ constructor(id, userLookup, localDB) {
3616
4059
  this.id = id;
3617
- this.db = getCourseDB2(this.id);
4060
+ const remote = getCourseDB2(this.id);
4061
+ this.remoteDB = remote;
4062
+ this.db = localDB ?? remote;
3618
4063
  this._getCurrentUser = userLookup;
3619
- this.updateQueue = new UpdateQueue(this.db);
4064
+ this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
3620
4065
  }
3621
4066
  getCourseID() {
3622
4067
  return this.id;
@@ -3704,7 +4149,7 @@ var init_courseDB = __esm({
3704
4149
  };
3705
4150
  }
3706
4151
  async removeCard(id) {
3707
- const doc = await this.db.get(id);
4152
+ const doc = await this.remoteDB.get(id);
3708
4153
  if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
3709
4154
  throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
3710
4155
  }
@@ -3725,7 +4170,7 @@ var init_courseDB = __esm({
3725
4170
  } catch (error) {
3726
4171
  logger.error(`Error removing card ${id} from tags: ${error}`);
3727
4172
  }
3728
- return this.db.remove(doc);
4173
+ return this.remoteDB.remove(doc);
3729
4174
  }
3730
4175
  async getCardDisplayableDataIDs(id) {
3731
4176
  logger.debug(id.join(", "));
@@ -3827,8 +4272,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3827
4272
  if (cardIds.length === 0) {
3828
4273
  return /* @__PURE__ */ new Map();
3829
4274
  }
3830
- const db = getCourseDB2(this.id);
3831
- const result = await db.query("getTags", {
4275
+ const result = await this.db.query("getTags", {
3832
4276
  keys: cardIds,
3833
4277
  include_docs: false
3834
4278
  });
@@ -3845,6 +4289,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3845
4289
  }
3846
4290
  return tagsByCard;
3847
4291
  }
4292
+ async getAllCardIds() {
4293
+ const result = await this.db.allDocs({
4294
+ startkey: "CARD-",
4295
+ endkey: "CARD-\uFFF0",
4296
+ include_docs: false
4297
+ });
4298
+ return result.rows.map((row) => row.id);
4299
+ }
3848
4300
  async addTagToCard(cardId, tagId, updateELO) {
3849
4301
  return await addTagToCard(
3850
4302
  this.id,
@@ -3911,10 +4363,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3911
4363
  }
3912
4364
  }
3913
4365
  async getCourseDoc(id, options) {
3914
- return await getCourseDoc(this.id, id, options);
4366
+ return await this.db.get(id, options);
3915
4367
  }
3916
4368
  async getCourseDocs(ids, options = {}) {
3917
- return await getCourseDocs(this.id, ids, options);
4369
+ return await this.db.allDocs({
4370
+ ...options,
4371
+ keys: ids
4372
+ });
3918
4373
  }
3919
4374
  ////////////////////////////////////
3920
4375
  // NavigationStrategyManager implementation
@@ -3948,7 +4403,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3948
4403
  }
3949
4404
  async addNavigationStrategy(data) {
3950
4405
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
3951
- return this.db.put(data).then(() => {
4406
+ return this.remoteDB.put(data).then(() => {
3952
4407
  });
3953
4408
  }
3954
4409
  updateNavigationStrategy(id, data) {
@@ -4364,6 +4819,16 @@ var init_adminDB2 = __esm({
4364
4819
  }
4365
4820
  });
4366
4821
 
4822
+ // src/impl/couch/CourseSyncService.ts
4823
+ var init_CourseSyncService = __esm({
4824
+ "src/impl/couch/CourseSyncService.ts"() {
4825
+ "use strict";
4826
+ init_pouchdb_setup();
4827
+ init_couch();
4828
+ init_logger();
4829
+ }
4830
+ });
4831
+
4367
4832
  // src/impl/couch/auth.ts
4368
4833
  var import_cross_fetch;
4369
4834
  var init_auth = __esm({
@@ -4417,15 +4882,6 @@ function getCourseDB2(courseID) {
4417
4882
  createPouchDBConfig()
4418
4883
  );
4419
4884
  }
4420
- function getCourseDocs(courseID, docIDs, options = {}) {
4421
- return getCourseDB2(courseID).allDocs({
4422
- ...options,
4423
- keys: docIDs
4424
- });
4425
- }
4426
- function getCourseDoc(courseID, docID, options = {}) {
4427
- return getCourseDB2(courseID).get(docID, options);
4428
- }
4429
4885
  function filterAllDocsByPrefix2(db, prefix, opts) {
4430
4886
  const options = {
4431
4887
  startkey: prefix,
@@ -4459,6 +4915,7 @@ var init_couch = __esm({
4459
4915
  init_classroomDB2();
4460
4916
  init_courseAPI();
4461
4917
  init_courseDB();
4918
+ init_CourseSyncService();
4462
4919
  init_CouchDBSyncStrategy();
4463
4920
  isBrowser = typeof window !== "undefined";
4464
4921
  if (isBrowser) {
@@ -4678,6 +5135,9 @@ Currently logged-in as ${this._username}.`
4678
5135
  const id = row.id;
4679
5136
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
4680
5137
  id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
5138
+ id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
5139
+ id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
5140
+ id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
4681
5141
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
4682
5142
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
4683
5143
  id === _BaseUser.DOC_IDS.CONFIG;