@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
@@ -528,8 +528,12 @@ __export(PipelineDebugger_exports, {
528
528
  buildRunReport: () => buildRunReport,
529
529
  captureRun: () => captureRun,
530
530
  mountPipelineDebugger: () => mountPipelineDebugger,
531
- pipelineDebugAPI: () => pipelineDebugAPI
531
+ pipelineDebugAPI: () => pipelineDebugAPI,
532
+ registerPipelineForDebug: () => registerPipelineForDebug
532
533
  });
534
+ function registerPipelineForDebug(pipeline) {
535
+ _activePipeline = pipeline;
536
+ }
533
537
  function getOrigin(card) {
534
538
  const firstEntry = card.provenance[0];
535
539
  if (!firstEntry) return "unknown";
@@ -557,6 +561,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
557
561
  origin: getOrigin(card),
558
562
  finalScore: card.score,
559
563
  provenance: card.provenance,
564
+ tags: card.tags,
560
565
  selected: selectedIds.has(card.cardId)
561
566
  }));
562
567
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
@@ -612,11 +617,13 @@ function mountPipelineDebugger() {
612
617
  win.skuilder = win.skuilder || {};
613
618
  win.skuilder.pipeline = pipelineDebugAPI;
614
619
  }
615
- var MAX_RUNS, runHistory, pipelineDebugAPI;
620
+ var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
616
621
  var init_PipelineDebugger = __esm({
617
622
  "src/core/navigators/PipelineDebugger.ts"() {
618
623
  "use strict";
624
+ init_navigators();
619
625
  init_logger();
626
+ _activePipeline = null;
620
627
  MAX_RUNS = 10;
621
628
  runHistory = [];
622
629
  pipelineDebugAPI = {
@@ -758,6 +765,81 @@ var init_PipelineDebugger = __esm({
758
765
  runHistory.length = 0;
759
766
  logger.info("[Pipeline Debug] Run history cleared.");
760
767
  },
768
+ /**
769
+ * Show the navigator registry: all registered classes and their roles.
770
+ *
771
+ * Useful for verifying that consumer-defined navigators were registered
772
+ * before pipeline assembly.
773
+ */
774
+ showRegistry() {
775
+ const names = getRegisteredNavigatorNames();
776
+ if (names.length === 0) {
777
+ logger.info("[Pipeline Debug] Navigator registry is empty.");
778
+ return;
779
+ }
780
+ console.group("\u{1F4E6} Navigator Registry");
781
+ console.table(
782
+ names.map((name) => {
783
+ const registryRole = getRegisteredNavigatorRole(name);
784
+ const builtinRole = NavigatorRoles[name];
785
+ const effectiveRole = builtinRole || registryRole || "\u26A0\uFE0F NONE";
786
+ const source = builtinRole ? "built-in" : registryRole ? "consumer" : "unclassified";
787
+ return {
788
+ name,
789
+ role: effectiveRole,
790
+ source,
791
+ isGenerator: isGenerator(name),
792
+ isFilter: isFilter(name)
793
+ };
794
+ })
795
+ );
796
+ console.groupEnd();
797
+ },
798
+ /**
799
+ * Show strategy documents from the last pipeline run and how they mapped
800
+ * to the registry.
801
+ *
802
+ * If no runs are captured yet, falls back to showing just the registry.
803
+ */
804
+ showStrategies() {
805
+ this.showRegistry();
806
+ if (runHistory.length === 0) {
807
+ logger.info("[Pipeline Debug] No pipeline runs captured yet \u2014 cannot show strategy doc mapping.");
808
+ return;
809
+ }
810
+ const run = runHistory[0];
811
+ console.group("\u{1F50C} Pipeline Strategy Mapping (last run)");
812
+ logger.info(`Generator: ${run.generatorName}`);
813
+ if (run.generators && run.generators.length > 0) {
814
+ for (const g of run.generators) {
815
+ logger.info(` \u{1F4E5} ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
816
+ }
817
+ }
818
+ if (run.filters.length > 0) {
819
+ logger.info("Filters:");
820
+ for (const f of run.filters) {
821
+ logger.info(` \u{1F538} ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
822
+ }
823
+ } else {
824
+ logger.info("Filters: (none)");
825
+ }
826
+ console.groupEnd();
827
+ },
828
+ /**
829
+ * Scan the full card space through the filter chain for the current user.
830
+ *
831
+ * Reports how many cards are well-indicated and how many are new.
832
+ * Use this to understand how the search space grows during onboarding.
833
+ *
834
+ * @param threshold - Score threshold for "well indicated" (default 0.10)
835
+ */
836
+ async diagnoseCardSpace(threshold) {
837
+ if (!_activePipeline) {
838
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
839
+ return null;
840
+ }
841
+ return _activePipeline.diagnoseCardSpace({ threshold });
842
+ },
761
843
  /**
762
844
  * Show help.
763
845
  */
@@ -770,6 +852,9 @@ Commands:
770
852
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
771
853
  .showCard(cardId) Show provenance trail for a specific card
772
854
  .explainReviews() Analyze why reviews were/weren't selected
855
+ .diagnoseCardSpace() Scan full card space through filters (async)
856
+ .showRegistry() Show navigator registry (classes + roles)
857
+ .showStrategies() Show registry + strategy mapping from last run
773
858
  .listRuns() List all captured runs in table format
774
859
  .export() Export run history as JSON for bug reports
775
860
  .clear() Clear run history
@@ -779,7 +864,7 @@ Commands:
779
864
  Example:
780
865
  window.skuilder.pipeline.showLastRun()
781
866
  window.skuilder.pipeline.showRun(1)
782
- window.skuilder.pipeline.showCard('abc123')
867
+ await window.skuilder.pipeline.diagnoseCardSpace()
783
868
  `);
784
869
  }
785
870
  };
@@ -1074,6 +1159,69 @@ var init_generators = __esm({
1074
1159
  }
1075
1160
  });
1076
1161
 
1162
+ // src/core/navigators/generators/prescribed.ts
1163
+ var prescribed_exports = {};
1164
+ __export(prescribed_exports, {
1165
+ default: () => PrescribedCardsGenerator
1166
+ });
1167
+ var PrescribedCardsGenerator;
1168
+ var init_prescribed = __esm({
1169
+ "src/core/navigators/generators/prescribed.ts"() {
1170
+ "use strict";
1171
+ init_navigators();
1172
+ init_logger();
1173
+ PrescribedCardsGenerator = class extends ContentNavigator {
1174
+ name;
1175
+ config;
1176
+ constructor(user, course, strategyData) {
1177
+ super(user, course, strategyData);
1178
+ this.name = strategyData.name || "Prescribed Cards";
1179
+ try {
1180
+ const parsed = JSON.parse(strategyData.serializedData);
1181
+ this.config = { cardIds: parsed.cardIds || [] };
1182
+ } catch {
1183
+ this.config = { cardIds: [] };
1184
+ }
1185
+ logger.debug(
1186
+ `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1187
+ );
1188
+ }
1189
+ async getWeightedCards(limit, _context) {
1190
+ if (this.config.cardIds.length === 0) {
1191
+ return [];
1192
+ }
1193
+ const courseId = this.course.getCourseID();
1194
+ const activeCards = await this.user.getActiveCards();
1195
+ const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1196
+ const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1197
+ if (eligibleIds.length === 0) {
1198
+ logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1199
+ return [];
1200
+ }
1201
+ const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1202
+ cardId,
1203
+ courseId,
1204
+ score: 1,
1205
+ provenance: [
1206
+ {
1207
+ strategy: "prescribed",
1208
+ strategyName: this.strategyName || this.name,
1209
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1210
+ action: "generated",
1211
+ score: 1,
1212
+ reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1213
+ }
1214
+ ]
1215
+ }));
1216
+ logger.info(
1217
+ `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1218
+ );
1219
+ return cards;
1220
+ }
1221
+ };
1222
+ }
1223
+ });
1224
+
1077
1225
  // src/core/navigators/generators/srs.ts
1078
1226
  var srs_exports = {};
1079
1227
  __export(srs_exports, {
@@ -1268,6 +1416,7 @@ var init_ = __esm({
1268
1416
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1269
1417
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1270
1418
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1419
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1271
1420
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1272
1421
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1273
1422
  });
@@ -1468,6 +1617,8 @@ var init_hierarchyDefinition = __esm({
1468
1617
  if (userTagElo.count < minCount) return false;
1469
1618
  if (prereq.masteryThreshold?.minElo !== void 0) {
1470
1619
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1620
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1621
+ return true;
1471
1622
  } else {
1472
1623
  return userTagElo.score >= userGlobalElo;
1473
1624
  }
@@ -1543,14 +1694,38 @@ var init_hierarchyDefinition = __esm({
1543
1694
  };
1544
1695
  }
1545
1696
  }
1697
+ /**
1698
+ * Build a map of prereq tag → max configured boost for all *closed* gates.
1699
+ *
1700
+ * When a gate is closed (prereqs unmet), cards carrying that gate's prereq
1701
+ * tags get boosted — steering the pipeline toward content that helps unlock
1702
+ * the gated material. Once the gate opens, the boost disappears.
1703
+ */
1704
+ getPreReqBoosts(unlockedTags, masteredTags) {
1705
+ const boosts = /* @__PURE__ */ new Map();
1706
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
1707
+ if (unlockedTags.has(tagId)) continue;
1708
+ for (const prereq of prereqs) {
1709
+ if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
1710
+ if (masteredTags.has(prereq.tag)) continue;
1711
+ const existing = boosts.get(prereq.tag) ?? 1;
1712
+ boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
1713
+ }
1714
+ }
1715
+ return boosts;
1716
+ }
1546
1717
  /**
1547
1718
  * CardFilter.transform implementation.
1548
1719
  *
1549
- * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
1720
+ * Two effects:
1721
+ * 1. Cards with locked tags receive score * 0.05 (gating penalty)
1722
+ * 2. Cards carrying prereq tags of closed gates receive a configured
1723
+ * boost (preReqBoost), steering toward content that unlocks gates
1550
1724
  */
1551
1725
  async transform(cards, context) {
1552
1726
  const masteredTags = await this.getMasteredTags(context);
1553
1727
  const unlockedTags = this.getUnlockedTags(masteredTags);
1728
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1554
1729
  const gated = [];
1555
1730
  for (const card of cards) {
1556
1731
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1559,9 +1734,27 @@ var init_hierarchyDefinition = __esm({
1559
1734
  unlockedTags,
1560
1735
  masteredTags
1561
1736
  );
1562
- const LOCKED_PENALTY = 0.01;
1563
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1564
- const action = isUnlocked ? "passed" : "penalized";
1737
+ const LOCKED_PENALTY = 0.02;
1738
+ let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1739
+ let action = isUnlocked ? "passed" : "penalized";
1740
+ let finalReason = reason;
1741
+ if (isUnlocked && preReqBoosts.size > 0) {
1742
+ const cardTags = card.tags ?? [];
1743
+ let maxBoost = 1;
1744
+ const boostedPrereqs = [];
1745
+ for (const tag of cardTags) {
1746
+ const boost = preReqBoosts.get(tag);
1747
+ if (boost && boost > maxBoost) {
1748
+ maxBoost = boost;
1749
+ boostedPrereqs.push(tag);
1750
+ }
1751
+ }
1752
+ if (maxBoost > 1) {
1753
+ finalScore *= maxBoost;
1754
+ action = "boosted";
1755
+ finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
1756
+ }
1757
+ }
1565
1758
  gated.push({
1566
1759
  ...card,
1567
1760
  score: finalScore,
@@ -1573,7 +1766,7 @@ var init_hierarchyDefinition = __esm({
1573
1766
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1574
1767
  action,
1575
1768
  score: finalScore,
1576
- reason
1769
+ reason: finalReason
1577
1770
  }
1578
1771
  ]
1579
1772
  });
@@ -2261,6 +2454,18 @@ var Pipeline_exports = {};
2261
2454
  __export(Pipeline_exports, {
2262
2455
  Pipeline: () => Pipeline
2263
2456
  });
2457
+ function globToRegex(pattern) {
2458
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
2459
+ const withWildcards = escaped.replace(/\*/g, ".*");
2460
+ return new RegExp(`^${withWildcards}$`);
2461
+ }
2462
+ function globMatch(value, pattern) {
2463
+ if (!pattern.includes("*")) return value === pattern;
2464
+ return globToRegex(pattern).test(value);
2465
+ }
2466
+ function cardMatchesTagPattern(card, pattern) {
2467
+ return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
2468
+ }
2264
2469
  function logPipelineConfig(generator, filters) {
2265
2470
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2266
2471
  logger.info(
@@ -2295,6 +2500,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2295
2500
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2296
2501
  );
2297
2502
  }
2503
+ function logResultCards(cards) {
2504
+ if (!VERBOSE_RESULTS || cards.length === 0) return;
2505
+ logger.info(`[Pipeline] Results (${cards.length} cards):`);
2506
+ for (let i = 0; i < cards.length; i++) {
2507
+ const c = cards[i];
2508
+ const tags = c.tags?.slice(0, 3).join(", ") || "";
2509
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
2510
+ const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
2511
+ return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
2512
+ }).join(" | ");
2513
+ logger.info(
2514
+ `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
2515
+ );
2516
+ }
2517
+ }
2298
2518
  function logCardProvenance(cards, maxCards = 3) {
2299
2519
  const cardsToLog = cards.slice(0, maxCards);
2300
2520
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2309,7 +2529,7 @@ function logCardProvenance(cards, maxCards = 3) {
2309
2529
  }
2310
2530
  }
