@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
@@ -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,12 +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";
595
600
  init_navigators();
596
601
  init_logger();
602
+ _activePipeline = null;
597
603
  MAX_RUNS = 10;
598
604
  runHistory = [];
599
605
  pipelineDebugAPI = {
@@ -795,6 +801,21 @@ var init_PipelineDebugger = __esm({
795
801
  }
796
802
  console.groupEnd();
797
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
+ },
798
819
  /**
799
820
  * Show help.
800
821
  */
@@ -807,6 +828,7 @@ Commands:
807
828
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
808
829
  .showCard(cardId) Show provenance trail for a specific card
809
830
  .explainReviews() Analyze why reviews were/weren't selected
831
+ .diagnoseCardSpace() Scan full card space through filters (async)
810
832
  .showRegistry() Show navigator registry (classes + roles)
811
833
  .showStrategies() Show registry + strategy mapping from last run
812
834
  .listRuns() List all captured runs in table format
@@ -818,7 +840,7 @@ Commands:
818
840
  Example:
819
841
  window.skuilder.pipeline.showLastRun()
820
842
  window.skuilder.pipeline.showRun(1)
821
- window.skuilder.pipeline.showCard('abc123')
843
+ await window.skuilder.pipeline.diagnoseCardSpace()
822
844
  `);
823
845
  }
824
846
  };
@@ -1113,6 +1135,69 @@ var init_generators = __esm({
1113
1135
  }
1114
1136
  });
1115
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
+
1116
1201
  // src/core/navigators/generators/srs.ts
1117
1202
  var srs_exports = {};
1118
1203
  __export(srs_exports, {
@@ -1307,6 +1392,7 @@ var init_ = __esm({
1307
1392
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1308
1393
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1309
1394
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1395
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
1310
1396
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1311
1397
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1312
1398
  });
@@ -1507,6 +1593,8 @@ var init_hierarchyDefinition = __esm({
1507
1593
  if (userTagElo.count < minCount) return false;
1508
1594
  if (prereq.masteryThreshold?.minElo !== void 0) {
1509
1595
  return userTagElo.score >= prereq.masteryThreshold.minElo;
1596
+ } else if (prereq.masteryThreshold?.minCount !== void 0) {
1597
+ return true;
1510
1598
  } else {
1511
1599
  return userTagElo.score >= userGlobalElo;
1512
1600
  }
@@ -1582,14 +1670,38 @@ var init_hierarchyDefinition = __esm({
1582
1670
  };
1583
1671
  }
1584
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
+ }
1585
1693
  /**
1586
1694
  * CardFilter.transform implementation.
1587
1695
  *
1588
- * 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
1589
1700
  */
1590
1701
  async transform(cards, context) {
1591
1702
  const masteredTags = await this.getMasteredTags(context);
1592
1703
  const unlockedTags = this.getUnlockedTags(masteredTags);
1704
+ const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
1593
1705
  const gated = [];
1594
1706
  for (const card of cards) {
1595
1707
  const { isUnlocked, reason } = await this.checkCardUnlock(
@@ -1598,9 +1710,27 @@ var init_hierarchyDefinition = __esm({
1598
1710
  unlockedTags,
1599
1711
  masteredTags
1600
1712
  );
1601
- const LOCKED_PENALTY = 0.01;
1602
- const finalScore = isUnlocked ? card.score : card.score * LOCKED_PENALTY;
1603
- 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
+ }
1604
1734
  gated.push({
1605
1735
  ...card,
1606
1736
  score: finalScore,
@@ -1612,7 +1742,7 @@ var init_hierarchyDefinition = __esm({
1612
1742
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1613
1743
  action,
1614
1744
  score: finalScore,
1615
- reason
1745
+ reason: finalReason
1616
1746
  }
1617
1747
  ]
1618
1748
  });
@@ -2301,6 +2431,18 @@ __export(Pipeline_exports, {
2301
2431
  Pipeline: () => Pipeline
2302
2432
  });
2303
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
+ }
2304
2446
  function logPipelineConfig(generator, filters) {
2305
2447
  const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2306
2448
  logger.info(
@@ -2335,6 +2477,21 @@ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCo
2335
2477
  \u{1F4A1} Inspect: window.skuilder.pipeline`
2336
2478
  );
2337
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
+ }
2338
2495
  function logCardProvenance(cards, maxCards = 3) {
2339
2496
  const cardsToLog = cards.slice(0, maxCards);
2340
2497
  logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
@@ -2349,7 +2506,7 @@ function logCardProvenance(cards, maxCards = 3) {
2349
2506
  }
2350
2507
  }
2351
2508
  }
