@vue-skuilder/db 0.1.31-b → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{contentSource-ygoFw9oV.d.ts → contentSource-Bdwkvqa8.d.ts} +16 -0
  2. package/dist/{contentSource-B7nXusjk.d.cts → contentSource-DF1nUbPQ.d.cts} +16 -0
  3. package/dist/core/index.d.cts +34 -3
  4. package/dist/core/index.d.ts +34 -3
  5. package/dist/core/index.js +510 -50
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +510 -50
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BW7HvkMt.d.cts → dataLayerProvider-BKmVoyJR.d.ts} +20 -1
  10. package/dist/{dataLayerProvider-BfXUVDuG.d.ts → dataLayerProvider-BQdfJuBN.d.cts} +20 -1
  11. package/dist/impl/couch/index.d.cts +156 -4
  12. package/dist/impl/couch/index.d.ts +156 -4
  13. package/dist/impl/couch/index.js +730 -41
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +729 -41
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +3 -2
  18. package/dist/impl/static/index.d.ts +3 -2
  19. package/dist/impl/static/index.js +467 -31
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +467 -31
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index.d.cts +64 -3
  24. package/dist/index.d.ts +64 -3
  25. package/dist/index.js +948 -72
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +948 -72
  28. package/dist/index.mjs.map +1 -1
  29. package/package.json +3 -3
  30. package/src/core/interfaces/contentSource.ts +6 -0
  31. package/src/core/interfaces/courseDB.ts +6 -0
  32. package/src/core/interfaces/dataLayerProvider.ts +20 -0
  33. package/src/core/navigators/Pipeline.ts +414 -9
  34. package/src/core/navigators/PipelineAssembler.ts +23 -18
  35. package/src/core/navigators/PipelineDebugger.ts +35 -1
  36. package/src/core/navigators/filters/hierarchyDefinition.ts +78 -8
  37. package/src/core/navigators/generators/prescribed.ts +95 -0
  38. package/src/core/navigators/index.ts +12 -0
  39. package/src/impl/common/BaseUserDB.ts +4 -1
  40. package/src/impl/couch/CourseSyncService.ts +356 -0
  41. package/src/impl/couch/PouchDataLayerProvider.ts +21 -1
  42. package/src/impl/couch/courseDB.ts +60 -13
  43. package/src/impl/couch/index.ts +1 -0
  44. package/src/impl/static/courseDB.ts +5 -0
  45. package/src/study/ItemQueue.ts +42 -0
  46. package/src/study/SessionController.ts +195 -22
  47. package/src/study/SpacedRepetition.ts +3 -1
  48. package/tests/core/navigators/Pipeline.test.ts +1 -1
  49. package/tests/core/navigators/PipelineAssembler.test.ts +15 -14
@@ -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,12 +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";
619
624
  init_navigators();
620
625
  init_logger();
626
+ _activePipeline = null;
621
627
  MAX_RUNS = 10;
622
628
  runHistory = [];
