@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
@@ -627,8 +627,12 @@ __export(PipelineDebugger_exports, {
627
627
  buildRunReport: () => buildRunReport,
628
628
  captureRun: () => captureRun,
629
629
  mountPipelineDebugger: () => mountPipelineDebugger,
630
- pipelineDebugAPI: () => pipelineDebugAPI
630
+ pipelineDebugAPI: () => pipelineDebugAPI,
631
+ registerPipelineForDebug: () => registerPipelineForDebug
631
632
  });
633
+ function registerPipelineForDebug(pipeline) {
634
+ _activePipeline = pipeline;
635
+ }
632
636
  function getOrigin(card) {
633
637
  const firstEntry = card.provenance[0];
634
638
  if (!firstEntry) return "unknown";
@@ -656,6 +660,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
656
660
  origin: getOrigin(card),
657
661
  finalScore: card.score,
658
662
  provenance: card.provenance,
663
+ tags: card.tags,
659
664
  selected: selectedIds.has(card.cardId)
660
665
  }));
661
666
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
@@ -711,11 +716,13 @@ function mountPipelineDebugger() {
711
716
  win.skuilder = win.skuilder || {};
712
717
  win.skuilder.pipeline = pipelineDebugAPI;
713
718
  }
714
- var MAX_RUNS, runHistory, pipelineDebugAPI;
719
+ var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
715
720
  var init_PipelineDebugger = __esm({
716
721
  "src/core/navigators/PipelineDebugger.ts"() {
717
722
  "use strict";
723
+ init_navigators();
718
724
  init_logger();
725
+ _activePipeline = null;
719
726
  MAX_RUNS = 10;
720
727
  runHistory = [];
721
728
  pipelineDebugAPI = {
@@ -857,6 +864,81 @@ var init_PipelineDebugger = __esm({
857
864
  runHistory.length = 0;
858
865
  logger.info("[Pipeline Debug] Run history cleared.");
859
866
  },
867
+ /**
868
+ * Show the navigator registry: all registered classes and their roles.
869
+ *
870
+ * Useful for verifying that consumer-defined navigators were registered
871
+ * before pipeline assembly.
872
+ */
873
+ showRegistry() {
874
+ const names = getRegisteredNavigatorNames();
875
+ if (names.length === 0) {
876
+ logger.info("[Pipeline Debug] Navigator registry is empty.");
877
+ return;
878
+ }
879
+ console.group("\u{1F4E6} Navigator Registry");
880
+ console.table(
881
+ names.map((name) => {
882
+ const registryRole = getRegisteredNavigatorRole(name);
883
+ const builtinRole = NavigatorRoles[name];
884
+ const effectiveRole = builtinRole || registryRole || "\u26A0\uFE0F NONE";
885
+ const source = builtinRole ? "built-in" : registryRole ? "consumer" : "unclassified";
886
+ return {
887
+ name,
888
+ role: effectiveRole,
889
+ source,
890
+ isGenerator: isGenerator(name),
891
+ isFilter: isFilter(name)
892
+ };
893
+ })
894
+ );
895
+ console.groupEnd();
896
+ },
897
+ /**
898
+ * Show strategy documents from the last pipeline run and how they mapped
899
+ * to the registry.
900
+ *
901
+ * If no runs are captured yet, falls back to showing just the registry.
902
+ */
903
+ showStrategies() {
904
+ this.showRegistry();
905
+ if (runHistory.length === 0) {
906
+ logger.info("[Pipeline Debug] No pipeline runs captured yet \u2014 cannot show strategy doc mapping.");
907
+ return;
908
+ }
909
+ const run = runHistory[0];
910
+ console.group("\u{1F50C} Pipeline Strategy Mapping (last run)");
911
+ logger.info(`Generator: ${run.generatorName}`);
912
+ if (run.generators && run.generators.length > 0) {
913
+ for (const g of run.generators) {
914
+ logger.info(` \u{1F4E5} ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
915
+ }
916
+ }
917
+ if (run.filters.length > 0) {
918
+ logger.info("Filters:");
919
+ for (const f of run.filters) {
920
+ logger.info(` \u{1F538} ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
921
+ }
922
+ } else {
923
+ logger.info("Filters: (none)");
924
+ }
925
+ console.groupEnd();
926
+ },
927
+ /**
928
+ * Scan the full card space through the filter chain for the current user.
929
+ *
930
+ * Reports how many cards are well-indicated and how many are new.
931
+ * Use this to understand how the search space grows during onboarding.
932
+ *
933
+ * @param threshold - Score threshold for "well indicated" (default 0.10)
934
+ */
935
+ async diagnoseCardSpace(threshold) {
936
+ if (!_activePipeline) {
937
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
938
+ return null;
939
+ }
940
+ return _activePipeline.diagnoseCardSpace({ threshold });
941
+ },
860
942
  /**
861
943
  * Show help.
862
944
  */
@@ -869,6 +951,9 @@ Commands:
869
951
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
870
952
  .showCard(cardId) Show provenance trail for a specific card
871
953
  .explainReviews() Analyze why reviews were/weren't selected
954
+ .diagnoseCardSpace() Scan full card space through filters (async)
955
+ .showRegistry() Show navigator registry (classes + roles)
956
+ .showStrategies() Show registry + strategy mapping from last run
872
957
  .listRuns() List all captured runs in table format
873
958
  .export() Export run history as JSON for bug reports
874
959
  .clear() Clear run history
@@ -878,7 +963,7 @@ Commands:
878
963
  Example:
879
964
  window.skuilder.pipeline.showLastRun()
880
965
  window.skuilder.pipeline.showRun(1)
881
- window.skuilder.pipeline.showCard('abc123')
966
+ await window.skuilder.pipeline.diagnoseCardSpace()
882
967
  `);
883
968
  }
884
969
  };
@@ -1173,6 +1258,69 @@ var init_generators = __esm({
1173
1258
  }
1174
1259
  });
1175
1260
 
1261
+ // src/core/navigators/generators/prescribed.ts
1262
+ var prescribed_exports = {};
1263
+ __export(prescribed_exports, {
1264
+ default: () => PrescribedCardsGenerator
1265
+ });
1266
+ var PrescribedCardsGenerator;
1267
+ var init_prescribed = __esm({
1268
+ "src/core/navigators/generators/prescribed.ts"() {
1269
+ "use strict";
1270
+ init_navigators();
1271
+ init_logger();
1272
+ PrescribedCardsGenerator = class extends ContentNavigator {
1273
+ name;
1274
+ config;
1275
+ constructor(user, course, strategyData) {
1276
+ super(user, course, strategyData);
1277
+ this.name = strategyData.name || "Prescribed Cards";
1278
+ try {
1279
+ const parsed = JSON.parse(strategyData.serializedData);
1280
+ this.config = { cardIds: parsed.cardIds || [] };
1281
+ } catch {
1282
+ this.config = { cardIds: [] };
1283
+ }
1284
+ logger.debug(
1285
+ `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1286
+ );
1287
+ }
1288
+ async getWeightedCards(limit, _context) {
1289
+ if (this.config.cardIds.length === 0) {
1290
+ return [];
1291
+ }
1292
+ const courseId = this.course.getCourseID();
1293
+ const activeCards = await this.user.getActiveCards();
1294
+ const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1295
+ const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1296
+ if (eligibleIds.length === 0) {
1297
+ logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1298
+ return [];
1299
+ }
1300
+ const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1301
+ cardId,
1302
+ courseId,
1303
+ score: 1,
1304
+ provenance: [
1305
+ {
1306
+ strategy: "prescribed",
1307
+ strategyName: this.strategyName || this.name,
1308
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1309
+ action: "generated",
1310
+ score: 1,
1311
+ reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1312
+ }
1313
+ ]
1314
+ }));
1315
+ logger.info(
1316
+ `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1317
+ );
1318
+ return cards;
1319
+ }
1320
+ };
1321
+ }
1322
+ });
1323
+
1176
1324
  // src/core/navigators/generators/srs.ts
1177
1325
  var srs_exports = {};
1178
1326
  __export(srs_exports, {
@@ -1367,6 +1515,7 @@ var init_ = __esm({
1367
1515
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1368
1516
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1369
1517
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1518
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1370
1519
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1371
1520
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1372
1521
  });
@@ -1567,6 +1716,8 @@ var init_hierarchyDefinition = __esm({
1567
1716
  if (userTagElo.count < minCount) return false;
1568
1717
  if (prereq.masteryThreshold?.minElo !== void 0) {
1569
1718
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1719
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1720
+ return true;
1570
1721
  } else {
1571
1722
  return userTagElo.score >= userGlobalElo;
1572
1723
  }
@@ -1642,14 +1793,38 @@ var init_hierarchyDefinition = __esm({
1642
1793
  };
1643
1794
  }
1644
1795
  }
1796
+ /**
1797
+ * Build a map of prereq tag → max configured boost for all *closed* gates.
1798
+ *
1799
+ * When a gate is closed (prereqs unmet), cards carrying that gate's prereq
1800
+ * tags get boosted — steering the pipeline toward content that helps unlock
1801
+ * the gated material. Once the gate opens, the boost disappears.
1802
+ */
1803
+ getPreReqBoosts(unlockedTags, masteredTags) {
1804
+ const boosts = /* @__PURE__ */ new Map();
1805
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
1806
+ if (unlockedTags.has(tagId)) continue;
1807
+ for (const prereq of prereqs) {
1808
+ if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
1809
+ if (masteredTags.has(prereq.tag)) continue;
1810
+ const existing = boosts.get(prereq.tag) ?? 1;
1811
+ boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
1812
+ }
1813
+ }
1814
+ return boosts;
1815
+ }
1645
1816
  /**
1646
1817
  * CardFilter.transform implementation.
1647
1818
  *
1648
- * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
1819
+ * Two effects:
1820
+ * 1. Cards with locked tags receive score * 0.05 (gating penalty)
1821
+ * 2. Cards carrying prereq tags of closed gates receive a configured
1822
+ * boost (preReqBoost), steering toward content that unlocks gates
1649
1823
  */
1650
1824
  async transform(cards, context) {
1651
1825
  const masteredTags = await this.getMasteredTags(context);
1652
1826
  const unlockedTags = this.getUnlockedTags(masteredTags);
1827
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1653
1828
  const gated = [];
1654
1829
  for (const card of cards) {
1655
1830
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1658,9 +1833,27 @@ var init_hierarchyDefinition = __esm({
1658
1833
  unlockedTags,
1659
1834
  masteredTags
1660
1835
  );
1661
- const LOCKED_PENALTY = 0.01;
1662
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1663
- const action = isUnlocked ? "passed" : "penalized";
1836
+ const LOCKED_PENALTY = 0.02;
1837
+ let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1838
+ let action = isUnlocked ? "passed" : "penalized";
1839
+ let finalReason = reason;
1840
+ if (isUnlocked && preReqBoosts.size > 0) {
1841
+ const cardTags = card.tags ?? [];
1842
+ let maxBoost = 1;
1843
+ const boostedPrereqs = [];
1844
+ for (const tag of cardTags) {
1845
+ const boost = preReqBoosts.get(tag);
1846
+ if (boost && boost > maxBoost) {
1847
+ maxBoost = boost;
1848
+ boostedPrereqs.push(tag);
1849
+ }
1850
+ }
1851
+ if (maxBoost > 1) {
1852
+ finalScore *= maxBoost;
1853
+ action = "boosted";
1854
+ finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
1855
+ }
1856
+ }
1664
1857
  gated.push({
1665
1858
  ...card,
1666
1859
  score: finalScore,
@@ -1672,7 +1865,7 @@ var init_hierarchyDefinition = __esm({
1672
1865
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1673
1866
  action,
1674
1867
  score: finalScore,
1675
- reason
1868
+ reason: finalReason
1676
1869
  }
1677
1870
  ]
1678
1871
  });
@@ -2361,6 +2554,18 @@ __export(Pipeline_exports, {
2361
2554
  Pipeline: () => Pipeline
2362
2555
  });
2363
2556
  import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
2557
+ function globToRegex(pattern) {
2558
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
2559
+ const withWildcards = escaped.replace(/\*/g, ".*");
2560
+ return new RegExp(`^${withWildcards}$`);
2561
+ }
2562
+ function globMatch(value, pattern) {
2563
+ if (!pattern.includes("*")) return value === pattern;
2564
+ return globToRegex(pattern).test(value);
2565
+ }
2566
+ function cardMatchesTagPattern(card, pattern) {
2567
+ return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
2568
+ }
2364
2569
  function logPipelineConfig(generator, filters) {
2365
2570
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2366
2571
  logger.info(
@@ -2395,6 +2600,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2395
2600
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2396
2601
  );
2397
2602
  }
2603
+ function logResultCards(cards) {
2604
+ if (!VERBOSE_RESULTS || cards.length === 0) return;
2605
+ logger.info(`[Pipeline] Results (${cards.length} cards):`);
2606
+ for (let i = 0; i < cards.length; i++) {
2607
+ const c = cards[i];
2608
+ const tags = c.tags?.slice(0, 3).join(", ") || "";
2609
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
2610
+ const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
2611
+ return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
2612
+ }).join(" | ");
2613
+ logger.info(
2614
+ `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
2615
+ );
2616
+ }
2617
+ }
2398
2618
  function logCardProvenance(cards, maxCards = 3) {
2399
2619
  const cardsToLog = cards.slice(0, maxCards);
2400
2620
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2409,7 +2629,7 @@ function logCardProvenance(cards, maxCards = 3) {
2409
2629
  }
2410
2630
  }