2352
- var Pipeline;
2509
+ var VERBOSE_RESULTS, Pipeline;
2353
2510
  var init_Pipeline = __esm({
2354
2511
  "src/core/navigators/Pipeline.ts"() {
2355
2512
  "use strict";
@@ -2357,9 +2514,31 @@ var init_Pipeline = __esm({
2357
2514
  init_logger();
2358
2515
  init_orchestration();
2359
2516
  init_PipelineDebugger();
2517
+ VERBOSE_RESULTS = true;
2360
2518
  Pipeline = class extends ContentNavigator {
2361
2519
  generator;
2362
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;
2363
2542
  /**
2364
2543
  * Create a new pipeline.
2365
2544
  *
@@ -2380,6 +2559,17 @@ var init_Pipeline = __esm({
2380
2559
  logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2381
2560
  });
2382
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)}`);
2383
2573
  }
2384
2574
  /**
2385
2575
  * Get weighted cards by running generator and applying filters.
@@ -2396,13 +2586,15 @@ var init_Pipeline = __esm({
2396
2586
  * @returns Cards sorted by score descending
2397
2587
  */
2398
2588
  async getWeightedCards(limit) {
2589
+ const t0 = performance.now();
2399
2590
  const context = await this.buildContext();
2400
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
2401
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
2591
+ const tContext = performance.now();
2592
+ const fetchLimit = 500;
2402
2593
  logger.debug(
2403
2594
  `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2404
2595
  );
2405
2596
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2597
+ const tGenerate = performance.now();
2406
2598
  const generatedCount = cards.length;
2407
2599
  let generatorSummaries;
2408
2600
  if (this.generator.generators) {
@@ -2431,6 +2623,7 @@ var init_Pipeline = __esm({
2431
2623
  }
2432
2624
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2433
2625
  cards = await this.hydrateTags(cards);
2626
+ const tHydrate = performance.now();
2434
2627
  const allCardsBeforeFiltering = [...cards];
2435
2628
  const filterImpacts = [];
2436
2629
  for (const filter of this.filters) {
@@ -2449,8 +2642,17 @@ var init_Pipeline = __esm({
2449
2642
  logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2450
2643
  }
2451
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
+ }
2452
2650
  cards.sort((a, b) => b.score - a.score);
2651
+ const tFilter = performance.now();
2453
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
+ );
2454
2656
  const topScores = result.slice(0, 3).map((c) => c.score);
2455
2657
  logExecutionSummary(
2456
2658
  this.generator.name,
@@ -2460,6 +2662,7 @@ var init_Pipeline = __esm({
2460
2662
  topScores,
2461
2663
  filterImpacts
2462
2664
  );
2665
+ logResultCards(result);
2463
2666
  logCardProvenance(result, 3);
2464
2667
  try {
2465
2668
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
@@ -2486,6 +2689,10 @@ var init_Pipeline = __esm({
2486
2689
  * to the WeightedCard objects. Filters can then use card.tags instead of
2487
2690
  * making individual getAppliedTags() calls.
2488
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
+ *
2489
2696
  * @param cards - Cards to hydrate
2490
2697
  * @returns Cards with tags populated
2491
2698
  */
@@ -2493,14 +2700,128 @@ var init_Pipeline = __esm({
2493
2700
  if (cards.length === 0) {
2494
2701
  return cards;
2495
2702
  }
2496
- const cardIds = cards.map((c) => c.cardId);
2497
- 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
+ }
2498
2719
  logTagHydration(cards, tagsByCard);
2499
2720
  return cards.map((card) => ({
2500
2721
  ...card,
2501
- tags: tagsByCard.get(card.cardId) ?? []
2722
+ tags: this._tagCache.get(card.cardId) ?? []
2502
2723
  }));
2503
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
+ }
2504
2825
  /**
2505
2826
  * Build shared context for generator and filters.
2506
2827
  *
@@ -2518,7 +2839,10 @@ var init_Pipeline = __esm({
2518
2839
  } catch (e) {
2519
2840
  logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
2520
2841
  }
2521
- 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;
2522
2846
  return {
2523
2847
  user: this.user,
2524
2848
  course: this.course,
@@ -2562,6 +2886,87 @@ var init_Pipeline = __esm({
2562
2886
  }
2563
2887
  return [...new Set(ids)];
2564
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
+ }
2565
2970
  };
2566
2971
  }
2567
2972
  });
@@ -2666,23 +3071,25 @@ var init_PipelineAssembler = __esm({
2666
3071
  warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
2667
3072
  }
2668
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
+ }
2669
3085
  if (generatorStrategies.length === 0) {
2670
- if (filterStrategies.length > 0) {
2671
- logger.debug(
2672
- "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2673
- );
2674
- const courseId = course.getCourseID();
2675
- generatorStrategies.push(createDefaultEloStrategy(courseId));
2676
- generatorStrategies.push(createDefaultSrsStrategy(courseId));
2677
- } else {
2678
- warnings.push("No generator strategy found");
2679
- return {
2680
- pipeline: null,
2681
- generatorStrategies: [],
2682
- filterStrategies: [],
2683
- warnings
2684
- };
2685
- }
3086
+ warnings.push("No generator strategy found");
3087
+ return {
3088
+ pipeline: null,
3089
+ generatorStrategies: [],
3090
+ filterStrategies: [],
3091
+ warnings
3092
+ };
2686
3093
  }
2687
3094
  let generator;
2688
3095
  if (generatorStrategies.length === 1) {
@@ -2760,6 +3167,7 @@ var init_3 = __esm({
2760
3167
  "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
2761
3168
  "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
2762
3169
  "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3170
+ "./generators/prescribed.ts": () => Promise.resolve().then(() => (init_prescribed(), prescribed_exports)),
2763
3171
  "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
2764
3172
  "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
2765
3173
  "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
@@ -2808,8 +3216,10 @@ async function initializeNavigatorRegistry() {
2808
3216
  Promise.resolve().then(() => (init_elo(), elo_exports)),
2809
3217
  Promise.resolve().then(() => (init_srs(), srs_exports))
2810
3218
  ]);
3219
+ const prescribedModule = await Promise.resolve().then(() => (init_prescribed(), prescribed_exports));
2811
3220
  registerNavigator("elo", eloModule.default);
2812
3221
  registerNavigator("srs", srsModule.default);
3222
+ registerNavigator("prescribed", prescribedModule.default);
2813
3223
  const [
2814
3224
  hierarchyModule,
2815
3225
  interferenceModule,
@@ -2864,6 +3274,7 @@ var init_navigators = __esm({
2864
3274
  Navigators = /* @__PURE__ */ ((Navigators2) => {
2865
3275
  Navigators2["ELO"] = "elo";
2866
3276
  Navigators2["SRS"] = "srs";
3277
+ Navigators2["PRESCRIBED"] = "prescribed";
2867
3278
  Navigators2["HIERARCHY"] = "hierarchyDefinition";
2868
3279
  Navigators2["INTERFERENCE"] = "interferenceMitigator";
2869
3280
  Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
@@ -2878,6 +3289,7 @@ var init_navigators = __esm({
2878
3289
  NavigatorRoles = {
2879
3290
  ["elo" /* ELO */]: "generator" /* GENERATOR */,
2880
3291
  ["srs" /* SRS */]: "generator" /* GENERATOR */,
3292
+ ["prescribed" /* PRESCRIBED */]: "generator" /* GENERATOR */,
2881
3293
  ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
2882
3294
  ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
2883
3295
  ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
@@ -3042,6 +3454,12 @@ var init_navigators = __esm({
3042
3454
  async getWeightedCards(_limit) {
3043
3455
  throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
3044
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
+ }
3045
3463
  };
3046
3464
  }
3047
3465
  });
@@ -3095,6 +3513,16 @@ var init_adminDB2 = __esm({
3095
3513
  }
3096
3514
  });
3097
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
+
3098
3526
  // src/impl/couch/auth.ts
3099
3527
  import fetch2 from "cross-fetch";
3100
3528
  var init_auth = __esm({
@@ -3156,6 +3584,7 @@ var init_couch = __esm({
3156
3584
  init_classroomDB2();
3157
3585
  init_courseAPI();
3158
3586
  init_courseDB();
3587
+ init_CourseSyncService();
3159
3588
  init_CouchDBSyncStrategy();
3160
3589
  isBrowser = typeof window !== "undefined";
3161
3590
  if (isBrowser) {
@@ -3455,6 +3884,9 @@ Currently logged-in as ${this._username}.`
3455
3884
  const id = row.id;
3456
3885
  return id.startsWith(DocTypePrefixes["CARDRECORD" /* CARDRECORD */]) || // Card interaction history
3457
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
3458
3890
  id === _BaseUser.DOC_IDS.COURSE_REGISTRATIONS || // Course registrations
3459
3891
  id === _BaseUser.DOC_IDS.CLASSROOM_REGISTRATIONS || // Classroom registrations
3460
3892
  id === _BaseUser.DOC_IDS.CONFIG;
@@ -5307,6 +5739,10 @@ var init_courseDB3 = __esm({
5307
5739
  }
5308
5740
  return tagsByCard;
5309
5741
  }
5742
+ async getAllCardIds() {
5743
+ const tagsIndex = await this.unpacker.getTagsIndex();
5744
+ return Object.keys(tagsIndex.byCard);
5745
+ }
5310
5746
  async addTagToCard(_cardId, _tagId) {
5311
5747
  throw new Error("Cannot modify tags in static mode");
5312
5748
  }