@vue-skuilder/db 0.2.7 → 0.2.9

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 (40) hide show
  1. package/dist/{contentSource-Cplhv3bJ.d.ts → contentSource-C-0t0y0V.d.ts} +7 -0
  2. package/dist/{contentSource-kI9_jwTu.d.cts → contentSource-jSkcOt2s.d.cts} +7 -0
  3. package/dist/core/index.d.cts +67 -4
  4. package/dist/core/index.d.ts +67 -4
  5. package/dist/core/index.js +201 -39
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +198 -39
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-DrBqOUa3.d.ts → dataLayerProvider-BB0oi9T0.d.ts} +1 -1
  10. package/dist/{dataLayerProvider-CiA2Rr0v.d.cts → dataLayerProvider-BDClIrFC.d.cts} +1 -1
  11. package/dist/impl/couch/index.d.cts +2 -2
  12. package/dist/impl/couch/index.d.ts +2 -2
  13. package/dist/impl/couch/index.js +195 -39
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +195 -39
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +2 -2
  18. package/dist/impl/static/index.d.ts +2 -2
  19. package/dist/impl/static/index.js +195 -39
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +195 -39
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index.d.cts +115 -81
  24. package/dist/index.d.ts +115 -81
  25. package/dist/index.js +440 -251
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +437 -251
  28. package/dist/index.mjs.map +1 -1
  29. package/docs/navigators-architecture.md +29 -13
  30. package/package.json +3 -3
  31. package/src/core/interfaces/contentSource.ts +7 -0
  32. package/src/core/navigators/Pipeline.ts +93 -1
  33. package/src/core/navigators/PipelineDebugger.ts +11 -1
  34. package/src/core/navigators/SrsDebugger.ts +53 -0
  35. package/src/core/navigators/generators/prescribed.ts +76 -9
  36. package/src/core/navigators/generators/srs.ts +81 -37
  37. package/src/core/navigators/index.ts +9 -0
  38. package/src/study/SessionController.ts +260 -249
  39. package/src/study/SessionDebugger.ts +15 -25
  40. package/src/study/SessionOverlay.ts +108 -13
@@ -1,5 +1,5 @@
1
- import { U as UserDBInterface, C as CourseDBInterface, b as CoursesDBInterface, c as ClassroomDBInterface, A as AdminDBInterface, a as UserDBReader, d as CourseInfo, S as StudySessionItem, D as DataLayerResult, e as ContentNavigationStrategyData, f as ContentNavigator, R as ReplanHints, G as GeneratorResult } from '../../contentSource-kI9_jwTu.cjs';
2
- import { D as DataLayerProvider } from '../../dataLayerProvider-CiA2Rr0v.cjs';
1
+ import { U as UserDBInterface, C as CourseDBInterface, b as CoursesDBInterface, c as ClassroomDBInterface, A as AdminDBInterface, a as UserDBReader, d as CourseInfo, S as StudySessionItem, D as DataLayerResult, e as ContentNavigationStrategyData, f as ContentNavigator, R as ReplanHints, G as GeneratorResult } from '../../contentSource-jSkcOt2s.cjs';
2
+ import { D as DataLayerProvider } from '../../dataLayerProvider-BDClIrFC.cjs';
3
3
  import { S as StaticCourseManifest } from '../../types-BFUa1pa3.cjs';
4
4
  import { CourseConfig, CourseElo, DataShape } from '@vue-skuilder/common';
5
5
  import { S as SkuilderCourseData, Q as QualifiedCardID, T as TagStub, a as Tag } from '../../types-legacy-4tlwHnXo.cjs';
@@ -1,5 +1,5 @@
1
- import { U as UserDBInterface, C as CourseDBInterface, b as CoursesDBInterface, c as ClassroomDBInterface, A as AdminDBInterface, a as UserDBReader, d as CourseInfo, S as StudySessionItem, D as DataLayerResult, e as ContentNavigationStrategyData, f as ContentNavigator, R as ReplanHints, G as GeneratorResult } from '../../contentSource-Cplhv3bJ.js';
2
- import { D as DataLayerProvider } from '../../dataLayerProvider-DrBqOUa3.js';
1
+ import { U as UserDBInterface, C as CourseDBInterface, b as CoursesDBInterface, c as ClassroomDBInterface, A as AdminDBInterface, a as UserDBReader, d as CourseInfo, S as StudySessionItem, D as DataLayerResult, e as ContentNavigationStrategyData, f as ContentNavigator, R as ReplanHints, G as GeneratorResult } from '../../contentSource-C-0t0y0V.js';
2
+ import { D as DataLayerProvider } from '../../dataLayerProvider-BB0oi9T0.js';
3
3
  import { S as StaticCourseManifest } from '../../types-CHgpWQAY.js';
