@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
@@ -649,8 +649,12 @@ __export(PipelineDebugger_exports, {
649
649
  buildRunReport: () => buildRunReport,
650
650
  captureRun: () => captureRun,
651
651
  mountPipelineDebugger: () => mountPipelineDebugger,
652
- pipelineDebugAPI: () => pipelineDebugAPI
652
+ pipelineDebugAPI: () => pipelineDebugAPI,
653
+ registerPipelineForDebug: () => registerPipelineForDebug
653
654
  });
655
+ function registerPipelineForDebug(pipeline) {
656
+ _activePipeline = pipeline;
657
+ }
654
658
  function getOrigin(card) {
655
659
  const firstEntry = card.provenance[0];
656
660
  if (!firstEntry) return "unknown";
@@ -678,6 +682,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
678
682
  origin: getOrigin(card),
679
683
  finalScore: card.score,
680
684
  provenance: card.provenance,
685
+ tags: card.tags,
681
686
  selected: selectedIds.has(card.cardId)
682
687
  }));
683
688
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
@@ -733,11 +738,13 @@ function mountPipelineDebugger() {
733
738
  win.skuilder = win.skuilder || {};
734
739
  win.skuilder.pipeline = pipelineDebugAPI;
735
740
  }
736
- var MAX_RUNS, runHistory, pipelineDebugAPI;
741
+ var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
737
742
  var init_PipelineDebugger = __esm({
738
743
  "src/core/navigators/PipelineDebugger.ts"() {
739
744
  "use strict";
745
+ init_navigators();
740
746
  init_logger();
747
+ _activePipeline = null;
741
748
  MAX_RUNS = 10;
742
749
  runHistory = [];
743
750
  pipelineDebugAPI = {
@@ -879,6 +886,81 @@ var init_PipelineDebugger = __esm({
879
886
  runHistory.length = 0;
880
887
  logger.info("[Pipeline Debug] Run history cleared.");
881
888
  },
889
+ /**
890
+ * Show the navigator registry: all registered classes and their roles.
891
+ *
892
+ * Useful for verifying that consumer-defined navigators were registered
893
+ * before pipeline assembly.
894
+ */
895
+ showRegistry() {
896
+ const names = getRegisteredNavigatorNames();
897
+ if (names.length === 0) {
898
+ logger.info("[Pipeline Debug] Navigator registry is empty.");
899
+ return;
900
+ }
901
+ console.group("\u{1F4E6} Navigator Registry");
902
+ console.table(
903
+ names.map((name) => {
904
+ const registryRole = getRegisteredNavigatorRole(name);
905
+ const builtinRole = NavigatorRoles[name];
906
+ const effectiveRole = builtinRole || registryRole || "\u26A0\uFE0F NONE";
907
+ const source = builtinRole ? "built-in" : registryRole ? "consumer" : "unclassified";
908
+ return {
909
+ name,
910
+ role: effectiveRole,
911
+ source,
912
+ isGenerator: isGenerator(name),
913
+ isFilter: isFilter(name)
914
+ };
915
+ })
916
+ );
917
+ console.groupEnd();
918
+ },
919
+ /**
920
+ * Show strategy documents from the last pipeline run and how they mapped
921
+ * to the registry.
922
+ *
923
+ * If no runs are captured yet, falls back to showing just the registry.
924
+ */
925
+ showStrategies() {
926
+ this.showRegistry();
927
+ if (runHistory.length === 0) {
928
+ logger.info("[Pipeline Debug] No pipeline runs captured yet \u2014 cannot show strategy doc mapping.");
929
+ return;
930
+ }
931
+ const run = runHistory[0];
932
+ console.group("\u{1F50C} Pipeline Strategy Mapping (last run)");
933
+ logger.info(`Generator: ${run.generatorName}`);
934
+ if (run.generators && run.generators.length > 0) {
935
+ for (const g of run.generators) {
936
+ logger.info(` \u{1F4E5} ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
937
+ }
938
+ }
939
+ if (run.filters.length > 0) {
940
+ logger.info("Filters:");
941
+ for (const f of run.filters) {
942
+ logger.info(` \u{1F538} ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
943
+ }
944
+ } else {
945
+ logger.info("Filters: (none)");
946
+ }
947
+ console.groupEnd();
948
+ },
949
+ /**
950
+ * Scan the full card space through the filter chain for the current user.
951
+ *
952
+ * Reports how many cards are well-indicated and how many are new.
953
+ * Use this to understand how the search space grows during onboarding.
954
+ *
955
+ * @param threshold - Score threshold for "well indicated" (default 0.10)
956
+ */
957
+ async diagnoseCardSpace(threshold) {
958
+ if (!_activePipeline) {
959
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
960
+ return null;
961
+ }
962
+ return _activePipeline.diagnoseCardSpace({ threshold });
963
+ },
882
964
  /**
883
965
  * Show help.
884
966
  */
@@ -891,6 +973,9 @@ Commands:
891
973
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
892
974
  .showCard(cardId) Show provenance trail for a specific card
893
975
  .explainReviews() Analyze why reviews were/weren't selected
976
+ .diagnoseCardSpace() Scan full card space through filters (async)
977
+ .showRegistry() Show navigator registry (classes + roles)
978
+ .showStrategies() Show registry + strategy mapping from last run
894
979
  .listRuns() List all captured runs in table format
895
980
  .export() Export run history as JSON for bug reports
896
981
  .clear() Clear run history
@@ -900,7 +985,7 @@ Commands:
900
985
  Example:
901
986
  window.skuilder.pipeline.showLastRun()
902
987
  window.skuilder.pipeline.showRun(1)
903
- window.skuilder.pipeline.showCard('abc123')
988
+ await window.skuilder.pipeline.diagnoseCardSpace()
904
989
  `);
905
990
  }
906
991
  };
@@ -1195,6 +1280,69 @@ var init_generators = __esm({
1195
1280
  }
1196
1281
  });
1197
1282
 
1283
+ // src/core/navigators/generators/prescribed.ts
1284
+ var prescribed_exports = {};
1285
+ __export(prescribed_exports, {
1286
+ default: () => PrescribedCardsGenerator
1287
+ });
1288
+ var PrescribedCardsGenerator;
1289
+ var init_prescribed = __esm({
1290
+ "src/core/navigators/generators/prescribed.ts"() {
1291
+ "use strict";
1292
+ init_navigators();
1293
+ init_logger();
1294
+ PrescribedCardsGenerator = class extends ContentNavigator {
1295
+ name;
1296
+ config;
1297
+ constructor(user, course, strategyData) {
1298
+ super(user, course, strategyData);
1299
+ this.name = strategyData.name || "Prescribed Cards";
1300
+ try {
1301
+ const parsed = JSON.parse(strategyData.serializedData);
1302
+ this.config = { cardIds: parsed.cardIds || [] };
1303
+ } catch {
1304
+ this.config = { cardIds: [] };
1305
+ }
1306
+ logger.debug(
1307
+ `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1308
+ );
1309
+ }
1310
+ async getWeightedCards(limit, _context) {
1311
+ if (this.config.cardIds.length === 0) {
1312
+ return [];
1313
+ }
1314
+ const courseId = this.course.getCourseID();
1315
+ const activeCards = await this.user.getActiveCards();
1316
+ const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1317
+ const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1318
+ if (eligibleIds.length === 0) {
1319
+ logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1320
+ return [];
1321
+ }
1322
+ const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1323
+ cardId,
1324
+ courseId,
1325
+ score: 1,
1326
+ provenance: [
1327
+ {
1328
+ strategy: "prescribed",
1329
+ strategyName: this.strategyName || this.name,
1330
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1331
+ action: "generated",
1332
+ score: 1,
1333
+ reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1334
+ }
1335
+ ]
1336
+ }));
1337
+ logger.info(
1338
+ `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1339
+ );
1340
+ return cards;
1341
+ }
1342
+ };
1343
+ }
1344
+ });
1345
+
1198
1346
  // src/core/navigators/generators/srs.ts
1199
1347
  var srs_exports = {};
1200
1348
  __export(srs_exports, {
@@ -1389,6 +1537,7 @@ var init_ = __esm({
1389
1537
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1390
1538
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1391
1539
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1540
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1392
1541
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1393
1542
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1394
1543
  });
@@ -1589,6 +1738,8 @@ var init_hierarchyDefinition = __esm({
1589
1738
  if (userTagElo.count < minCount) return false;
1590
1739
  if (prereq.masteryThreshold?.minElo !== void 0) {
1591
1740
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1741
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1742
+ return true;
1592
1743
  } else {
1593
1744
  return userTagElo.score >= userGlobalElo;
1594
1745
  }
@@ -1664,14 +1815,38 @@ var init_hierarchyDefinition = __esm({
1664
1815
  };
1665
1816
  }
1666
1817
  }
1818
+ /**
1819
+ * Build a map of prereq tag → max configured boost for all *closed* gates.
1820
+ *
1821
+ * When a gate is closed (prereqs unmet), cards carrying that gate's prereq
1822
+ * tags get boosted — steering the pipeline toward content that helps unlock
1823
+ * the gated material. Once the gate opens, the boost disappears.
1824
+ */
1825
+ getPreReqBoosts(unlockedTags, masteredTags) {
1826
+ const boosts = /* @__PURE__ */ new Map();
1827
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
1828
+ if (unlockedTags.has(tagId)) continue;
1829
+ for (const prereq of prereqs) {
1830
+ if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
1831
+ if (masteredTags.has(prereq.tag)) continue;
1832
+ const existing = boosts.get(prereq.tag) ?? 1;
1833
+ boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
1834
+ }
1835
+ }
1836
+ return boosts;
1837
+ }
1667
1838
  /**
1668
1839
  * CardFilter.transform implementation.
1669
1840
  *
1670
- * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
1841
+ * Two effects:
1842
+ * 1. Cards with locked tags receive score * 0.05 (gating penalty)
1843
+ * 2. Cards carrying prereq tags of closed gates receive a configured
1844
+ * boost (preReqBoost), steering toward content that unlocks gates
1671
1845
  */
1672
1846
  async transform(cards, context) {
1673
1847
  const masteredTags = await this.getMasteredTags(context);
1674
1848
  const unlockedTags = this.getUnlockedTags(masteredTags);
1849
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1675
1850
  const gated = [];
1676
1851
  for (const card of cards) {
1677
1852
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1680,9 +1855,27 @@ var init_hierarchyDefinition = __esm({
1680
1855
  unlockedTags,
1681
1856
  masteredTags
1682
1857
  );
1683
- const LOCKED_PENALTY = 0.01;
1684
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1685
- const action = isUnlocked ? "passed" : "penalized";
1858
+ const LOCKED_PENALTY = 0.02;
1859
+ let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1860
+ let action = isUnlocked ? "passed" : "penalized";
1861
+ let finalReason = reason;
1862
+ if (isUnlocked && preReqBoosts.size > 0) {
1863
+ const cardTags = card.tags ?? [];
1864
+ let maxBoost = 1;
1865
+ const boostedPrereqs = [];
1866
+ for (const tag of cardTags) {
1867
+ const boost = preReqBoosts.get(tag);
1868
+ if (boost && boost > maxBoost) {
1869
+ maxBoost = boost;
1870
+ boostedPrereqs.push(tag);
1871
+ }
1872
+ }
1873
+ if (maxBoost > 1) {
1874
+ finalScore *= maxBoost;
1875
+ action = "boosted";
1876
+ finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
1877
+ }
1878
+ }
1686
1879
  gated.push({
1687
1880
  ...card,
1688
1881
  score: finalScore,
@@ -1694,7 +1887,7 @@ var init_hierarchyDefinition = __esm({
1694
1887
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1695
1888
  action,
1696
1889
  score: finalScore,
1697
- reason
1890
+ reason: finalReason
1698
1891
  }
1699
1892
  ]
1700
1893
  });
@@ -2382,6 +2575,18 @@ var Pipeline_exports = {};
2382
2575
  __export(Pipeline_exports, {
2383
2576
  Pipeline: () => Pipeline
2384
2577
  });
2578
+ function globToRegex(pattern) {
2579
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
2580
+ const withWildcards = escaped.replace(/\*/g, ".*");
2581
+ return new RegExp(`^${withWildcards}$`);
2582
+ }
2583
+ function globMatch(value, pattern) {
2584
+ if (!pattern.includes("*")) return value === pattern;
2585
+ return globToRegex(pattern).test(value);
2586
+ }
2587
+ function cardMatchesTagPattern(card, pattern) {
2588
+ return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
2589
+ }
2385
2590
  function logPipelineConfig(generator, filters) {
2386
2591
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2387
2592
  logger.info(
@@ -2416,6 +2621,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2416
2621
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2417
2622
  );
2418
2623
  }
2624
+ function logResultCards(cards) {
2625
+ if (!VERBOSE_RESULTS || cards.length === 0) return;
2626
+ logger.info(`[Pipeline] Results (${cards.length} cards):`);
2627
+ for (let i = 0; i < cards.length; i++) {
2628
+ const c = cards[i];
2629
+ const tags = c.tags?.slice(0, 3).join(", ") || "";
2630
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
2631
+ const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
2632
+ return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
2633
+ }).join(" | ");
2634
+ logger.info(
2635
+ `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
2636
+ );
2637
+ }
2638
+ }
2419
2639
  function logCardProvenance(cards, maxCards = 3) {
2420
2640
  const cardsToLog = cards.slice(0, maxCards);
2421
2641
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2430,7 +2650,7 @@ function logCardProvenance(cards, maxCards = 3) {
2430
2650
  }
2431
2651
  }