623
629
  pipelineDebugAPI = {
@@ -819,6 +825,21 @@ var init_PipelineDebugger = __esm({
819
825
  }
820
826
  console.groupEnd();
821
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
+ },
822
843
  /**
823
844
  * Show help.
824
845
  */
@@ -831,6 +852,7 @@ Commands:
831
852
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
832
853
  .showCard(cardId) Show provenance trail for a specific card
833
854
  .explainReviews() Analyze why reviews were/weren't selected
855
+ .diagnoseCardSpace() Scan full card space through filters (async)
834
856
  .showRegistry() Show navigator registry (classes + roles)
835
857
  .showStrategies() Show registry + strategy mapping from last run
836
858
  .listRuns() List all captured runs in table format
@@ -842,7 +864,7 @@ Commands:
842
864
  Example:
843
865
  window.skuilder.pipeline.showLastRun()
844
866
  window.skuilder.pipeline.showRun(1)
845
- window.skuilder.pipeline.showCard('abc123')
867
+ await window.skuilder.pipeline.diagnoseCardSpace()
846
868
  `);
847
869
  }
848
870
  };
@@ -1137,6 +1159,69 @@ var init_generators = __esm({
1137
1159
  }
1138
1160
  });
1139
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
+
1140
1225
  // src/core/navigators/generators/srs.ts
1141
1226
  var srs_exports = {};
1142
1227
  __export(srs_exports, {
@@ -1331,6 +1416,7 @@ var init_ = __esm({
1331
1416
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1332
1417
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1333
1418
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1419
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1334
1420
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1335
1421
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1336
1422
  });
@@ -1531,6 +1617,8 @@ var init_hierarchyDefinition = __esm({
1531
1617
  if (userTagElo.count < minCount) return false;
1532
1618
  if (prereq.masteryThreshold?.minElo !== void 0) {
1533
1619
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1620
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1621
+ return true;
1534
1622
  } else {
1535
1623
  return userTagElo.score >= userGlobalElo;
1536
1624
  }
@@ -1606,14 +1694,38 @@ var init_hierarchyDefinition = __esm({
1606
1694
  };
1607
1695
  }
1608
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
+ }
1609
1717
  /**
1610
1718
  * CardFilter.transform implementation.
1611
1719
  *
1612
- * 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
1613
1724
  */
1614
1725
  async transform(cards, context) {
1615
1726
  const masteredTags = await this.getMasteredTags(context);
1616
1727
  const unlockedTags = this.getUnlockedTags(masteredTags);
1728
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1617
1729
  const gated = [];
1618
1730
  for (const card of cards) {
1619
1731
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1622,9 +1734,27 @@ var init_hierarchyDefinition = __esm({
1622
1734
  unlockedTags,
1623
1735
  masteredTags
1624
1736
  );
1625
- const LOCKED_PENALTY = 0.01;
1626
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1627
- 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
+ }
1628
1758
  gated.push({
1629
1759
  ...card,
1630
1760
  score: finalScore,
@@ -1636,7 +1766,7 @@ var init_hierarchyDefinition = __esm({
1636
1766
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1637
1767
  action,
1638
1768
  score: finalScore,
1639
- reason
1769
+ reason: finalReason
1640
1770
  }
1641
1771
  ]
1642
1772
  });
@@ -2324,6 +2454,18 @@ var Pipeline_exports = {};
2324
2454
  __export(Pipeline_exports, {
2325
2455
  Pipeline: () => Pipeline
2326
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
+ }
2327
2469
  function logPipelineConfig(generator, filters) {
2328
2470
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2329
2471
  logger.info(
@@ -2358,6 +2500,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2358
2500
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2359
2501
  );
2360
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
+ }
2361
2518
  function logCardProvenance(cards, maxCards = 3) {
2362
2519
  const cardsToLog = cards.slice(0, maxCards);
2363
2520
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2372,7 +2529,7 @@ function logCardProvenance(cards, maxCards = 3) {
2372
2529
  }
2373
2530
  }
2374
2531
  }
2375
- var import_common8, Pipeline;
2532
+ var import_common8, VERBOSE_RESULTS, Pipeline;
2376
2533
  var init_Pipeline = __esm({
2377
2534
  "src/core/navigators/Pipeline.ts"() {
2378
2535
  "use strict";
@@ -2381,9 +2538,31 @@ var init_Pipeline = __esm({
2381
2538
  init_logger();
2382
2539
  init_orchestration();
2383
2540
  init_PipelineDebugger();
2541
+ VERBOSE_RESULTS = true;
2384
2542
  Pipeline = class extends ContentNavigator {
2385
2543
  generator;
2386
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;
2387
2566
  /**
2388
2567
  * Create a new pipeline.
2389
2568
  *
@@ -2404,6 +2583,17 @@ var init_Pipeline = __esm({
2404
2583
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2405
2584
  });
2406
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)}`);
2407
2597
  }
