@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
@@ -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,12 +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";
793
798
  init_navigators();
794
799
  init_logger();
800
+ _activePipeline = null;
795
801
  MAX_RUNS = 10;
796
802
  runHistory = [];
797
803
  pipelineDebugAPI = {
@@ -993,6 +999,21 @@ var init_PipelineDebugger = __esm({
993
999
  }
994
1000
  console.groupEnd();
995
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
+ },
996
1017
  /**
997
1018
  * Show help.
998
1019
  */
@@ -1005,6 +1026,7 @@ Commands:
1005
1026
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
1006
1027
  .showCard(cardId) Show provenance trail for a specific card
1007
1028
  .explainReviews() Analyze why reviews were/weren't selected
1029
+ .diagnoseCardSpace() Scan full card space through filters (async)
1008
1030
  .showRegistry() Show navigator registry (classes + roles)
1009
1031
  .showStrategies() Show registry + strategy mapping from last run
1010
1032
  .listRuns() List all captured runs in table format
@@ -1016,7 +1038,7 @@ Commands:
1016
1038
  Example:
1017
1039
  window.skuilder.pipeline.showLastRun()
1018
1040
  window.skuilder.pipeline.showRun(1)
1019
- window.skuilder.pipeline.showCard('abc123')
1041
+ await window.skuilder.pipeline.diagnoseCardSpace()
1020
1042
  `);
1021
1043
  }
1022
1044
  };
@@ -1311,6 +1333,69 @@ var init_generators = __esm({
1311
1333
  }
1312
1334
  });
1313
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
+
1314
1399
  // src/core/navigators/generators/srs.ts
1315
1400
  var srs_exports = {};
1316
1401
  __export(srs_exports, {
@@ -1505,6 +1590,7 @@ var init_ = __esm({
1505
1590
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1506
1591
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1507
1592
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1593
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1508
1594
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1509
1595
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1510
1596
  });
@@ -1705,6 +1791,8 @@ var init_hierarchyDefinition = __esm({
1705
1791
  if (userTagElo.count < minCount) return false;
1706
1792
  if (prereq.masteryThreshold?.minElo !== void 0) {
1707
1793
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1794
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1795
+ return true;
1708
1796
  } else {
1709
1797
  return userTagElo.score >= userGlobalElo;
1710
1798
  }
@@ -1780,14 +1868,38 @@ var init_hierarchyDefinition = __esm({
1780
1868
  };
1781
1869
  }
1782
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
+ }
1783
1891
  /**
1784
1892
  * CardFilter.transform implementation.
1785
1893
  *
1786
- * 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
1787
1898
  */
1788
1899
  async transform(cards, context) {
1789
1900
  const masteredTags = await this.getMasteredTags(context);
1790
1901
  const unlockedTags = this.getUnlockedTags(masteredTags);
1902
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1791
1903
  const gated = [];
1792
1904
  for (const card of cards) {
1793
1905
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1796,9 +1908,27 @@ var init_hierarchyDefinition = __esm({
1796
1908
  unlockedTags,
1797
1909
  masteredTags
1798
1910
  );
1799
- const LOCKED_PENALTY = 0.01;
1800
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1801
- 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
+ }
1802
1932
  gated.push({
1803
1933
  ...card,
1804
1934
  score: finalScore,
@@ -1810,7 +1940,7 @@ var init_hierarchyDefinition = __esm({
1810
1940
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1811
1941
  action,
1812
1942
  score: finalScore,
1813
- reason
1943
+ reason: finalReason
1814
1944
  }
1815
1945
  ]
1816
1946
  });
@@ -2745,6 +2875,18 @@ __export(Pipeline_exports, {
2745
2875
  Pipeline: () => Pipeline
2746
2876
  });
2747
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
+ }
2748
2890
  function logPipelineConfig(generator, filters) {
2749
2891
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2750
2892
  logger.info(
@@ -2779,6 +2921,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2779
2921
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2780
2922
  );
2781
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
+ }
2782
2939
  function logCardProvenance(cards, maxCards = 3) {
2783
2940
  const cardsToLog = cards.slice(0, maxCards);
2784
2941
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2793,7 +2950,7 @@ function logCardProvenance(cards, maxCards = 3) {
2793
2950
  }
2794
2951
  }
2795
2952
  }
2796
- var Pipeline;
2953
+ var VERBOSE_RESULTS, Pipeline;
2797
2954
  var init_Pipeline = __esm({
2798
2955
  "src/core/navigators/Pipeline.ts"() {
2799
2956
  "use strict";
@@ -2801,9 +2958,31 @@ var init_Pipeline = __esm({
2801
2958
  init_logger();
2802
2959
  init_orchestration();
2803
2960
  init_PipelineDebugger();
2961
+ VERBOSE_RESULTS = true;
2804
2962
  Pipeline = class extends ContentNavigator {
2805
2963
  generator;
2806
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;
2807
2986
  /**
2808
2987
  * Create a new pipeline.
2809
2988
  *
@@ -2824,6 +3003,17 @@ var init_Pipeline = __esm({
2824
3003
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2825
3004
  });
2826
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)}`);
2827
3017
  }