2432
2652
  }
2433
- var import_common8, Pipeline;
2653
+ var import_common8, VERBOSE_RESULTS, Pipeline;
2434
2654
  var init_Pipeline = __esm({
2435
2655
  "src/core/navigators/Pipeline.ts"() {
2436
2656
  "use strict";
@@ -2439,9 +2659,31 @@ var init_Pipeline = __esm({
2439
2659
  init_logger();
2440
2660
  init_orchestration();
2441
2661
  init_PipelineDebugger();
2662
+ VERBOSE_RESULTS = true;
2442
2663
  Pipeline = class extends ContentNavigator {
2443
2664
  generator;
2444
2665
  filters;
2666
+ /**
2667
+ * Cached orchestration context. Course config and salt don't change within
2668
+ * a page load, so we build the orchestration context once and reuse it on
2669
+ * subsequent getWeightedCards() calls (e.g. mid-session replans).
2670
+ *
2671
+ * This eliminates a remote getCourseConfig() round trip per pipeline run.
2672
+ */
2673
+ _cachedOrchestration = null;
2674
+ /**
2675
+ * Persistent tag cache. Maps cardId → tag names.
2676
+ *
2677
+ * Tags are static within a session (they're set at card generation time),
2678
+ * so we cache them across pipeline runs. On replans, many of the same cards
2679
+ * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
2680
+ */
2681
+ _tagCache = /* @__PURE__ */ new Map();
2682
+ /**
2683
+ * One-shot replan hints. Applied after the filter chain on the next
2684
+ * getWeightedCards() call, then cleared.
2685
+ */
2686
+ _ephemeralHints = null;
2445
2687
  /**
2446
2688
  * Create a new pipeline.
2447
2689
  *
@@ -2462,6 +2704,17 @@ var init_Pipeline = __esm({
2462
2704
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2463
2705
  });
2464
2706
  logPipelineConfig(generator, filters);
2707
+ registerPipelineForDebug(this);
2708
+ }
2709
+ /**
2710
+ * Set one-shot hints for the next pipeline run.
2711
+ * Consumed after one getWeightedCards() call, then cleared.
2712
+ *
2713
+ * Overrides ContentNavigator.setEphemeralHints() no-op.
2714
+ */
2715
+ setEphemeralHints(hints) {
2716
+ this._ephemeralHints = hints;
2717
+ logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
2465
2718
  }
2466
2719
  /**
2467
2720
  * Get weighted cards by running generator and applying filters.
@@ -2478,13 +2731,15 @@ var init_Pipeline = __esm({
2478
2731
  * @returns Cards sorted by score descending
2479
2732
  */
2480
2733
  async getWeightedCards(limit) {
2734
+ const t0 = performance.now();
2481
2735
  const context = await this.buildContext();
2482
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2483
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
2736
+ const tContext = performance.now();
2737
+ const fetchLimit = 500;
2484
2738
  logger.debug(
2485
2739
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2486
2740
  );
2487
2741
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2742
+ const tGenerate = performance.now();
2488
2743
  const generatedCount = cards.length;
2489
2744
  let generatorSummaries;
2490
2745
  if (this.generator.generators) {
@@ -2513,6 +2768,7 @@ var init_Pipeline = __esm({
2513
2768
  }
2514
2769
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2515
2770
  cards = await this.hydrateTags(cards);
2771
+ const tHydrate = performance.now();
2516
2772
  const allCardsBeforeFiltering = [...cards];
2517
2773
  const filterImpacts = [];
2518
2774
  for (const filter of this.filters) {
@@ -2531,8 +2787,17 @@ var init_Pipeline = __esm({
2531
2787
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2532
2788
  }
2533
2789
  cards = cards.filter((c) => c.score > 0);
2790
+ const hints = this._ephemeralHints;
2791
+ if (hints) {
2792
+ this._ephemeralHints = null;
2793
+ cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
2794
+ }
2534
2795
  cards.sort((a, b) => b.score - a.score);
2796
+ const tFilter = performance.now();
2535
2797
  const result = cards.slice(0, limit);
2798
+ logger.info(
2799
+ `[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)})`
2800
+ );
2536
2801
  const topScores = result.slice(0, 3).map((c) => c.score);
2537
2802
  logExecutionSummary(
2538
2803
  this.generator.name,
@@ -2542,6 +2807,7 @@ var init_Pipeline = __esm({
2542
2807
  topScores,
2543
2808
  filterImpacts
2544
2809
  );
2810
+ logResultCards(result);
2545
2811
  logCardProvenance(result, 3);
2546
2812
  try {
2547
2813
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -2568,6 +2834,10 @@ var init_Pipeline = __esm({
2568
2834
  * to the WeightedCard objects. Filters can then use card.tags instead of
2569
2835
  * making individual getAppliedTags() calls.
2570
2836
  *
2837
+ * Uses a persistent tag cache across pipeline runs — tags are static within
2838
+ * a session, so cards seen in a prior run (e.g. before a replan) don't
2839
+ * require a second DB query.
2840
+ *
2571
2841
  * @param cards - Cards to hydrate
2572
2842
  * @returns Cards with tags populated
2573
2843
  */
@@ -2575,14 +2845,128 @@ var init_Pipeline = __esm({
2575
2845
  if (cards.length === 0) {
2576
2846
  return cards;
2577
2847
  }
2578
- const cardIds = cards.map((c) => c.cardId);
2579
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
2848
+ const uncachedIds = [];
2849
+ for (const card of cards) {
2850
+ if (!this._tagCache.has(card.cardId)) {
2851
+ uncachedIds.push(card.cardId);
2852
+ }
2853
+ }
2854
+ if (uncachedIds.length > 0) {
2855
+ const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
2856
+ for (const [cardId, tags] of freshTags) {
2857
+ this._tagCache.set(cardId, tags);
2858
+ }
2859
+ }
2860
+ const tagsByCard = /* @__PURE__ */ new Map();
2861
+ for (const card of cards) {
2862
+ tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
2863
+ }
2580
2864
  logTagHydration(cards, tagsByCard);
2581
2865
  return cards.map((card) => ({
2582
2866
  ...card,
2583
- tags: tagsByCard.get(card.cardId) ?? []
2867
+ tags: this._tagCache.get(card.cardId) ?? []
2584
2868
  }));
2585
2869
  }
2870
+ // ---------------------------------------------------------------------------
2871
+ // Ephemeral hints application
2872
+ // ---------------------------------------------------------------------------
2873
+ /**
2874
+ * Apply one-shot replan hints to the post-filter card set.
2875
+ *
2876
+ * Order of operations:
2877
+ * 1. Exclude (remove unwanted cards)
2878
+ * 2. Boost (multiply scores)
2879
+ * 3. Require (inject must-have cards from the full pre-filter pool)
2880
+ *
2881
+ * @param cards - Post-filter cards (score > 0)
2882
+ * @param hints - The ephemeral hints to apply
2883
+ * @param allCards - Full pre-filter card pool (for require injection)
2884
+ */
2885
+ applyHints(cards, hints, allCards) {
2886
+ const beforeCount = cards.length;
2887
+ if (hints.excludeCards?.length) {
2888
+ cards = cards.filter(
2889
+ (c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
2890
+ );
2891
+ }
2892
+ if (hints.excludeTags?.length) {
2893
+ cards = cards.filter(
2894
+ (c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
2895
+ );
2896
+ }
2897
+ if (hints.boostTags) {
2898
+ for (const [pattern, factor] of Object.entries(hints.boostTags)) {
2899
+ for (const card of cards) {
2900
+ if (cardMatchesTagPattern(card, pattern)) {
2901
+ card.score *= factor;
2902
+ card.provenance.push({
2903
+ strategy: "ephemeralHint",
2904
+ strategyId: "ephemeral-hint",
2905
+ strategyName: "Replan Hint",
2906
+ action: "boosted",
2907
+ score: card.score,
2908
+ reason: `boostTag ${pattern} \xD7${factor}`
2909
+ });
2910
+ }
2911
+ }
2912
+ }
2913
+ }
2914
+ if (hints.boostCards) {
2915
+ for (const [pattern, factor] of Object.entries(hints.boostCards)) {
2916
+ for (const card of cards) {
2917
+ if (globMatch(card.cardId, pattern)) {
2918
+ card.score *= factor;
2919
+ card.provenance.push({
2920
+ strategy: "ephemeralHint",
2921
+ strategyId: "ephemeral-hint",
2922
+ strategyName: "Replan Hint",
2923
+ action: "boosted",
2924
+ score: card.score,
2925
+ reason: `boostCard ${pattern} \xD7${factor}`
2926
+ });
2927
+ }
2928
+ }
2929
+ }
2930
+ }
2931
+ const cardIds = new Set(cards.map((c) => c.cardId));
2932
+ const inject = (card, reason) => {
2933
+ if (!cardIds.has(card.cardId)) {
2934
+ const floorScore = Math.max(card.score, 1);
2935
+ cards.push({
2936
+ ...card,
2937
+ score: floorScore,
2938
+ provenance: [
2939
+ ...card.provenance,
2940
+ {
2941
+ strategy: "ephemeralHint",
2942
+ strategyId: "ephemeral-hint",
2943
+ strategyName: "Replan Hint",
2944
+ action: "boosted",
2945
+ score: floorScore,
2946
+ reason
2947
+ }
2948
+ ]
2949
+ });
2950
+ cardIds.add(card.cardId);
2951
+ }
2952
+ };
2953
+ if (hints.requireCards?.length) {
2954
+ for (const pattern of hints.requireCards) {
2955
+ for (const card of allCards) {
2956
+ if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
2957
+ }
2958
+ }
2959
+ }
2960
+ if (hints.requireTags?.length) {
2961
+ for (const pattern of hints.requireTags) {
2962
+ for (const card of allCards) {
2963
+ if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
2964
+ }
2965
+ }
2966
+ }
2967
+ logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
2968
+ return cards;
2969
+ }
2586
2970
  /**
2587
2971
  * Build shared context for generator and filters.
2588
2972
  *
@@ -2600,7 +2984,10 @@ var init_Pipeline = __esm({
2600
2984
  } catch (e) {
2601
2985
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
2602
2986
  }
2603
- const orchestration = await createOrchestrationContext(this.user, this.course);
2987
+ if (!this._cachedOrchestration) {
2988
+ this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
2989
+ }
2990
+ const orchestration = this._cachedOrchestration;
2604
2991
  return {
2605
2992
  user: this.user,
2606
2993
  course: this.course,
@@ -2644,6 +3031,87 @@ var init_Pipeline = __esm({
2644
3031
  }
2645
3032
  return [...new Set(ids)];
2646
3033
  }
3034
+ // ---------------------------------------------------------------------------
3035
+ // Card-space diagnostic
3036
+ // ---------------------------------------------------------------------------
3037
+ /**
3038
+ * Scan every card in the course through the filter chain and report
3039
+ * how many are "well indicated" (score >= threshold) for the current user.
3040
+ *
3041
+ * Also reports how many well-indicated cards the user has NOT yet encountered.
3042
+ *
3043
+ * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
3044
+ */
3045
+ async diagnoseCardSpace(opts) {
3046
+ const THRESHOLD = opts?.threshold ?? 0.1;
3047
+ const t0 = performance.now();
3048
+ const allCardIds = await this.course.getAllCardIds();
3049
+ let cards = allCardIds.map((cardId) => ({
3050
+ cardId,
3051
+ courseId: this.course.getCourseID(),
3052
+ score: 1,
3053
+ provenance: []
3054
+ }));
3055
+ cards = await this.hydrateTags(cards);
3056
+ const context = await this.buildContext();
3057
+ const filterBreakdown = [];
3058
+ for (const filter of this.filters) {
3059
+ cards = await filter.transform(cards, context);
3060
+ const wi = cards.filter((c) => c.score >= THRESHOLD).length;
3061
+ filterBreakdown.push({ name: filter.name, wellIndicated: wi });
3062
+ }
3063
+ const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
3064
+ const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
3065
+ let encounteredIds;
3066
+ try {
3067
+ const courseId = this.course.getCourseID();
3068
+ const seenCards = await this.user.getSeenCards(courseId);
3069
+ encounteredIds = new Set(seenCards);
3070
+ } catch {
3071
+ encounteredIds = /* @__PURE__ */ new Set();
3072
+ }
3073
+ const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
3074
+ const byType = /* @__PURE__ */ new Map();
3075
+ for (const card of cards) {
3076
+ const type = card.cardId.split("-")[1] || "unknown";
3077
+ if (!byType.has(type)) {
3078
+ byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
3079
+ }
3080
+ const entry = byType.get(type);
3081
+ entry.total++;
3082
+ if (card.score >= THRESHOLD) {
3083
+ entry.wellIndicated++;
3084
+ if (!encounteredIds.has(card.cardId)) entry.new++;
3085
+ }
3086
+ }
3087
+ const elapsed = performance.now() - t0;
3088
+ const result = {
3089
+ totalCards: allCardIds.length,
3090
+ threshold: THRESHOLD,
3091
+ wellIndicated: wellIndicatedIds.size,
3092
+ encountered: encounteredIds.size,
3093
+ wellIndicatedNew: wellIndicatedNew.length,
3094
+ byType: Object.fromEntries(byType),
3095
+ filterBreakdown,
3096
+ elapsedMs: Math.round(elapsed)
3097
+ };
3098
+ logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
3099
+ logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
3100
+ logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
3101
+ logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
3102
+ logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
3103
+ logger.info(`[Pipeline:diagnose] By type:`);
3104
+ for (const [type, counts] of byType) {
3105
+ logger.info(
3106
+ `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
3107
+ );
3108
+ }
3109
+ logger.info(`[Pipeline:diagnose] After each filter:`);
3110
+ for (const fb of filterBreakdown) {
3111
+ logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
3112
+ }
3113
+ return result;
3114
+ }
2647
3115
  };
2648
3116
  }
2649
3117
  });
@@ -2748,23 +3216,25 @@ var init_PipelineAssembler = __esm({
2748
3216
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
2749
3217
  }
2750
3218
  }
3219
+ const courseId = course.getCourseID();
3220
+ const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
3221
+ const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
3222
+ if (!hasElo) {
3223
+ logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
3224
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3225
+ }
3226
+ if (!hasSrs) {
3227
+ logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
3228
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
3229
+ }
2751
3230
  if (generatorStrategies.length === 0) {
2752
- if (filterStrategies.length > 0) {
2753
- logger.debug(
2754
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2755
- );
2756
- const courseId = course.getCourseID();
2757
- generatorStrategies.push(createDefaultEloStrategy(courseId));
2758
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
2759
- } else {
2760
- warnings.push("No generator strategy found");
2761
- return {
2762
- pipeline: null,
2763
- generatorStrategies: [],
2764
- filterStrategies: [],
2765
- warnings
2766
- };
2767
- }
3231
+ warnings.push("No generator strategy found");
3232
+ return {
3233
+ pipeline: null,
3234
+ generatorStrategies: [],
3235
+ filterStrategies: [],
3236
+ warnings
3237
+ };
2768
3238
  }
2769
3239
  let generator;
2770
3240
  if (generatorStrategies.length === 1) {
@@ -2842,6 +3312,7 @@ var init_3 = __esm({
2842
3312
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
2843
3313
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
2844
3314
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3315
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
2845
3316
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
2846
3317
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
2847
3318
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -2859,6 +3330,7 @@ __export(navigators_exports, {
2859
3330
  getCardOrigin: () => getCardOrigin,
2860
3331
  getRegisteredNavigator: () => getRegisteredNavigator,
2861
3332
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
3333
+ getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
2862
3334
  hasRegisteredNavigator: () => hasRegisteredNavigator,
2863
3335
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
2864
3336
  isFilter: () => isFilter,
@@ -2867,16 +3339,19 @@ __export(navigators_exports, {
2867
3339
  pipelineDebugAPI: () => pipelineDebugAPI,
2868
3340
  registerNavigator: () => registerNavigator
2869
3341
  });
2870
- function registerNavigator(implementingClass, constructor) {
2871
- navigatorRegistry.set(implementingClass, constructor);
2872
- logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
3342
+ function registerNavigator(implementingClass, constructor, role) {
3343
+ navigatorRegistry.set(implementingClass, { constructor, role });
3344
+ logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ""}`);
2873
3345
  }
2874
3346
  function getRegisteredNavigator(implementingClass) {
2875
- return navigatorRegistry.get(implementingClass);
3347
+ return navigatorRegistry.get(implementingClass)?.constructor;
2876
3348
  }
2877
3349
  function hasRegisteredNavigator(implementingClass) {
2878
3350
  return navigatorRegistry.has(implementingClass);
2879
3351
  }
3352
+ function getRegisteredNavigatorRole(implementingClass) {
3353
+ return navigatorRegistry.get(implementingClass)?.role;
3354
+ }
2880
3355
  function getRegisteredNavigatorNames() {
2881
3356
  return Array.from(navigatorRegistry.keys());
2882
3357
  }
@@ -2886,8 +3361,10 @@ async function initializeNavigatorRegistry() {
2886
3361
  Promise.resolve().then(() => (init_elo(), elo_exports)),
2887
3362
  Promise.resolve().then(() => (init_srs(), srs_exports))
2888
3363
  ]);
3364
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
2889
3365
  registerNavigator("elo", eloModule.default);
2890
3366
  registerNavigator("srs", srsModule.default);
3367
+ registerNavigator("prescribed", prescribedModule.default);
2891
3368
  const [
2892
3369
  hierarchyModule,
2893
3370
  interferenceModule,
@@ -2922,10 +3399,12 @@ function getCardOrigin(card) {
2922
3399
  return "new";
2923
3400
  }
2924
3401
  function isGenerator(impl) {
2925
- return NavigatorRoles[impl] === "generator" /* GENERATOR */;
3402
+ if (NavigatorRoles[impl] === "generator" /* GENERATOR */) return true;
3403
+ return getRegisteredNavigatorRole(impl) === "generator" /* GENERATOR */;
2926
3404
  }
2927
3405
  function isFilter(impl) {
2928
- return NavigatorRoles[impl] === "filter" /* FILTER */;
3406
+ if (NavigatorRoles[impl] === "filter" /* FILTER */) return true;
3407
+ return getRegisteredNavigatorRole(impl) === "filter" /* FILTER */;
2929
3408
  }
2930
3409
  var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
2931
3410
  var init_navigators = __esm({
@@ -2940,6 +3419,7 @@ var init_navigators = __esm({
2940
3419
  Navigators = /* @__PURE__ */ ((Navigators2) => {
2941
3420
  Navigators2["ELO"] = "elo";
2942
3421
  Navigators2["SRS"] = "srs";
3422
+ Navigators2["PRESCRIBED"] = "prescribed";
2943
3423
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
2944
3424
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
2945
3425
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -2954,6 +3434,7 @@ var init_navigators = __esm({
2954
3434
  NavigatorRoles = {
2955
3435
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
2956
3436
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3437
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
2957
3438
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
2958
3439
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
2959
3440
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -3118,6 +3599,12 @@ var init_navigators = __esm({
3118
3599
  async getWeightedCards(_limit) {
3119
3600
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
3120
3601
  }
3602
+ /**
3603
+ * Set ephemeral hints for the next pipeline run.
3604
+ * No-op for non-Pipeline navigators. Pipeline overrides this.
3605
+ */
3606
+ setEphemeralHints(_hints) {
3607
+ }
3121
3608
  };
3122
3609
  }
3123
3610
  });
@@ -3304,15 +3791,42 @@ var init_courseDB = __esm({
3304
3791
  // private log(msg: string): void {
3305
3792
  // log(`CourseLog: ${this.id}\n ${msg}`);
3306
3793
  // }
3794
+ /**
3795
+ * Primary database handle used for all **read** operations (queries, gets).
3796
+ *
3797
+ * When local sync is active, this points to the local PouchDB replica for
3798
+ * fast, network-free reads. Otherwise it points to the remote CouchDB.
3799
+ */
3307
3800
  db;
3801
+ /**
3802
+ * Remote database handle used for all **write** operations.
3803
+ *
3804
+ * Always points to the remote CouchDB so that writes (ELO updates, tag
3805
+ * mutations, admin operations) aggregate on the server. The local replica
3806
+ * is a read-only snapshot that refreshes on the next page load.
3807
+ *
3808
+ * When local sync is NOT active, this is the same instance as `this.db`.
3809
+ */
3810
+ remoteDB;
3308
3811
  id;
3309
3812
  _getCurrentUser;
3310
3813
  updateQueue;
3311
- constructor(id, userLookup) {
3814
+ /**
3815
+ * @param id - Course ID
3816
+ * @param userLookup - Async function returning the current user DB
3817
+ * @param localDB - Optional local PouchDB replica for reads. When provided,
3818
+ * `this.db` uses the local replica and `this.remoteDB` stays remote.
3819
+ * The UpdateQueue reads from remote and writes to remote (local `_rev`
3820
+ * values may be stale, so read-modify-write cycles must go through
3821
+ * the remote DB to avoid conflicts).
3822
+ */
3823
+ constructor(id, userLookup, localDB) {
3312
3824
  this.id = id;
3313
- this.db = getCourseDB2(this.id);
3825
+ const remote = getCourseDB2(this.id);
3826
+ this.remoteDB = remote;
3827
+ this.db = localDB ?? remote;
3314
3828
  this._getCurrentUser = userLookup;
3315
- this.updateQueue = new UpdateQueue(this.db);
3829
+ this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
3316
3830
  }
3317
3831
  getCourseID() {
3318
3832
  return this.id;
@@ -3400,7 +3914,7 @@ var init_courseDB = __esm({
3400
3914
  };
3401
3915
  }
3402
3916
  async removeCard(id) {
3403
- const doc = await this.db.get(id);
3917
+ const doc = await this.remoteDB.get(id);
3404
3918
  if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
3405
3919
  throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
3406
3920
  }
@@ -3421,7 +3935,7 @@ var init_courseDB = __esm({
3421
3935
  } catch (error) {
3422
3936
  logger.error(`Error removing card ${id} from tags: ${error}`);
3423
3937
  }
3424
- return this.db.remove(doc);
3938
+ return this.remoteDB.remove(doc);
3425
3939
  }
3426
3940
  async getCardDisplayableDataIDs(id) {
3427
3941
  logger.debug(id.join(", "));
@@ -3523,8 +4037,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3523
4037
  if (cardIds.length === 0) {
3524
4038
  return /* @__PURE__ */ new Map();
3525
4039
  }
3526
- const db = getCourseDB2(this.id);
3527
- const result = await db.query("getTags", {
4040
+ const result = await this.db.query("getTags", {
3528
4041
  keys: cardIds,
3529
4042
  include_docs: false
3530
4043
  });
@@ -3541,6 +4054,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3541
4054
  }
3542
4055
  return tagsByCard;
3543
4056
  }
4057
+ async getAllCardIds() {
4058
+ const result = await this.db.allDocs({
4059
+ startkey: "CARD-",
4060
+ endkey: "CARD-\uFFF0",
4061
+ include_docs: false
4062
+ });
4063
+ return result.rows.map((row) => row.id);
4064
+ }
3544
4065
  async addTagToCard(cardId, tagId, updateELO) {
3545
4066
  return await addTagToCard(
3546
4067
  this.id,
@@ -3607,10 +4128,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3607
4128
  }
3608
4129
  }
3609
4130
  async getCourseDoc(id, options) {
3610
- return await getCourseDoc(this.id, id, options);
4131
+ return await this.db.get(id, options);
3611
4132
  }
3612
4133
  async getCourseDocs(ids, options = {}) {
3613
- return await getCourseDocs(this.id, ids, options);
4134
+ return await this.db.allDocs({
4135
+ ...options,
4136
+ keys: ids
4137
+ });
3614
4138
  }
3615
4139
  ////////////////////////////////////
3616
4140
  // NavigationStrategyManager implementation
@@ -3644,7 +4168,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3644
4168
  }
3645
4169
  async addNavigationStrategy(data) {
3646
4170
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
3647
- return this.db.put(data).then(() => {
4171
+ return this.remoteDB.put(data).then(() => {
3648
4172
  });
3649
4173
  }
3650
4174
  updateNavigationStrategy(id, data) {
@@ -5238,6 +5762,9 @@ Currently logged-in as ${this._username}.`
5238
5762
  const id = row.id;
5239
5763
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
5240
5764
  id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
5765
+ id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
5766
+ id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
5767
+ id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
5241
5768
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
5242
5769
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
5243
5770
  id === _BaseUser.DOC_IDS.CONFIG;
@@ -6115,6 +6642,234 @@ var init_adminDB2 = __esm({
6115
6642
  }
6116
6643
  });
6117
6644
 
6645
+ // src/impl/couch/CourseSyncService.ts
6646
+ var CourseSyncService;
6647
+ var init_CourseSyncService = __esm({
6648
+ "src/impl/couch/CourseSyncService.ts"() {
6649
+ "use strict";
6650
+ init_pouchdb_setup();
6651
+ init_couch();
6652
+ init_logger();
6653
+ CourseSyncService = class _CourseSyncService {
6654
+ static instance = null;
6655
+ entries = /* @__PURE__ */ new Map();
6656
+ constructor() {
6657
+ }
6658
+ static getInstance() {
6659
+ if (!_CourseSyncService.instance) {
6660
+ _CourseSyncService.instance = new _CourseSyncService();
6661
+ }
6662
+ return _CourseSyncService.instance;
6663
+ }
6664
+ /**
6665
+ * Reset the singleton (for testing).
6666
+ */
6667
+ static resetInstance() {
6668
+ if (_CourseSyncService.instance) {
6669
+ for (const [, entry] of _CourseSyncService.instance.entries) {
6670
+ if (entry.localDB) {
6671
+ entry.localDB.close().catch(() => {
6672
+ });
6673
+ }
6674
+ }
6675
+ _CourseSyncService.instance.entries.clear();
6676
+ }
6677
+ _CourseSyncService.instance = null;
6678
+ }
6679
+ // --------------------------------------------------------------------------
6680
+ // Public API
6681
+ // --------------------------------------------------------------------------
6682
+ /**
6683
+ * Ensure a course's local replica is synced.
6684
+ *
6685
+ * On first call for a course:
6686
+ * 1. Fetches CourseConfig from remote to check localSync.enabled
6687
+ * 2. If enabled, performs one-shot replication remote → local
6688
+ * 3. Pre-warms PouchDB view indices (elo, getTags)
6689
+ *
6690
+ * On subsequent calls: returns immediately if already synced, or awaits
6691
+ * the in-flight sync if one is in progress.
6692
+ *
6693
+ * Safe to call multiple times — concurrent calls coalesce to one sync.
6694
+ *
6695
+ * @param courseId - The course to sync
6696
+ * @param forceEnabled - Skip the CourseConfig check and sync regardless.
6697
+ * Useful when the caller already knows local sync is desired (e.g.,
6698
+ * LettersPractice hardcodes this).
6699
+ */
6700
+ async ensureSynced(courseId, forceEnabled) {
6701
+ const existing = this.entries.get(courseId);
6702
+ if (existing?.status.state === "ready") {
6703
+ return;
6704
+ }
6705
+ if (existing?.status.state === "disabled") {
6706
+ return;
6707
+ }
6708
+ if (existing?.readyPromise) {
6709
+ return existing.readyPromise;
6710
+ }
6711
+ const entry = {
6712
+ localDB: null,
6713
+ status: { state: "not-started" },
6714
+ readyPromise: null
6715
+ };
6716
+ this.entries.set(courseId, entry);
6717
+ entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
6718
+ return entry.readyPromise;
6719
+ }
6720
+ /**
6721
+ * Get the local PouchDB for a course, or null if not available.
6722
+ *
6723
+ * Returns null when:
6724
+ * - Local sync is not enabled for this course
6725
+ * - Sync has not been triggered yet
6726
+ * - Sync is still in progress
6727
+ * - Sync failed
6728
+ */
6729
+ getLocalDB(courseId) {
6730
+ const entry = this.entries.get(courseId);
6731
+ if (entry?.status.state === "ready" && entry.localDB) {
6732
+ return entry.localDB;
6733
+ }
6734
+ return null;
6735
+ }
6736
+ /**
6737
+ * Check whether a course has a ready local replica.
6738
+ */
6739
+ isReady(courseId) {
6740
+ return this.entries.get(courseId)?.status.state === "ready";
6741
+ }
6742
+ /**
6743
+ * Get detailed sync status for a course.
6744
+ */
6745
+ getStatus(courseId) {
6746
+ return this.entries.get(courseId)?.status ?? { state: "not-started" };
6747
+ }
6748
+ // --------------------------------------------------------------------------
6749
+ // Internal
6750
+ // --------------------------------------------------------------------------
6751
+ async performSync(courseId, entry, forceEnabled) {
6752
+ try {
6753
+ if (!forceEnabled) {
6754
+ entry.status = { state: "checking-config" };
6755
+ const enabled = await this.checkLocalSyncEnabled(courseId);
6756
+ if (!enabled) {
6757
+ entry.status = { state: "disabled" };
6758
+ entry.readyPromise = null;
6759
+ logger.debug(
6760
+ `[CourseSyncService] Local sync disabled for course ${courseId}`
6761
+ );
6762
+ return;
6763
+ }
6764
+ }
6765
+ entry.status = { state: "syncing" };
6766
+ const localDBName = this.localDBName(courseId);
6767
+ const localDB = new pouchdb_setup_default(localDBName);
6768
+ entry.localDB = localDB;
6769
+ const remoteDB = this.getRemoteDB(courseId);
6770
+ const syncStart = Date.now();
6771
+ logger.info(
6772
+ `[CourseSyncService] Starting one-shot replication for course ${courseId}`
6773
+ );
6774
+ const result = await this.replicate(remoteDB, localDB);
6775
+ const syncTimeMs = Date.now() - syncStart;
6776
+ logger.info(
6777
+ `[CourseSyncService] Replication complete for course ${courseId}: ${result.docs_written} docs in ${syncTimeMs}ms`
6778
+ );
6779
+ entry.status = { state: "warming-views" };
6780
+ const warmStart = Date.now();
6781
+ await this.warmViewIndices(localDB);
6782
+ const viewWarmTimeMs = Date.now() - warmStart;
6783
+ logger.info(
6784
+ `[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
6785
+ );
6786
+ entry.status = {
6787
+ state: "ready",
6788
+ docsReplicated: result.docs_written,
6789
+ syncTimeMs,
6790
+ viewWarmTimeMs
6791
+ };
6792
+ } catch (e) {
6793
+ const errorMsg = e instanceof Error ? e.message : String(e);
6794
+ logger.error(
6795
+ `[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
6796
+ );
6797
+ entry.status = { state: "error", error: errorMsg };
6798
+ entry.readyPromise = null;
6799
+ if (entry.localDB) {
6800
+ try {
6801
+ await entry.localDB.destroy();
6802
+ } catch {
6803
+ }
6804
+ entry.localDB = null;
6805
+ }
6806
+ }
6807
+ }
6808
+ /**
6809
+ * Check CourseConfig.localSync.enabled on the remote DB.
6810
+ */
6811
+ async checkLocalSyncEnabled(courseId) {
6812
+ try {
6813
+ const remoteDB = this.getRemoteDB(courseId);
6814
+ const config = await remoteDB.get("CourseConfig");
6815
+ return config.localSync?.enabled === true;
6816
+ } catch (e) {
6817
+ logger.warn(
6818
+ `[CourseSyncService] Could not read CourseConfig for ${courseId}, assuming local sync disabled: ${e}`
6819
+ );
6820
+ return false;
6821
+ }
6822
+ }
6823
+ /**
6824
+ * One-shot replication from remote to local.
6825
+ */
6826
+ replicate(source, target) {
6827
+ return new Promise((resolve, reject) => {
6828
+ void pouchdb_setup_default.replicate(source, target, {
6829
+ // One-shot, not live. Local is a read-only snapshot.
6830
+ }).on("complete", (info) => {
6831
+ resolve(info);
6832
+ }).on("error", (err) => {
6833
+ reject(err);
6834
+ });
6835
+ });
6836
+ }
6837
+ /**
6838
+ * Pre-warm PouchDB view indices by running a minimal query against each
6839
+ * design doc. This forces PouchDB to build the MapReduce index now
6840
+ * (during a loading phase) rather than on first pipeline query.
6841
+ */
6842
+ async warmViewIndices(localDB) {
6843
+ const viewsToWarm = ["elo", "getTags"];
6844
+ for (const viewName of viewsToWarm) {
6845
+ try {
6846
+ await localDB.query(viewName, { limit: 1 });
6847
+ logger.debug(
6848
+ `[CourseSyncService] Warmed view index: ${viewName}`
6849
+ );
6850
+ } catch (e) {
6851
+ logger.debug(
6852
+ `[CourseSyncService] Could not warm view ${viewName}: ${e}`
6853
+ );
6854
+ }
6855
+ }
6856
+ }
6857
+ /**
6858
+ * Get a remote PouchDB handle for a course.
6859
+ */
6860
+ getRemoteDB(courseId) {
6861
+ return getCourseDB2(courseId);
6862
+ }
6863
+ /**
6864
+ * Local DB naming convention.
6865
+ */
6866
+ localDBName(courseId) {
6867
+ return `coursedb-local-${courseId}`;
6868
+ }
6869
+ };
6870
+ }
6871
+ });
6872
+
6118
6873
  // src/impl/couch/auth.ts
6119
6874
  async function getCurrentSession() {
6120
6875
  try {
@@ -6391,6 +7146,7 @@ __export(couch_exports, {
6391
7146
  ClassroomLookupDB: () => ClassroomLookupDB,
6392
7147
  CouchDBSyncStrategy: () => CouchDBSyncStrategy,
6393
7148
  CourseDB: () => CourseDB,
7149
+ CourseSyncService: () => CourseSyncService,
6394
7150
  CoursesDB: () => CoursesDB,
6395
7151
  REVIEW_TIME_FORMAT: () => REVIEW_TIME_FORMAT,
6396
7152
  StudentClassroomDB: () => StudentClassroomDB,
@@ -6607,6 +7363,7 @@ var init_couch = __esm({
6607
7363
  init_classroomDB2();
6608
7364
  init_courseAPI();
6609
7365
  init_courseDB();
7366
+ init_CourseSyncService();
6610
7367
  init_CouchDBSyncStrategy();
6611
7368
  isBrowser = typeof window !== "undefined";
6612
7369
  if (isBrowser) {
@@ -6632,6 +7389,7 @@ init_couch();
6632
7389
  ClassroomLookupDB,
6633
7390
  CouchDBSyncStrategy,
6634
7391
  CourseDB,
7392
+ CourseSyncService,
6635
7393
  CoursesDB,
6636
7394
  REVIEW_TIME_FORMAT,
6637
7395
  StudentClassroomDB,