@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
@@ -504,8 +504,12 @@ __export(PipelineDebugger_exports, {
504
504
  buildRunReport: () => buildRunReport,
505
505
  captureRun: () => captureRun,
506
506
  mountPipelineDebugger: () => mountPipelineDebugger,
507
- pipelineDebugAPI: () => pipelineDebugAPI
507
+ pipelineDebugAPI: () => pipelineDebugAPI,
508
+ registerPipelineForDebug: () => registerPipelineForDebug
508
509
  });
510
+ function registerPipelineForDebug(pipeline) {
511
+ _activePipeline = pipeline;
512
+ }
509
513
  function getOrigin(card) {
510
514
  const firstEntry = card.provenance[0];
511
515
  if (!firstEntry) return "unknown";
@@ -533,6 +537,7 @@ function buildRunReport(courseId, courseName, generatorName, generators, generat
533
537
  origin: getOrigin(card),
534
538
  finalScore: card.score,
535
539
  provenance: card.provenance,
540
+ tags: card.tags,
536
541
  selected: selectedIds.has(card.cardId)
537
542
  }));
538
543
  const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
@@ -588,11 +593,13 @@ function mountPipelineDebugger() {
588
593
  win.skuilder = win.skuilder || {};
589
594
  win.skuilder.pipeline = pipelineDebugAPI;
590
595
  }