2408
2598
  /**
2409
2599
  * Get weighted cards by running generator and applying filters.
@@ -2420,13 +2610,15 @@ var init_Pipeline = __esm({
2420
2610
  * @returns Cards sorted by score descending
2421
2611
  */
2422
2612
  async getWeightedCards(limit) {
2613
+ const t0 = performance.now();
2423
2614
  const context = await this.buildContext();
2424
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2425
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
2615
+ const tContext = performance.now();
2616
+ const fetchLimit = 500;
2426
2617
  logger.debug(
2427
2618
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2428
2619
  );
2429
2620
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2621
+ const tGenerate = performance.now();
2430
2622
  const generatedCount = cards.length;
2431
2623
  let generatorSummaries;
2432
2624
  if (this.generator.generators) {
@@ -2455,6 +2647,7 @@ var init_Pipeline = __esm({
2455
2647
  }
2456
2648
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2457
2649
  cards = await this.hydrateTags(cards);
2650
+ const tHydrate = performance.now();
2458
2651
  const allCardsBeforeFiltering = [...cards];
2459
2652
  const filterImpacts = [];
2460
2653
  for (const filter of this.filters) {
@@ -2473,8 +2666,17 @@ var init_Pipeline = __esm({
2473
2666
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2474
2667
  }
2475
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
+ }
2476
2674
  cards.sort((a, b) => b.score - a.score);
2675
+ const tFilter = performance.now();
2477
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
+ );
2478
2680
  const topScores = result.slice(0, 3).map((c) => c.score);
2479
2681
  logExecutionSummary(
2480
2682
  this.generator.name,
@@ -2484,6 +2686,7 @@ var init_Pipeline = __esm({
2484
2686
  topScores,
2485
2687
  filterImpacts
2486
2688
  );
2689
+ logResultCards(result);
2487
2690
  logCardProvenance(result, 3);
2488
2691
  try {
2489
2692
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -2510,6 +2713,10 @@ var init_Pipeline = __esm({
2510
2713
  * to the WeightedCard objects. Filters can then use card.tags instead of
2511
2714
  * making individual getAppliedTags() calls.
2512
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
+ *
2513
2720
  * @param cards - Cards to hydrate
2514
2721
  * @returns Cards with tags populated
2515
2722
  */
@@ -2517,14 +2724,128 @@ var init_Pipeline = __esm({
2517
2724
  if (cards.length === 0) {
2518
2725
  return cards;
2519
2726
  }
2520
- const cardIds = cards.map((c) => c.cardId);
2521
- 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
+ }
2522
2743
  logTagHydration(cards, tagsByCard);
2523
2744
  return cards.map((card) => ({
2524
2745
  ...card,
2525
- tags: tagsByCard.get(card.cardId) ?? []
2746
+ tags: this._tagCache.get(card.cardId) ?? []
2526
2747
  }));
2527
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
+ }
2528
2849
  /**
2529
2850
  * Build shared context for generator and filters.
2530
2851
  *
@@ -2542,7 +2863,10 @@ var init_Pipeline = __esm({
2542
2863
  } catch (e) {
2543
2864
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
2544
2865
  }
2545
- 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;
2546
2870
  return {
2547
2871
  user: this.user,
2548
2872
  course: this.course,
@@ -2586,6 +2910,87 @@ var init_Pipeline = __esm({
2586
2910
  }
2587
2911
  return [...new Set(ids)];
2588
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
+ }
2589
2994
  };
2590
2995
  }
2591
2996
  });
@@ -2690,23 +3095,25 @@ var init_PipelineAssembler = __esm({
2690
3095
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
2691
3096
  }
2692
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
+ }
2693
3109
  if (generatorStrategies.length === 0) {
2694
- if (filterStrategies.length > 0) {
2695
- logger.debug(
2696
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2697
- );
2698
- const courseId = course.getCourseID();
2699
- generatorStrategies.push(createDefaultEloStrategy(courseId));
2700
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
2701
- } else {
2702
- warnings.push("No generator strategy found");
2703
- return {
2704
- pipeline: null,
2705
- generatorStrategies: [],
2706
- filterStrategies: [],
2707
- warnings
2708
- };
2709
- }
3110
+ warnings.push("No generator strategy found");
3111
+ return {
3112
+ pipeline: null,
3113
+ generatorStrategies: [],
3114
+ filterStrategies: [],
3115
+ warnings
3116
+ };
2710
3117
  }
2711
3118
  let generator;
2712
3119
  if (generatorStrategies.length === 1) {
@@ -2784,6 +3191,7 @@ var init_3 = __esm({
2784
3191
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
2785
3192
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
2786
3193
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3194
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
2787
3195
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
2788
3196
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
2789
3197
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -2832,8 +3240,10 @@ async function initializeNavigatorRegistry() {
2832
3240
  Promise.resolve().then(() => (init_elo(), elo_exports)),
2833
3241
  Promise.resolve().then(() => (init_srs(), srs_exports))
2834
3242
  ]);
3243
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
2835
3244
  registerNavigator("elo", eloModule.default);
2836
3245
  registerNavigator("srs", srsModule.default);
3246
+ registerNavigator("prescribed", prescribedModule.default);
2837
3247
  const [
2838
3248
  hierarchyModule,
2839
3249
  interferenceModule,
@@ -2888,6 +3298,7 @@ var init_navigators = __esm({
2888
3298
  Navigators = /* @__PURE__ */ ((Navigators2) => {
2889
3299
  Navigators2["ELO"] = "elo";
2890
3300
  Navigators2["SRS"] = "srs";
3301
+ Navigators2["PRESCRIBED"] = "prescribed";
2891
3302
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
2892
3303
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
2893
3304
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -2902,6 +3313,7 @@ var init_navigators = __esm({
2902
3313
  NavigatorRoles = {
2903
3314
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
2904
3315
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3316
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
2905
3317
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
2906
3318
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
2907
3319
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -3066,6 +3478,12 @@ var init_navigators = __esm({
3066
3478
  async getWeightedCards(_limit) {
3067
3479
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
3068
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
+ }
3069
3487
  };
3070
3488
  }
3071
3489
  });
@@ -3116,6 +3534,16 @@ var init_adminDB2 = __esm({
3116
3534
  }
3117
3535
  });
3118
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
+
3119
3547
  // src/impl/couch/auth.ts
3120
3548
  var import_cross_fetch;
3121
3549
  var init_auth = __esm({
@@ -3179,6 +3607,7 @@ var init_couch = __esm({
3179
3607
  init_classroomDB2();
3180
3608
  init_courseAPI();
3181
3609
  init_courseDB();
3610
+ init_CourseSyncService();
3182
3611
  init_CouchDBSyncStrategy();
3183
3612
  isBrowser = typeof window !== "undefined";
3184
3613
  if (isBrowser) {
@@ -3478,6 +3907,9 @@ Currently logged-in as ${this._username}.`
3478
3907
  const id = row.id;
3479
3908
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
3480
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
3481
3913
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
3482
3914
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
3483
3915
  id === _BaseUser.DOC_IDS.CONFIG;
@@ -5333,6 +5765,10 @@ var init_courseDB3 = __esm({
5333
5765
  }
5334
5766
  return tagsByCard;
5335
5767
  }
5768
+ async getAllCardIds() {
5769
+ const tagsIndex = await this.unpacker.getTagsIndex();
5770
+ return Object.keys(tagsIndex.byCard);
5771
+ }
5336
5772
  async addTagToCard(_cardId, _tagId) {
5337
5773
  throw new Error("Cannot modify tags in static mode");
5338
5774
  }