2311
2531
  }
2312
- var import_common8, Pipeline;
2532
+ var import_common8, VERBOSE_RESULTS, Pipeline;
2313
2533
  var init_Pipeline = __esm({
2314
2534
  "src/core/navigators/Pipeline.ts"() {
2315
2535
  "use strict";
@@ -2318,9 +2538,31 @@ var init_Pipeline = __esm({
2318
2538
  init_logger();
2319
2539
  init_orchestration();
2320
2540
  init_PipelineDebugger();
2541
+ VERBOSE_RESULTS = true;
2321
2542
  Pipeline = class extends ContentNavigator {
2322
2543
  generator;
2323
2544
  filters;
2545
+ /**
2546
+ * Cached orchestration context. Course config and salt don't change within
2547
+ * a page load, so we build the orchestration context once and reuse it on
2548
+ * subsequent getWeightedCards() calls (e.g. mid-session replans).
2549
+ *
2550
+ * This eliminates a remote getCourseConfig() round trip per pipeline run.
2551
+ */
2552
+ _cachedOrchestration = null;
2553
+ /**
2554
+ * Persistent tag cache. Maps cardId → tag names.
2555
+ *
2556
+ * Tags are static within a session (they're set at card generation time),
2557
+ * so we cache them across pipeline runs. On replans, many of the same cards
2558
+ * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
2559
+ */
2560
+ _tagCache = /* @__PURE__ */ new Map();
2561
+ /**
2562
+ * One-shot replan hints. Applied after the filter chain on the next
2563
+ * getWeightedCards() call, then cleared.
2564
+ */
2565
+ _ephemeralHints = null;
2324
2566
  /**
2325
2567
  * Create a new pipeline.
2326
2568
  *
@@ -2341,6 +2583,17 @@ var init_Pipeline = __esm({
2341
2583
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2342
2584
  });
2343
2585
  logPipelineConfig(generator, filters);
2586
+ registerPipelineForDebug(this);
2587
+ }
2588
+ /**
2589
+ * Set one-shot hints for the next pipeline run.
2590
+ * Consumed after one getWeightedCards() call, then cleared.
2591
+ *
2592
+ * Overrides ContentNavigator.setEphemeralHints() no-op.
2593
+ */
2594
+ setEphemeralHints(hints) {
2595
+ this._ephemeralHints = hints;
2596
+ logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
2344
2597
  }
2345
2598
  /**
2346
2599
  * Get weighted cards by running generator and applying filters.
@@ -2357,13 +2610,15 @@ var init_Pipeline = __esm({
2357
2610
  * @returns Cards sorted by score descending
2358
2611
  */
2359
2612
  async getWeightedCards(limit) {
2613
+ const t0 = performance.now();
2360
2614
  const context = await this.buildContext();
2361
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2362
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
2615
+ const tContext = performance.now();
2616
+ const fetchLimit = 500;
2363
2617
  logger.debug(
2364
2618
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2365
2619
  );
2366
2620
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2621
+ const tGenerate = performance.now();
2367
2622
  const generatedCount = cards.length;
2368
2623
  let generatorSummaries;
2369
2624
  if (this.generator.generators) {
@@ -2392,6 +2647,7 @@ var init_Pipeline = __esm({
2392
2647
  }
2393
2648
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2394
2649
  cards = await this.hydrateTags(cards);
2650
+ const tHydrate = performance.now();
2395
2651
  const allCardsBeforeFiltering = [...cards];
2396
2652
  const filterImpacts = [];
2397
2653
  for (const filter of this.filters) {
@@ -2410,8 +2666,17 @@ var init_Pipeline = __esm({
2410
2666
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2411
2667
  }
2412
2668
  cards = cards.filter((c) => c.score > 0);
2669
+ const hints = this._ephemeralHints;
2670
+ if (hints) {
2671
+ this._ephemeralHints = null;
2672
+ cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
2673
+ }
2413
2674
  cards.sort((a, b) => b.score - a.score);
2675
+ const tFilter = performance.now();
2414
2676
  const result = cards.slice(0, limit);
2677
+ logger.info(
2678
+ `[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)})`
2679
+ );
2415
2680
  const topScores = result.slice(0, 3).map((c) => c.score);
2416
2681
  logExecutionSummary(
2417
2682
  this.generator.name,
@@ -2421,6 +2686,7 @@ var init_Pipeline = __esm({
2421
2686
  topScores,
2422
2687
  filterImpacts
2423
2688
  );
2689
+ logResultCards(result);
2424
2690
  logCardProvenance(result, 3);
2425
2691
  try {
2426
2692
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -2447,6 +2713,10 @@ var init_Pipeline = __esm({
2447
2713
  * to the WeightedCard objects. Filters can then use card.tags instead of
2448
2714
  * making individual getAppliedTags() calls.
2449
2715
  *
2716
+ * Uses a persistent tag cache across pipeline runs — tags are static within
2717
+ * a session, so cards seen in a prior run (e.g. before a replan) don't
2718
+ * require a second DB query.
2719
+ *
2450
2720
  * @param cards - Cards to hydrate
2451
2721
  * @returns Cards with tags populated
2452
2722
  */
@@ -2454,14 +2724,128 @@ var init_Pipeline = __esm({
2454
2724
  if (cards.length === 0) {
2455
2725
  return cards;
2456
2726
  }
2457
- const cardIds = cards.map((c) => c.cardId);
2458
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
2727
+ const uncachedIds = [];
2728
+ for (const card of cards) {
2729
+ if (!this._tagCache.has(card.cardId)) {
2730
+ uncachedIds.push(card.cardId);
2731
+ }
2732
+ }
2733
+ if (uncachedIds.length > 0) {
2734
+ const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
2735
+ for (const [cardId, tags] of freshTags) {
2736
+ this._tagCache.set(cardId, tags);
2737
+ }
2738
+ }
2739
+ const tagsByCard = /* @__PURE__ */ new Map();
2740
+ for (const card of cards) {
2741
+ tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
2742
+ }
2459
2743
  logTagHydration(cards, tagsByCard);
2460
2744
  return cards.map((card) => ({
2461
2745
  ...card,
2462
- tags: tagsByCard.get(card.cardId) ?? []
2746
+ tags: this._tagCache.get(card.cardId) ?? []
2463
2747
  }));
2464
2748
  }
2749
+ // ---------------------------------------------------------------------------
2750
+ // Ephemeral hints application
2751
+ // ---------------------------------------------------------------------------
2752
+ /**
2753
+ * Apply one-shot replan hints to the post-filter card set.
2754
+ *
2755
+ * Order of operations:
2756
+ * 1. Exclude (remove unwanted cards)
2757
+ * 2. Boost (multiply scores)
2758
+ * 3. Require (inject must-have cards from the full pre-filter pool)
2759
+ *
2760
+ * @param cards - Post-filter cards (score > 0)
2761
+ * @param hints - The ephemeral hints to apply
2762
+ * @param allCards - Full pre-filter card pool (for require injection)
2763
+ */
2764
+ applyHints(cards, hints, allCards) {
2765
+ const beforeCount = cards.length;
2766
+ if (hints.excludeCards?.length) {
2767
+ cards = cards.filter(
2768
+ (c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
2769
+ );
2770
+ }
2771
+ if (hints.excludeTags?.length) {
2772
+ cards = cards.filter(
2773
+ (c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
2774
+ );
2775
+ }
2776
+ if (hints.boostTags) {
2777
+ for (const [pattern, factor] of Object.entries(hints.boostTags)) {
2778
+ for (const card of cards) {
2779
+ if (cardMatchesTagPattern(card, pattern)) {
2780
+ card.score *= factor;
2781
+ card.provenance.push({
2782
+ strategy: "ephemeralHint",
2783
+ strategyId: "ephemeral-hint",
2784
+ strategyName: "Replan Hint",
2785
+ action: "boosted",
2786
+ score: card.score,
2787
+ reason: `boostTag ${pattern} \xD7${factor}`
2788
+ });
2789
+ }
2790
+ }
2791
+ }
2792
+ }
2793
+ if (hints.boostCards) {
2794
+ for (const [pattern, factor] of Object.entries(hints.boostCards)) {
2795
+ for (const card of cards) {
2796
+ if (globMatch(card.cardId, pattern)) {
2797
+ card.score *= factor;
2798
+ card.provenance.push({
2799
+ strategy: "ephemeralHint",
2800
+ strategyId: "ephemeral-hint",
2801
+ strategyName: "Replan Hint",
2802
+ action: "boosted",
2803
+ score: card.score,
2804
+ reason: `boostCard ${pattern} \xD7${factor}`
2805
+ });
2806
+ }
2807
+ }
2808
+ }
2809
+ }
2810
+ const cardIds = new Set(cards.map((c) => c.cardId));
2811
+ const inject = (card, reason) => {
2812
+ if (!cardIds.has(card.cardId)) {
2813
+ const floorScore = Math.max(card.score, 1);
2814
+ cards.push({
2815
+ ...card,
2816
+ score: floorScore,
2817
+ provenance: [
2818
+ ...card.provenance,
2819
+ {
2820
+ strategy: "ephemeralHint",
2821
+ strategyId: "ephemeral-hint",
2822
+ strategyName: "Replan Hint",
2823
+ action: "boosted",
2824
+ score: floorScore,
2825
+ reason
2826
+ }
2827
+ ]
2828
+ });
2829
+ cardIds.add(card.cardId);
2830
+ }
2831
+ };
2832
+ if (hints.requireCards?.length) {
2833
+ for (const pattern of hints.requireCards) {
2834
+ for (const card of allCards) {
2835
+ if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
2836
+ }
2837
+ }
2838
+ }
2839
+ if (hints.requireTags?.length) {
2840
+ for (const pattern of hints.requireTags) {
2841
+ for (const card of allCards) {
2842
+ if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
2843
+ }
2844
+ }
2845
+ }
2846
+ logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
2847
+ return cards;
2848
+ }
2465
2849
  /**
2466
2850
  * Build shared context for generator and filters.
2467
2851
  *
@@ -2479,7 +2863,10 @@ var init_Pipeline = __esm({
2479
2863
  } catch (e) {
2480
2864
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
2481
2865
  }
2482
- const orchestration = await createOrchestrationContext(this.user, this.course);
2866
+ if (!this._cachedOrchestration) {
2867
+ this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
2868
+ }
2869
+ const orchestration = this._cachedOrchestration;
2483
2870
  return {
2484
2871
  user: this.user,
2485
2872
  course: this.course,
@@ -2523,6 +2910,87 @@ var init_Pipeline = __esm({
2523
2910
  }
2524
2911
  return [...new Set(ids)];
2525
2912
  }
2913
+ // ---------------------------------------------------------------------------
2914
+ // Card-space diagnostic
2915
+ // ---------------------------------------------------------------------------
2916
+ /**
2917
+ * Scan every card in the course through the filter chain and report
2918
+ * how many are "well indicated" (score >= threshold) for the current user.
2919
+ *
2920
+ * Also reports how many well-indicated cards the user has NOT yet encountered.
2921
+ *
2922
+ * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
2923
+ */
2924
+ async diagnoseCardSpace(opts) {
2925
+ const THRESHOLD = opts?.threshold ?? 0.1;
2926
+ const t0 = performance.now();
2927
+ const allCardIds = await this.course.getAllCardIds();
2928
+ let cards = allCardIds.map((cardId) => ({
2929
+ cardId,
2930
+ courseId: this.course.getCourseID(),
2931
+ score: 1,
2932
+ provenance: []
2933
+ }));
2934
+ cards = await this.hydrateTags(cards);
2935
+ const context = await this.buildContext();
2936
+ const filterBreakdown = [];
2937
+ for (const filter of this.filters) {
2938
+ cards = await filter.transform(cards, context);
2939
+ const wi = cards.filter((c) => c.score >= THRESHOLD).length;
2940
+ filterBreakdown.push({ name: filter.name, wellIndicated: wi });
2941
+ }
2942
+ const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
2943
+ const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
2944
+ let encounteredIds;
2945
+ try {
2946
+ const courseId = this.course.getCourseID();
2947
+ const seenCards = await this.user.getSeenCards(courseId);
2948
+ encounteredIds = new Set(seenCards);
2949
+ } catch {
2950
+ encounteredIds = /* @__PURE__ */ new Set();
2951
+ }
2952
+ const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
2953
+ const byType = /* @__PURE__ */ new Map();
2954
+ for (const card of cards) {
2955
+ const type = card.cardId.split("-")[1] || "unknown";
2956
+ if (!byType.has(type)) {
2957
+ byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
2958
+ }
2959
+ const entry = byType.get(type);
2960
+ entry.total++;
2961
+ if (card.score >= THRESHOLD) {
2962
+ entry.wellIndicated++;
2963
+ if (!encounteredIds.has(card.cardId)) entry.new++;
2964
+ }
2965
+ }
2966
+ const elapsed = performance.now() - t0;
2967
+ const result = {
2968
+ totalCards: allCardIds.length,
2969
+ threshold: THRESHOLD,
2970
+ wellIndicated: wellIndicatedIds.size,
2971
+ encountered: encounteredIds.size,
2972
+ wellIndicatedNew: wellIndicatedNew.length,
2973
+ byType: Object.fromEntries(byType),
2974
+ filterBreakdown,
2975
+ elapsedMs: Math.round(elapsed)
2976
+ };
2977
+ logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
2978
+ logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
2979
+ logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
2980
+ logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
2981
+ logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
2982
+ logger.info(`[Pipeline:diagnose] By type:`);
2983
+ for (const [type, counts] of byType) {
2984
+ logger.info(
2985
+ `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
2986
+ );
2987
+ }
2988
+ logger.info(`[Pipeline:diagnose] After each filter:`);
2989
+ for (const fb of filterBreakdown) {
2990
+ logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
2991
+ }
2992
+ return result;
2993
+ }
2526
2994
  };
2527
2995
  }
2528
2996
  });
@@ -2627,23 +3095,25 @@ var init_PipelineAssembler = __esm({
2627
3095
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
2628
3096
  }
2629
3097
  }
3098
+ const courseId = course.getCourseID();
3099
+ const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
3100
+ const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
3101
+ if (!hasElo) {
3102
+ logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
3103
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3104
+ }
3105
+ if (!hasSrs) {
3106
+ logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
3107
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
3108
+ }
2630
3109
  if (generatorStrategies.length === 0) {
2631
- if (filterStrategies.length > 0) {
2632
- logger.debug(
2633
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2634
- );
2635
- const courseId = course.getCourseID();
2636
- generatorStrategies.push(createDefaultEloStrategy(courseId));
2637
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
2638
- } else {
2639
- warnings.push("No generator strategy found");
2640
- return {
2641
- pipeline: null,
2642
- generatorStrategies: [],
2643
- filterStrategies: [],
2644
- warnings
2645
- };
2646
- }
3110
+ warnings.push("No generator strategy found");
3111
+ return {
3112
+ pipeline: null,
3113
+ generatorStrategies: [],
3114
+ filterStrategies: [],
3115
+ warnings
3116
+ };
2647
3117
  }
2648
3118
  let generator;
2649
3119
  if (generatorStrategies.length === 1) {
@@ -2721,6 +3191,7 @@ var init_3 = __esm({
2721
3191
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
2722
3192
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
2723
3193
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3194
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
2724
3195
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
2725
3196
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
2726
3197
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -2738,6 +3209,7 @@ __export(navigators_exports, {
2738
3209
  getCardOrigin: () => getCardOrigin,
2739
3210
  getRegisteredNavigator: () => getRegisteredNavigator,
2740
3211
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
3212
+ getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
2741
3213
  hasRegisteredNavigator: () => hasRegisteredNavigator,
2742
3214
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
2743
3215
  isFilter: () => isFilter,
@@ -2746,16 +3218,19 @@ __export(navigators_exports, {
2746
3218
  pipelineDebugAPI: () => pipelineDebugAPI,
2747
3219
  registerNavigator: () => registerNavigator
2748
3220
  });
2749
- function registerNavigator(implementingClass, constructor) {
2750
- navigatorRegistry.set(implementingClass, constructor);
2751
- logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
3221
+ function registerNavigator(implementingClass, constructor, role) {
3222
+ navigatorRegistry.set(implementingClass, { constructor, role });
3223
+ logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ""}`);
2752
3224
  }
2753
3225
  function getRegisteredNavigator(implementingClass) {
2754
- return navigatorRegistry.get(implementingClass);
3226
+ return navigatorRegistry.get(implementingClass)?.constructor;
2755
3227
  }
2756
3228
  function hasRegisteredNavigator(implementingClass) {
2757
3229
  return navigatorRegistry.has(implementingClass);
2758
3230
  }
3231
+ function getRegisteredNavigatorRole(implementingClass) {
3232
+ return navigatorRegistry.get(implementingClass)?.role;
3233
+ }
2759
3234
  function getRegisteredNavigatorNames() {
2760
3235
  return Array.from(navigatorRegistry.keys());
2761
3236
  }
@@ -2765,8 +3240,10 @@ async function initializeNavigatorRegistry() {
2765
3240
  Promise.resolve().then(() => (init_elo(), elo_exports)),
2766
3241
  Promise.resolve().then(() => (init_srs(), srs_exports))
2767
3242
  ]);
3243
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
2768
3244
  registerNavigator("elo", eloModule.default);
2769
3245
  registerNavigator("srs", srsModule.default);
3246
+ registerNavigator("prescribed", prescribedModule.default);
2770
3247
  const [
2771
3248
  hierarchyModule,
2772
3249
  interferenceModule,
@@ -2801,10 +3278,12 @@ function getCardOrigin(card) {
2801
3278
  return "new";
2802
3279
  }
2803
3280
  function isGenerator(impl) {
2804
- return NavigatorRoles[impl] === "generator" /* GENERATOR */;
3281
+ if (NavigatorRoles[impl] === "generator" /* GENERATOR */) return true;
3282
+ return getRegisteredNavigatorRole(impl) === "generator" /* GENERATOR */;
2805
3283
  }
2806
3284
  function isFilter(impl) {
2807
- return NavigatorRoles[impl] === "filter" /* FILTER */;
3285
+ if (NavigatorRoles[impl] === "filter" /* FILTER */) return true;
3286
+ return getRegisteredNavigatorRole(impl) === "filter" /* FILTER */;
2808
3287
  }
2809
3288
  var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
2810
3289
  var init_navigators = __esm({
@@ -2819,6 +3298,7 @@ var init_navigators = __esm({
2819
3298
  Navigators = /* @__PURE__ */ ((Navigators2) => {
2820
3299
  Navigators2["ELO"] = "elo";
2821
3300
  Navigators2["SRS"] = "srs";
3301
+ Navigators2["PRESCRIBED"] = "prescribed";
2822
3302
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
2823
3303
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
2824
3304
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -2833,6 +3313,7 @@ var init_navigators = __esm({
2833
3313
  NavigatorRoles = {
2834
3314
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
2835
3315
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3316
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
2836
3317
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
2837
3318
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
2838
3319
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -2997,6 +3478,12 @@ var init_navigators = __esm({
2997
3478
  async getWeightedCards(_limit) {
2998
3479
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
2999
3480
  }
3481
+ /**
3482
+ * Set ephemeral hints for the next pipeline run.
3483
+ * No-op for non-Pipeline navigators. Pipeline overrides this.
3484
+ */
3485
+ setEphemeralHints(_hints) {
3486
+ }
3000
3487
  };
3001
3488
  }
3002
3489
  });
@@ -3047,6 +3534,16 @@ var init_adminDB2 = __esm({
3047
3534
  }
3048
3535
  });
3049
3536
 
3537
+ // src/impl/couch/CourseSyncService.ts
3538
+ var init_CourseSyncService = __esm({
3539
+ "src/impl/couch/CourseSyncService.ts"() {
3540
+ "use strict";
3541
+ init_pouchdb_setup();
3542
+ init_couch();
3543
+ init_logger();
3544
+ }
3545
+ });
3546
+
3050
3547
  // src/impl/couch/auth.ts
3051
3548
  var import_cross_fetch;
3052
3549
  var init_auth = __esm({
@@ -3110,6 +3607,7 @@ var init_couch = __esm({
3110
3607
  init_classroomDB2();
3111
3608
  init_courseAPI();
3112
3609
  init_courseDB();
3610
+ init_CourseSyncService();
3113
3611
  init_CouchDBSyncStrategy();
3114
3612
  isBrowser = typeof window !== "undefined";
3115
3613
  if (isBrowser) {
@@ -3409,6 +3907,9 @@ Currently logged-in as ${this._username}.`
3409
3907
  const id = row.id;
3410
3908
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
3411
3909
  id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
3910
+ id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
3911
+ id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
3912
+ id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
3412
3913
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
3413
3914
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
3414
3915
  id === _BaseUser.DOC_IDS.CONFIG;
@@ -5264,6 +5765,10 @@ var init_courseDB3 = __esm({
5264
5765
  }
5265
5766
  return tagsByCard;
5266
5767
  }
5768
+ async getAllCardIds() {
5769
+ const tagsIndex = await this.unpacker.getTagsIndex();
5770
+ return Object.keys(tagsIndex.byCard);
5771
+ }
5267
5772
  async addTagToCard(_cardId, _tagId) {
5268
5773
  throw new Error("Cannot modify tags in static mode");
5269
5774
  }