591
- var MAX_RUNS, runHistory, pipelineDebugAPI;
596
+ var _activePipeline, MAX_RUNS, runHistory, pipelineDebugAPI;
592
597
  var init_PipelineDebugger = __esm({
593
598
  "src/core/navigators/PipelineDebugger.ts"() {
594
599
  "use strict";
600
+ init_navigators();
595
601
  init_logger();
602
+ _activePipeline = null;
596
603
  MAX_RUNS = 10;
597
604
  runHistory = [];
598
605
  pipelineDebugAPI = {
@@ -734,6 +741,81 @@ var init_PipelineDebugger = __esm({
734
741
  runHistory.length = 0;
735
742
  logger.info("[Pipeline Debug] Run history cleared.");
736
743
  },
744
+ /**
745
+ * Show the navigator registry: all registered classes and their roles.
746
+ *
747
+ * Useful for verifying that consumer-defined navigators were registered
748
+ * before pipeline assembly.
749
+ */
750
+ showRegistry() {
751
+ const names = getRegisteredNavigatorNames();
752
+ if (names.length === 0) {
753
+ logger.info("[Pipeline Debug] Navigator registry is empty.");
754
+ return;
755
+ }
756
+ console.group("\u{1F4E6} Navigator Registry");
757
+ console.table(
758
+ names.map((name) => {
759
+ const registryRole = getRegisteredNavigatorRole(name);
760
+ const builtinRole = NavigatorRoles[name];
761
+ const effectiveRole = builtinRole || registryRole || "\u26A0\uFE0F NONE";
762
+ const source = builtinRole ? "built-in" : registryRole ? "consumer" : "unclassified";
763
+ return {
764
+ name,
765
+ role: effectiveRole,
766
+ source,
767
+ isGenerator: isGenerator(name),
768
+ isFilter: isFilter(name)
769
+ };
770
+ })
771
+ );
772
+ console.groupEnd();
773
+ },
774
+ /**
775
+ * Show strategy documents from the last pipeline run and how they mapped
776
+ * to the registry.
777
+ *
778
+ * If no runs are captured yet, falls back to showing just the registry.
779
+ */
780
+ showStrategies() {
781
+ this.showRegistry();
782
+ if (runHistory.length === 0) {
783
+ logger.info("[Pipeline Debug] No pipeline runs captured yet \u2014 cannot show strategy doc mapping.");
784
+ return;
785
+ }
786
+ const run = runHistory[0];
787
+ console.group("\u{1F50C} Pipeline Strategy Mapping (last run)");
788
+ logger.info(`Generator: ${run.generatorName}`);
789
+ if (run.generators && run.generators.length > 0) {
790
+ for (const g of run.generators) {
791
+ logger.info(` \u{1F4E5} ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews)`);
792
+ }
793
+ }
794
+ if (run.filters.length > 0) {
795
+ logger.info("Filters:");
796
+ for (const f of run.filters) {
797
+ logger.info(` \u{1F538} ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
798
+ }
799
+ } else {
800
+ logger.info("Filters: (none)");
801
+ }
802
+ console.groupEnd();
803
+ },
804
+ /**
805
+ * Scan the full card space through the filter chain for the current user.
806
+ *
807
+ * Reports how many cards are well-indicated and how many are new.
808
+ * Use this to understand how the search space grows during onboarding.
809
+ *
810
+ * @param threshold - Score threshold for "well indicated" (default 0.10)
811
+ */
812
+ async diagnoseCardSpace(threshold) {
813
+ if (!_activePipeline) {
814
+ logger.info("[Pipeline Debug] No active pipeline. Run a session first.");
815
+ return null;
816
+ }
817
+ return _activePipeline.diagnoseCardSpace({ threshold });
818
+ },
737
819
  /**
738
820
  * Show help.
739
821
  */
@@ -746,6 +828,9 @@ Commands:
746
828
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
747
829
  .showCard(cardId) Show provenance trail for a specific card
748
830
  .explainReviews() Analyze why reviews were/weren't selected
831
+ .diagnoseCardSpace() Scan full card space through filters (async)
832
+ .showRegistry() Show navigator registry (classes + roles)
833
+ .showStrategies() Show registry + strategy mapping from last run
749
834
  .listRuns() List all captured runs in table format
750
835
  .export() Export run history as JSON for bug reports
751
836
  .clear() Clear run history
@@ -755,7 +840,7 @@ Commands:
755
840
  Example:
756
841
  window.skuilder.pipeline.showLastRun()
757
842
  window.skuilder.pipeline.showRun(1)
758
- window.skuilder.pipeline.showCard('abc123')
843
+ await window.skuilder.pipeline.diagnoseCardSpace()
759
844
  `);
760
845
  }
761
846
  };
@@ -1050,6 +1135,69 @@ var init_generators = __esm({
1050
1135
  }
1051
1136
  });
1052
1137
 
1138
+ // src/core/navigators/generators/prescribed.ts
1139
+ var prescribed_exports = {};
1140
+ __export(prescribed_exports, {
1141
+ default: () => PrescribedCardsGenerator
1142
+ });
1143
+ var PrescribedCardsGenerator;
1144
+ var init_prescribed = __esm({
1145
+ "src/core/navigators/generators/prescribed.ts"() {
1146
+ "use strict";
1147
+ init_navigators();
1148
+ init_logger();
1149
+ PrescribedCardsGenerator = class extends ContentNavigator {
1150
+ name;
1151
+ config;
1152
+ constructor(user, course, strategyData) {
1153
+ super(user, course, strategyData);
1154
+ this.name = strategyData.name || "Prescribed Cards";
1155
+ try {
1156
+ const parsed = JSON.parse(strategyData.serializedData);
1157
+ this.config = { cardIds: parsed.cardIds || [] };
1158
+ } catch {
1159
+ this.config = { cardIds: [] };
1160
+ }
1161
+ logger.debug(
1162
+ `[Prescribed] Initialized with ${this.config.cardIds.length} prescribed cards`
1163
+ );
1164
+ }
1165
+ async getWeightedCards(limit, _context) {
1166
+ if (this.config.cardIds.length === 0) {
1167
+ return [];
1168
+ }
1169
+ const courseId = this.course.getCourseID();
1170
+ const activeCards = await this.user.getActiveCards();
1171
+ const activeIds = new Set(activeCards.map((ac) => ac.cardID));
1172
+ const eligibleIds = this.config.cardIds.filter((id) => !activeIds.has(id));
1173
+ if (eligibleIds.length === 0) {
1174
+ logger.debug("[Prescribed] All prescribed cards already active, returning empty");
1175
+ return [];
1176
+ }
1177
+ const cards = eligibleIds.slice(0, limit).map((cardId) => ({
1178
+ cardId,
1179
+ courseId,
1180
+ score: 1,
1181
+ provenance: [
1182
+ {
1183
+ strategy: "prescribed",
1184
+ strategyName: this.strategyName || this.name,
1185
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
1186
+ action: "generated",
1187
+ score: 1,
1188
+ reason: `Prescribed card (${eligibleIds.length} eligible of ${this.config.cardIds.length} configured)`
1189
+ }
1190
+ ]
1191
+ }));
1192
+ logger.info(
1193
+ `[Prescribed] Emitting ${cards.length} cards (${eligibleIds.length} eligible, ${activeIds.size} already active)`
1194
+ );
1195
+ return cards;
1196
+ }
1197
+ };
1198
+ }
1199
+ });
1200
+
1053
1201
  // src/core/navigators/generators/srs.ts
1054
1202
  var srs_exports = {};
1055
1203
  __export(srs_exports, {
@@ -1244,6 +1392,7 @@ var init_ = __esm({
1244
1392
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1245
1393
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1246
1394
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1395
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1247
1396
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1248
1397
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1249
1398
  });
@@ -1444,6 +1593,8 @@ var init_hierarchyDefinition = __esm({
1444
1593
  if (userTagElo.count < minCount) return false;
1445
1594
  if (prereq.masteryThreshold?.minElo !== void 0) {
1446
1595
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1596
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1597
+ return true;
1447
1598
  } else {
1448
1599
  return userTagElo.score >= userGlobalElo;
1449
1600
  }
@@ -1519,14 +1670,38 @@ var init_hierarchyDefinition = __esm({
1519
1670
  };
1520
1671
  }
1521
1672
  }
1673
+ /**
1674
+ * Build a map of prereq tag → max configured boost for all *closed* gates.
1675
+ *
1676
+ * When a gate is closed (prereqs unmet), cards carrying that gate's prereq
1677
+ * tags get boosted — steering the pipeline toward content that helps unlock
1678
+ * the gated material. Once the gate opens, the boost disappears.
1679
+ */
1680
+ getPreReqBoosts(unlockedTags, masteredTags) {
1681
+ const boosts = /* @__PURE__ */ new Map();
1682
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
1683
+ if (unlockedTags.has(tagId)) continue;
1684
+ for (const prereq of prereqs) {
1685
+ if (!prereq.preReqBoost || prereq.preReqBoost <= 1) continue;
1686
+ if (masteredTags.has(prereq.tag)) continue;
1687
+ const existing = boosts.get(prereq.tag) ?? 1;
1688
+ boosts.set(prereq.tag, Math.max(existing, prereq.preReqBoost));
1689
+ }
1690
+ }
1691
+ return boosts;
1692
+ }
1522
1693
  /**
1523
1694
  * CardFilter.transform implementation.
1524
1695
  *
1525
- * Apply prerequisite gating to cards. Cards with locked tags receive score * 0.01.
1696
+ * Two effects:
1697
+ * 1. Cards with locked tags receive score * 0.05 (gating penalty)
1698
+ * 2. Cards carrying prereq tags of closed gates receive a configured
1699
+ * boost (preReqBoost), steering toward content that unlocks gates
1526
1700
  */
1527
1701
  async transform(cards, context) {
1528
1702
  const masteredTags = await this.getMasteredTags(context);
1529
1703
  const unlockedTags = this.getUnlockedTags(masteredTags);
1704
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1530
1705
  const gated = [];
1531
1706
  for (const card of cards) {
1532
1707
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1535,9 +1710,27 @@ var init_hierarchyDefinition = __esm({
1535
1710
  unlockedTags,
1536
1711
  masteredTags
1537
1712
  );
1538
- const LOCKED_PENALTY = 0.01;
1539
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1540
- const action = isUnlocked ? "passed" : "penalized";
1713
+ const LOCKED_PENALTY = 0.02;
1714
+ let finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1715
+ let action = isUnlocked ? "passed" : "penalized";
1716
+ let finalReason = reason;
1717
+ if (isUnlocked && preReqBoosts.size > 0) {
1718
+ const cardTags = card.tags ?? [];
1719
+ let maxBoost = 1;
1720
+ const boostedPrereqs = [];
1721
+ for (const tag of cardTags) {
1722
+ const boost = preReqBoosts.get(tag);
1723
+ if (boost && boost > maxBoost) {
1724
+ maxBoost = boost;
1725
+ boostedPrereqs.push(tag);
1726
+ }
1727
+ }
1728
+ if (maxBoost > 1) {
1729
+ finalScore *= maxBoost;
1730
+ action = "boosted";
1731
+ finalReason = `${reason} | preReqBoost \xD7${maxBoost.toFixed(2)} for ${boostedPrereqs.join(", ")}`;
1732
+ }
1733
+ }
1541
1734
  gated.push({
1542
1735
  ...card,
1543
1736
  score: finalScore,
@@ -1549,7 +1742,7 @@ var init_hierarchyDefinition = __esm({
1549
1742
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1550
1743
  action,
1551
1744
  score: finalScore,
1552
- reason
1745
+ reason: finalReason
1553
1746
  }
1554
1747
  ]
1555
1748
  });
@@ -2238,6 +2431,18 @@ __export(Pipeline_exports, {
2238
2431
  Pipeline: () => Pipeline
2239
2432
  });
2240
2433
  import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
2434
+ function globToRegex(pattern) {
2435
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
2436
+ const withWildcards = escaped.replace(/\*/g, ".*");
2437
+ return new RegExp(`^${withWildcards}$`);
2438
+ }
2439
+ function globMatch(value, pattern) {
2440
+ if (!pattern.includes("*")) return value === pattern;
2441
+ return globToRegex(pattern).test(value);
2442
+ }
2443
+ function cardMatchesTagPattern(card, pattern) {
2444
+ return (card.tags ?? []).some((tag) => globMatch(tag, pattern));
2445
+ }
2241
2446
  function logPipelineConfig(generator, filters) {
2242
2447
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2243
2448
  logger.info(
@@ -2272,6 +2477,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2272
2477
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2273
2478
  );
2274
2479
  }
2480
+ function logResultCards(cards) {
2481
+ if (!VERBOSE_RESULTS || cards.length === 0) return;
2482
+ logger.info(`[Pipeline] Results (${cards.length} cards):`);
2483
+ for (let i = 0; i < cards.length; i++) {
2484
+ const c = cards[i];
2485
+ const tags = c.tags?.slice(0, 3).join(", ") || "";
2486
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
2487
+ const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
2488
+ return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
2489
+ }).join(" | ");
2490
+ logger.info(
2491
+ `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ""}`
2492
+ );
2493
+ }
2494
+ }
2275
2495
  function logCardProvenance(cards, maxCards = 3) {
2276
2496
  const cardsToLog = cards.slice(0, maxCards);
2277
2497
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2286,7 +2506,7 @@ function logCardProvenance(cards, maxCards = 3) {
2286
2506
  }
2287
2507
  }
2288
2508
  }
2289
- var Pipeline;
2509
+ var VERBOSE_RESULTS, Pipeline;
2290
2510
  var init_Pipeline = __esm({
2291
2511
  "src/core/navigators/Pipeline.ts"() {
2292
2512
  "use strict";
@@ -2294,9 +2514,31 @@ var init_Pipeline = __esm({
2294
2514
  init_logger();
2295
2515
  init_orchestration();
2296
2516
  init_PipelineDebugger();
2517
+ VERBOSE_RESULTS = true;
2297
2518
  Pipeline = class extends ContentNavigator {
2298
2519
  generator;
2299
2520
  filters;
2521
+ /**
2522
+ * Cached orchestration context. Course config and salt don't change within
2523
+ * a page load, so we build the orchestration context once and reuse it on
2524
+ * subsequent getWeightedCards() calls (e.g. mid-session replans).
2525
+ *
2526
+ * This eliminates a remote getCourseConfig() round trip per pipeline run.
2527
+ */
2528
+ _cachedOrchestration = null;
2529
+ /**
2530
+ * Persistent tag cache. Maps cardId → tag names.
2531
+ *
2532
+ * Tags are static within a session (they're set at card generation time),
2533
+ * so we cache them across pipeline runs. On replans, many of the same cards
2534
+ * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries.
2535
+ */
2536
+ _tagCache = /* @__PURE__ */ new Map();
2537
+ /**
2538
+ * One-shot replan hints. Applied after the filter chain on the next
2539
+ * getWeightedCards() call, then cleared.
2540
+ */
2541
+ _ephemeralHints = null;
2300
2542
  /**
2301
2543
  * Create a new pipeline.
2302
2544
  *
@@ -2317,6 +2559,17 @@ var init_Pipeline = __esm({
2317
2559
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2318
2560
  });
2319
2561
  logPipelineConfig(generator, filters);
2562
+ registerPipelineForDebug(this);
2563
+ }
2564
+ /**
2565
+ * Set one-shot hints for the next pipeline run.
2566
+ * Consumed after one getWeightedCards() call, then cleared.
2567
+ *
2568
+ * Overrides ContentNavigator.setEphemeralHints() no-op.
2569
+ */
2570
+ setEphemeralHints(hints) {
2571
+ this._ephemeralHints = hints;
2572
+ logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
2320
2573
  }
2321
2574
  /**
2322
2575
  * Get weighted cards by running generator and applying filters.
@@ -2333,13 +2586,15 @@ var init_Pipeline = __esm({
2333
2586
  * @returns Cards sorted by score descending
2334
2587
  */
2335
2588
  async getWeightedCards(limit) {
2589
+ const t0 = performance.now();
2336
2590
  const context = await this.buildContext();
2337
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2338
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
2591
+ const tContext = performance.now();
2592
+ const fetchLimit = 500;
2339
2593
  logger.debug(
2340
2594
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2341
2595
  );
2342
2596
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2597
+ const tGenerate = performance.now();
2343
2598
  const generatedCount = cards.length;
2344
2599
  let generatorSummaries;
2345
2600
  if (this.generator.generators) {
@@ -2368,6 +2623,7 @@ var init_Pipeline = __esm({
2368
2623
  }
2369
2624
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2370
2625
  cards = await this.hydrateTags(cards);
2626
+ const tHydrate = performance.now();
2371
2627
  const allCardsBeforeFiltering = [...cards];
2372
2628
  const filterImpacts = [];
2373
2629
  for (const filter of this.filters) {
@@ -2386,8 +2642,17 @@ var init_Pipeline = __esm({
2386
2642
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2387
2643
  }
2388
2644
  cards = cards.filter((c) => c.score > 0);
2645
+ const hints = this._ephemeralHints;
2646
+ if (hints) {
2647
+ this._ephemeralHints = null;
2648
+ cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
2649
+ }
2389
2650
  cards.sort((a, b) => b.score - a.score);
2651
+ const tFilter = performance.now();
2390
2652
  const result = cards.slice(0, limit);
2653
+ logger.info(
2654
+ `[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)})`
2655
+ );
2391
2656
  const topScores = result.slice(0, 3).map((c) => c.score);
2392
2657
  logExecutionSummary(
2393
2658
  this.generator.name,
@@ -2397,6 +2662,7 @@ var init_Pipeline = __esm({
2397
2662
  topScores,
2398
2663
  filterImpacts
2399
2664
  );
2665
+ logResultCards(result);
2400
2666
  logCardProvenance(result, 3);
2401
2667
  try {
2402
2668
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -2423,6 +2689,10 @@ var init_Pipeline = __esm({
2423
2689
  * to the WeightedCard objects. Filters can then use card.tags instead of
2424
2690
  * making individual getAppliedTags() calls.
2425
2691
  *
2692
+ * Uses a persistent tag cache across pipeline runs — tags are static within
2693
+ * a session, so cards seen in a prior run (e.g. before a replan) don't
2694
+ * require a second DB query.
2695
+ *
2426
2696
  * @param cards - Cards to hydrate
2427
2697
  * @returns Cards with tags populated
2428
2698
  */
@@ -2430,14 +2700,128 @@ var init_Pipeline = __esm({
2430
2700
  if (cards.length === 0) {
2431
2701
  return cards;
2432
2702
  }
2433
- const cardIds = cards.map((c) => c.cardId);
2434
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
2703
+ const uncachedIds = [];
2704
+ for (const card of cards) {
2705
+ if (!this._tagCache.has(card.cardId)) {
2706
+ uncachedIds.push(card.cardId);
2707
+ }
2708
+ }
2709
+ if (uncachedIds.length > 0) {
2710
+ const freshTags = await this.course.getAppliedTagsBatch(uncachedIds);
2711
+ for (const [cardId, tags] of freshTags) {
2712
+ this._tagCache.set(cardId, tags);
2713
+ }
2714
+ }
2715
+ const tagsByCard = /* @__PURE__ */ new Map();
2716
+ for (const card of cards) {
2717
+ tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []);
2718
+ }
2435
2719
  logTagHydration(cards, tagsByCard);
2436
2720
  return cards.map((card) => ({
2437
2721
  ...card,
2438
- tags: tagsByCard.get(card.cardId) ?? []
2722
+ tags: this._tagCache.get(card.cardId) ?? []
2439
2723
  }));
2440
2724
  }
2725
+ // ---------------------------------------------------------------------------
2726
+ // Ephemeral hints application
2727
+ // ---------------------------------------------------------------------------
2728
+ /**
2729
+ * Apply one-shot replan hints to the post-filter card set.
2730
+ *
2731
+ * Order of operations:
2732
+ * 1. Exclude (remove unwanted cards)
2733
+ * 2. Boost (multiply scores)
2734
+ * 3. Require (inject must-have cards from the full pre-filter pool)
2735
+ *
2736
+ * @param cards - Post-filter cards (score > 0)
2737
+ * @param hints - The ephemeral hints to apply
2738
+ * @param allCards - Full pre-filter card pool (for require injection)
2739
+ */
2740
+ applyHints(cards, hints, allCards) {
2741
+ const beforeCount = cards.length;
2742
+ if (hints.excludeCards?.length) {
2743
+ cards = cards.filter(
2744
+ (c) => !hints.excludeCards.some((pat) => globMatch(c.cardId, pat))
2745
+ );
2746
+ }
2747
+ if (hints.excludeTags?.length) {
2748
+ cards = cards.filter(
2749
+ (c) => !hints.excludeTags.some((pat) => cardMatchesTagPattern(c, pat))
2750
+ );
2751
+ }
2752
+ if (hints.boostTags) {
2753
+ for (const [pattern, factor] of Object.entries(hints.boostTags)) {
2754
+ for (const card of cards) {
2755
+ if (cardMatchesTagPattern(card, pattern)) {
2756
+ card.score *= factor;
2757
+ card.provenance.push({
2758
+ strategy: "ephemeralHint",
2759
+ strategyId: "ephemeral-hint",
2760
+ strategyName: "Replan Hint",
2761
+ action: "boosted",
2762
+ score: card.score,
2763
+ reason: `boostTag ${pattern} \xD7${factor}`
2764
+ });
2765
+ }
2766
+ }
2767
+ }
2768
+ }
2769
+ if (hints.boostCards) {
2770
+ for (const [pattern, factor] of Object.entries(hints.boostCards)) {
2771
+ for (const card of cards) {
2772
+ if (globMatch(card.cardId, pattern)) {
2773
+ card.score *= factor;
2774
+ card.provenance.push({
2775
+ strategy: "ephemeralHint",
2776
+ strategyId: "ephemeral-hint",
2777
+ strategyName: "Replan Hint",
2778
+ action: "boosted",
2779
+ score: card.score,
2780
+ reason: `boostCard ${pattern} \xD7${factor}`
2781
+ });
2782
+ }
2783
+ }
2784
+ }
2785
+ }
2786
+ const cardIds = new Set(cards.map((c) => c.cardId));
2787
+ const inject = (card, reason) => {
2788
+ if (!cardIds.has(card.cardId)) {
2789
+ const floorScore = Math.max(card.score, 1);
2790
+ cards.push({
2791
+ ...card,
2792
+ score: floorScore,
2793
+ provenance: [
2794
+ ...card.provenance,
2795
+ {
2796
+ strategy: "ephemeralHint",
2797
+ strategyId: "ephemeral-hint",
2798
+ strategyName: "Replan Hint",
2799
+ action: "boosted",
2800
+ score: floorScore,
2801
+ reason
2802
+ }
2803
+ ]
2804
+ });
2805
+ cardIds.add(card.cardId);
2806
+ }
2807
+ };
2808
+ if (hints.requireCards?.length) {
2809
+ for (const pattern of hints.requireCards) {
2810
+ for (const card of allCards) {
2811
+ if (globMatch(card.cardId, pattern)) inject(card, `requireCard ${pattern}`);
2812
+ }
2813
+ }
2814
+ }
2815
+ if (hints.requireTags?.length) {
2816
+ for (const pattern of hints.requireTags) {
2817
+ for (const card of allCards) {
2818
+ if (cardMatchesTagPattern(card, pattern)) inject(card, `requireTag ${pattern}`);
2819
+ }
2820
+ }
2821
+ }
2822
+ logger.info(`[Pipeline] Hints applied: ${beforeCount} \u2192 ${cards.length} cards`);
2823
+ return cards;
2824
+ }
2441
2825
  /**
2442
2826
  * Build shared context for generator and filters.
2443
2827
  *
@@ -2455,7 +2839,10 @@ var init_Pipeline = __esm({
2455
2839
  } catch (e) {
2456
2840
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
2457
2841
  }
2458
- const orchestration = await createOrchestrationContext(this.user, this.course);
2842
+ if (!this._cachedOrchestration) {
2843
+ this._cachedOrchestration = await createOrchestrationContext(this.user, this.course);
2844
+ }
2845
+ const orchestration = this._cachedOrchestration;
2459
2846
  return {
2460
2847
  user: this.user,
2461
2848
  course: this.course,
@@ -2499,6 +2886,87 @@ var init_Pipeline = __esm({
2499
2886
  }
2500
2887
  return [...new Set(ids)];
2501
2888
  }
2889
+ // ---------------------------------------------------------------------------
2890
+ // Card-space diagnostic
2891
+ // ---------------------------------------------------------------------------
2892
+ /**
2893
+ * Scan every card in the course through the filter chain and report
2894
+ * how many are "well indicated" (score >= threshold) for the current user.
2895
+ *
2896
+ * Also reports how many well-indicated cards the user has NOT yet encountered.
2897
+ *
2898
+ * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`.
2899
+ */
2900
+ async diagnoseCardSpace(opts) {
2901
+ const THRESHOLD = opts?.threshold ?? 0.1;
2902
+ const t0 = performance.now();
2903
+ const allCardIds = await this.course.getAllCardIds();
2904
+ let cards = allCardIds.map((cardId) => ({
2905
+ cardId,
2906
+ courseId: this.course.getCourseID(),
2907
+ score: 1,
2908
+ provenance: []
2909
+ }));
2910
+ cards = await this.hydrateTags(cards);
2911
+ const context = await this.buildContext();
2912
+ const filterBreakdown = [];
2913
+ for (const filter of this.filters) {
2914
+ cards = await filter.transform(cards, context);
2915
+ const wi = cards.filter((c) => c.score >= THRESHOLD).length;
2916
+ filterBreakdown.push({ name: filter.name, wellIndicated: wi });
2917
+ }
2918
+ const wellIndicated = cards.filter((c) => c.score >= THRESHOLD);
2919
+ const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId));
2920
+ let encounteredIds;
2921
+ try {
2922
+ const courseId = this.course.getCourseID();
2923
+ const seenCards = await this.user.getSeenCards(courseId);
2924
+ encounteredIds = new Set(seenCards);
2925
+ } catch {
2926
+ encounteredIds = /* @__PURE__ */ new Set();
2927
+ }
2928
+ const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId));
2929
+ const byType = /* @__PURE__ */ new Map();
2930
+ for (const card of cards) {
2931
+ const type = card.cardId.split("-")[1] || "unknown";
2932
+ if (!byType.has(type)) {
2933
+ byType.set(type, { total: 0, wellIndicated: 0, new: 0 });
2934
+ }
2935
+ const entry = byType.get(type);
2936
+ entry.total++;
2937
+ if (card.score >= THRESHOLD) {
2938
+ entry.wellIndicated++;
2939
+ if (!encounteredIds.has(card.cardId)) entry.new++;
2940
+ }
2941
+ }
2942
+ const elapsed = performance.now() - t0;
2943
+ const result = {
2944
+ totalCards: allCardIds.length,
2945
+ threshold: THRESHOLD,
2946
+ wellIndicated: wellIndicatedIds.size,
2947
+ encountered: encounteredIds.size,
2948
+ wellIndicatedNew: wellIndicatedNew.length,
2949
+ byType: Object.fromEntries(byType),
2950
+ filterBreakdown,
2951
+ elapsedMs: Math.round(elapsed)
2952
+ };
2953
+ logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`);
2954
+ logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`);
2955
+ logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`);
2956
+ logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`);
2957
+ logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`);
2958
+ logger.info(`[Pipeline:diagnose] By type:`);
2959
+ for (const [type, counts] of byType) {
2960
+ logger.info(
2961
+ `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new`
2962
+ );
2963
+ }
2964
+ logger.info(`[Pipeline:diagnose] After each filter:`);
2965
+ for (const fb of filterBreakdown) {
2966
+ logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`);
2967
+ }
2968
+ return result;
2969
+ }
2502
2970
  };
2503
2971
  }
2504
2972
  });
@@ -2603,23 +3071,25 @@ var init_PipelineAssembler = __esm({
2603
3071
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
2604
3072
  }
2605
3073
  }
3074
+ const courseId = course.getCourseID();
3075
+ const hasElo = generatorStrategies.some((s) => s.implementingClass === "elo" /* ELO */);
3076
+ const hasSrs = generatorStrategies.some((s) => s.implementingClass === "srs" /* SRS */);
3077
+ if (!hasElo) {
3078
+ logger.debug("[PipelineAssembler] No ELO generator configured, adding default");
3079
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3080
+ }
3081
+ if (!hasSrs) {
3082
+ logger.debug("[PipelineAssembler] No SRS generator configured, adding default");
3083
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
3084
+ }
2606
3085
  if (generatorStrategies.length === 0) {
2607
- if (filterStrategies.length > 0) {
2608
- logger.debug(
2609
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2610
- );
2611
- const courseId = course.getCourseID();
2612
- generatorStrategies.push(createDefaultEloStrategy(courseId));
2613
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
2614
- } else {
2615
- warnings.push("No generator strategy found");
2616
- return {
2617
- pipeline: null,
2618
- generatorStrategies: [],
2619
- filterStrategies: [],
2620
- warnings
2621
- };
2622
- }
3086
+ warnings.push("No generator strategy found");
3087
+ return {
3088
+ pipeline: null,
3089
+ generatorStrategies: [],
3090
+ filterStrategies: [],
3091
+ warnings
3092
+ };
2623
3093
  }
2624
3094
  let generator;
2625
3095
  if (generatorStrategies.length === 1) {
@@ -2697,6 +3167,7 @@ var init_3 = __esm({
2697
3167
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
2698
3168
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
2699
3169
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3170
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
2700
3171
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
2701
3172
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
2702
3173
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -2714,6 +3185,7 @@ __export(navigators_exports, {
2714
3185
  getCardOrigin: () => getCardOrigin,
2715
3186
  getRegisteredNavigator: () => getRegisteredNavigator,
2716
3187
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
3188
+ getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
2717
3189
  hasRegisteredNavigator: () => hasRegisteredNavigator,
2718
3190
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
2719
3191
  isFilter: () => isFilter,
@@ -2722,16 +3194,19 @@ __export(navigators_exports, {
2722
3194
  pipelineDebugAPI: () => pipelineDebugAPI,
2723
3195
  registerNavigator: () => registerNavigator
2724
3196
  });
2725
- function registerNavigator(implementingClass, constructor) {
2726
- navigatorRegistry.set(implementingClass, constructor);
2727
- logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
3197
+ function registerNavigator(implementingClass, constructor, role) {
3198
+ navigatorRegistry.set(implementingClass, { constructor, role });
3199
+ logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}${role ? ` (${role})` : ""}`);
2728
3200
  }
2729
3201
  function getRegisteredNavigator(implementingClass) {
2730
- return navigatorRegistry.get(implementingClass);
3202
+ return navigatorRegistry.get(implementingClass)?.constructor;
2731
3203
  }
2732
3204
  function hasRegisteredNavigator(implementingClass) {
2733
3205
  return navigatorRegistry.has(implementingClass);
2734
3206
  }
3207
+ function getRegisteredNavigatorRole(implementingClass) {
3208
+ return navigatorRegistry.get(implementingClass)?.role;
3209
+ }
2735
3210
  function getRegisteredNavigatorNames() {
2736
3211
  return Array.from(navigatorRegistry.keys());
2737
3212
  }
@@ -2741,8 +3216,10 @@ async function initializeNavigatorRegistry() {
2741
3216
  Promise.resolve().then(() => (init_elo(), elo_exports)),
2742
3217
  Promise.resolve().then(() => (init_srs(), srs_exports))
2743
3218
  ]);
3219
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
2744
3220
  registerNavigator("elo", eloModule.default);
2745
3221
  registerNavigator("srs", srsModule.default);
3222
+ registerNavigator("prescribed", prescribedModule.default);
2746
3223
  const [
2747
3224
  hierarchyModule,
2748
3225
  interferenceModule,
@@ -2777,10 +3254,12 @@ function getCardOrigin(card) {
2777
3254
  return "new";
2778
3255
  }
2779
3256
  function isGenerator(impl) {
2780
- return NavigatorRoles[impl] === "generator" /* GENERATOR */;
3257
+ if (NavigatorRoles[impl] === "generator" /* GENERATOR */) return true;
3258
+ return getRegisteredNavigatorRole(impl) === "generator" /* GENERATOR */;
2781
3259
  }
2782
3260
  function isFilter(impl) {
2783
- return NavigatorRoles[impl] === "filter" /* FILTER */;
3261
+ if (NavigatorRoles[impl] === "filter" /* FILTER */) return true;
3262
+ return getRegisteredNavigatorRole(impl) === "filter" /* FILTER */;
2784
3263
  }
2785
3264
  var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
2786
3265
  var init_navigators = __esm({
@@ -2795,6 +3274,7 @@ var init_navigators = __esm({
2795
3274
  Navigators = /* @__PURE__ */ ((Navigators2) => {
2796
3275
  Navigators2["ELO"] = "elo";
2797
3276
  Navigators2["SRS"] = "srs";
3277
+ Navigators2["PRESCRIBED"] = "prescribed";
2798
3278
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
2799
3279
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
2800
3280
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -2809,6 +3289,7 @@ var init_navigators = __esm({
2809
3289
  NavigatorRoles = {
2810
3290
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
2811
3291
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3292
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
2812
3293
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
2813
3294
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
2814
3295
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -2973,6 +3454,12 @@ var init_navigators = __esm({
2973
3454
  async getWeightedCards(_limit) {
2974
3455
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
2975
3456
  }
3457
+ /**
3458
+ * Set ephemeral hints for the next pipeline run.
3459
+ * No-op for non-Pipeline navigators. Pipeline overrides this.
3460
+ */
3461
+ setEphemeralHints(_hints) {
3462
+ }
2976
3463
  };
2977
3464
  }
2978
3465
  });
@@ -3026,6 +3513,16 @@ var init_adminDB2 = __esm({
3026
3513
  }
3027
3514
  });
3028
3515
 
3516
+ // src/impl/couch/CourseSyncService.ts
3517
+ var init_CourseSyncService = __esm({
3518
+ "src/impl/couch/CourseSyncService.ts"() {
3519
+ "use strict";
3520
+ init_pouchdb_setup();
3521
+ init_couch();
3522
+ init_logger();
3523
+ }
3524
+ });
3525
+
3029
3526
  // src/impl/couch/auth.ts
3030
3527
  import fetch2 from "cross-fetch";
3031
3528
  var init_auth = __esm({
@@ -3087,6 +3584,7 @@ var init_couch = __esm({
3087
3584
  init_classroomDB2();
3088
3585
  init_courseAPI();
3089
3586
  init_courseDB();
3587
+ init_CourseSyncService();
3090
3588
  init_CouchDBSyncStrategy();
3091
3589
  isBrowser = typeof window !== "undefined";
3092
3590
  if (isBrowser) {
@@ -3386,6 +3884,9 @@ Currently logged-in as ${this._username}.`
3386
3884
  const id = row.id;
3387
3885
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
3388
3886
  id.startsWith(DocTypePrefixes["SCHEDULED_CARD" /* SCHEDULED_CARD */]) || // Scheduled reviews
3887
+ id.startsWith(DocTypePrefixes["STRATEGY_STATE" /* STRATEGY_STATE */]) || // Strategy state (user prefs, progression)
3888
+ id.startsWith(DocTypePrefixes["USER_OUTCOME" /* USER_OUTCOME */]) || // Evolutionary orchestration outcomes
3889
+ id.startsWith(DocTypePrefixes["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]) || // Strategy learning state
3389
3890
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
3390
3891
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
3391
3892
  id === _BaseUser.DOC_IDS.CONFIG;
@@ -5238,6 +5739,10 @@ var init_courseDB3 = __esm({
5238
5739
  }
5239
5740
  return tagsByCard;
5240
5741
  }
5742
+ async getAllCardIds() {
5743
+ const tagsIndex = await this.unpacker.getTagsIndex();
5744
+ return Object.keys(tagsIndex.byCard);
5745
+ }
5241
5746
  async addTagToCard(_cardId, _tagId) {
5242
5747
  throw new Error("Cannot modify tags in static mode");
5243
5748
  }