4
4
  import { CourseConfig, CourseElo, DataShape } from '@vue-skuilder/common';
5
5
  import { S as SkuilderCourseData, Q as QualifiedCardID, T as TagStub, a as Tag } from '../../types-legacy-4tlwHnXo.js';
@@ -617,6 +617,7 @@ __export(PipelineDebugger_exports, {
617
617
  buildRunReport: () => buildRunReport,
618
618
  captureRun: () => captureRun,
619
619
  clearRunHistory: () => clearRunHistory,
620
+ getActivePipeline: () => getActivePipeline,
620
621
  mountPipelineDebugger: () => mountPipelineDebugger,
621
622
  pipelineDebugAPI: () => pipelineDebugAPI,
622
623
  registerPipelineForDebug: () => registerPipelineForDebug
@@ -624,6 +625,9 @@ __export(PipelineDebugger_exports, {
624
625
  function registerPipelineForDebug(pipeline) {
625
626
  _activePipeline = pipeline;
626
627
  }
628
+ function getActivePipeline() {
629
+ return _activePipeline;
630
+ }
627
631
  function clearRunHistory() {
628
632
  runHistory.length = 0;
629
633
  }
@@ -1439,6 +1443,30 @@ Example:
1439
1443
  }
1440
1444
  });
1441
1445
 
1446
+ // src/core/navigators/SrsDebugger.ts
1447
+ var SrsDebugger_exports = {};
1448
+ __export(SrsDebugger_exports, {
1449
+ captureSrsBacklog: () => captureSrsBacklog,
1450
+ clearSrsBacklogDebug: () => clearSrsBacklogDebug,
1451
+ getSrsBacklogDebug: () => getSrsBacklogDebug
1452
+ });
1453
+ function captureSrsBacklog(snapshot) {
1454
+ snapshots.set(snapshot.courseId, snapshot);
1455
+ }
1456
+ function getSrsBacklogDebug() {
1457
+ return [...snapshots.values()].sort((a, b) => b.timestamp - a.timestamp);
1458
+ }
1459
+ function clearSrsBacklogDebug() {
1460
+ snapshots.clear();
1461
+ }
1462
+ var snapshots;
1463
+ var init_SrsDebugger = __esm({
1464
+ "src/core/navigators/SrsDebugger.ts"() {
1465
+ "use strict";
1466
+ snapshots = /* @__PURE__ */ new Map();
1467
+ }
1468
+ });
1469
+
1442
1470
  // src/core/navigators/generators/CompositeGenerator.ts
1443
1471
  var CompositeGenerator_exports = {};
1444
1472
  __export(CompositeGenerator_exports, {
@@ -1806,7 +1834,7 @@ function shuffleInPlace(arr) {
1806
1834
  function pickTopByScore(cards, limit) {
1807
1835
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1808
1836
  }
1809
- var DEFAULT_FRESHNESS_WINDOW, DEFAULT_MAX_DIRECT_PER_RUN, DEFAULT_MAX_SUPPORT_PER_RUN, DEFAULT_HIERARCHY_DEPTH, DEFAULT_MIN_COUNT, DEFAULT_PRACTICE_MIN_COUNT, DEFAULT_MAX_PRACTICE_PER_RUN, BASE_TARGET_SCORE, BASE_SUPPORT_SCORE, DISCOVERED_SUPPORT_SCORE, BASE_PRACTICE_SCORE, MAX_TARGET_MULTIPLIER, MAX_SUPPORT_MULTIPLIER, PRESCRIBED_DEBUG_VERSION, PrescribedCardsGenerator;
1837
+ var DEFAULT_FRESHNESS_WINDOW, DEFAULT_MAX_DIRECT_PER_RUN, DEFAULT_MAX_SUPPORT_PER_RUN, DEFAULT_HIERARCHY_DEPTH, DEFAULT_MIN_COUNT, DEFAULT_PRACTICE_MIN_COUNT, DEFAULT_MAX_PRACTICE_PER_RUN, BASE_TARGET_SCORE, BASE_SUPPORT_SCORE, DISCOVERED_SUPPORT_SCORE, BASE_PRACTICE_SCORE, PRACTICE_BASE_MULT, MAX_PRACTICE_MULTIPLIER, PRACTICE_STALENESS_BUMP_PER_DAY, MAX_TARGET_MULTIPLIER, MAX_SUPPORT_MULTIPLIER, PRESCRIBED_DEBUG_VERSION, PrescribedCardsGenerator;
1810
1838
  var init_prescribed = __esm({
1811
1839
  "src/core/navigators/generators/prescribed.ts"() {
1812
1840
  "use strict";
@@ -1823,6 +1851,9 @@ var init_prescribed = __esm({
1823
1851
  BASE_SUPPORT_SCORE = 0.8;
1824
1852
  DISCOVERED_SUPPORT_SCORE = 12;
1825
1853
  BASE_PRACTICE_SCORE = 1;
1854
+ PRACTICE_BASE_MULT = 2;
1855
+ MAX_PRACTICE_MULTIPLIER = 4;
1856
+ PRACTICE_STALENESS_BUMP_PER_DAY = 0.5;
1826
1857
  MAX_TARGET_MULTIPLIER = 8;
1827
1858
  MAX_SUPPORT_MULTIPLIER = 4;
1828
1859
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -1882,6 +1913,8 @@ var init_prescribed = __esm({
1882
1913
  const emitted = [];
1883
1914
  const emittedIds = /* @__PURE__ */ new Set();
1884
1915
  const groupRuntimes = [];
1916
+ const priorPracticeDebt = progress.practiceDebt ?? {};
1917
+ const nextPracticeDebt = {};
1885
1918
  for (const group of this.config.groups) {
1886
1919
  const runtime = this.buildGroupRuntimeState({
1887
1920
  group,
@@ -1939,10 +1972,13 @@ var init_prescribed = __esm({
1939
1972
  userTagElo,
1940
1973
  userGlobalElo,
1941
1974
  activeIds,
1942
- seenIds
1975
+ seenIds,
1976
+ priorPracticeDebt,
1977
+ nextPracticeDebt
1943
1978
  });
1944
1979
  emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
1945
1980
  }
1981
+ nextState.practiceDebt = nextPracticeDebt;
1946
1982
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
1947
1983
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
1948
1984
  boostTags: hintSummary.boostTags,
@@ -2276,9 +2312,16 @@ var init_prescribed = __esm({
2276
2312
  * `practiceMinCount`), this resolves cards carrying that tag and emits them
2277
2313
  * into the candidate pool. It exists because global-ELO retrieval
2278
2314
  * systematically fails to fetch the (low-ELO) drill cards for a
2279
- * freshly-introduced skill — putting them in the pool here lets the pipeline's
2280
- * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2281
- * this method's job; it only guarantees presence.
2315
+ * freshly-introduced skill — putting them in the pool here guarantees presence.
2316
+ *
2317
+ * Emphasis is a **practice-debt pressure** (parallel to SRS backlog pressure):
2318
+ * cards score `base × multiplier`, where the multiplier starts at
2319
+ * PRACTICE_BASE_MULT (so a few reps land promptly post-intro, competing with
2320
+ * pressured reviews) and escalates by how long the debt has stayed open
2321
+ * (per-tag, time-based via `priorPracticeDebt`/`nextPracticeDebt`), clamped at
2322
+ * MAX_PRACTICE_MULTIPLIER. The debt is durable and self-discharges the instant
2323
+ * the skill reaches `practiceMinCount` — so this no longer relies on the
2324
+ * session-scoped intro boost to actually surface.
2282
2325
  *
2283
2326
  * Fully data-driven: the unlock relation comes from the hierarchy config and
2284
2327
  * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
@@ -2293,7 +2336,9 @@ var init_prescribed = __esm({
2293
2336
  userTagElo,
2294
2337
  userGlobalElo,
2295
2338
  activeIds,
2296
- seenIds
2339
+ seenIds,
2340
+ priorPracticeDebt,
2341
+ nextPracticeDebt
2297
2342
  } = args;
2298
2343
  const patterns = group.practiceTagPatterns ?? [];
2299
2344
  if (patterns.length === 0) return [];
@@ -2303,6 +2348,20 @@ var init_prescribed = __esm({
2303
2348
  (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2304
2349
  );
2305
2350
  if (practiceTags.length === 0) return [];
2351
+ const now = Date.now();
2352
+ const DAY_MS = 24 * 60 * 60 * 1e3;
2353
+ const tagMultiplier = /* @__PURE__ */ new Map();
2354
+ for (const tag of practiceTags) {
2355
+ const firstOwedAt = priorPracticeDebt[tag] ?? isoNow();
2356
+ nextPracticeDebt[tag] = firstOwedAt;
2357
+ const staleDays = Math.max(0, (now - new Date(firstOwedAt).getTime()) / DAY_MS);
2358
+ const mult = clamp(
2359
+ PRACTICE_BASE_MULT + staleDays * PRACTICE_STALENESS_BUMP_PER_DAY,
2360
+ PRACTICE_BASE_MULT,
2361
+ MAX_PRACTICE_MULTIPLIER
2362
+ );
2363
+ tagMultiplier.set(tag, mult);
2364
+ }
2306
2365
  const practiceCardIds = this.findDiscoveredSupportCards({
2307
2366
  supportTags: practiceTags,
2308
2367
  cardsByTag,
@@ -2318,18 +2377,25 @@ var init_prescribed = __esm({
2318
2377
  const cards = [];
2319
2378
  for (const cardId of practiceCardIds) {
2320
2379
  emittedIds.add(cardId);
2380
+ let mult = PRACTICE_BASE_MULT;
2381
+ for (const tag of practiceTags) {
2382
+ if (cardsByTag.get(tag)?.includes(cardId) ?? false) {
2383
+ mult = Math.max(mult, tagMultiplier.get(tag) ?? PRACTICE_BASE_MULT);
2384
+ }
2385
+ }
2386
+ const score = BASE_PRACTICE_SCORE * mult;
2321
2387
  cards.push({
2322
2388
  cardId,
2323
2389
  courseId,
2324
- score: BASE_PRACTICE_SCORE,
2390
+ score,
2325
2391
  provenance: [
2326
2392
  {
2327
2393
  strategy: "prescribed",
2328
2394
  strategyName: this.strategyName || this.name,
2329
2395
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2330
2396
  action: "generated",
2331
- score: BASE_PRACTICE_SCORE,
2332
- reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2397
+ score,
2398
+ reason: `mode=practice;group=${group.id};debtMult=\xD7${mult.toFixed(2)};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2333
2399
  }
2334
2400
  ]
2335
2401
  });
@@ -2502,15 +2568,16 @@ var srs_exports = {};
2502
2568
  __export(srs_exports, {
2503
2569
  default: () => SRSNavigator
2504
2570
  });
2505
- var import_moment3, DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_PRESSURE, SRSNavigator;
2571
+ var import_moment3, DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_MULTIPLIER, SRSNavigator;
2506
2572
  var init_srs = __esm({
2507
2573
  "src/core/navigators/generators/srs.ts"() {
2508
2574
  "use strict";
2509
2575
  import_moment3 = __toESM(require("moment"), 1);
2510
2576
  init_navigators();
2577
+ init_SrsDebugger();
2511
2578
  init_logger();
2512
2579
  DEFAULT_HEALTHY_BACKLOG = 20;
2513
- MAX_BACKLOG_PRESSURE = 0.5;
2580
+ MAX_BACKLOG_MULTIPLIER = 2;
2514
2581
  SRSNavigator = class extends ContentNavigator {
2515
2582
  /** Human-readable name for CardGenerator interface */
2516
2583
  name;
@@ -2577,9 +2644,18 @@ var init_srs = __esm({
2577
2644
  }
2578
2645
  }
2579
2646
  }
2580
- const backlogPressure = this.computeBacklogPressure(dueReviews.length);
2647
+ const backlogMultiplier = this.computeBacklogMultiplier(dueReviews.length);
2648
+ const notDue = reviews.filter((r) => !now.isAfter(import_moment3.default.utc(r.reviewTime)));
2649
+ let nextDueIn = null;
2650
+ if (notDue.length > 0) {
2651
+ const next = notDue.reduce(
2652
+ (a, b) => import_moment3.default.utc(a.reviewTime).isBefore(import_moment3.default.utc(b.reviewTime)) ? a : b
2653
+ );
2654
+ const until = import_moment3.default.duration(import_moment3.default.utc(next.reviewTime).diff(now));
2655
+ nextDueIn = until.asHours() < 1 ? `${Math.round(until.asMinutes())}m` : until.asHours() < 24 ? `${Math.round(until.asHours())}h` : `${Math.round(until.asDays())}d`;
2656
+ }
2581
2657
  if (dueReviews.length > 0) {
2582
- const pressureNote = backlogPressure > 0 ? ` [backlog pressure: +${backlogPressure.toFixed(2)}]` : ` [healthy backlog]`;
2658
+ const pressureNote = backlogMultiplier > 1 ? ` [backlog pressure: \xD7${backlogMultiplier.toFixed(2)}]` : ` [healthy backlog]`;
2583
2659
  logger.info(
2584
2660
  `[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
2585
2661
  );
@@ -2598,7 +2674,7 @@ var init_srs = __esm({
2598
2674
  logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
2599
2675
  }
2600
2676
  const scored = dueReviews.map((review) => {
2601
- const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
2677
+ const { score, reason } = this.computeUrgencyScore(review, now, backlogMultiplier);
2602
2678
  return {
2603
2679
  cardId: review.cardId,
2604
2680
  courseId: review.courseId,
@@ -2616,30 +2692,42 @@ var init_srs = __esm({
2616
2692
  ]
2617
2693
  };
2618
2694
  });
2619
- return { cards: scored.sort((a, b) => b.score - a.score).slice(0, limit) };
2695
+ const sorted = scored.sort((a, b) => b.score - a.score);
2696
+ captureSrsBacklog({
2697
+ courseId,
2698
+ scheduledTotal: reviews.length,
2699
+ dueNow: dueReviews.length,
2700
+ healthyBacklog: this.healthyBacklog,
2701
+ backlogMultiplier,
2702
+ maxBacklogMultiplier: MAX_BACKLOG_MULTIPLIER,
2703
+ topReviewScore: sorted.length > 0 ? sorted[0].score : null,
2704
+ nextDueIn,
2705
+ timestamp: Date.now()
2706
+ });
2707
+ return { cards: sorted.slice(0, limit) };
2620
2708
  }
2621
2709
  /**
2622
- * Compute backlog pressure based on number of due reviews.
2710
+ * Compute the multiplicative backlog pressure based on number of due reviews.
2623
2711
  *
2624
- * Backlog pressure is 0 when at or below healthy threshold,
2625
- * and increases linearly above it, maxing out at MAX_BACKLOG_PRESSURE.
2712
+ * ×1.0 at or below the healthy threshold (no boost), increasing linearly above
2713
+ * it and maxing out at MAX_BACKLOG_MULTIPLIER at the healthy backlog.
2626
2714
  *
2627
- * Examples (with default healthyBacklog=20):
2628
- * - 10 due reviews → 0.00 (healthy)
2629
- * - 20 due reviews → 0.00 (at threshold)
2630
- * - 40 due reviews → 0.25 (2x threshold)
2631
- * - 60 due reviews → 0.50 (3x threshold, maxed)
2715
+ * Examples (with default healthyBacklog=20, MAX_BACKLOG_MULTIPLIER=2.0):
2716
+ * - 10 due reviews → ×1.00 (healthy)
2717
+ * - 20 due reviews → ×1.00 (at threshold)
2718
+ * - 40 due reviews → ×1.50 (2x threshold)
2719
+ * - 60 due reviews → ×2.00 (3x threshold, maxed)
2632
2720
  *
2633
2721
  * @param dueCount - Number of reviews currently due
2634
- * @returns Backlog pressure score to add to urgency (0 to MAX_BACKLOG_PRESSURE)
2722
+ * @returns Multiplier applied to review urgency (1.0 to MAX_BACKLOG_MULTIPLIER)
2635
2723
  */
2636
- computeBacklogPressure(dueCount) {
2724
+ computeBacklogMultiplier(dueCount) {
2637
2725
  if (dueCount <= this.healthyBacklog) {
2638
- return 0;
2726
+ return 1;
2639
2727
  }
2640
2728
  const excess = dueCount - this.healthyBacklog;
2641
- const pressure = excess / this.healthyBacklog * (MAX_BACKLOG_PRESSURE / 2);
2642
- return Math.min(MAX_BACKLOG_PRESSURE, pressure);
2729
+ const multiplier = 1 + excess / this.healthyBacklog * ((MAX_BACKLOG_MULTIPLIER - 1) / 2);
2730
+ return Math.min(MAX_BACKLOG_MULTIPLIER, multiplier);
2643
2731
  }
2644
2732
  /**
2645
2733
  * Compute urgency score for a review card.
@@ -2654,19 +2742,20 @@ var init_srs = __esm({
2654
2742
  * - 30 days (720h) → ~0.56
2655
2743
  * - 180 days → ~0.30
2656
2744
  *
2657
- * 3. Backlog pressure = global boost when review backlog exceeds healthy threshold
2658
- * - At healthy backlog: 0
2659
- * - At 2x healthy: +0.25
2660
- * - At 3x+ healthy: +0.50 (max)
2745
+ * 3. Backlog pressure = global *multiplier* when review backlog exceeds the
2746
+ * healthy threshold (×1.0 healthy up to MAX_BACKLOG_MULTIPLIER at 3×).
2661
2747
  *
2662
- * Combined: base 0.5 + (urgency factors * 0.45) + backlog pressure
2663
- * Result range: 0.5 to 1.0 (uncapped to allow high-urgency reviews to compete with new cards)
2748
+ * Combined: (base 0.5 + urgency factors * 0.45) × backlog multiplier.
2749
+ * Per-card range before pressure: ~0.57–0.95. NOT clamped to 1.0 under a
2750
+ * heavy backlog reviews scale onto the open scale to compete with (and exceed)
2751
+ * new cards; what keeps them from running away is the bounded multiplier, not
2752
+ * a hard ceiling.
2664
2753
  *
2665
2754
  * @param review - The scheduled card to score
2666
2755
  * @param now - Current time
2667
- * @param backlogPressure - Pre-computed backlog pressure (0 to 0.5)
2756
+ * @param backlogMultiplier - Pre-computed backlog multiplier (1.0 to MAX_BACKLOG_MULTIPLIER)
2668
2757
  */
2669
- computeUrgencyScore(review, now, backlogPressure) {
2758
+ computeUrgencyScore(review, now, backlogMultiplier) {
2670
2759
  const scheduledAt = import_moment3.default.utc(review.scheduledAt);
2671
2760
  const due = import_moment3.default.utc(review.reviewTime);
2672
2761
  const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
@@ -2676,15 +2765,15 @@ var init_srs = __esm({
2676
2765
  const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
2677
2766
  const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
2678
2767
  const baseScore = 0.5 + urgency * 0.45;
2679
- const score = Math.min(1, baseScore + backlogPressure);
2768
+ const score = baseScore * backlogMultiplier;
2680
2769
  const reasonParts = [
2681
2770
  `${Math.round(hoursOverdue)}h overdue`,
2682
2771
  `interval: ${Math.round(intervalHours)}h`,
2683
2772
  `relative: ${relativeOverdue.toFixed(2)}`,
2684
2773
  `recency: ${recencyFactor.toFixed(2)}`
2685
2774
  ];
2686
- if (backlogPressure > 0) {
2687
- reasonParts.push(`backlog: +${backlogPressure.toFixed(2)}`);
2775
+ if (backlogMultiplier > 1) {
2776
+ reasonParts.push(`backlog: \xD7${backlogMultiplier.toFixed(2)}`);
2688
2777
  }
2689
2778
  reasonParts.push("review");
2690
2779
  const reason = reasonParts.join(", ");
@@ -4410,6 +4499,68 @@ var init_Pipeline = __esm({
4410
4499
  // ---------------------------------------------------------------------------
4411
4500
  // Card-space diagnostic
4412
4501
  // ---------------------------------------------------------------------------
4502
+ /**
4503
+ * Commit-free forecast: score the user's full card space through the filter
4504
+ * chain and return the cards that are currently *reachable* (score >=
4505
+ * threshold), optionally nudged by caller-supplied hints and/or restricted
4506
+ * to cards the user hasn't seen yet.
4507
+ *
4508
+ * This is a GENERIC primitive — it returns scored, tag-hydrated cards and
4509
+ * stops there. It has no knowledge of any particular tag convention; callers
4510
+ * decide what the surviving cards mean (e.g. filter to their own "intro"
4511
+ * tag family). Nothing is written and no session is started.
4512
+ *
4513
+ * The optional `hints` are the "out-of-band kick": they run through the same
4514
+ * {@link applyHints} path a live replan uses, so the two semantics carry over —
4515
+ * - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
4516
+ * stays out), and
4517
+ * - `requireTags`/`requireCards` inject from the full pre-filter pool,
4518
+ * *bypassing* gating (use when you want a card regardless of reachability).
4519
+ * Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
4520
+ * user has already seen — pass `unseenOnly: false` if that matters.
4521
+ *
4522
+ * Cost note: like {@link diagnoseCardSpace}, this scans every card through the
4523
+ * filters, so it's heavier than a normal replan. Intended for one-shot
4524
+ * out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
4525
+ *
4526
+ * @param opts.hints Optional ephemeral hints to apply after the filter chain.
4527
+ * @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
4528
+ * @param opts.threshold Min score to count as reachable (default 0.10).
4529
+ * @param opts.limit Optional cap on results (already sorted desc).
4530
+ */
4531
+ async forecast(opts) {
4532
+ const threshold = opts?.threshold ?? 0.1;
4533
+ const unseenOnly = opts?.unseenOnly ?? true;
4534
+ const courseId = this.course.getCourseID();
4535
+ const allCardIds = await this.course.getAllCardIds();
4536
+ let cards = allCardIds.map((cardId) => ({
4537
+ cardId,
4538
+ courseId,
4539
+ score: 1,
4540
+ provenance: []
4541
+ }));
4542
+ cards = await this.hydrateTags(cards);
4543
+ const fullPool = cards.slice();
4544
+ const context = await this.buildContext();
4545
+ for (const filter of this.filters) {
4546
+ cards = await filter.transform(cards, context);
4547
+ }
4548
+ if (opts?.hints) {
4549
+ cards = this.applyHints(cards, opts.hints, fullPool);
4550
+ }
4551
+ cards = cards.filter((c) => c.score >= threshold);
4552
+ if (unseenOnly) {
4553
+ let encountered;
4554
+ try {
4555
+ encountered = new Set(await this.user.getSeenCards(courseId));
4556
+ } catch {
4557
+ encountered = /* @__PURE__ */ new Set();
4558
+ }
4559
+ cards = cards.filter((c) => !encountered.has(c.cardId));
4560
+ }
4561
+ cards.sort((a, b) => b.score - a.score);
4562
+ return opts?.limit ? cards.slice(0, opts.limit) : cards;
4563
+ }
4413
4564
  /**
4414
4565
  * Scan every card in the course through the filter chain and report
4415
4566
  * how many are "well indicated" (score >= threshold) for the current user.
@@ -4674,6 +4825,7 @@ var init_3 = __esm({
4674
4825
  "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
4675
4826
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
4676
4827
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
4828
+ "./SrsDebugger.ts": () => Promise.resolve().then(() => (init_SrsDebugger(), SrsDebugger_exports)),
4677
4829
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
4678
4830
  "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
4679
4831
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
@@ -4706,11 +4858,14 @@ __export(navigators_exports, {
4706
4858
  NavigatorRole: () => NavigatorRole,
4707
4859
  NavigatorRoles: () => NavigatorRoles,
4708
4860
  Navigators: () => Navigators,
4861
+ clearSrsBacklogDebug: () => clearSrsBacklogDebug,
4709
4862
  diversityRerank: () => diversityRerank,
4863
+ getActivePipeline: () => getActivePipeline,
4710
4864
  getCardOrigin: () => getCardOrigin,
4711
4865
  getRegisteredNavigator: () => getRegisteredNavigator,
4712
4866
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
4713
4867
  getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
4868
+ getSrsBacklogDebug: () => getSrsBacklogDebug,
4714
4869
  hasRegisteredNavigator: () => hasRegisteredNavigator,
4715
4870
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
4716
4871
  isFilter: () => isFilter,
@@ -4792,6 +4947,7 @@ var init_navigators = __esm({
4792
4947
  "use strict";
4793
4948
  init_diversityRerank();
4794
4949
  init_PipelineDebugger();
4950
+ init_SrsDebugger();
4795
4951
  init_logger();
4796
4952
  init_();
4797
4953
  init_2();