@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
package/dist/index.mjs CHANGED
@@ -944,6 +944,7 @@ __export(PipelineDebugger_exports, {
944
944
  buildRunReport: () => buildRunReport,
945
945
  captureRun: () => captureRun,
946
946
  clearRunHistory: () => clearRunHistory,
947
+ getActivePipeline: () => getActivePipeline,
947
948
  mountPipelineDebugger: () => mountPipelineDebugger,
948
949
  pipelineDebugAPI: () => pipelineDebugAPI,
949
950
  registerPipelineForDebug: () => registerPipelineForDebug
@@ -951,6 +952,9 @@ __export(PipelineDebugger_exports, {
951
952
  function registerPipelineForDebug(pipeline) {
952
953
  _activePipeline = pipeline;
953
954
  }
955
+ function getActivePipeline() {
956
+ return _activePipeline;
957
+ }
954
958
  function clearRunHistory() {
955
959
  runHistory.length = 0;
956
960
  }
@@ -1766,6 +1770,30 @@ Example:
1766
1770
  }
1767
1771
  });
1768
1772
 
1773
+ // src/core/navigators/SrsDebugger.ts
1774
+ var SrsDebugger_exports = {};
1775
+ __export(SrsDebugger_exports, {
1776
+ captureSrsBacklog: () => captureSrsBacklog,
1777
+ clearSrsBacklogDebug: () => clearSrsBacklogDebug,
1778
+ getSrsBacklogDebug: () => getSrsBacklogDebug
1779
+ });
1780
+ function captureSrsBacklog(snapshot) {
1781
+ snapshots.set(snapshot.courseId, snapshot);
1782
+ }
1783
+ function getSrsBacklogDebug() {
1784
+ return [...snapshots.values()].sort((a, b) => b.timestamp - a.timestamp);
1785
+ }
1786
+ function clearSrsBacklogDebug() {
1787
+ snapshots.clear();
1788
+ }
1789
+ var snapshots;
1790
+ var init_SrsDebugger = __esm({
1791
+ "src/core/navigators/SrsDebugger.ts"() {
1792
+ "use strict";
1793
+ snapshots = /* @__PURE__ */ new Map();
1794
+ }
1795
+ });
1796
+
1769
1797
  // src/core/navigators/generators/CompositeGenerator.ts
1770
1798
  var CompositeGenerator_exports = {};
1771
1799
  __export(CompositeGenerator_exports, {
@@ -2133,7 +2161,7 @@ function shuffleInPlace(arr) {
2133
2161
  function pickTopByScore(cards, limit) {
2134
2162
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
2135
2163
  }
2136
- 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;
2164
+ 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;
2137
2165
  var init_prescribed = __esm({
2138
2166
  "src/core/navigators/generators/prescribed.ts"() {
2139
2167
  "use strict";
@@ -2150,6 +2178,9 @@ var init_prescribed = __esm({
2150
2178
  BASE_SUPPORT_SCORE = 0.8;
2151
2179
  DISCOVERED_SUPPORT_SCORE = 12;
2152
2180
  BASE_PRACTICE_SCORE = 1;
2181
+ PRACTICE_BASE_MULT = 2;
2182
+ MAX_PRACTICE_MULTIPLIER = 4;
2183
+ PRACTICE_STALENESS_BUMP_PER_DAY = 0.5;
2153
2184
  MAX_TARGET_MULTIPLIER = 8;
2154
2185
  MAX_SUPPORT_MULTIPLIER = 4;
2155
2186
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -2209,6 +2240,8 @@ var init_prescribed = __esm({
2209
2240
  const emitted = [];
2210
2241
  const emittedIds = /* @__PURE__ */ new Set();
2211
2242
  const groupRuntimes = [];
2243
+ const priorPracticeDebt = progress.practiceDebt ?? {};
2244
+ const nextPracticeDebt = {};
2212
2245
  for (const group of this.config.groups) {
2213
2246
  const runtime = this.buildGroupRuntimeState({
2214
2247
  group,
@@ -2266,10 +2299,13 @@ var init_prescribed = __esm({
2266
2299
  userTagElo,
2267
2300
  userGlobalElo,
2268
2301
  activeIds,
2269
- seenIds
2302
+ seenIds,
2303
+ priorPracticeDebt,
2304
+ nextPracticeDebt
2270
2305
  });
2271
2306
  emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
2272
2307
  }
2308
+ nextState.practiceDebt = nextPracticeDebt;
2273
2309
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
2274
2310
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
2275
2311
  boostTags: hintSummary.boostTags,
@@ -2603,9 +2639,16 @@ var init_prescribed = __esm({
2603
2639
  * `practiceMinCount`), this resolves cards carrying that tag and emits them
2604
2640
  * into the candidate pool. It exists because global-ELO retrieval
2605
2641
  * systematically fails to fetch the (low-ELO) drill cards for a
2606
- * freshly-introduced skill — putting them in the pool here lets the pipeline's
2607
- * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2608
- * this method's job; it only guarantees presence.
2642
+ * freshly-introduced skill — putting them in the pool here guarantees presence.
2643
+ *
2644
+ * Emphasis is a **practice-debt pressure** (parallel to SRS backlog pressure):
2645
+ * cards score `base × multiplier`, where the multiplier starts at
2646
+ * PRACTICE_BASE_MULT (so a few reps land promptly post-intro, competing with
2647
+ * pressured reviews) and escalates by how long the debt has stayed open
2648
+ * (per-tag, time-based via `priorPracticeDebt`/`nextPracticeDebt`), clamped at
2649
+ * MAX_PRACTICE_MULTIPLIER. The debt is durable and self-discharges the instant
2650
+ * the skill reaches `practiceMinCount` — so this no longer relies on the
2651
+ * session-scoped intro boost to actually surface.
2609
2652
  *
2610
2653
  * Fully data-driven: the unlock relation comes from the hierarchy config and
2611
2654
  * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
@@ -2620,7 +2663,9 @@ var init_prescribed = __esm({
2620
2663
  userTagElo,
2621
2664
  userGlobalElo,
2622
2665
  activeIds,
2623
- seenIds
2666
+ seenIds,
2667
+ priorPracticeDebt,
2668
+ nextPracticeDebt
2624
2669
  } = args;
2625
2670
  const patterns = group.practiceTagPatterns ?? [];
2626
2671
  if (patterns.length === 0) return [];
@@ -2630,6 +2675,20 @@ var init_prescribed = __esm({
2630
2675
  (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2631
2676
  );
2632
2677
  if (practiceTags.length === 0) return [];
2678
+ const now = Date.now();
2679
+ const DAY_MS = 24 * 60 * 60 * 1e3;
2680
+ const tagMultiplier = /* @__PURE__ */ new Map();
2681
+ for (const tag of practiceTags) {
2682
+ const firstOwedAt = priorPracticeDebt[tag] ?? isoNow();
2683
+ nextPracticeDebt[tag] = firstOwedAt;
2684
+ const staleDays = Math.max(0, (now - new Date(firstOwedAt).getTime()) / DAY_MS);
2685
+ const mult = clamp(
2686
+ PRACTICE_BASE_MULT + staleDays * PRACTICE_STALENESS_BUMP_PER_DAY,
2687
+ PRACTICE_BASE_MULT,
2688
+ MAX_PRACTICE_MULTIPLIER
2689
+ );
2690
+ tagMultiplier.set(tag, mult);
2691
+ }
2633
2692
  const practiceCardIds = this.findDiscoveredSupportCards({
2634
2693
  supportTags: practiceTags,
2635
2694
  cardsByTag,
@@ -2645,18 +2704,25 @@ var init_prescribed = __esm({
2645
2704
  const cards = [];
2646
2705
  for (const cardId of practiceCardIds) {
2647
2706
  emittedIds.add(cardId);
2707
+ let mult = PRACTICE_BASE_MULT;
2708
+ for (const tag of practiceTags) {
2709
+ if (cardsByTag.get(tag)?.includes(cardId) ?? false) {
2710
+ mult = Math.max(mult, tagMultiplier.get(tag) ?? PRACTICE_BASE_MULT);
2711
+ }
2712
+ }
2713
+ const score = BASE_PRACTICE_SCORE * mult;
2648
2714
  cards.push({
2649
2715
  cardId,
2650
2716
  courseId,
2651
- score: BASE_PRACTICE_SCORE,
2717
+ score,
2652
2718
  provenance: [
2653
2719
  {
2654
2720
  strategy: "prescribed",
2655
2721
  strategyName: this.strategyName || this.name,
2656
2722
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2657
2723
  action: "generated",
2658
- score: BASE_PRACTICE_SCORE,
2659
- reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2724
+ score,
2725
+ 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}`
2660
2726
  }
2661
2727
  ]
2662
2728
  });
@@ -2830,14 +2896,15 @@ __export(srs_exports, {
2830
2896
  default: () => SRSNavigator
2831
2897
  });
2832
2898
  import moment3 from "moment";
2833
- var DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_PRESSURE, SRSNavigator;
2899
+ var DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_MULTIPLIER, SRSNavigator;
2834
2900
  var init_srs = __esm({
2835
2901
  "src/core/navigators/generators/srs.ts"() {
2836
2902
  "use strict";
2837
2903
  init_navigators();
2904
+ init_SrsDebugger();
2838
2905
  init_logger();
2839
2906
  DEFAULT_HEALTHY_BACKLOG = 20;
2840
- MAX_BACKLOG_PRESSURE = 0.5;
2907
+ MAX_BACKLOG_MULTIPLIER = 2;
2841
2908
  SRSNavigator = class extends ContentNavigator {
2842
2909
  /** Human-readable name for CardGenerator interface */
2843
2910
  name;
@@ -2904,9 +2971,18 @@ var init_srs = __esm({
2904
2971
  }
2905
2972
  }
2906
2973
  }
2907
- const backlogPressure = this.computeBacklogPressure(dueReviews.length);
2974
+ const backlogMultiplier = this.computeBacklogMultiplier(dueReviews.length);
2975
+ const notDue = reviews.filter((r) => !now.isAfter(moment3.utc(r.reviewTime)));
2976
+ let nextDueIn = null;
2977
+ if (notDue.length > 0) {
2978
+ const next = notDue.reduce(
2979
+ (a, b) => moment3.utc(a.reviewTime).isBefore(moment3.utc(b.reviewTime)) ? a : b
2980
+ );
2981
+ const until = moment3.duration(moment3.utc(next.reviewTime).diff(now));
2982
+ nextDueIn = until.asHours() < 1 ? `${Math.round(until.asMinutes())}m` : until.asHours() < 24 ? `${Math.round(until.asHours())}h` : `${Math.round(until.asDays())}d`;
2983
+ }
2908
2984
  if (dueReviews.length > 0) {
2909
- const pressureNote = backlogPressure > 0 ? ` [backlog pressure: +${backlogPressure.toFixed(2)}]` : ` [healthy backlog]`;
2985
+ const pressureNote = backlogMultiplier > 1 ? ` [backlog pressure: \xD7${backlogMultiplier.toFixed(2)}]` : ` [healthy backlog]`;
2910
2986
  logger.info(
2911
2987
  `[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
2912
2988
  );
@@ -2925,7 +3001,7 @@ var init_srs = __esm({
2925
3001
  logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
2926
3002
  }
2927
3003
  const scored = dueReviews.map((review) => {
2928
- const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
3004
+ const { score, reason } = this.computeUrgencyScore(review, now, backlogMultiplier);
2929
3005
  return {
2930
3006
  cardId: review.cardId,
2931
3007
  courseId: review.courseId,
@@ -2943,30 +3019,42 @@ var init_srs = __esm({
2943
3019
  ]
2944
3020
  };
2945
3021
  });
2946
- return { cards: scored.sort((a, b) => b.score - a.score).slice(0, limit) };
3022
+ const sorted = scored.sort((a, b) => b.score - a.score);
3023
+ captureSrsBacklog({
3024
+ courseId,
3025
+ scheduledTotal: reviews.length,
3026
+ dueNow: dueReviews.length,
3027
+ healthyBacklog: this.healthyBacklog,
3028
+ backlogMultiplier,
3029
+ maxBacklogMultiplier: MAX_BACKLOG_MULTIPLIER,
3030
+ topReviewScore: sorted.length > 0 ? sorted[0].score : null,
3031
+ nextDueIn,
3032
+ timestamp: Date.now()
3033
+ });
3034
+ return { cards: sorted.slice(0, limit) };
2947
3035
  }
2948
3036
  /**
2949
- * Compute backlog pressure based on number of due reviews.
3037
+ * Compute the multiplicative backlog pressure based on number of due reviews.
2950
3038
  *
2951
- * Backlog pressure is 0 when at or below healthy threshold,
2952
- * and increases linearly above it, maxing out at MAX_BACKLOG_PRESSURE.
3039
+ * ×1.0 at or below the healthy threshold (no boost), increasing linearly above
3040
+ * it and maxing out at MAX_BACKLOG_MULTIPLIER at the healthy backlog.
2953
3041
  *
2954
- * Examples (with default healthyBacklog=20):
2955
- * - 10 due reviews → 0.00 (healthy)
2956
- * - 20 due reviews → 0.00 (at threshold)
2957
- * - 40 due reviews → 0.25 (2x threshold)
2958
- * - 60 due reviews → 0.50 (3x threshold, maxed)
3042
+ * Examples (with default healthyBacklog=20, MAX_BACKLOG_MULTIPLIER=2.0):
3043
+ * - 10 due reviews → ×1.00 (healthy)
3044
+ * - 20 due reviews → ×1.00 (at threshold)
3045
+ * - 40 due reviews → ×1.50 (2x threshold)
3046
+ * - 60 due reviews → ×2.00 (3x threshold, maxed)
2959
3047
  *
2960
3048
  * @param dueCount - Number of reviews currently due
2961
- * @returns Backlog pressure score to add to urgency (0 to MAX_BACKLOG_PRESSURE)
3049
+ * @returns Multiplier applied to review urgency (1.0 to MAX_BACKLOG_MULTIPLIER)
2962
3050
  */
2963
- computeBacklogPressure(dueCount) {
3051
+ computeBacklogMultiplier(dueCount) {
2964
3052
  if (dueCount <= this.healthyBacklog) {
2965
- return 0;
3053
+ return 1;
2966
3054
  }
2967
3055
  const excess = dueCount - this.healthyBacklog;
2968
- const pressure = excess / this.healthyBacklog * (MAX_BACKLOG_PRESSURE / 2);
2969
- return Math.min(MAX_BACKLOG_PRESSURE, pressure);
3056
+ const multiplier = 1 + excess / this.healthyBacklog * ((MAX_BACKLOG_MULTIPLIER - 1) / 2);
3057
+ return Math.min(MAX_BACKLOG_MULTIPLIER, multiplier);
2970
3058
  }
2971
3059
  /**
2972
3060
  * Compute urgency score for a review card.
@@ -2981,19 +3069,20 @@ var init_srs = __esm({
2981
3069
  * - 30 days (720h) → ~0.56
2982
3070
  * - 180 days → ~0.30
2983
3071
  *
2984
- * 3. Backlog pressure = global boost when review backlog exceeds healthy threshold
2985
- * - At healthy backlog: 0
2986
- * - At 2x healthy: +0.25
2987
- * - At 3x+ healthy: +0.50 (max)
3072
+ * 3. Backlog pressure = global *multiplier* when review backlog exceeds the
3073
+ * healthy threshold (×1.0 healthy up to MAX_BACKLOG_MULTIPLIER at 3×).
2988
3074
  *
2989
- * Combined: base 0.5 + (urgency factors * 0.45) + backlog pressure
2990
- * Result range: 0.5 to 1.0 (uncapped to allow high-urgency reviews to compete with new cards)
3075
+ * Combined: (base 0.5 + urgency factors * 0.45) × backlog multiplier.
3076
+ * Per-card range before pressure: ~0.57–0.95. NOT clamped to 1.0 under a
3077
+ * heavy backlog reviews scale onto the open scale to compete with (and exceed)
3078
+ * new cards; what keeps them from running away is the bounded multiplier, not
3079
+ * a hard ceiling.
2991
3080
  *
2992
3081
  * @param review - The scheduled card to score
2993
3082
  * @param now - Current time
2994
- * @param backlogPressure - Pre-computed backlog pressure (0 to 0.5)
3083
+ * @param backlogMultiplier - Pre-computed backlog multiplier (1.0 to MAX_BACKLOG_MULTIPLIER)
2995
3084
  */
2996
- computeUrgencyScore(review, now, backlogPressure) {
3085
+ computeUrgencyScore(review, now, backlogMultiplier) {
2997
3086
  const scheduledAt = moment3.utc(review.scheduledAt);
2998
3087
  const due = moment3.utc(review.reviewTime);
2999
3088
  const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
@@ -3003,15 +3092,15 @@ var init_srs = __esm({
3003
3092
  const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
3004
3093
  const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
3005
3094
  const baseScore = 0.5 + urgency * 0.45;
3006
- const score = Math.min(1, baseScore + backlogPressure);
3095
+ const score = baseScore * backlogMultiplier;
3007
3096
  const reasonParts = [
3008
3097
  `${Math.round(hoursOverdue)}h overdue`,
3009
3098
  `interval: ${Math.round(intervalHours)}h`,
3010
3099
  `relative: ${relativeOverdue.toFixed(2)}`,
3011
3100
  `recency: ${recencyFactor.toFixed(2)}`
3012
3101
  ];
3013
- if (backlogPressure > 0) {
3014
- reasonParts.push(`backlog: +${backlogPressure.toFixed(2)}`);
3102
+ if (backlogMultiplier > 1) {
3103
+ reasonParts.push(`backlog: \xD7${backlogMultiplier.toFixed(2)}`);
3015
3104
  }
3016
3105
  reasonParts.push("review");
3017
3106
  const reason = reasonParts.join(", ");
@@ -4983,6 +5072,68 @@ var init_Pipeline = __esm({
4983
5072
  // ---------------------------------------------------------------------------
4984
5073
  // Card-space diagnostic
4985
5074
  // ---------------------------------------------------------------------------
5075
+ /**
5076
+ * Commit-free forecast: score the user's full card space through the filter
5077
+ * chain and return the cards that are currently *reachable* (score >=
5078
+ * threshold), optionally nudged by caller-supplied hints and/or restricted
5079
+ * to cards the user hasn't seen yet.
5080
+ *
5081
+ * This is a GENERIC primitive — it returns scored, tag-hydrated cards and
5082
+ * stops there. It has no knowledge of any particular tag convention; callers
5083
+ * decide what the surviving cards mean (e.g. filter to their own "intro"
5084
+ * tag family). Nothing is written and no session is started.
5085
+ *
5086
+ * The optional `hints` are the "out-of-band kick": they run through the same
5087
+ * {@link applyHints} path a live replan uses, so the two semantics carry over —
5088
+ * - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
5089
+ * stays out), and
5090
+ * - `requireTags`/`requireCards` inject from the full pre-filter pool,
5091
+ * *bypassing* gating (use when you want a card regardless of reachability).
5092
+ * Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
5093
+ * user has already seen — pass `unseenOnly: false` if that matters.
5094
+ *
5095
+ * Cost note: like {@link diagnoseCardSpace}, this scans every card through the
5096
+ * filters, so it's heavier than a normal replan. Intended for one-shot
5097
+ * out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
5098
+ *
5099
+ * @param opts.hints Optional ephemeral hints to apply after the filter chain.
5100
+ * @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
5101
+ * @param opts.threshold Min score to count as reachable (default 0.10).
5102
+ * @param opts.limit Optional cap on results (already sorted desc).
5103
+ */
5104
+ async forecast(opts) {
5105
+ const threshold = opts?.threshold ?? 0.1;
5106
+ const unseenOnly = opts?.unseenOnly ?? true;
5107
+ const courseId = this.course.getCourseID();
5108
+ const allCardIds = await this.course.getAllCardIds();
5109
+ let cards = allCardIds.map((cardId) => ({
5110
+ cardId,
5111
+ courseId,
5112
+ score: 1,
5113
+ provenance: []
5114
+ }));
5115
+ cards = await this.hydrateTags(cards);
5116
+ const fullPool = cards.slice();
5117
+ const context = await this.buildContext();
5118
+ for (const filter of this.filters) {
5119
+ cards = await filter.transform(cards, context);
5120
+ }
5121
+ if (opts?.hints) {
5122
+ cards = this.applyHints(cards, opts.hints, fullPool);
5123
+ }
5124
+ cards = cards.filter((c) => c.score >= threshold);
5125
+ if (unseenOnly) {
5126
+ let encountered;
5127
+ try {
5128
+ encountered = new Set(await this.user.getSeenCards(courseId));
5129
+ } catch {
5130
+ encountered = /* @__PURE__ */ new Set();
5131
+ }
5132
+ cards = cards.filter((c) => !encountered.has(c.cardId));
5133
+ }
5134
+ cards.sort((a, b) => b.score - a.score);
5135
+ return opts?.limit ? cards.slice(0, opts.limit) : cards;
5136
+ }
4986
5137
  /**
4987
5138
  * Scan every card in the course through the filter chain and report
4988
5139
  * how many are "well indicated" (score >= threshold) for the current user.
@@ -5247,6 +5398,7 @@ var init_3 = __esm({
5247
5398
  "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
5248
5399
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
5249
5400
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
5401
+ "./SrsDebugger.ts": () => Promise.resolve().then(() => (init_SrsDebugger(), SrsDebugger_exports)),
5250
5402
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
5251
5403
  "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
5252
5404
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
@@ -5279,11 +5431,14 @@ __export(navigators_exports, {
5279
5431
  NavigatorRole: () => NavigatorRole,
5280
5432
  NavigatorRoles: () => NavigatorRoles,
5281
5433
  Navigators: () => Navigators,
5434
+ clearSrsBacklogDebug: () => clearSrsBacklogDebug,
5282
5435
  diversityRerank: () => diversityRerank,
5436
+ getActivePipeline: () => getActivePipeline,
5283
5437
  getCardOrigin: () => getCardOrigin,
5284
5438
  getRegisteredNavigator: () => getRegisteredNavigator,
5285
5439
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
5286
5440
  getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
5441
+ getSrsBacklogDebug: () => getSrsBacklogDebug,
5287
5442
  hasRegisteredNavigator: () => hasRegisteredNavigator,
5288
5443
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
5289
5444
  isFilter: () => isFilter,
@@ -5365,6 +5520,7 @@ var init_navigators = __esm({
5365
5520
  "use strict";
5366
5521
  init_diversityRerank();
5367
5522
  init_PipelineDebugger();
5523
+ init_SrsDebugger();
5368
5524
  init_logger();
5369
5525
  init_();
5370
5526
  init_2();
@@ -13271,6 +13427,7 @@ mountMixerDebugger();
13271
13427
  // src/study/SessionDebugger.ts
13272
13428
  init_logger();
13273
13429
  init_PipelineDebugger();
13430
+ init_SrsDebugger();
13274
13431
 
13275
13432
  // src/study/SessionOverlay.ts
13276
13433
  init_logger();
@@ -13292,8 +13449,7 @@ var lastSnapshot = null;
13292
13449
  var copyFlashUntil = 0;
13293
13450
  var minified = false;
13294
13451
  var expanded = {
13295
- reviewQ: false,
13296
- newQ: false,
13452
+ supplyQ: false,
13297
13453
  failedQ: false,
13298
13454
  drawn: false
13299
13455
  };
@@ -13362,7 +13518,7 @@ function render() {
13362
13518
  attachHandlers();
13363
13519
  return;
13364
13520
  }
13365
- overlayEl.innerHTML = headerHtml() + replanHtml(s) + metaHtml(s) + hintsHtml(s.sessionHints) + queueHtml("reviewQ", "reviewQ", s.reviewQ) + queueHtml("newQ", "newQ", s.newQ) + queueHtml("failedQ", "failedQ", s.failedQ) + drawnHtml("drawn", s.drawnCards);
13521
+ overlayEl.innerHTML = headerHtml() + replanHtml(s) + metaHtml(s) + hintsHtml(s.sessionHints) + backlogHtml(s.reviewBacklog) + queueHtml("supplyQ", "supplyQ", s.supplyQ) + queueHtml("failedQ", "failedQ", s.failedQ) + drawnHtml("drawn", s.drawnCards);
13366
13522
  attachHandlers();
13367
13523
  }
13368
13524
  function attachHandlers() {
@@ -13454,6 +13610,29 @@ function hintsHtml(h) {
13454
13610
  const body = parts.length ? parts.map((p) => `<div style="margin-left:6px">${p}</div>`).join("") : `<div style="margin-left:6px;opacity:.6">none</div>`;
13455
13611
  return `<div style="margin-bottom:6px"><div style="color:#86efac">sessionHints</div>${body}</div>`;
13456
13612
  }
13613
+ function backlogHtml(backlog) {
13614
+ if (!backlog.length) return "";
13615
+ const rows = backlog.map((b) => {
13616
+ const maxed = b.backlogMultiplier >= b.maxBacklogMultiplier - 1e-9;
13617
+ const multColor = b.backlogMultiplier <= 1 ? "#86efac" : maxed ? "#fca5a5" : "#fcd34d";
13618
+ const headroom = maxed ? "maxed \u2014 boosts decide order until they relax" : b.backlogMultiplier > 1 ? "climbing as backlog grows" : "healthy \u2014 no pressure";
13619
+ const top = b.topReviewScore !== null ? b.topReviewScore.toFixed(2) : "\u2014";
13620
+ const next = b.nextDueIn ? ` <span style="opacity:.6">\xB7 next due ${esc(b.nextDueIn)}</span>` : "";
13621
+ return `<div style="margin-left:6px"><span style="opacity:.7">${esc(b.courseId.slice(0, 8))}</span> due ${b.dueNow}/${b.scheduledTotal} <span style="opacity:.6">(healthy ${b.healthyBacklog})</span>${next}<div style="margin-left:6px">pressure <span style="color:${multColor}">\xD7${b.backlogMultiplier.toFixed(2)}/${b.maxBacklogMultiplier.toFixed(2)}</span> <span style="opacity:.6">${headroom} \xB7 top review ${top}</span></div></div>`;
13622
+ }).join("");
13623
+ return `<div style="margin-bottom:6px"><div style="color:#93c5fd">review backpressure</div>${rows}</div>`;
13624
+ }
13625
+ function fmtScore(score) {
13626
+ if (score === void 0) return "";
13627
+ if (!Number.isFinite(score)) return "REQ";
13628
+ return score.toFixed(2);
13629
+ }
13630
+ function queueItemHtml(item) {
13631
+ const tagColor = item.origin === "review" ? "#93c5fd" : "#fcd34d";
13632
+ const score = fmtScore(item.score);
13633
+ const label = `${item.origin === "review" ? "r" : "n"}${score ? " " + score : ""}`;
13634
+ return `${esc(item.cardID)}<span style="color:${tagColor};opacity:.85"> [${label}]</span>`;
13635
+ }
13457
13636
  function queueHtml(key, label, q) {
13458
13637
  const collapsible = q.length > INLINE_THRESHOLD;
13459
13638
  const isOpen = collapsible && expanded[key];
@@ -13468,7 +13647,7 @@ function queueHtml(key, label, q) {
13468
13647
  const shown = isOpen ? q.cards : q.cards.slice(0, INLINE_THRESHOLD);
13469
13648
  const hiddenCount = q.length - shown.length;
13470
13649
  const listMarginBottom = collapsible ? 2 : 6;
13471
- let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">` + shown.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join("") + `</ol>`;
13650
+ let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">` + shown.map((c) => `<li style="white-space:nowrap">${queueItemHtml(c)}</li>`).join("") + `</ol>`;
13472
13651
  if (collapsible) {
13473
13652
  const footer = isOpen ? "\u25BE show less" : `\u2026 +${hiddenCount} more`;
13474
13653
  body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
@@ -13531,13 +13710,29 @@ function snapshotToText(s) {
13531
13710
  if (h.excludeCards?.length) hintParts.push(` excludeCards: ${h.excludeCards.join(", ")}`);
13532
13711
  }
13533
13712
  lines.push(hintParts.length ? hintParts.join("\n") : " none");
13713
+ if (s.reviewBacklog.length) {
13714
+ lines.push("");
13715
+ lines.push("review backpressure:");
13716
+ for (const b of s.reviewBacklog) {
13717
+ const maxed = b.backlogMultiplier >= b.maxBacklogMultiplier - 1e-9;
13718
+ const headroom = maxed ? "maxed (boosts decide order)" : b.backlogMultiplier > 1 ? "climbing" : "healthy";
13719
+ const top = b.topReviewScore !== null ? b.topReviewScore.toFixed(2) : "\u2014";
13720
+ const next = b.nextDueIn ? `, next due ${b.nextDueIn}` : "";
13721
+ lines.push(
13722
+ ` ${b.courseId.slice(0, 8)}: due ${b.dueNow}/${b.scheduledTotal} (healthy ${b.healthyBacklog})${next}; pressure \xD7${b.backlogMultiplier.toFixed(2)}/${b.maxBacklogMultiplier.toFixed(2)} ${headroom}; top review ${top}`
13723
+ );
13724
+ }
13725
+ }
13534
13726
  const queueText = (label, q) => {
13535
13727
  lines.push("");
13536
13728
  lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
13537
- q.cards.forEach((c, i) => lines.push(` ${i + 1}. ${c}`));
13729
+ q.cards.forEach((c, i) => {
13730
+ const score = fmtScore(c.score);
13731
+ const tag = `${c.origin === "review" ? "r" : "n"}${score ? " " + score : ""}`;
13732
+ lines.push(` ${i + 1}. ${c.cardID} [${tag}]`);
13733
+ });
13538
13734
  };
13539
- queueText("reviewQ", s.reviewQ);
13540
- queueText("newQ", s.newQ);
13735
+ queueText("supplyQ", s.supplyQ);
13541
13736
  queueText("failedQ", s.failedQ);
13542
13737
  lines.push("");
13543
13738
  lines.push(`drawn: ${s.drawnCards.length}`);
@@ -13562,16 +13757,16 @@ function esc(value) {
13562
13757
  var activeSession = null;
13563
13758
  var sessionHistory = [];
13564
13759
  var MAX_HISTORY = 5;
13565
- function startSessionTracking(reviewQLength, newQLength, failedQLength) {
13760
+ function startSessionTracking(supplyQLength, failedQLength) {
13566
13761
  clearRunHistory();
13762
+ clearSrsBacklogDebug();
13567
13763
  const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
13568
13764
  activeSession = {
13569
13765
  sessionId,
13570
13766
  startTime: /* @__PURE__ */ new Date(),
13571
13767
  initialQueues: {
13572
13768
  timestamp: /* @__PURE__ */ new Date(),
13573
- reviewQLength,
13574
- newQLength,
13769
+ supplyQLength,
13575
13770
  failedQLength
13576
13771
  },
13577
13772
  presentations: [],
@@ -13595,17 +13790,15 @@ function recordCardPresentation(cardId, courseId, courseName, origin, queueSourc
13595
13790
  score
13596
13791
  });
13597
13792
  }
13598
- function snapshotQueues(reviewQLength, newQLength, failedQLength, reviewQNext3, newQNext3) {
13793
+ function snapshotQueues(supplyQLength, failedQLength, supplyQNext3) {
13599
13794
  if (!activeSession) {
13600
13795
  return;
13601
13796
  }
13602
13797
  activeSession.queueSnapshots.push({
13603
13798
  timestamp: /* @__PURE__ */ new Date(),
13604
- reviewQLength,
13605
- newQLength,
13799
+ supplyQLength,
13606
13800
  failedQLength,
13607
- reviewQNext3,
13608
- newQNext3
13801
+ supplyQNext3
13609
13802
  });
13610
13803
  }
13611
13804
  function endSessionTracking() {
@@ -13627,13 +13820,9 @@ function showCurrentQueue() {
13627
13820
  }
13628
13821
  const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
13629
13822
  console.group("\u{1F4CA} Current Queue State");
13630
- logger.info(`Review Queue: ${latest.reviewQLength} cards`);
13631
- if (latest.reviewQNext3 && latest.reviewQNext3.length > 0) {
13632
- logger.info(` Next: ${latest.reviewQNext3.join(", ")}`);
13633
- }
13634
- logger.info(`New Queue: ${latest.newQLength} cards`);
13635
- if (latest.newQNext3 && latest.newQNext3.length > 0) {
13636
- logger.info(` Next: ${latest.newQNext3.join(", ")}`);
13823
+ logger.info(`Supply Queue: ${latest.supplyQLength} cards`);
13824
+ if (latest.supplyQNext3 && latest.supplyQNext3.length > 0) {
13825
+ logger.info(` Next: ${latest.supplyQNext3.join(", ")}`);
13637
13826
  }
13638
13827
  logger.info(`Failed Queue: ${latest.failedQLength} cards`);
13639
13828
  console.groupEnd();
@@ -13850,15 +14039,6 @@ var SessionController = class _SessionController extends Loggable {
13850
14039
  * Individual replans can override via `ReplanOptions.limit`.
13851
14040
  */
13852
14041
  _defaultBatchLimit = 20;
13853
- /**
13854
- * Maximum number of reviews enqueued at session start. Reviews live
13855
- * outside the replan flow — the queue drains via consumption and is
13856
- * not refilled mid-session. The session timer caps total review
13857
- * exposure, so overfilling here is intentional. Default is generous
13858
- * to accommodate Anki-style power users with hundreds of due reviews;
13859
- * apps targeting nimbler sessions should override via constructor.
13860
- */
13861
- _initialReviewCap = 200;
13862
14042
  sources;
13863
14043
  // dataLayer and getViewComponent now injected into CardHydrationService
13864
14044
  _sessionRecord = [];
@@ -13867,10 +14047,28 @@ var SessionController = class _SessionController extends Loggable {
13867
14047
  }
13868
14048
  // Session card stores
13869
14049
  _currentCard = null;
13870
- reviewQ = new ItemQueue();
13871
- newQ = new ItemQueue();
14050
+ /**
14051
+ * The single supply queue: `new` + `review` items interleaved in pipeline
14052
+ * rank order (the mixer's score-ordered, source-interleaved output, with
14053
+ * `+INF` required cards floated to the front). Drawn front-to-back; reviews
14054
+ * and new compete on one cross-comparable scale rather than being re-mixed
14055
+ * by a probability gate. Replaced/re-ranked wholesale on replan. See
14056
+ * `docs/decision-single-supply-queue.md`.
14057
+ */
14058
+ supplyQ = new ItemQueue();
13872
14059
  failedQ = new ItemQueue();
13873
14060
  // END Session card stores
14061
+ /**
14062
+ * Supply draws since the last failed-queue *event* (a failed draw, or a card
14063
+ * entering failedQ on failure). Drives the light steady failed-interleave
14064
+ * (§7): after this many consecutive supply draws, a pending failed card is
14065
+ * drawn so remediation doesn't starve mid-session. Incremented on each supply
14066
+ * draw; reset to 0 both when a failed card is drawn AND when one is added to
14067
+ * failedQ — the latter gives a just-failed card spacing instead of an instant
14068
+ * retry (the counter would otherwise already be ≥ threshold from the preceding
14069
+ * supply run).
14070
+ */
14071
+ _supplyDrawsSinceFailed = 0;
13874
14072
  /**
13875
14073
  * Promise tracking a currently in-progress replan, or null if idle.
13876
14074
  * Used by nextCard() to await completion before drawing from queues.
@@ -13884,8 +14082,8 @@ var SessionController = class _SessionController extends Loggable {
13884
14082
  */
13885
14083
  _activeReplanLabel = null;
13886
14084
  /**
13887
- * Number of well-indicated new cards remaining before the queue
13888
- * degrades to poorly-indicated content. Decremented on each newQ
14085
+ * Number of well-indicated supply cards remaining before the queue
14086
+ * degrades to poorly-indicated content. Decremented on each supplyQ
13889
14087
  * draw; when it hits 0, a replan is triggered automatically
13890
14088
  * (user state has changed from completing good cards).
13891
14089
  */
@@ -13894,7 +14092,7 @@ var SessionController = class _SessionController extends Loggable {
13894
14092
  * When true, suppresses the quality-based auto-replan trigger in
13895
14093
  * nextCard(). Set after a burst replan (small limit) to prevent the
13896
14094
  * auto-replan from clobbering the burst cards before they're consumed.
13897
- * Cleared when the depletion-triggered replan fires (newQ exhausted).
14095
+ * Cleared when the depletion-triggered replan fires (supplyQ exhausted).
13898
14096
  */
13899
14097
  _suppressQualityReplan = false;
13900
14098
  /**
@@ -13923,13 +14121,15 @@ var SessionController = class _SessionController extends Loggable {
13923
14121
  * a draw the instant it happens — earlier than `_sessionRecord`, which only
13924
14122
  * lands once the card is *responded to*.
13925
14123
  *
13926
- * Used to keep already-served cards out of newQ on every (re)plan: a `new`
13927
- * card shown once must never re-enter newQ this session. This is the general
13928
- * guard against re-presentation including the case where a replan in flight
13929
- * captured a now-drawn card (e.g. a +INF require-injected follow-up the
13930
- * depletion prefetch grabbed just before it was drawn). Reviews/failed cards
13931
- * legitimately recur and are tracked by their own queues, so this only gates
13932
- * `new`-origin candidates.
14124
+ * Used to keep already-served cards out of supplyQ on every (re)plan, across
14125
+ * ALL origins: a `new` card shown once must never re-enter, and once replans
14126
+ * re-pull reviews, an answered/in-flight review must not re-enter the supply
14127
+ * before its SRS reschedule clears the due-window (the review-loop guard,
14128
+ * decision doc §4). This is the general guard against re-presentation —
14129
+ * including the case where a replan in flight captured a now-drawn card (e.g.
14130
+ * a +INF require-injected follow-up the depletion prefetch grabbed just before
14131
+ * it was drawn). failedQ is separate and controller-owned, so failed cards
14132
+ * legitimately recur there without being gated here.
13933
14133
  */
13934
14134
  _servedCardIds = /* @__PURE__ */ new Set();
13935
14135
  /**
@@ -13954,14 +14154,12 @@ var SessionController = class _SessionController extends Loggable {
13954
14154
  return this._minCardsGuarantee > 0;
13955
14155
  }
13956
14156
  get report() {
13957
- const reviewCount = this.reviewQ.dequeueCount;
13958
- const newCount = this.newQ.dequeueCount;
13959
- const reviewWord = reviewCount === 1 ? "review" : "reviews";
13960
- const newCardWord = newCount === 1 ? "new card" : "new cards";
13961
- return `${reviewCount} ${reviewWord}, ${newCount} ${newCardWord}`;
14157
+ const supplyCount = this.supplyQ.dequeueCount;
14158
+ const supplyWord = supplyCount === 1 ? "card" : "cards";
14159
+ return `${supplyCount} supply ${supplyWord} drawn`;
13962
14160
  }
13963
14161
  get detailedReport() {
13964
- return this.newQ.toString + "\n" + this.reviewQ.toString + "\n" + this.failedQ.toString;
14162
+ return this.supplyQ.toString + "\n" + this.failedQ.toString;
13965
14163
  }
13966
14164
  // @ts-expect-error NodeJS.Timeout type not available in browser context
13967
14165
  _intervalHandle;
@@ -13972,11 +14170,9 @@ var SessionController = class _SessionController extends Loggable {
13972
14170
  * @param getViewComponent - Function to resolve view components
13973
14171
  * @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
13974
14172
  * @param options - Optional session-level configuration
13975
- * @param options.defaultBatchLimit - Default pipeline batch size (default: 20).
14173
+ * @param options.defaultBatchLimit - Default supply working-set size (default: 20).
13976
14174
  * Smaller values for newer users cause more frequent replans, keeping plans
13977
14175
  * aligned with rapidly-changing user state.
13978
- * @param options.initialReviewCap - Max reviews loaded at session start (default: 200).
13979
- * Applied only on initial planning; replans do not refill the review queue.
13980
14176
  */
13981
14177
  constructor(sources, time, dataLayer, getViewComponent, mixer, options) {
13982
14178
  super();
@@ -13999,17 +14195,13 @@ var SessionController = class _SessionController extends Loggable {
13999
14195
  if (options?.defaultBatchLimit !== void 0) {
14000
14196
  this._defaultBatchLimit = options.defaultBatchLimit;
14001
14197
  }
14002
- if (options?.initialReviewCap !== void 0) {
14003
- this._initialReviewCap = options.initialReviewCap;
14004
- }
14005
14198
  if (options?.outcomeObservers?.length) {
14006
14199
  this._outcomeObservers = [...options.outcomeObservers];
14007
14200
  }
14008
14201
  this.log(`Session constructed:
14009
14202
  startTime: ${this.startTime}
14010
14203
  endTime: ${this.endTime}
14011
- defaultBatchLimit: ${this._defaultBatchLimit}
14012
- initialReviewCap: ${this._initialReviewCap}`);
14204
+ defaultBatchLimit: ${this._defaultBatchLimit}`);
14013
14205
  registerActiveController(this);
14014
14206
  }
14015
14207
  tick() {
@@ -14043,15 +14235,6 @@ var SessionController = class _SessionController extends Loggable {
14043
14235
  this.log(`Failed card cleanup estimate: ${Math.round(ret)}`);
14044
14236
  return ret;
14045
14237
  }
14046
- /**
14047
- * Extremely rough, conservative, estimate of amound of time to complete
14048
- * all scheduled reviews
14049
- */
14050
- estimateReviewTime() {
14051
- const ret = 5 * this.reviewQ.length;
14052
- this.log(`Review card time estimate: ${ret}`);
14053
- return ret;
14054
- }
14055
14238
  async prepareSession() {
14056
14239
  if (this.sources.some((s) => typeof s.getWeightedCards !== "function")) {
14057
14240
  throw new Error(
@@ -14066,15 +14249,15 @@ var SessionController = class _SessionController extends Loggable {
14066
14249
  );
14067
14250
  }
14068
14251
  await this.hydrationService.ensureHydratedCards();
14069
- startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
14252
+ startSessionTracking(this.supplyQ.length, this.failedQ.length);
14070
14253
  this._intervalHandle = setInterval(() => {
14071
14254
  this.tick();
14072
14255
  }, 1e3);
14073
14256
  }
14074
14257
  /**
14075
14258
  * Request a mid-session replan. Re-runs the pipeline with current user state
14076
- * and atomically replaces the newQ contents. Safe to call at any time during
14077
- * a session.
14259
+ * and atomically replaces (or merges into) the supplyQ contents. Safe to call
14260
+ * at any time during a session.
14078
14261
  *
14079
14262
  * Concurrency policy:
14080
14263
  * - Two unhinted auto-replans never run in parallel; the second coalesces
@@ -14088,7 +14271,8 @@ var SessionController = class _SessionController extends Loggable {
14088
14271
  * results (e.g. surfacing another gpc-intro card right after one
14089
14272
  * completed, skipping the prescribed `c-wst-*` follow-up).
14090
14273
  *
14091
- * Does NOT affect reviewQ or failedQ.
14274
+ * Re-pulls and re-ranks the whole supply (including reviews); does NOT affect
14275
+ * failedQ (controller-owned remediation).
14092
14276
  *
14093
14277
  * If nextCard() is called while a replan is in flight, it will automatically
14094
14278
  * await the replan before drawing from queues, ensuring the user always sees
@@ -14154,7 +14338,7 @@ var SessionController = class _SessionController extends Loggable {
14154
14338
  * excludeCards happen at *invocation* time, not at *queue* time. For a
14155
14339
  * queued replan that means excludes reflect the state after the prior
14156
14340
  * replan landed — which is what we want, since the prior replan's
14157
- * newQ.peek(0) is the imminent draw we need to exclude.
14341
+ * supplyQ.peek(0) is the imminent draw we need to exclude.
14158
14342
  */
14159
14343
  async _runReplan(opts) {
14160
14344
  this._activeReplanLabel = opts.label ?? "(auto)";
@@ -14167,8 +14351,8 @@ var SessionController = class _SessionController extends Loggable {
14167
14351
  for (const rec of this._sessionRecord) {
14168
14352
  excludeSet.add(rec.card.card_id);
14169
14353
  }
14170
- if (this.newQ.length > 0) {
14171
- excludeSet.add(this.newQ.peek(0).cardID);
14354
+ if (this.supplyQ.length > 0) {
14355
+ excludeSet.add(this.supplyQ.peek(0).cardID);
14172
14356
  }
14173
14357
  hints.excludeCards = [...excludeSet];
14174
14358
  if (opts.sessionHints !== void 0) {
@@ -14229,7 +14413,13 @@ var SessionController = class _SessionController extends Loggable {
14229
14413
  const describe = (q) => {
14230
14414
  const cards = [];
14231
14415
  for (let i = 0; i < q.length; i++) {
14232
- cards.push(q.peek(i).cardID);
14416
+ const item = q.peek(i);
14417
+ cards.push({
14418
+ cardID: item.cardID,
14419
+ status: item.status,
14420
+ origin: isReview(item) ? "review" : "new",
14421
+ score: item.score
14422
+ });
14233
14423
  }
14234
14424
  return { length: q.length, dequeueCount: q.dequeueCount, cards };
14235
14425
  };
@@ -14252,9 +14442,9 @@ var SessionController = class _SessionController extends Loggable {
14252
14442
  sessionHints: this._sessionHints,
14253
14443
  replanActive: this._replanPromise !== null,
14254
14444
  replanLabel: this._activeReplanLabel,
14255
- reviewQ: describe(this.reviewQ),
14256
- newQ: describe(this.newQ),
14445
+ supplyQ: describe(this.supplyQ),
14257
14446
  failedQ: describe(this.failedQ),
14447
+ reviewBacklog: getSrsBacklogDebug(),
14258
14448
  drawnCards
14259
14449
  };
14260
14450
  }
@@ -14374,7 +14564,7 @@ var SessionController = class _SessionController extends Loggable {
14374
14564
  */
14375
14565
  static WELL_INDICATED_SCORE = 0.1;
14376
14566
  /**
14377
- * newQ length at or below which the opportunistic depletion-prefetch
14567
+ * supplyQ length at or below which the opportunistic depletion-prefetch
14378
14568
  * fires. Sets the lead time available for the background replan to land
14379
14569
  * before the user actually empties the queue and falls into the
14380
14570
  * (synchronous) wedge-breaker path.
@@ -14387,7 +14577,7 @@ var SessionController = class _SessionController extends Loggable {
14387
14577
  */
14388
14578
  static DEPLETION_PREFETCH_THRESHOLD = 3;
14389
14579
  /**
14390
- * Internal replan execution. Runs the pipeline, builds a new newQ,
14580
+ * Internal replan execution. Runs the pipeline, rebuilds the supplyQ,
14391
14581
  * atomically swaps it in, and triggers hydration for the new contents.
14392
14582
  *
14393
14583
  * If the initial replan produces fewer than MIN_WELL_INDICATED cards that
@@ -14416,8 +14606,8 @@ var SessionController = class _SessionController extends Loggable {
14416
14606
  }
14417
14607
  await this.hydrationService.ensureHydratedCards();
14418
14608
  const labelTag = opts.label ? ` [${opts.label}]` : "";
14419
- this.log(`Replan complete${labelTag}: newQ now has ${this.newQ.length} cards (mode=${mode})`);
14420
- snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
14609
+ this.log(`Replan complete${labelTag}: supplyQ now has ${this.supplyQ.length} cards (mode=${mode})`);
14610
+ snapshotQueues(this.supplyQ.length, this.failedQ.length);
14421
14611
  }
14422
14612
  addTime(seconds) {
14423
14613
  this.endTime = new Date(this.endTime.valueOf() + 1e3 * seconds);
@@ -14426,10 +14616,10 @@ var SessionController = class _SessionController extends Loggable {
14426
14616
  return this.failedQ.length;
14427
14617
  }
14428
14618
  toString() {
14429
- return `Session: ${this.reviewQ.length} Reviews, ${this.newQ.length} New, ${this.failedQ.length} failed`;
14619
+ return `Session: ${this.supplyQ.length} supply, ${this.failedQ.length} failed`;
14430
14620
  }
14431
14621
  reportString() {
14432
- return `${this.reviewQ.dequeueCount} Reviews, ${this.newQ.dequeueCount} New, ${this.failedQ.dequeueCount} failed`;
14622
+ return `${this.supplyQ.dequeueCount} supply, ${this.failedQ.dequeueCount} failed`;
14433
14623
  }
14434
14624
  /**
14435
14625
  * Returns debug information about the current session state.
@@ -14446,7 +14636,8 @@ var SessionController = class _SessionController extends Loggable {
14446
14636
  items.push({
14447
14637
  courseID: item.courseID || "unknown",
14448
14638
  cardID: item.cardID || "unknown",
14449
- status: item.status || "unknown"
14639
+ status: item.status || "unknown",
14640
+ score: item.score
14450
14641
  });
14451
14642
  }
14452
14643
  return items;
@@ -14456,15 +14647,10 @@ var SessionController = class _SessionController extends Loggable {
14456
14647
  mode: supportsWeightedCards ? "weighted" : "legacy",
14457
14648
  description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "ERROR: getWeightedCards() not a function."
14458
14649
  },
14459
- reviewQueue: {
14460
- length: this.reviewQ.length,
14461
- dequeueCount: this.reviewQ.dequeueCount,
14462
- items: extractQueueItems(this.reviewQ)
14463
- },
14464
- newQueue: {
14465
- length: this.newQ.length,
14466
- dequeueCount: this.newQ.dequeueCount,
14467
- items: extractQueueItems(this.newQ)
14650
+ supplyQueue: {
14651
+ length: this.supplyQ.length,
14652
+ dequeueCount: this.supplyQ.dequeueCount,
14653
+ items: extractQueueItems(this.supplyQ)
14468
14654
  },
14469
14655
  failedQueue: {
14470
14656
  length: this.failedQ.length,
@@ -14484,30 +14670,29 @@ var SessionController = class _SessionController extends Loggable {
14484
14670
  };
14485
14671
  }
14486
14672
  /**
14487
- * Fetch content using the getWeightedCards API and mix across sources.
14673
+ * Fetch weighted content from all sources, mix across sources, and populate
14674
+ * the single supply queue in pipeline rank order.
14488
14675
  *
14489
- * This method:
14490
- * 1. Fetches weighted cards from each source
14491
- * 2. Fetches full review data (we need ScheduledCard fields for queue)
14492
- * 3. Uses SourceMixer to balance content across sources
14493
- * 4. Populates review and new card queues with mixed results
14494
- */
14495
- /**
14496
- * Fetch weighted content from all sources and populate session queues.
14676
+ * Reviews and new cards compete on one cross-comparable scale (SRS 0.5–1.0
14677
+ * w/ backlog pressure vs ELO 0.0–1.0) there is no origin split and no
14678
+ * second mixer. The working set is `supplyLimit` cards (the top of the mixed
14679
+ * ranking, plus any `+INF` required cards floated to the front); replans
14680
+ * re-pull and re-rank the whole supply, so a heavy review backlog surfaces as
14681
+ * a refreshed top-ranked working set rather than a frozen 200-card snapshot.
14497
14682
  *
14498
14683
  * @param options.replan - If true, this is a mid-session replan rather than
14499
- * initial session setup. Skips review queue population (avoiding duplicates),
14500
- * atomically replaces newQ contents, and treats empty results as non-fatal.
14501
- * @param options.additive - If true (replan only), merge new high-quality
14502
- * candidates into the front of the existing newQ instead of replacing it.
14684
+ * initial session setup. Atomically replaces supplyQ contents and treats
14685
+ * empty results as non-fatal.
14686
+ * @param options.additive - If true (replan only), merge high-quality
14687
+ * candidates into the front of the existing supplyQ instead of replacing it.
14503
14688
  * @returns Number of "well-indicated" cards (passed all hierarchy filters)
14504
14689
  * in the new content. Returns -1 if no content was loaded.
14505
14690
  */
14506
14691
  async getWeightedContent(options) {
14507
14692
  const replan = options?.replan ?? false;
14508
14693
  const additive = options?.additive ?? false;
14509
- const newLimit = options?.limit ?? this._defaultBatchLimit;
14510
- const fetchLimit = replan ? newLimit : newLimit + this._initialReviewCap;
14694
+ const supplyLimit = options?.limit ?? this._defaultBatchLimit;
14695
+ const fetchLimit = supplyLimit;
14511
14696
  if (!replan) {
14512
14697
  this._applyHintsToSources();
14513
14698
  }
@@ -14529,7 +14714,7 @@ var SessionController = class _SessionController extends Loggable {
14529
14714
  }
14530
14715
  if (batches.length === 0) {
14531
14716
  if (replan) {
14532
- this.log("Replan: no content from any source, keeping existing newQ");
14717
+ this.log("Replan: no content from any source, keeping existing supplyQ");
14533
14718
  return -1;
14534
14719
  }
14535
14720
  throw new Error(
@@ -14563,64 +14748,59 @@ var SessionController = class _SessionController extends Loggable {
14563
14748
  quotaPerSource,
14564
14749
  mixedWeighted
14565
14750
  );
14566
- const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review").slice(0, this._initialReviewCap);
14567
- const newCandidates = mixedWeighted.filter(
14568
- (w) => getCardOrigin(w) === "new" && !this._servedCardIds.has(w.cardId)
14569
- );
14570
- const mandatoryWeighted = newCandidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
14571
- const optionalWeighted = newCandidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
14572
- const newWeighted = [
14751
+ const candidates = mixedWeighted.filter((w) => !this._servedCardIds.has(w.cardId));
14752
+ const mandatoryWeighted = candidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
14753
+ const optionalWeighted = candidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
14754
+ const supplyWeighted = [
14573
14755
  ...mandatoryWeighted,
14574
- ...optionalWeighted.slice(0, Math.max(0, newLimit - mandatoryWeighted.length))
14756
+ ...optionalWeighted.slice(0, Math.max(0, supplyLimit - mandatoryWeighted.length))
14575
14757
  ];
14576
14758
  const mandatoryIds = new Set(mandatoryWeighted.map((w) => w.cardId));
14577
- logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
14578
- let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
14579
- if (!replan) {
14580
- for (const w of reviewWeighted) {
14581
- const reviewItem = {
14582
- cardID: w.cardId,
14583
- courseID: w.courseId,
14584
- contentSourceType: "course",
14585
- contentSourceID: w.courseId,
14586
- reviewID: w.reviewID,
14587
- status: "review"
14588
- };
14589
- this.reviewQ.add(reviewItem, reviewItem.cardID);
14590
- report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
14591
- `;
14592
- }
14593
- }
14594
- const wellIndicated = newWeighted.filter(
14759
+ const wellIndicated = supplyWeighted.filter(
14595
14760
  (w) => w.score >= _SessionController.WELL_INDICATED_SCORE
14596
14761
  ).length;
14597
- const newItems = [];
14598
- for (const w of newWeighted) {
14599
- const newItem = {
14600
- cardID: w.cardId,
14601
- courseID: w.courseId,
14602
- contentSourceType: "course",
14603
- contentSourceID: w.courseId,
14604
- status: "new"
14605
- };
14606
- newItems.push(newItem);
14607
- report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
14762
+ let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
14763
+ const supplyItems = supplyWeighted.map((w) => {
14764
+ const origin = getCardOrigin(w);
14765
+ const scoreStr = Number.isFinite(w.score) ? w.score.toFixed(2) : "+INF";
14766
+ report += `${origin === "review" ? "Review" : "New"}: ${w.courseId}::${w.cardId} (score: ${scoreStr})
14608
14767
  `;
14609
- }
14768
+ return this._buildSupplyItem(w, origin);
14769
+ });
14610
14770
  if (additive) {
14611
- const added = this.newQ.mergeToFront(newItems, (item) => item.cardID, mandatoryIds);
14612
- report += `Additive merge: ${added} new cards added to front of newQ
14771
+ const added = this.supplyQ.mergeToFront(supplyItems, (item) => item.cardID, mandatoryIds);
14772
+ report += `Additive merge: ${added} cards added to front of supplyQ
14613
14773
  `;
14614
14774
  } else if (replan) {
14615
- this.newQ.replaceAll(newItems, (item) => item.cardID);
14775
+ this.supplyQ.replaceAll(supplyItems, (item) => item.cardID);
14616
14776
  } else {
14617
- for (const item of newItems) {
14618
- this.newQ.add(item, item.cardID);
14777
+ for (const item of supplyItems) {
14778
+ this.supplyQ.add(item, item.cardID);
14619
14779
  }
14620
14780
  }
14621
14781
  this.log(report);
14622
14782
  return wellIndicated;
14623
14783
  }
14784
+ /**
14785
+ * Build a supply item from a weighted candidate. Review-origin cards carry
14786
+ * their `reviewID` so SRS outcome tracking and re-presentation work; new
14787
+ * cards do not. `score` is carried on both for the debug overlay.
14788
+ */
14789
+ _buildSupplyItem(w, origin = getCardOrigin(w)) {
14790
+ const base = {
14791
+ cardID: w.cardId,
14792
+ courseID: w.courseId,
14793
+ contentSourceType: "course",
14794
+ contentSourceID: w.courseId,
14795
+ score: w.score
14796
+ };
14797
+ if (origin === "review") {
14798
+ const reviewItem = { ...base, status: "review", reviewID: w.reviewID };
14799
+ return reviewItem;
14800
+ }
14801
+ const newItem = { ...base, status: "new" };
14802
+ return newItem;
14803
+ }
14624
14804
  /**
14625
14805
  * Returns items that should be pre-hydrated.
14626
14806
  * Deterministic: top N items from each queue to ensure coverage.
@@ -14628,71 +14808,73 @@ var SessionController = class _SessionController extends Loggable {
14628
14808
  */
14629
14809
  _getItemsToHydrate() {
14630
14810
  const items = [];
14631
- const ITEMS_PER_QUEUE = 2;
14632
- for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.reviewQ.length); i++) {
14633
- items.push(this.reviewQ.peek(i));
14634
- }
14635
- for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.newQ.length); i++) {
14636
- items.push(this.newQ.peek(i));
14811
+ const SUPPLY_PREFETCH = 3;
14812
+ const FAILED_PREFETCH = 2;
14813
+ for (let i = 0; i < Math.min(SUPPLY_PREFETCH, this.supplyQ.length); i++) {
14814
+ items.push(this.supplyQ.peek(i));
14637
14815
  }
14638
- for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.failedQ.length); i++) {
14816
+ for (let i = 0; i < Math.min(FAILED_PREFETCH, this.failedQ.length); i++) {
14639
14817
  items.push(this.failedQ.peek(i));
14640
14818
  }
14641
14819
  return items;
14642
14820
  }
14643
14821
  /**
14644
14822
  * Selects the next item to present to the user.
14645
- * Nondeterministic: uses probability to balance between queues based on session state.
14823
+ *
14824
+ * The supplyQ is already rank-ordered (the pipeline + mixer did the mixing,
14825
+ * with `+INF` required cards floated to the front), so the primary path is a
14826
+ * deterministic front-to-back draw — no second new-vs-review mixer. The only
14827
+ * remaining decisions are (a) when the session ends and (b) when to interleave
14828
+ * a remediation card from failedQ. See decision doc §2/§3/§7.
14646
14829
  */
14647
14830
  _selectNextItemToHydrate() {
14648
- const choice = Math.random();
14649
- let newBound = 0.1;
14650
- let reviewBound = 0.75;
14651
- if (this.reviewQ.length === 0 && this.failedQ.length === 0 && this.newQ.length === 0) {
14831
+ if (this.supplyQ.length === 0 && this.failedQ.length === 0) {
14652
14832
  return null;
14653
14833
  }
14654
14834
  if (this._secondsRemaining < 2 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
14655
14835
  return null;
14656
14836
  }
14657
14837
  if (this._secondsRemaining <= 0 && this._minCardsGuarantee <= 0) {
14658
- if (this.failedQ.length > 0) {
14659
- return this.failedQ.peek(0);
14660
- } else {
14661
- return null;
14662
- }
14838
+ return this.failedQ.length > 0 ? this.failedQ.peek(0) : null;
14663
14839
  }
14664
- if (this.newQ.dequeueCount < this.sources.length && this.newQ.length) {
14665
- return this.newQ.peek(0);
14840
+ const supplyTop = this.supplyQ.length > 0 ? this.supplyQ.peek(0) : null;
14841
+ if (this._minCardsGuarantee > 0 && supplyTop) {
14842
+ return supplyTop;
14666
14843
  }
14667
- const cleanupTime = this.estimateCleanupTime();
14668
- const reviewTime = this.estimateReviewTime();
14669
- const availableTime = this._secondsRemaining - (cleanupTime + reviewTime);
14670
- if (availableTime > 20) {
14671
- newBound = 0.5;
14672
- reviewBound = 0.9;
14673
- } else if (this._secondsRemaining - cleanupTime > 20) {
14674
- newBound = 0.05;
14675
- reviewBound = 0.9;
14676
- } else {
14677
- newBound = 0.01;
14678
- reviewBound = 0.1;
14679
- }
14680
- if (this.failedQ.length === 0) {
14681
- reviewBound = 1;
14844
+ if (this.failedQ.length > 0 && this._shouldInterleaveFailed(supplyTop !== null)) {
14845
+ return this.failedQ.peek(0);
14682
14846
  }
14683
- if (this.reviewQ.length === 0) {
14684
- newBound = reviewBound;
14847
+ if (supplyTop) {
14848
+ return supplyTop;
14685
14849
  }
14686
- if (choice < newBound && this.newQ.length) {
14687
- return this.newQ.peek(0);
14688
- } else if (choice < reviewBound && this.reviewQ.length) {
14689
- return this.reviewQ.peek(0);
14690
- } else if (this.failedQ.length) {
14850
+ if (this.failedQ.length > 0) {
14691
14851
  return this.failedQ.peek(0);
14692
- } else {
14693
- this.log(`No more cards available for the session!`);
14694
- return null;
14695
14852
  }
14853
+ this.log(`No more cards available for the session!`);
14854
+ return null;
14855
+ }
14856
+ /** Supply draws between forced failed-queue interleaves (light steady cadence). */
14857
+ static FAILED_INTERLEAVE_EVERY = 4;
14858
+ /**
14859
+ * Slack (seconds) below which the endgame failed-pressure kicks in: when the
14860
+ * time left after clearing remediation drops under this, bias hard to failed
14861
+ * so the session doesn't end with un-cleared remediation. Mirrors the old
14862
+ * `availableTime > 20` ladder thresholds.
14863
+ */
14864
+ static FAILED_ENDGAME_SLACK_SECONDS = 20;
14865
+ /**
14866
+ * Whether to interleave a failed (remediation) card now instead of drawing
14867
+ * the supply head. Replaces the old `newBound`/`reviewBound` probability
14868
+ * ladder's failed path (decision doc §7).
14869
+ *
14870
+ * @param supplyAvailable - whether supplyQ has a card to draw instead.
14871
+ */
14872
+ _shouldInterleaveFailed(supplyAvailable) {
14873
+ if (this.failedQ.length === 0) return false;
14874
+ if (!supplyAvailable) return true;
14875
+ const availableTime = this._secondsRemaining - this.estimateCleanupTime();
14876
+ if (availableTime <= _SessionController.FAILED_ENDGAME_SLACK_SECONDS) return true;
14877
+ return this._supplyDrawsSinceFailed >= _SessionController.FAILED_INTERLEAVE_EVERY;
14696
14878
  }
14697
14879
  async nextCard(action = "dismiss-success") {
14698
14880
  this.dismissCurrentCard(action);
@@ -14700,22 +14882,21 @@ var SessionController = class _SessionController extends Loggable {
14700
14882
  this._minCardsGuarantee--;
14701
14883
  this.log(`[CardGuarantee] ${this._minCardsGuarantee} guaranteed cards remaining`);
14702
14884
  }
14703
- if (this._replanPromise && this.newQ.length === 0 && this.reviewQ.length === 0 && this.failedQ.length === 0) {
14885
+ if (this._replanPromise && this.supplyQ.length === 0 && this.failedQ.length === 0) {
14704
14886
  this.log("nextCard: queues empty, awaiting in-flight replan before drawing");
14705
14887
  await this._replanPromise;
14706
14888
  }
14707
- if (this.newQ.length <= _SessionController.DEPLETION_PREFETCH_THRESHOLD && this._secondsRemaining > 0 && !this._replanPromise) {
14889
+ if (this.supplyQ.length <= _SessionController.DEPLETION_PREFETCH_THRESHOLD && this._secondsRemaining > 0 && !this._replanPromise) {
14708
14890
  this._suppressQualityReplan = false;
14709
- const otherContent = this.reviewQ.length + this.failedQ.length;
14710
14891
  this.log(
14711
- `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) (${otherContent} in other queues) with ${this._secondsRemaining}s remaining. Triggering background replan.`
14892
+ `[AutoReplan:depletion] supplyQ has ${this.supplyQ.length} card(s) (${this.failedQ.length} failed pending) with ${this._secondsRemaining}s remaining. Triggering background replan.`
14712
14893
  );
14713
14894
  void this.requestReplan({ label: "auto:depletion", mode: "merge" });
14714
14895
  }
14715
14896
  const REPLAN_BUFFER = 3;
14716
- if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
14897
+ if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.supplyQ.length > 0 && !this._replanPromise) {
14717
14898
  this.log(
14718
- `[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining (newQ: ${this.newQ.length}). Triggering background replan.`
14899
+ `[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining (supplyQ: ${this.supplyQ.length}). Triggering background replan.`
14719
14900
  );
14720
14901
  void this.requestReplan({ label: "auto:quality" });
14721
14902
  }
@@ -14727,12 +14908,12 @@ var SessionController = class _SessionController extends Loggable {
14727
14908
  const WEDGE_MAX_EMPTY_STREAK = 3;
14728
14909
  const WEDGE_BACKOFF_MS = 250;
14729
14910
  let wedgeEmptyStreak = 0;
14730
- while (this._secondsRemaining > 0 && this.newQ.length === 0 && this.reviewQ.length === 0 && this.failedQ.length === 0) {
14911
+ while (this._secondsRemaining > 0 && this.supplyQ.length === 0 && this.failedQ.length === 0) {
14731
14912
  this.log(
14732
14913
  `[WedgeBreaker] All queues empty with ${this._secondsRemaining}s remaining. Running pipeline (attempt ${wedgeEmptyStreak + 1}/${WEDGE_MAX_EMPTY_STREAK}).`
14733
14914
  );
14734
14915
  await this._replanUncoalesced({ label: "wedge-breaker" });
14735
- if (this.newQ.length === 0 && this.reviewQ.length === 0 && this.failedQ.length === 0) {
14916
+ if (this.supplyQ.length === 0 && this.failedQ.length === 0) {
14736
14917
  wedgeEmptyStreak++;
14737
14918
  if (wedgeEmptyStreak >= WEDGE_MAX_EMPTY_STREAK) {
14738
14919
  this.log(
@@ -14762,15 +14943,16 @@ var SessionController = class _SessionController extends Loggable {
14762
14943
  await this.hydrationService.ensureHydratedCards();
14763
14944
  this._currentCard = card;
14764
14945
  const origin = nextItem.status === "review" || nextItem.status === "failed-review" ? "review" : nextItem.status === "new" || nextItem.status === "failed-new" ? "new" : "failed";
14765
- const queueSource = nextItem.status.startsWith("failed") ? "failedQ" : nextItem.status === "review" ? "reviewQ" : "newQ";
14946
+ const queueSource = nextItem.status.startsWith("failed") ? "failedQ" : "supplyQ";
14766
14947
  recordCardPresentation(
14767
14948
  nextItem.cardID,
14768
14949
  nextItem.courseID,
14769
14950
  this.courseNameCache.get(nextItem.courseID),
14770
14951
  origin,
14771
- queueSource
14952
+ queueSource,
14953
+ nextItem.score
14772
14954
  );
14773
- snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
14955
+ snapshotQueues(this.supplyQ.length, this.failedQ.length);
14774
14956
  return card;
14775
14957
  }
14776
14958
  this.log(`Skipping card ${nextItem.cardID}: hydration failed, trying next`);
@@ -14840,6 +15022,7 @@ var SessionController = class _SessionController extends Loggable {
14840
15022
  };
14841
15023
  }
14842
15024
  this.failedQ.add(failedItem, failedItem.cardID);
15025
+ this._supplyDrawsSinceFailed = 0;
14843
15026
  } else if (action === "dismiss-error") {
14844
15027
  this.hydrationService.removeCard(this._currentCard.item.cardID);
14845
15028
  } else if (action === "dismiss-failed") {
@@ -14853,15 +15036,15 @@ var SessionController = class _SessionController extends Loggable {
14853
15036
  removeItemFromQueue(item) {
14854
15037
  this._clearDurableRequirement(item.cardID);
14855
15038
  this._servedCardIds.add(item.cardID);
14856
- if (this.reviewQ.peek(0)?.cardID === item.cardID) {
14857
- this.reviewQ.dequeue((queueItem) => queueItem.cardID);
14858
- } else if (this.newQ.peek(0)?.cardID === item.cardID) {
14859
- this.newQ.dequeue((queueItem) => queueItem.cardID);
15039
+ if (this.failedQ.peek(0)?.cardID === item.cardID) {
15040
+ this.failedQ.dequeue((queueItem) => queueItem.cardID);
15041
+ this._supplyDrawsSinceFailed = 0;
15042
+ } else if (this.supplyQ.peek(0)?.cardID === item.cardID) {
15043
+ this.supplyQ.dequeue((queueItem) => queueItem.cardID);
15044
+ this._supplyDrawsSinceFailed++;
14860
15045
  if (this._wellIndicatedRemaining > 0) {
14861
15046
  this._wellIndicatedRemaining--;
14862
15047
  }
14863
- } else if (this.failedQ.peek(0)?.cardID === item.cardID) {
14864
- this.failedQ.dequeue((queueItem) => queueItem.cardID);
14865
15048
  }
14866
15049
  }
14867
15050
  /**
@@ -14960,6 +15143,7 @@ export {
14960
15143
  areQuestionRecords,
14961
15144
  buildStrategyStateId,
14962
15145
  captureMixerRun,
15146
+ clearSrsBacklogDebug,
14963
15147
  computeDeviation,
14964
15148
  computeEffectiveWeight,
14965
15149
  computeOutcomeSignal,
@@ -14970,6 +15154,7 @@ export {
14970
15154
  docIsDeleted,
14971
15155
  endSessionTracking,
14972
15156
  ensureAppDataDirectory,
15157
+ getActivePipeline,
14973
15158
  getAppDataDirectory,
14974
15159
  getCardHistoryID,
14975
15160
  getCardOrigin,
@@ -14979,6 +15164,7 @@ export {
14979
15164
  getRegisteredNavigator,
14980
15165
  getRegisteredNavigatorNames,
14981
15166
  getRegisteredNavigatorRole,
15167
+ getSrsBacklogDebug,
14982
15168
  getStudySource,
14983
15169
  hasRegisteredNavigator,
14984
15170
  importParsedCards,