2828
3018
  /**
2829
3019
  * Get weighted cards by running generator and applying filters.
@@ -2840,13 +3030,15 @@ var init_Pipeline = __esm({
2840
3030
  * @returns Cards sorted by score descending
2841
3031
  */
2842
3032
  async getWeightedCards(limit) {
3033
+ const t0 = performance.now();
2843
3034
  const context = await this.buildContext();
2844
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2845
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
3035
+ const tContext = performance.now();
3036
+ const fetchLimit = 500;
2846
3037
  logger.debug(
2847
3038
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2848
3039
  );
2849
3040
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
3041
+ const tGenerate = performance.now();
2850
3042
  const generatedCount = cards.length;
2851
3043
  let generatorSummaries;
2852
3044
  if (this.generator.generators) {
@@ -2875,6 +3067,7 @@ var init_Pipeline = __esm({
2875
3067
  }
2876
3068
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2877
3069
  cards = await this.hydrateTags(cards);
3070
+ const tHydrate = performance.now();
2878
3071
  const allCardsBeforeFiltering = [...cards];
2879
3072
  const filterImpacts = [];
2880
3073
  for (const filter of this.filters) {
@@ -2893,8 +3086,17 @@ var init_Pipeline = __esm({
2893
3086
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2894
3087
  }
2895
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
+ }
2896
3094
  cards.sort((a, b) => b.score - a.score);
3095
+ const tFilter = performance.now();
2897
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
+ );
2898
3100
  const topScores = result.slice(0, 3).map((c) => c.score);
2899
3101
  logExecutionSummary(
2900
3102
  this.generator.name,
@@ -2904,6 +3106,7 @@ var init_Pipeline = __esm({
2904
3106
  topScores,
2905
3107
  filterImpacts
2906
3108
  );
3109
+ logResultCards(result);
2907
3110
  logCardProvenance(result, 3);
2908
3111
  try {
2909
3112
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -2930,6 +3133,10 @@ var init_Pipeline = __esm({
2930
3133
  * to the WeightedCard objects. Filters can then use card.tags instead of
2931
3134
  * making individual getAppliedTags() calls.
2932
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
+ *
2933
3140
  * @param cards - Cards to hydrate
2934
3141
  * @returns Cards with tags populated
2935
3142
  */
@@ -2937,14 +3144,128 @@ var init_Pipeline = __esm({
2937
3144
  if (cards.length === 0) {
2938
3145
  return cards;
2939
3146
  }
2940
- const cardIds = cards.map((c) => c.cardId);
2941
- 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
+ }
2942
3163
  logTagHydration(cards, tagsByCard);
2943
3164
  return cards.map((card) => ({
2944
3165
  ...card,
2945
- tags: tagsByCard.get(card.cardId) ?? []
3166
+ tags: this._tagCache.get(card.cardId) ?? []
2946
3167
  }));
2947
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
+ }
2948
3269
  /**
2949
3270
  * Build shared context for generator and filters.
2950
3271
  *
@@ -2962,7 +3283,10 @@ var init_Pipeline = __esm({
2962
3283
  } catch (e) {
2963
3284
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
2964
3285
  }
2965
- 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;
2966
3290
  return {
2967
3291
  user: this.user,
2968
3292
  course: this.course,
@@ -3006,6 +3330,87 @@ var init_Pipeline = __esm({
3006
3330
  }
3007
3331
  return [...new Set(ids)];
3008
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
+ }
3009
3414
  };
3010
3415
  }
3011
3416
  });
@@ -3110,23 +3515,25 @@ var init_PipelineAssembler = __esm({
3110
3515
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
3111
3516
  }
3112
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
+ }
3113
3529
  if (generatorStrategies.length === 0) {
3114
- if (filterStrategies.length > 0) {
3115
- logger.debug(
3116
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
3117
- );
3118
- const courseId = course.getCourseID();
3119
- generatorStrategies.push(createDefaultEloStrategy(courseId));
3120
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
3121
- } else {
3122
- warnings.push("No generator strategy found");
3123
- return {
3124
- pipeline: null,
3125
- generatorStrategies: [],
3126
- filterStrategies: [],
3127
- warnings
3128
- };
3129
- }
3530
+ warnings.push("No generator strategy found");
3531
+ return {
3532
+ pipeline: null,
3533
+ generatorStrategies: [],
3534
+ filterStrategies: [],
3535
+ warnings
3536
+ };
3130
3537
  }
3131
3538
  let generator;
3132
3539
  if (generatorStrategies.length === 1) {
@@ -3204,6 +3611,7 @@ var init_3 = __esm({
3204
3611
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
3205
3612
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
3206
3613
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3614
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
3207
3615
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
3208
3616
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
3209
3617
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -3252,8 +3660,10 @@ async function initializeNavigatorRegistry() {
3252
3660
  Promise.resolve().then(() => (init_elo(), elo_exports)),
3253
3661
  Promise.resolve().then(() => (init_srs(), srs_exports))
3254
3662
  ]);
3663
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
3255
3664
  registerNavigator("elo", eloModule.default);
3256
3665
  registerNavigator("srs", srsModule.default);
3666
+ registerNavigator("prescribed", prescribedModule.default);
3257
3667
  const [
3258
3668
  hierarchyModule,
3259
3669
  interferenceModule,
@@ -3308,6 +3718,7 @@ var init_navigators = __esm({
3308
3718
  Navigators = /* @__PURE__ */ ((Navigators2) => {
3309
3719
  Navigators2["ELO"] = "elo";
3310
3720
  Navigators2["SRS"] = "srs";
3721
+ Navigators2["PRESCRIBED"] = "prescribed";
3311
3722
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
3312
3723
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
3313
3724
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -3322,6 +3733,7 @@ var init_navigators = __esm({
3322
3733
  NavigatorRoles = {
3323
3734
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
3324
3735
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3736
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
3325
3737
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
3326
3738
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
3327
3739
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -3486,6 +3898,12 @@ var init_navigators = __esm({
3486
3898
  async getWeightedCards(_limit) {
3487
3899
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
3488
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
+ }
3489
3907
  };
3490
3908
  }
3491
3909
  });
@@ -3590,15 +4008,42 @@ var init_courseDB = __esm({
3590
4008
  // private log(msg: string): void {
3591
4009
  // log(`CourseLog: ${this.id}\n ${msg}`);
3592
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
+ */
3593
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;
3594
4028
  id;
3595
4029
  _getCurrentUser;
3596
4030
  updateQueue;
3597
- 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) {
3598
4041
  this.id = id;
3599
- this.db = getCourseDB2(this.id);
4042
+ const remote = getCourseDB2(this.id);
4043
+ this.remoteDB = remote;
4044
+ this.db = localDB ?? remote;
3600
4045
  this._getCurrentUser = userLookup;
3601
- this.updateQueue = new UpdateQueue(this.db);
4046
+ this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
3602
4047
  }
3603
4048
  getCourseID() {
3604
4049
  return this.id;
@@ -3686,7 +4131,7 @@ var init_courseDB = __esm({
3686
4131
  };
3687
4132
  }
3688
4133
  async removeCard(id) {
3689
- const doc = await this.db.get(id);
4134
+ const doc = await this.remoteDB.get(id);
3690
4135
  if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
3691
4136
  throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
3692
4137
  }
@@ -3707,7 +4152,7 @@ var init_courseDB = __esm({
3707
4152
  } catch (error) {
3708
4153
  logger.error(`Error removing card ${id} from tags: ${error}`);
3709
4154
  }
3710
- return this.db.remove(doc);
4155
+ return this.remoteDB.remove(doc);
3711
4156
  }
3712
4157
  async getCardDisplayableDataIDs(id) {
3713
4158
  logger.debug(id.join(", "));
@@ -3809,8 +4254,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3809
4254
  if (cardIds.length === 0) {
3810
4255
  return /* @__PURE__ */ new Map();
3811
4256
  }
3812
- const db = getCourseDB2(this.id);
3813
- const result = await db.query("getTags", {
4257
+ const result = await this.db.query("getTags", {
3814
4258
  keys: cardIds,
3815
4259
  include_docs: false
3816
4260
  });
@@ -3827,6 +4271,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3827
4271
  }
3828
4272
  return tagsByCard;
3829
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
+ }
3830
4282
  async addTagToCard(cardId, tagId, updateELO) {
3831
4283
  return await addTagToCard(
3832
4284
  this.id,
@@ -3893,10 +4345,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3893
4345
  }
3894
4346
  }
3895
4347
  async getCourseDoc(id, options) {
3896
- return await getCourseDoc(this.id, id, options);
4348
+ return await this.db.get(id, options);
3897
4349
  }
3898
4350
  async getCourseDocs(ids, options = {}) {
3899
- return await getCourseDocs(this.id, ids, options);
4351
+ return await this.db.allDocs({
4352
+ ...options,
4353
+ keys: ids
4354
+ });
3900
4355
  }
3901
4356
  ////////////////////////////////////
3902
4357
  // NavigationStrategyManager implementation
@@ -3930,7 +4385,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3930
4385
  }
3931
4386
  async addNavigationStrategy(data) {
3932
4387
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
3933
- return this.db.put(data).then(() => {
4388
+ return this.remoteDB.put(data).then(() => {
3934
4389
  });
3935
4390
  }
3936
4391
  updateNavigationStrategy(id, data) {
@@ -4346,6 +4801,16 @@ var init_adminDB2 = __esm({
4346
4801
  }
4347
4802
  });
4348
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
+
4349
4814
  // src/impl/couch/auth.ts
4350
4815
  import fetch from "cross-fetch";
4351
4816
  var init_auth = __esm({
@@ -4400,15 +4865,6 @@ function getCourseDB2(courseID) {
4400
4865
  createPouchDBConfig()
4401
4866
  );
4402
4867
  }
4403
- function getCourseDocs(courseID, docIDs, options = {}) {
4404
- return getCourseDB2(courseID).allDocs({
4405
- ...options,
4406
- keys: docIDs
4407
- });
4408
- }
4409
- function getCourseDoc(courseID, docID, options = {}) {
4410
- return getCourseDB2(courseID).get(docID, options);
4411
- }
4412
4868
  function filterAllDocsByPrefix2(db, prefix, opts) {
4413
4869
  const options = {
4414
4870
  startkey: prefix,
@@ -4439,6 +4895,7 @@ var init_couch = __esm({
4439
4895
  init_classroomDB2();
4440
4896
  init_courseAPI();
4441
4897
  init_courseDB();
4898
+ init_CourseSyncService();
4442
4899
  init_CouchDBSyncStrategy();
4443
4900
  isBrowser = typeof window !== "undefined";
4444
4901
  if (isBrowser) {
@@ -4658,6 +5115,9 @@ Currently logged-in as ${this._username}.`
4658
5115
  const id = row.id;
4659
5116
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
4660
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
4661
5121
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
4662
5122
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
4663
5123
  id === _BaseUser.DOC_IDS.CONFIG;