2411
2631
  }
2412
- var Pipeline;
2632
+ var VERBOSE_RESULTS, Pipeline;
2413
2633
  var init_Pipeline = __esm({
2414
2634
  "src/core/navigators/Pipeline.ts"() {
2415
2635
  "use strict";
@@ -2417,9 +2637,31 @@ var init_Pipeline = __esm({
2417
2637
  init_logger();
2418
2638
  init_orchestration();
2419
2639
  init_PipelineDebugger();
2640
+ VERBOSE_RESULTS = true;
2420
2641
  Pipeline = class extends ContentNavigator {
2421
2642
  generator;
2422
2643
  filters;
2644
+ /**
2645
+ * Cached orchestration context. Course config and salt don't change within
2646
+ * a page load, so we build the orchestration context once and reuse it on
2647
+ * subsequent getWeightedCards() calls (e.g. mid-session replans).
2648
+ *
2649
+ * This eliminates a remote getCourseConfig() round trip per pipeline run.
2650
+ */
2651
+ _cachedOrchestration = null;
2652
+ /**
2653
+ * Persistent tag cache. Maps cardId → tag names.
2654
+ *
2655
+ * Tags are static within a session (they're set at card generation time),
2656
+ * so we cache them across pipeline runs. On replans, many of the same cards
2657
+ * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
2658
+ */
2659
+ _tagCache = /* @__PURE__ */ new Map();
2660
+ /**
2661
+ * One-shot replan hints. Applied after the filter chain on the next
2662
+ * getWeightedCards() call, then cleared.
2663
+ */
2664
+ _ephemeralHints = null;
2423
2665
  /**
2424
2666
  * Create a new pipeline.
2425
2667
  *
@@ -2440,6 +2682,17 @@ var init_Pipeline = __esm({
2440
2682
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2441
2683
  });
2442
2684
  logPipelineConfig(generator, filters);
2685
+ registerPipelineForDebug(this);
2686
+ }
2687
+ /**
2688
+ * Set one-shot hints for the next pipeline run.
2689
+ * Consumed after one getWeightedCards() call, then cleared.
2690
+ *
2691
+ * Overrides ContentNavigator.setEphemeralHints() no-op.
2692
+ */
2693
+ setEphemeralHints(hints) {
2694
+ this._ephemeralHints = hints;
2695
+ logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
2443
2696
  }
2444
2697
  /**
2445
2698
  * Get weighted cards by running generator and applying filters.
@@ -2456,13 +2709,15 @@ var init_Pipeline = __esm({
2456
2709
  * @returns Cards sorted by score descending
2457
2710
  */
2458
2711
  async getWeightedCards(limit) {
2712
+ const t0 = performance.now();
2459
2713
  const context = await this.buildContext();
2460
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2461
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
2714
+ const tContext = performance.now();
2715
+ const fetchLimit = 500;
2462
2716
  logger.debug(
2463
2717
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2464
2718
  );
2465
2719
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2720
+ const tGenerate = performance.now();
2466
2721
  const generatedCount = cards.length;
2467
2722
  let generatorSummaries;
2468
2723
  if (this.generator.generators) {
@@ -2491,6 +2746,7 @@ var init_Pipeline = __esm({
2491
2746
  }
2492
2747
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2493
2748
  cards = await this.hydrateTags(cards);
2749
+ const tHydrate = performance.now();
2494
2750
  const allCardsBeforeFiltering = [...cards];
2495
2751
  const filterImpacts = [];
2496
2752
  for (const filter of this.filters) {
@@ -2509,8 +2765,17 @@ var init_Pipeline = __esm({
2509
2765
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2510
2766
  }
2511
2767
  cards = cards.filter((c) => c.score > 0);
2768
+ const hints = this._ephemeralHints;
2769
+ if (hints) {
2770
+ this._ephemeralHints = null;
2771
+ cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
2772
+ }
2512
2773
  cards.sort((a, b) => b.score - a.score);
2774
+ const tFilter = performance.now();
2513
2775
  const result = cards.slice(0, limit);
2776
+ logger.info(
2777
+ `[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)})`
2778
+ );
2514
2779
  const topScores = result.slice(0, 3).map((c) => c.score);
2515
2780
  logExecutionSummary(
2516
2781
  this.generator.name,
@@ -2520,6 +2785,7 @@ var init_Pipeline = __esm({
2520
2785
  topScores,
2521
2786
  filterImpacts
2522
2787
  );
2788
+ logResultCards(result);
2523
2789
  logCardProvenance(result, 3);
2524
2790
  try {
2525
2791
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -2546,6 +2812,10 @@ var init_Pipeline = __esm({
2546
2812
  * to the WeightedCard objects. Filters can then use card.tags instead of
2547
2813
  * making individual getAppliedTags() calls.
2548
2814
  *
2815
+ * Uses a persistent tag cache across pipeline runs — tags are static within
2816
+ * a session, so cards seen in a prior run (e.g. before a replan) don't
2817
+ * require a second DB query.
2818
+ *
2549
2819
  * @param cards - Cards to hydrate
2550
2820
  * @returns Cards with tags populated
2551
2821
  */
@@ -2553,14 +2823,128 @@ var init_Pipeline = __esm({
2553
2823
  if (cards.length === 0) {
2554
2824
  return cards;
2555
2825
  }
2556
- const cardIds = cards.map((c) => c.cardId);
2557
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
2826
+ const uncachedIds = [];
2827
+ for (const card of cards) {
2828
+ if (!this._tagCache.has(card.cardId)) {
2829
+ uncachedIds.push(card.cardId);
2830
+ }
2831
+ }
2832
+ if (uncachedIds.length > 0) {
2833
+ const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
2834
+ for (const [cardId, tags] of freshTags) {
2835
+ this._tagCache.set(cardId, tags);
2836
+ }
2837
+ }
2838
+ const tagsByCard = /* @__PURE__ */ new Map();
2839
+ for (const card of cards) {
2840
+ tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
2841
+ }
2558
2842
  logTagHydration(cards, tagsByCard);
2559
2843
  return cards.map((card) => ({
2560
2844
  ...card,
2561
- tags: tagsByCard.get(card.cardId) ?? []
2845
+ tags: this._tagCache.get(card.cardId) ?? []
2562
2846
  }));
2563
2847
  }
2848
+ // ---------------------------------------------------------------------------
2849
+ // Ephemeral hints application
2850
+ // ---------------------------------------------------------------------------
2851
+ /**
2852
+ * Apply one-shot replan hints to the post-filter card set.
2853
+ *
2854
+ * Order of operations:
2855
+ * 1. Exclude (remove unwanted cards)
2856
+ * 2. Boost (multiply scores)
2857
+ * 3. Require (inject must-have cards from the full pre-filter pool)
2858
+ *
2859
+ * @param cards - Post-filter cards (score > 0)
2860
+ * @param hints - The ephemeral hints to apply
2861
+ * @param allCards - Full pre-filter card pool (for require injection)
2862
+ */
2863
+ applyHints(cards, hints, allCards) {
2864
+ const beforeCount = cards.length;
2865
+ if (hints.excludeCards?.length) {
2866
+ cards = cards.filter(
2867
+ (c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
2868
+ );
2869
+ }
2870
+ if (hints.excludeTags?.length) {
2871
+ cards = cards.filter(
2872
+ (c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
2873
+ );
2874
+ }
2875
+ if (hints.boostTags) {
2876
+ for (const [pattern, factor] of Object.entries(hints.boostTags)) {
2877
+ for (const card of cards) {
2878
+ if (cardMatchesTagPattern(card, pattern)) {
2879
+ card.score *= factor;
2880
+ card.provenance.push({
2881
+ strategy: "ephemeralHint",
2882
+ strategyId: "ephemeral-hint",
2883
+ strategyName: "Replan Hint",
2884
+ action: "boosted",
2885
+ score: card.score,
2886
+ reason: `boostTag ${pattern} \xD7${factor}`
2887
+ });
2888
+ }
2889
+ }
2890
+ }
2891
+ }
2892
+ if (hints.boostCards) {
2893
+ for (const [pattern, factor] of Object.entries(hints.boostCards)) {
2894
+ for (const card of cards) {
2895
+ if (globMatch(card.cardId, pattern)) {
2896
+ card.score *= factor;
2897
+ card.provenance.push({
2898
+ strategy: "ephemeralHint",
2899
+ strategyId: "ephemeral-hint",
2900
+ strategyName: "Replan Hint",
2901
+ action: "boosted",
2902
+ score: card.score,
2903
+ reason: `boostCard ${pattern} \xD7${factor}`
2904
+ });
2905
+ }
2906
+ }
2907
+ }
2908
+ }
2909
+ const cardIds = new Set(cards.map((c) => c.cardId));
2910
+ const inject = (card, reason) => {
2911
+ if (!cardIds.has(card.cardId)) {
2912
+ const floorScore = Math.max(card.score, 1);
2913
+ cards.push({
2914
+ ...card,
2915
+ score: floorScore,
2916
+ provenance: [
2917
+ ...card.provenance,
2918
+ {
2919
+ strategy: "ephemeralHint",
2920
+ strategyId: "ephemeral-hint",
2921
+ strategyName: "Replan Hint",
2922
+ action: "boosted",
2923
+ score: floorScore,
2924
+ reason
2925
+ }
2926
+ ]
2927
+ });
2928
+ cardIds.add(card.cardId);
2929
+ }
2930
+ };
2931
+ if (hints.requireCards?.length) {
2932
+ for (const pattern of hints.requireCards) {
2933
+ for (const card of allCards) {
2934
+ if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
2935
+ }
2936
+ }
2937
+ }
2938
+ if (hints.requireTags?.length) {
2939
+ for (const pattern of hints.requireTags) {
2940
+ for (const card of allCards) {
2941
+ if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
2942
+ }
2943
+ }
2944
+ }
2945
+ logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
2946
+ return cards;
2947
+ }
2564
2948
  /**
2565
2949
  * Build shared context for generator and filters.
2566
2950
  *
@@ -2578,7 +2962,10 @@ var init_Pipeline = __esm({
2578
2962
  } catch (e) {
2579
2963
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
2580
2964
  }
2581
- const orchestration = await createOrchestrationContext(this.user, this.course);
2965
+ if (!this._cachedOrchestration) {
2966
+ this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
2967
+ }
2968
+ const orchestration = this._cachedOrchestration;
2582
2969
  return {
2583
2970
  user: this.user,
2584
2971
  course: this.course,
@@ -2622,6 +3009,87 @@ var init_Pipeline = __esm({
2622
3009
  }
2623
3010
  return [...new Set(ids)];
2624
3011
  }
3012
+ // ---------------------------------------------------------------------------
3013
+ // Card-space diagnostic
3014
+ // ---------------------------------------------------------------------------
3015
+ /**
3016
+ * Scan every card in the course through the filter chain and report
3017
+ * how many are "well indicated" (score >= threshold) for the current user.
3018
+ *
3019
+ * Also reports how many well-indicated cards the user has NOT yet encountered.
3020
+ *
3021
+ * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
3022
+ */
3023
+ async diagnoseCardSpace(opts) {
3024
+ const THRESHOLD = opts?.threshold ?? 0.1;
3025
+ const t0 = performance.now();
3026
+ const allCardIds = await this.course.getAllCardIds();
3027
+ let cards = allCardIds.map((cardId) => ({
3028
+ cardId,
3029
+ courseId: this.course.getCourseID(),
3030
+ score: 1,
3031
+ provenance: []
3032
+ }));
3033
+ cards = await this.hydrateTags(cards);
3034
+ const context = await this.buildContext();
3035
+ const filterBreakdown = [];
3036
+ for (const filter of this.filters) {
3037
+ cards = await filter.transform(cards, context);
3038
+ const wi = cards.filter((c) => c.score >= THRESHOLD).length;
3039
+ filterBreakdown.push({ name: filter.name, wellIndicated: wi });
3040
+ }
3041
+ const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
3042
+ const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
3043
+ let encounteredIds;
3044
+ try {
3045
+ const courseId = this.course.getCourseID();
3046
+ const seenCards = await this.user.getSeenCards(courseId);
3047
+ encounteredIds = new Set(seenCards);
3048
+ } catch {
3049
+ encounteredIds = /* @__PURE__ */ new Set();
3050
+ }
3051
+ const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
3052
+ const byType = /* @__PURE__ */ new Map();
3053
+ for (const card of cards) {
3054
+ const type = card.cardId.split("-")[1] || "unknown";
3055
+ if (!byType.has(type)) {
3056
+ byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
3057
+ }
3058
+ const entry = byType.get(type);
3059
+ entry.total++;
3060
+ if (card.score >= THRESHOLD) {
3061
+ entry.wellIndicated++;
3062
+ if (!encounteredIds.has(card.cardId)) entry.new++;
3063
+ }
3064
+ }
3065
+ const elapsed = performance.now() - t0;
3066
+ const result = {
3067
+ totalCards: allCardIds.length,
3068
+ threshold: THRESHOLD,
3069
+ wellIndicated: wellIndicatedIds.size,
3070
+ encountered: encounteredIds.size,
3071
+ wellIndicatedNew: wellIndicatedNew.length,
3072
+ byType: Object.fromEntries(byType),
3073
+ filterBreakdown,
3074
+ elapsedMs: Math.round(elapsed)
3075
+ };
3076
+ logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
3077
+ logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
3078
+ logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
3079
+ logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
3080
+ logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
3081
+ logger.info(`[Pipeline:diagnose] By type:`);
3082
+ for (const [type, counts] of byType) {
3083
+ logger.info(
3084
+ `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
3085
+ );
3086
+ }
3087
+ logger.info(`[Pipeline:diagnose] After each filter:`);
3088
+ for (const fb of filterBreakdown) {
3089
+ logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
3090
+ }
3091
+ return result;
3092
+ }
2625
3093
  };
2626
3094
  }
2627
3095
  });
@@ -2726,23 +3194,25 @@ var init_PipelineAssembler = __esm({
2726
3194
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
2727
3195
  }
2728
3196
  }
3197
+ const courseId = course.getCourseID();
3198
+ const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
3199
+ const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
3200
+ if (!hasElo) {
3201
+ logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
3202
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3203
+ }
3204
+ if (!hasSrs) {
3205
+ logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
3206
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
3207
+ }
2729
3208
  if (generatorStrategies.length === 0) {
2730
- if (filterStrategies.length > 0) {
2731
- logger.debug(
2732
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2733
- );
2734
- const courseId = course.getCourseID();
2735
- generatorStrategies.push(createDefaultEloStrategy(courseId));
2736
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
2737
- } else {
2738
- warnings.push("No generator strategy found");
2739
- return {
2740
- pipeline: null,
2741
- generatorStrategies: [],
2742
- filterStrategies: [],
2743
- warnings
2744
- };
2745
- }
3209
+ warnings.push("No generator strategy found");
3210
+ return {
3211
+ pipeline: null,
3212
+ generatorStrategies: [],
3213
+ filterStrategies: [],
3214
+ warnings
3215
+ };
2746
3216
  }
2747
3217
  let generator;
2748
3218
  if (generatorStrategies.length === 1) {
@@ -2820,6 +3290,7 @@ var init_3 = __esm({
2820
3290
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
2821
3291
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
2822
3292
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3293
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
2823
3294
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
2824
3295
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
2825
3296
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -2837,6 +3308,7 @@ __export(navigators_exports, {
2837
3308
  getCardOrigin: () => getCardOrigin,
2838
3309
  getRegisteredNavigator: () => getRegisteredNavigator,
2839
3310
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
3311
+ getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
2840
3312
  hasRegisteredNavigator: () => hasRegisteredNavigator,
2841
3313
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
2842
3314
  isFilter: () => isFilter,
@@ -2845,16 +3317,19 @@ __export(navigators_exports, {
2845
3317
  pipelineDebugAPI: () => pipelineDebugAPI,
2846
3318
  registerNavigator: () => registerNavigator
2847
3319
  });
2848
- function registerNavigator(implementingClass, constructor) {
2849
- navigatorRegistry.set(implementingClass, constructor);
2850
- logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
3320
+ function registerNavigator(implementingClass, constructor, role) {
3321
+ navigatorRegistry.set(implementingClass, { constructor, role });
3322
+ logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ""}`);
2851
3323
  }
2852
3324
  function getRegisteredNavigator(implementingClass) {
2853
- return navigatorRegistry.get(implementingClass);
3325
+ return navigatorRegistry.get(implementingClass)?.constructor;
2854
3326
  }
2855
3327
  function hasRegisteredNavigator(implementingClass) {
2856
3328
  return navigatorRegistry.has(implementingClass);
2857
3329
  }
3330
+ function getRegisteredNavigatorRole(implementingClass) {
3331
+ return navigatorRegistry.get(implementingClass)?.role;
3332
+ }
2858
3333
  function getRegisteredNavigatorNames() {
2859
3334
  return Array.from(navigatorRegistry.keys());
2860
3335
  }
@@ -2864,8 +3339,10 @@ async function initializeNavigatorRegistry() {
2864
3339
  Promise.resolve().then(() => (init_elo(), elo_exports)),
2865
3340
  Promise.resolve().then(() => (init_srs(), srs_exports))
2866
3341
  ]);
3342
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
2867
3343
  registerNavigator("elo", eloModule.default);
2868
3344
  registerNavigator("srs", srsModule.default);
3345
+ registerNavigator("prescribed", prescribedModule.default);
2869
3346
  const [
2870
3347
  hierarchyModule,
2871
3348
  interferenceModule,
@@ -2900,10 +3377,12 @@ function getCardOrigin(card) {
2900
3377
  return "new";
2901
3378
  }
2902
3379
  function isGenerator(impl) {
2903
- return NavigatorRoles[impl] === "generator" /* GENERATOR */;
3380
+ if (NavigatorRoles[impl] === "generator" /* GENERATOR */) return true;
3381
+ return getRegisteredNavigatorRole(impl) === "generator" /* GENERATOR */;
2904
3382
  }
2905
3383
  function isFilter(impl) {
2906
- return NavigatorRoles[impl] === "filter" /* FILTER */;
3384
+ if (NavigatorRoles[impl] === "filter" /* FILTER */) return true;
3385
+ return getRegisteredNavigatorRole(impl) === "filter" /* FILTER */;
2907
3386
  }
2908
3387
  var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
2909
3388
  var init_navigators = __esm({
@@ -2918,6 +3397,7 @@ var init_navigators = __esm({
2918
3397
  Navigators = /* @__PURE__ */ ((Navigators2) => {
2919
3398
  Navigators2["ELO"] = "elo";
2920
3399
  Navigators2["SRS"] = "srs";
3400
+ Navigators2["PRESCRIBED"] = "prescribed";
2921
3401
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
2922
3402
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
2923
3403
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -2932,6 +3412,7 @@ var init_navigators = __esm({
2932
3412
  NavigatorRoles = {
2933
3413
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
2934
3414
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3415
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
2935
3416
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
2936
3417
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
2937
3418
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -3096,6 +3577,12 @@ var init_navigators = __esm({
3096
3577
  async getWeightedCards(_limit) {
3097
3578
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
3098
3579
  }
3580
+ /**
3581
+ * Set ephemeral hints for the next pipeline run.
3582
+ * No-op for non-Pipeline navigators. Pipeline overrides this.
3583
+ */
3584
+ setEphemeralHints(_hints) {
3585
+ }
3099
3586
  };
3100
3587
  }
3101
3588
  });
@@ -3287,15 +3774,42 @@ var init_courseDB = __esm({
3287
3774
  // private log(msg: string): void {
3288
3775
  // log(`CourseLog: ${this.id}\n ${msg}`);
3289
3776
  // }
3777
+ /**
3778
+ * Primary database handle used for all **read** operations (queries, gets).
3779
+ *
3780
+ * When local sync is active, this points to the local PouchDB replica for
3781
+ * fast, network-free reads. Otherwise it points to the remote CouchDB.
3782
+ */
3290
3783
  db;
3784
+ /**
3785
+ * Remote database handle used for all **write** operations.
3786
+ *
3787
+ * Always points to the remote CouchDB so that writes (ELO updates, tag
3788
+ * mutations, admin operations) aggregate on the server. The local replica
3789
+ * is a read-only snapshot that refreshes on the next page load.
3790
+ *
3791
+ * When local sync is NOT active, this is the same instance as `this.db`.
3792
+ */
3793
+ remoteDB;
3291
3794
  id;
3292
3795
  _getCurrentUser;
3293
3796
  updateQueue;
3294
- constructor(id, userLookup) {
3797
+ /**
3798
+ * @param id - Course ID
3799
+ * @param userLookup - Async function returning the current user DB
3800
+ * @param localDB - Optional local PouchDB replica for reads. When provided,
3801
+ * `this.db` uses the local replica and `this.remoteDB` stays remote.
3802
+ * The UpdateQueue reads from remote and writes to remote (local `_rev`
3803
+ * values may be stale, so read-modify-write cycles must go through
3804
+ * the remote DB to avoid conflicts).
3805
+ */
3806
+ constructor(id, userLookup, localDB) {
3295
3807
  this.id = id;
3296
- this.db = getCourseDB2(this.id);
3808
+ const remote = getCourseDB2(this.id);
3809
+ this.remoteDB = remote;
3810
+ this.db = localDB ?? remote;
3297
3811
  this._getCurrentUser = userLookup;
3298
- this.updateQueue = new UpdateQueue(this.db);
3812
+ this.updateQueue = new UpdateQueue(this.remoteDB, this.remoteDB);
3299
3813
  }
3300
3814
  getCourseID() {
3301
3815
  return this.id;
@@ -3383,7 +3897,7 @@ var init_courseDB = __esm({
3383
3897
  };
3384
3898
  }
3385
3899
  async removeCard(id) {
3386
- const doc = await this.db.get(id);
3900
+ const doc = await this.remoteDB.get(id);
3387
3901
  if (!doc.docType || !(doc.docType === "CARD" /* CARD */)) {
3388
3902
  throw new Error(`failed to remove ${id} from course ${this.id}. id does not point to a card`);
3389
3903
  }
@@ -3404,7 +3918,7 @@ var init_courseDB = __esm({
3404
3918
  } catch (error) {
3405
3919
  logger.error(`Error removing card ${id} from tags: ${error}`);
3406
3920
  }
3407
- return this.db.remove(doc);
3921
+ return this.remoteDB.remove(doc);
3408
3922
  }
3409
3923
  async getCardDisplayableDataIDs(id) {
3410
3924
  logger.debug(id.join(", "));
@@ -3506,8 +4020,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3506
4020
  if (cardIds.length === 0) {
3507
4021
  return /* @__PURE__ */ new Map();
3508
4022
  }
3509
- const db = getCourseDB2(this.id);
3510
- const result = await db.query("getTags", {
4023
+ const result = await this.db.query("getTags", {
3511
4024
  keys: cardIds,
3512
4025
  include_docs: false
3513
4026
  });
@@ -3524,6 +4037,14 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3524
4037
  }
3525
4038
  return tagsByCard;
3526
4039
  }
4040
+ async getAllCardIds() {
4041
+ const result = await this.db.allDocs({
4042
+ startkey: "CARD-",
4043
+ endkey: "CARD-\uFFF0",
4044
+ include_docs: false
4045
+ });
4046
+ return result.rows.map((row) => row.id);
4047
+ }
3527
4048
  async addTagToCard(cardId, tagId, updateELO) {
3528
4049
  return await addTagToCard(
3529
4050
  this.id,
@@ -3590,10 +4111,13 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3590
4111
  }
3591
4112
  }
3592
4113
  async getCourseDoc(id, options) {
3593
- return await getCourseDoc(this.id, id, options);
4114
+ return await this.db.get(id, options);
3594
4115
  }
3595
4116
  async getCourseDocs(ids, options = {}) {
3596
- return await getCourseDocs(this.id, ids, options);
4117
+ return await this.db.allDocs({
4118
+ ...options,
4119
+ keys: ids
4120
+ });
3597
4121
  }
3598
4122
  ////////////////////////////////////
3599
4123
  // NavigationStrategyManager implementation
@@ -3627,7 +4151,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
3627
4151
  }
3628
4152
  async addNavigationStrategy(data) {
3629
4153
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
3630
- return this.db.put(data).then(() => {
4154
+ return this.remoteDB.put(data).then(() => {
3631
4155
  });
3632
4156
  }
3633
4157
  updateNavigationStrategy(id, data) {
@@ -5218,6 +5742,9 @@ Currently logged-in as ${this._username}.`
5218
5742
  const id = row.id;
5219
5743
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
5220
5744
  id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
5745
+ id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
5746
+ id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
5747
+ id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
5221
5748
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
5222
5749
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
5223
5750
  id === _BaseUser.DOC_IDS.CONFIG;
@@ -6095,6 +6622,234 @@ var init_adminDB2 = __esm({
6095
6622
  }
6096
6623
  });
6097
6624
 
6625
+ // src/impl/couch/CourseSyncService.ts
6626
+ var CourseSyncService;
6627
+ var init_CourseSyncService = __esm({
6628
+ "src/impl/couch/CourseSyncService.ts"() {
6629
+ "use strict";
6630
+ init_pouchdb_setup();
6631
+ init_couch();
6632
+ init_logger();
6633
+ CourseSyncService = class _CourseSyncService {
6634
+ static instance = null;
6635
+ entries = /* @__PURE__ */ new Map();
6636
+ constructor() {
6637
+ }
6638
+ static getInstance() {
6639
+ if (!_CourseSyncService.instance) {
6640
+ _CourseSyncService.instance = new _CourseSyncService();
6641
+ }
6642
+ return _CourseSyncService.instance;
6643
+ }
6644
+ /**
6645
+ * Reset the singleton (for testing).
6646
+ */
6647
+ static resetInstance() {
6648
+ if (_CourseSyncService.instance) {
6649
+ for (const [, entry] of _CourseSyncService.instance.entries) {
6650
+ if (entry.localDB) {
6651
+ entry.localDB.close().catch(() => {
6652
+ });
6653
+ }
6654
+ }
6655
+ _CourseSyncService.instance.entries.clear();
6656
+ }
6657
+ _CourseSyncService.instance = null;
6658
+ }
6659
+ // --------------------------------------------------------------------------
6660
+ // Public API
6661
+ // --------------------------------------------------------------------------
6662
+ /**
6663
+ * Ensure a course's local replica is synced.
6664
+ *
6665
+ * On first call for a course:
6666
+ * 1. Fetches CourseConfig from remote to check localSync.enabled
6667
+ * 2. If enabled, performs one-shot replication remote → local
6668
+ * 3. Pre-warms PouchDB view indices (elo, getTags)
6669
+ *
6670
+ * On subsequent calls: returns immediately if already synced, or awaits
6671
+ * the in-flight sync if one is in progress.
6672
+ *
6673
+ * Safe to call multiple times — concurrent calls coalesce to one sync.
6674
+ *
6675
+ * @param courseId - The course to sync
6676
+ * @param forceEnabled - Skip the CourseConfig check and sync regardless.
6677
+ * Useful when the caller already knows local sync is desired (e.g.,
6678
+ * LettersPractice hardcodes this).
6679
+ */
6680
+ async ensureSynced(courseId, forceEnabled) {
6681
+ const existing = this.entries.get(courseId);
6682
+ if (existing?.status.state === "ready") {
6683
+ return;
6684
+ }
6685
+ if (existing?.status.state === "disabled") {
6686
+ return;
6687
+ }
6688
+ if (existing?.readyPromise) {
6689
+ return existing.readyPromise;
6690
+ }
6691
+ const entry = {
6692
+ localDB: null,
6693
+ status: { state: "not-started" },
6694
+ readyPromise: null
6695
+ };
6696
+ this.entries.set(courseId, entry);
6697
+ entry.readyPromise = this.performSync(courseId, entry, forceEnabled);
6698
+ return entry.readyPromise;
6699
+ }
6700
+ /**
6701
+ * Get the local PouchDB for a course, or null if not available.
6702
+ *
6703
+ * Returns null when:
6704
+ * - Local sync is not enabled for this course
6705
+ * - Sync has not been triggered yet
6706
+ * - Sync is still in progress
6707
+ * - Sync failed
6708
+ */
6709
+ getLocalDB(courseId) {
6710
+ const entry = this.entries.get(courseId);
6711
+ if (entry?.status.state === "ready" && entry.localDB) {
6712
+ return entry.localDB;
6713
+ }
6714
+ return null;
6715
+ }
6716
+ /**
6717
+ * Check whether a course has a ready local replica.
6718
+ */
6719
+ isReady(courseId) {
6720
+ return this.entries.get(courseId)?.status.state === "ready";
6721
+ }
6722
+ /**
6723
+ * Get detailed sync status for a course.
6724
+ */
6725
+ getStatus(courseId) {
6726
+ return this.entries.get(courseId)?.status ?? { state: "not-started" };
6727
+ }
6728
+ // --------------------------------------------------------------------------
6729
+ // Internal
6730
+ // --------------------------------------------------------------------------
6731
+ async performSync(courseId, entry, forceEnabled) {
6732
+ try {
6733
+ if (!forceEnabled) {
6734
+ entry.status = { state: "checking-config" };
6735
+ const enabled = await this.checkLocalSyncEnabled(courseId);
6736
+ if (!enabled) {
6737
+ entry.status = { state: "disabled" };
6738
+ entry.readyPromise = null;
6739
+ logger.debug(
6740
+ `[CourseSyncService] Local sync disabled for course ${courseId}`
6741
+ );
6742
+ return;
6743
+ }
6744
+ }
6745
+ entry.status = { state: "syncing" };
6746
+ const localDBName = this.localDBName(courseId);
6747
+ const localDB = new pouchdb_setup_default(localDBName);
6748
+ entry.localDB = localDB;
6749
+ const remoteDB = this.getRemoteDB(courseId);
6750
+ const syncStart = Date.now();
6751
+ logger.info(
6752
+ `[CourseSyncService] Starting one-shot replication for course ${courseId}`
6753
+ );
6754
+ const result = await this.replicate(remoteDB, localDB);
6755
+ const syncTimeMs = Date.now() - syncStart;
6756
+ logger.info(
6757
+ `[CourseSyncService] Replication complete for course ${courseId}: ${result.docs_written} docs in ${syncTimeMs}ms`
6758
+ );
6759
+ entry.status = { state: "warming-views" };
6760
+ const warmStart = Date.now();
6761
+ await this.warmViewIndices(localDB);
6762
+ const viewWarmTimeMs = Date.now() - warmStart;
6763
+ logger.info(
6764
+ `[CourseSyncService] View indices warmed for course ${courseId} in ${viewWarmTimeMs}ms`
6765
+ );
6766
+ entry.status = {
6767
+ state: "ready",
6768
+ docsReplicated: result.docs_written,
6769
+ syncTimeMs,
6770
+ viewWarmTimeMs
6771
+ };
6772
+ } catch (e) {
6773
+ const errorMsg = e instanceof Error ? e.message : String(e);
6774
+ logger.error(
6775
+ `[CourseSyncService] Sync failed for course ${courseId}: ${errorMsg}`
6776
+ );
6777
+ entry.status = { state: "error", error: errorMsg };
6778
+ entry.readyPromise = null;
6779
+ if (entry.localDB) {
6780
+ try {
6781
+ await entry.localDB.destroy();
6782
+ } catch {
6783
+ }
6784
+ entry.localDB = null;
6785
+ }
6786
+ }
6787
+ }
6788
+ /**
6789
+ * Check CourseConfig.localSync.enabled on the remote DB.
6790
+ */
6791
+ async checkLocalSyncEnabled(courseId) {
6792
+ try {
6793
+ const remoteDB = this.getRemoteDB(courseId);
6794
+ const config = await remoteDB.get("CourseConfig");
6795
+ return config.localSync?.enabled === true;
6796
+ } catch (e) {
6797
+ logger.warn(
6798
+ `[CourseSyncService] Could not read CourseConfig for ${courseId}, assuming local sync disabled: ${e}`
6799
+ );
6800
+ return false;
6801
+ }
6802
+ }
6803
+ /**
6804
+ * One-shot replication from remote to local.
6805
+ */
6806
+ replicate(source, target) {
6807
+ return new Promise((resolve, reject) => {
6808
+ void pouchdb_setup_default.replicate(source, target, {
6809
+ // One-shot, not live. Local is a read-only snapshot.
6810
+ }).on("complete", (info) => {
6811
+ resolve(info);
6812
+ }).on("error", (err) => {
6813
+ reject(err);
6814
+ });
6815
+ });
6816
+ }
6817
+ /**
6818
+ * Pre-warm PouchDB view indices by running a minimal query against each
6819
+ * design doc. This forces PouchDB to build the MapReduce index now
6820
+ * (during a loading phase) rather than on first pipeline query.
6821
+ */
6822
+ async warmViewIndices(localDB) {
6823
+ const viewsToWarm = ["elo", "getTags"];
6824
+ for (const viewName of viewsToWarm) {
6825
+ try {
6826
+ await localDB.query(viewName, { limit: 1 });
6827
+ logger.debug(
6828
+ `[CourseSyncService] Warmed view index: ${viewName}`
6829
+ );
6830
+ } catch (e) {
6831
+ logger.debug(
6832
+ `[CourseSyncService] Could not warm view ${viewName}: ${e}`
6833
+ );
6834
+ }
6835
+ }
6836
+ }
6837
+ /**
6838
+ * Get a remote PouchDB handle for a course.
6839
+ */
6840
+ getRemoteDB(courseId) {
6841
+ return getCourseDB2(courseId);
6842
+ }
6843
+ /**
6844
+ * Local DB naming convention.
6845
+ */
6846
+ localDBName(courseId) {
6847
+ return `coursedb-local-${courseId}`;
6848
+ }
6849
+ };
6850
+ }
6851
+ });
6852
+
6098
6853
  // src/impl/couch/auth.ts
6099
6854
  import fetch from "cross-fetch";
6100
6855
  async function getCurrentSession() {
@@ -6537,6 +7292,7 @@ var init_couch = __esm({
6537
7292
  init_classroomDB2();
6538
7293
  init_courseAPI();
6539
7294
  init_courseDB();
7295
+ init_CourseSyncService();
6540
7296
  init_CouchDBSyncStrategy();
6541
7297
  isBrowser = typeof window !== "undefined";
6542
7298
  if (isBrowser) {
@@ -6561,6 +7317,7 @@ export {
6561
7317
  ClassroomLookupDB,
6562
7318
  CouchDBSyncStrategy,
6563
7319
  CourseDB,
7320
+ CourseSyncService,
6564
7321
  CoursesDB,
6565
7322
  REVIEW_TIME_FORMAT,
6566
7323
  StudentClassroomDB,