@vue-skuilder/db 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/{contentSource-BotbOOfX.d.ts → contentSource-BmnmvH8C.d.ts} +41 -0
  2. package/dist/{contentSource-C90LH-OH.d.cts → contentSource-DfBbaLA-.d.cts} +41 -0
  3. package/dist/core/index.d.cts +94 -4
  4. package/dist/core/index.d.ts +94 -4
  5. package/dist/core/index.js +530 -83
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +528 -83
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-DGKp4zFB.d.cts → dataLayerProvider-BeRXVMs5.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-SBpz9jQf.d.ts → dataLayerProvider-CG9GfaAY.d.ts} +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 +526 -83
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +526 -83
  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 +526 -83
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +526 -83
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index.d.cts +247 -14
  24. package/dist/index.d.ts +247 -14
  25. package/dist/index.js +1419 -140
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +1409 -137
  28. package/dist/index.mjs.map +1 -1
  29. package/docs/navigators-architecture.md +22 -4
  30. package/docs/todo-review-urgency-adaptation.md +205 -0
  31. package/package.json +3 -3
  32. package/src/core/interfaces/userDB.ts +44 -0
  33. package/src/core/navigators/Pipeline.ts +86 -5
  34. package/src/core/navigators/PipelineAssembler.ts +7 -21
  35. package/src/core/navigators/PipelineDebugger.ts +426 -0
  36. package/src/core/navigators/generators/CompositeGenerator.ts +21 -0
  37. package/src/core/navigators/generators/elo.ts +14 -1
  38. package/src/core/navigators/generators/srs.ts +146 -18
  39. package/src/core/navigators/index.ts +9 -0
  40. package/src/impl/couch/user-course-relDB.ts +12 -0
  41. package/src/study/MixerDebugger.ts +555 -0
  42. package/src/study/SessionController.ts +95 -19
  43. package/src/study/SessionDebugger.ts +442 -0
  44. package/src/study/SourceMixer.ts +36 -17
  45. package/src/study/TODO-session-scheduling.md +133 -0
  46. package/src/study/index.ts +2 -0
  47. package/src/study/services/EloService.ts +79 -4
  48. package/src/study/services/ResponseProcessor.ts +130 -72
  49. package/src/study/services/SrsService.ts +9 -0
  50. package/tests/core/navigators/PipelineAssembler.test.ts +4 -4
@@ -427,6 +427,15 @@ var init_user_course_relDB = __esm({
427
427
  void this.user.updateCourseSettings(this._courseId, updates);
428
428
  }
429
429
  }
430
+ async getStrategyState(strategyKey) {
431
+ return this.user.getStrategyState(this._courseId, strategyKey);
432
+ }
433
+ async putStrategyState(strategyKey, data) {
434
+ return this.user.putStrategyState(this._courseId, strategyKey, data);
435
+ }
436
+ async deleteStrategyState(strategyKey) {
437
+ return this.user.deleteStrategyState(this._courseId, strategyKey);
438
+ }
430
439
  async getReviewstoDate(targetDate) {
431
440
  const allReviews = await this.user.getPendingReviews(this._courseId);
432
441
  logger.debug(
@@ -497,6 +506,271 @@ var init_courseLookupDB = __esm({
497
506
  }
498
507
  });
499
508
 
509
+ // src/core/navigators/PipelineDebugger.ts
510
+ var PipelineDebugger_exports = {};
511
+ __export(PipelineDebugger_exports, {
512
+ buildRunReport: () => buildRunReport,
513
+ captureRun: () => captureRun,
514
+ mountPipelineDebugger: () => mountPipelineDebugger,
515
+ pipelineDebugAPI: () => pipelineDebugAPI
516
+ });
517
+ function getOrigin(card) {
518
+ const firstEntry = card.provenance[0];
519
+ if (!firstEntry) return "unknown";
520
+ const reason = firstEntry.reason?.toLowerCase() || "";
521
+ if (reason.includes("new card")) return "new";
522
+ if (reason.includes("review")) return "review";
523
+ return "unknown";
524
+ }
525
+ function captureRun(report) {
526
+ const fullReport = {
527
+ ...report,
528
+ runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
529
+ timestamp: /* @__PURE__ */ new Date()
530
+ };
531
+ runHistory.unshift(fullReport);
532
+ if (runHistory.length > MAX_RUNS) {
533
+ runHistory.pop();
534
+ }
535
+ }
536
+ function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards) {
537
+ const selectedIds = new Set(selectedCards.map((c) => c.cardId));
538
+ const cards = allCards.map((card) => ({
539
+ cardId: card.cardId,
540
+ courseId: card.courseId,
541
+ origin: getOrigin(card),
542
+ finalScore: card.score,
543
+ provenance: card.provenance,
544
+ selected: selectedIds.has(card.cardId)
545
+ }));
546
+ const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
547
+ const newSelected = selectedCards.filter((c) => getOrigin(c) === "new").length;
548
+ return {
549
+ courseId,
550
+ courseName,
551
+ generatorName,
552
+ generators,
553
+ generatedCount,
554
+ filters,
555
+ finalCount: selectedCards.length,
556
+ reviewsSelected,
557
+ newSelected,
558
+ cards
559
+ };
560
+ }
561
+ function formatProvenance(provenance) {
562
+ return provenance.map((p) => {
563
+ const actionSymbol = p.action === "generated" ? "\u{1F3B2}" : p.action === "boosted" ? "\u2B06\uFE0F" : p.action === "penalized" ? "\u2B07\uFE0F" : "\u27A1\uFE0F";
564
+ return ` ${actionSymbol} ${p.strategyName}: ${p.score.toFixed(3)} - ${p.reason}`;
565
+ }).join("\n");
566
+ }
567
+ function printRunSummary(run) {
568
+ console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
569
+ logger.info(`Run ID: ${run.runId}`);
570
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
571
+ logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
572
+ if (run.generators && run.generators.length > 0) {
573
+ console.group("Generator breakdown:");
574
+ for (const g of run.generators) {
575
+ logger.info(
576
+ ` ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews, top: ${g.topScore.toFixed(2)})`
577
+ );
578
+ }
579
+ console.groupEnd();
580
+ }
581
+ if (run.filters.length > 0) {
582
+ console.group("Filter impact:");
583
+ for (const f of run.filters) {
584
+ logger.info(` ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
585
+ }
586
+ console.groupEnd();
587
+ }
588
+ logger.info(
589
+ `Result: ${run.finalCount} cards selected (${run.newSelected} new, ${run.reviewsSelected} reviews)`
590
+ );
591
+ console.groupEnd();
592
+ }
593
+ function mountPipelineDebugger() {
594
+ if (typeof window === "undefined") return;
595
+ const win = window;
596
+ win.skuilder = win.skuilder || {};
597
+ win.skuilder.pipeline = pipelineDebugAPI;
598
+ }
599
+ var MAX_RUNS, runHistory, pipelineDebugAPI;
600
+ var init_PipelineDebugger = __esm({
601
+ "src/core/navigators/PipelineDebugger.ts"() {
602
+ "use strict";
603
+ init_logger();
604
+ MAX_RUNS = 10;
605
+ runHistory = [];
606
+ pipelineDebugAPI = {
607
+ /**
608
+ * Get raw run history for programmatic access.
609
+ */
610
+ get runs() {
611
+ return [...runHistory];
612
+ },
613
+ /**
614
+ * Show summary of a specific pipeline run.
615
+ */
616
+ showRun(idOrIndex = 0) {
617
+ if (runHistory.length === 0) {
618
+ logger.info("[Pipeline Debug] No runs captured yet.");
619
+ return;
620
+ }
621
+ let run;
622
+ if (typeof idOrIndex === "number") {
623
+ run = runHistory[idOrIndex];
624
+ if (!run) {
625
+ logger.info(
626
+ `[Pipeline Debug] No run found at index ${idOrIndex}. History length: ${runHistory.length}`
627
+ );
628
+ return;
629
+ }
630
+ } else {
631
+ run = runHistory.find((r) => r.runId.endsWith(idOrIndex));
632
+ if (!run) {
633
+ logger.info(`[Pipeline Debug] No run found matching ID '${idOrIndex}'.`);
634
+ return;
635
+ }
636
+ }
637
+ printRunSummary(run);
638
+ },
639
+ /**
640
+ * Show summary of the last pipeline run.
641
+ */
642
+ showLastRun() {
643
+ this.showRun(0);
644
+ },
645
+ /**
646
+ * Show detailed provenance for a specific card.
647
+ */
648
+ showCard(cardId) {
649
+ for (const run of runHistory) {
650
+ const card = run.cards.find((c) => c.cardId === cardId);
651
+ if (card) {
652
+ console.group(`\u{1F3B4} Card: ${cardId}`);
653
+ logger.info(`Course: ${card.courseId}`);
654
+ logger.info(`Origin: ${card.origin}`);
655
+ logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
656
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
657
+ logger.info("Provenance:");
658
+ logger.info(formatProvenance(card.provenance));
659
+ console.groupEnd();
660
+ return;
661
+ }
662
+ }
663
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
664
+ },
665
+ /**
666
+ * Explain why reviews may or may not have been selected.
667
+ */
668
+ explainReviews() {
669
+ if (runHistory.length === 0) {
670
+ logger.info("[Pipeline Debug] No runs captured yet.");
671
+ return;
672
+ }
673
+ console.group("\u{1F4CB} Review Selection Analysis");
674
+ for (const run of runHistory) {
675
+ console.group(`Run: ${run.courseId} @ ${run.timestamp.toLocaleTimeString()}`);
676
+ const allReviews = run.cards.filter((c) => c.origin === "review");
677
+ const selectedReviews = allReviews.filter((c) => c.selected);
678
+ if (allReviews.length === 0) {
679
+ logger.info("\u274C No reviews were generated. Check SRS logs for why.");
680
+ } else if (selectedReviews.length === 0) {
681
+ logger.info(`\u26A0\uFE0F ${allReviews.length} reviews generated but none selected.`);
682
+ logger.info("Possible reasons:");
683
+ const topNewScore = Math.max(
684
+ ...run.cards.filter((c) => c.origin === "new" && c.selected).map((c) => c.finalScore),
685
+ 0
686
+ );
687
+ const topReviewScore = Math.max(...allReviews.map((c) => c.finalScore), 0);
688
+ if (topReviewScore < topNewScore) {
689
+ logger.info(
690
+ ` - New cards scored higher (top new: ${topNewScore.toFixed(2)}, top review: ${topReviewScore.toFixed(2)})`
691
+ );
692
+ }
693
+ const topReview = allReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
694
+ if (topReview) {
695
+ logger.info(` - Top review score: ${topReview.finalScore.toFixed(3)}`);
696
+ logger.info(" - Its provenance:");
697
+ logger.info(formatProvenance(topReview.provenance));
698
+ }
699
+ } else {
700
+ logger.info(`\u2705 ${selectedReviews.length}/${allReviews.length} reviews selected.`);
701
+ logger.info("Top selected review:");
702
+ const topSelected = selectedReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
703
+ logger.info(formatProvenance(topSelected.provenance));
704
+ }
705
+ console.groupEnd();
706
+ }
707
+ console.groupEnd();
708
+ },
709
+ /**
710
+ * Show all runs in compact format.
711
+ */
712
+ listRuns() {
713
+ if (runHistory.length === 0) {
714
+ logger.info("[Pipeline Debug] No runs captured yet.");
715
+ return;
716
+ }
717
+ console.table(
718
+ runHistory.map((r) => ({
719
+ id: r.runId.slice(-8),
720
+ time: r.timestamp.toLocaleTimeString(),
721
+ course: r.courseName || r.courseId.slice(0, 8),
722
+ generated: r.generatedCount,
723
+ selected: r.finalCount,
724
+ new: r.newSelected,
725
+ reviews: r.reviewsSelected
726
+ }))
727
+ );
728
+ },
729
+ /**
730
+ * Export run history as JSON for bug reports.
731
+ */
732
+ export() {
733
+ const json = JSON.stringify(runHistory, null, 2);
734
+ logger.info("[Pipeline Debug] Run history exported. Copy the returned string or use:");
735
+ logger.info(" copy(window.skuilder.pipeline.export())");
736
+ return json;
737
+ },
738
+ /**
739
+ * Clear run history.
740
+ */
741
+ clear() {
742
+ runHistory.length = 0;
743
+ logger.info("[Pipeline Debug] Run history cleared.");
744
+ },
745
+ /**
746
+ * Show help.
747
+ */
748
+ help() {
749
+ logger.info(`
750
+ \u{1F527} Pipeline Debug API
751
+
752
+ Commands:
753
+ .showLastRun() Show summary of most recent pipeline run
754
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
755
+ .showCard(cardId) Show provenance trail for a specific card
756
+ .explainReviews() Analyze why reviews were/weren't selected
757
+ .listRuns() List all captured runs in table format
758
+ .export() Export run history as JSON for bug reports
759
+ .clear() Clear run history
760
+ .runs Access raw run history array
761
+ .help() Show this help message
762
+
763
+ Example:
764
+ window.skuilder.pipeline.showLastRun()
765
+ window.skuilder.pipeline.showRun(1)
766
+ window.skuilder.pipeline.showCard('abc123')
767
+ `);
768
+ }
769
+ };
770
+ mountPipelineDebugger();
771
+ }
772
+ });
773
+
500
774
  // src/core/navigators/generators/CompositeGenerator.ts
501
775
  var CompositeGenerator_exports = {};
502
776
  __export(CompositeGenerator_exports, {
@@ -565,6 +839,24 @@ var init_CompositeGenerator = __esm({
565
839
  const results = await Promise.all(
566
840
  this.generators.map((g) => g.getWeightedCards(limit, context))
567
841
  );
842
+ const generatorSummaries = [];
843
+ results.forEach((cards, index) => {
844
+ const gen = this.generators[index];
845
+ const genName = gen.name || `Generator ${index}`;
846
+ const newCards = cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
847
+ const reviewCards = cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
848
+ if (cards.length > 0) {
849
+ const topScore = Math.max(...cards.map((c) => c.score)).toFixed(2);
850
+ const parts = [];
851
+ if (newCards.length > 0) parts.push(`${newCards.length} new`);
852
+ if (reviewCards.length > 0) parts.push(`${reviewCards.length} reviews`);
853
+ const breakdown = parts.length > 0 ? parts.join(", ") : `${cards.length} cards`;
854
+ generatorSummaries.push(`${genName}: ${breakdown} (top: ${topScore})`);
855
+ } else {
856
+ generatorSummaries.push(`${genName}: 0 cards`);
857
+ }
858
+ });
859
+ logger.info(`[Composite] Generator breakdown: ${generatorSummaries.join(" | ")}`);
568
860
  const byCardId = /* @__PURE__ */ new Map();
569
861
  results.forEach((cards, index) => {
570
862
  const gen = this.generators[index];
@@ -682,6 +974,7 @@ var init_elo = __esm({
682
974
  "use strict";
683
975
  init_navigators();
684
976
  import_common5 = require("@vue-skuilder/common");
977
+ init_logger();
685
978
  ELONavigator = class extends ContentNavigator {
686
979
  /** Human-readable name for CardGenerator interface */
687
980
  name;
@@ -741,7 +1034,16 @@ var init_elo = __esm({
741
1034
  };
742
1035
  });
743
1036
  scored.sort((a, b) => b.score - a.score);
744
- return scored.slice(0, limit);
1037
+ const result = scored.slice(0, limit);
1038
+ if (result.length > 0) {
1039
+ const topScores = result.slice(0, 3).map((c) => c.score.toFixed(2)).join(", ");
1040
+ logger.info(
1041
+ `[ELO] Course ${this.course.getCourseID()}: ${result.length} new cards (top scores: ${topScores})`
1042
+ );
1043
+ } else {
1044
+ logger.info(`[ELO] Course ${this.course.getCourseID()}: No new cards available`);
1045
+ }
1046
+ return result;
745
1047
  }
746
1048
  };
747
1049
  }
@@ -760,19 +1062,37 @@ var srs_exports = {};
760
1062
  __export(srs_exports, {
761
1063
  default: () => SRSNavigator
762
1064
  });
763
- var import_moment3, SRSNavigator;
1065
+ var import_moment3, DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_PRESSURE, SRSNavigator;
764
1066
  var init_srs = __esm({
765
1067
  "src/core/navigators/generators/srs.ts"() {
766
1068
  "use strict";
767
1069
  import_moment3 = __toESM(require("moment"), 1);
768
1070
  init_navigators();
769
1071
  init_logger();
1072
+ DEFAULT_HEALTHY_BACKLOG = 20;
1073
+ MAX_BACKLOG_PRESSURE = 0.5;
770
1074
  SRSNavigator = class extends ContentNavigator {
771
1075
  /** Human-readable name for CardGenerator interface */
772
1076
  name;
1077
+ /** Healthy backlog threshold - when exceeded, backlog pressure kicks in */
1078
+ healthyBacklog;
773
1079
  constructor(user, course, strategyData) {
774
1080
  super(user, course, strategyData);
775
1081
  this.name = strategyData?.name || "SRS";
1082
+ const config = this.parseConfig(strategyData?.serializedData);
1083
+ this.healthyBacklog = config.healthyBacklog ?? DEFAULT_HEALTHY_BACKLOG;
1084
+ }
1085
+ /**
1086
+ * Parse configuration from serialized JSON.
1087
+ */
1088
+ parseConfig(serializedData) {
1089
+ if (!serializedData) return {};
1090
+ try {
1091
+ return JSON.parse(serializedData);
1092
+ } catch {
1093
+ logger.warn("[SRS] Failed to parse strategy config, using defaults");
1094
+ return {};
1095
+ }
776
1096
  }
777
1097
  /**
778
1098
  * Get review cards scored by urgency.
@@ -780,6 +1100,7 @@ var init_srs = __esm({
780
1100
  * Score formula combines:
781
1101
  * - Relative overdueness: hoursOverdue / intervalHours
782
1102
  * - Interval recency: exponential decay favoring shorter intervals
1103
+ * - Backlog pressure: boost when due reviews exceed healthy threshold
783
1104
  *
784
1105
  * Cards not yet due are excluded (not scored as 0).
785
1106
  *
@@ -793,11 +1114,32 @@ var init_srs = __esm({
793
1114
  if (!this.user || !this.course) {
794
1115
  throw new Error("SRSNavigator requires user and course to be set");
795
1116
  }
796
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1117
+ const courseId = this.course.getCourseID();
1118
+ const reviews = await this.user.getPendingReviews(courseId);
797
1119
  const now = import_moment3.default.utc();
798
1120
  const dueReviews = reviews.filter((r) => now.isAfter(import_moment3.default.utc(r.reviewTime)));
1121
+ const backlogPressure = this.computeBacklogPressure(dueReviews.length);
1122
+ if (dueReviews.length > 0) {
1123
+ const pressureNote = backlogPressure > 0 ? ` [backlog pressure: +${backlogPressure.toFixed(2)}]` : ` [healthy backlog]`;
1124
+ logger.info(
1125
+ `[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
1126
+ );
1127
+ } else if (reviews.length > 0) {
1128
+ const sortedByDue = [...reviews].sort(
1129
+ (a, b) => import_moment3.default.utc(a.reviewTime).diff(import_moment3.default.utc(b.reviewTime))
1130
+ );
1131
+ const nextDue = sortedByDue[0];
1132
+ const nextDueTime = import_moment3.default.utc(nextDue.reviewTime);
1133
+ const untilDue = import_moment3.default.duration(nextDueTime.diff(now));
1134
+ const untilDueStr = untilDue.asHours() < 1 ? `${Math.round(untilDue.asMinutes())}m` : untilDue.asHours() < 24 ? `${Math.round(untilDue.asHours())}h` : `${Math.round(untilDue.asDays())}d`;
1135
+ logger.info(
1136
+ `[SRS] Course ${courseId}: 0 reviews due now (${reviews.length} scheduled, next in ${untilDueStr})`
1137
+ );
1138
+ } else {
1139
+ logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
1140
+ }
799
1141
  const scored = dueReviews.map((review) => {
800
- const { score, reason } = this.computeUrgencyScore(review, now);
1142
+ const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
801
1143
  return {
802
1144
  cardId: review.cardId,
803
1145
  courseId: review.courseId,
@@ -815,13 +1157,35 @@ var init_srs = __esm({
815
1157
  ]
816
1158
  };
817
1159
  });
818
- logger.debug(`[srsNav] got ${scored.length} weighted cards`);
819
1160
  return scored.sort((a, b) => b.score - a.score).slice(0, limit);
820
1161
  }
1162
+ /**
1163
+ * Compute backlog pressure based on number of due reviews.
1164
+ *
1165
+ * Backlog pressure is 0 when at or below healthy threshold,
1166
+ * and increases linearly above it, maxing out at MAX_BACKLOG_PRESSURE.
1167
+ *
1168
+ * Examples (with default healthyBacklog=20):
1169
+ * - 10 due reviews → 0.00 (healthy)
1170
+ * - 20 due reviews → 0.00 (at threshold)
1171
+ * - 40 due reviews → 0.25 (2x threshold)
1172
+ * - 60 due reviews → 0.50 (3x threshold, maxed)
1173
+ *
1174
+ * @param dueCount - Number of reviews currently due
1175
+ * @returns Backlog pressure score to add to urgency (0 to MAX_BACKLOG_PRESSURE)
1176
+ */
1177
+ computeBacklogPressure(dueCount) {
1178
+ if (dueCount <= this.healthyBacklog) {
1179
+ return 0;
1180
+ }
1181
+ const excess = dueCount - this.healthyBacklog;
1182
+ const pressure = excess / this.healthyBacklog * (MAX_BACKLOG_PRESSURE / 2);
1183
+ return Math.min(MAX_BACKLOG_PRESSURE, pressure);
1184
+ }
821
1185
  /**
822
1186
  * Compute urgency score for a review card.
823
1187
  *
824
- * Two factors:
1188
+ * Three factors:
825
1189
  * 1. Relative overdueness = hoursOverdue / intervalHours
826
1190
  * - 2 days overdue on 3-day interval = 0.67 (urgent)
827
1191
  * - 2 days overdue on 180-day interval = 0.01 (not urgent)
@@ -831,10 +1195,19 @@ var init_srs = __esm({
831
1195
  * - 30 days (720h) → ~0.56
832
1196
  * - 180 days → ~0.30
833
1197
  *
834
- * Combined: base 0.5 + weighted average of factors * 0.45
835
- * Result range: approximately 0.5 to 0.95
1198
+ * 3. Backlog pressure = global boost when review backlog exceeds healthy threshold
1199
+ * - At healthy backlog: 0
1200
+ * - At 2x healthy: +0.25
1201
+ * - At 3x+ healthy: +0.50 (max)
1202
+ *
1203
+ * Combined: base 0.5 + (urgency factors * 0.45) + backlog pressure
1204
+ * Result range: 0.5 to 1.0 (uncapped to allow high-urgency reviews to compete with new cards)
1205
+ *
1206
+ * @param review - The scheduled card to score
1207
+ * @param now - Current time
1208
+ * @param backlogPressure - Pre-computed backlog pressure (0 to 0.5)
836
1209
  */
837
- computeUrgencyScore(review, now) {
1210
+ computeUrgencyScore(review, now, backlogPressure) {
838
1211
  const scheduledAt = import_moment3.default.utc(review.scheduledAt);
839
1212
  const due = import_moment3.default.utc(review.reviewTime);
840
1213
  const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
@@ -843,8 +1216,19 @@ var init_srs = __esm({
843
1216
  const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
844
1217
  const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
845
1218
  const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
846
- const score = Math.min(0.95, 0.5 + urgency * 0.45);
847
- const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
1219
+ const baseScore = 0.5 + urgency * 0.45;
1220
+ const score = Math.min(1, baseScore + backlogPressure);
1221
+ const reasonParts = [
1222
+ `${Math.round(hoursOverdue)}h overdue`,
1223
+ `interval: ${Math.round(intervalHours)}h`,
1224
+ `relative: ${relativeOverdue.toFixed(2)}`,
1225
+ `recency: ${recencyFactor.toFixed(2)}`
1226
+ ];
1227
+ if (backlogPressure > 0) {
1228
+ reasonParts.push(`backlog: +${backlogPressure.toFixed(2)}`);
1229
+ }
1230
+ reasonParts.push("review");
1231
+ const reason = reasonParts.join(", ");
848
1232
  return { score, reason };
849
1233
  }
850
1234
  };
@@ -1874,10 +2258,23 @@ function logTagHydration(cards, tagsByCard) {
1874
2258
  `[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
1875
2259
  );
1876
2260
  }
1877
- function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
2261
+ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores, filterImpacts) {
1878
2262
  const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
2263
+ let filterSummary = "";
2264
+ if (filterImpacts.length > 0) {
2265
+ const impacts = filterImpacts.map((f) => {
2266
+ const parts = [];
2267
+ if (f.boosted > 0) parts.push(`+${f.boosted}`);
2268
+ if (f.penalized > 0) parts.push(`-${f.penalized}`);
2269
+ if (f.passed > 0) parts.push(`=${f.passed}`);
2270
+ return `${f.name}: ${parts.join("/")}`;
2271
+ });
2272
+ filterSummary = `
2273
+ Filter impact: ${impacts.join(", ")}`;
2274
+ }
1879
2275
  logger.info(
1880
- `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
2276
+ `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})` + filterSummary + `
2277
+ \u{1F4A1} Inspect: window.skuilder.pipeline`
1881
2278
  );
1882
2279
  }
1883
2280
  function logCardProvenance(cards, maxCards = 3) {
@@ -1902,6 +2299,7 @@ var init_Pipeline = __esm({
1902
2299
  init_navigators();
1903
2300
  init_logger();
1904
2301
  init_orchestration();
2302
+ init_PipelineDebugger();
1905
2303
  Pipeline = class extends ContentNavigator {
1906
2304
  generator;
1907
2305
  filters;
@@ -1949,12 +2347,49 @@ var init_Pipeline = __esm({
1949
2347
  );
1950
2348
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
1951
2349
  const generatedCount = cards.length;
2350
+ let generatorSummaries;
2351
+ if (this.generator.generators) {
2352
+ const genMap = /* @__PURE__ */ new Map();
2353
+ for (const card of cards) {
2354
+ const firstProv = card.provenance[0];
2355
+ if (firstProv) {
2356
+ const genName = firstProv.strategyName;
2357
+ if (!genMap.has(genName)) {
2358
+ genMap.set(genName, { cards: [] });
2359
+ }
2360
+ genMap.get(genName).cards.push(card);
2361
+ }
2362
+ }
2363
+ generatorSummaries = Array.from(genMap.entries()).map(([name, data]) => {
2364
+ const newCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
2365
+ const reviewCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
2366
+ return {
2367
+ name,
2368
+ cardCount: data.cards.length,
2369
+ newCount: newCards.length,
2370
+ reviewCount: reviewCards.length,
2371
+ topScore: Math.max(...data.cards.map((c) => c.score), 0)
2372
+ };
2373
+ });
2374
+ }
1952
2375
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
1953
2376
  cards = await this.hydrateTags(cards);
2377
+ const allCardsBeforeFiltering = [...cards];
2378
+ const filterImpacts = [];
1954
2379
  for (const filter of this.filters) {
1955
2380
  const beforeCount = cards.length;
2381
+ const beforeScores = new Map(cards.map((c) => [c.cardId, c.score]));
1956
2382
  cards = await filter.transform(cards, context);
1957
- logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
2383
+ let boosted = 0, penalized = 0, passed = 0;
2384
+ const removed = beforeCount - cards.length;
2385
+ for (const card of cards) {
2386
+ const before = beforeScores.get(card.cardId) ?? 0;
2387
+ if (card.score > before) boosted++;
2388
+ else if (card.score < before) penalized++;
2389
+ else passed++;
2390
+ }
2391
+ filterImpacts.push({ name: filter.name, boosted, penalized, passed, removed });
2392
+ logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
1958
2393
  }
1959
2394
  cards = cards.filter((c) => c.score > 0);
1960
2395
  cards.sort((a, b) => b.score - a.score);
@@ -1965,9 +2400,26 @@ var init_Pipeline = __esm({
1965
2400
  generatedCount,
1966
2401
  this.filters.length,
1967
2402
  result.length,
1968
- topScores
2403
+ topScores,
2404
+ filterImpacts
1969
2405
  );
1970
2406
  logCardProvenance(result, 3);
2407
+ try {
2408
+ const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
2409
+ const report = buildRunReport(
2410
+ this.course?.getCourseID() || "unknown",
2411
+ courseName,
2412
+ this.generator.name,
2413
+ generatorSummaries,
2414
+ generatedCount,
2415
+ filterImpacts,
2416
+ allCardsBeforeFiltering,
2417
+ result
2418
+ );
2419
+ captureRun(report);
2420
+ } catch (e) {
2421
+ logger.debug(`[Pipeline] Failed to capture debug run: ${e}`);
2422
+ }
1971
2423
  return result;
1972
2424
  }
1973
2425
  /**
@@ -2057,6 +2509,56 @@ var init_Pipeline = __esm({
2057
2509
  }
2058
2510
  });
2059
2511
 
2512
+ // src/core/navigators/defaults.ts
2513
+ var defaults_exports = {};
2514
+ __export(defaults_exports, {
2515
+ createDefaultEloStrategy: () => createDefaultEloStrategy,
2516
+ createDefaultPipeline: () => createDefaultPipeline,
2517
+ createDefaultSrsStrategy: () => createDefaultSrsStrategy
2518
+ });
2519
+ function createDefaultEloStrategy(courseId) {
2520
+ return {
2521
+ _id: "NAVIGATION_STRATEGY-ELO-default",
2522
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2523
+ name: "ELO (default)",
2524
+ description: "Default ELO-based navigation strategy for new cards",
2525
+ implementingClass: "elo" /* ELO */,
2526
+ course: courseId,
2527
+ serializedData: ""
2528
+ };
2529
+ }
2530
+ function createDefaultSrsStrategy(courseId) {
2531
+ return {
2532
+ _id: "NAVIGATION_STRATEGY-SRS-default",
2533
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2534
+ name: "SRS (default)",
2535
+ description: "Default SRS-based navigation strategy for reviews",
2536
+ implementingClass: "srs" /* SRS */,
2537
+ course: courseId,
2538
+ serializedData: ""
2539
+ };
2540
+ }
2541
+ function createDefaultPipeline(user, course) {
2542
+ const courseId = course.getCourseID();
2543
+ const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
2544
+ const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
2545
+ const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
2546
+ const eloDistanceFilter = createEloDistanceFilter();
2547
+ return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
2548
+ }
2549
+ var init_defaults = __esm({
2550
+ "src/core/navigators/defaults.ts"() {
2551
+ "use strict";
2552
+ init_navigators();
2553
+ init_Pipeline();
2554
+ init_CompositeGenerator();
2555
+ init_elo();
2556
+ init_srs();
2557
+ init_eloDistance();
2558
+ init_types_legacy();
2559
+ }
2560
+ });
2561
+
2060
2562
  // src/core/navigators/PipelineAssembler.ts
2061
2563
  var PipelineAssembler_exports = {};
2062
2564
  __export(PipelineAssembler_exports, {
@@ -2069,9 +2571,9 @@ var init_PipelineAssembler = __esm({
2069
2571
  init_navigators();
2070
2572
  init_WeightedFilter();
2071
2573
  init_Pipeline();
2072
- init_types_legacy();
2073
2574
  init_logger();
2074
2575
  init_CompositeGenerator();
2576
+ init_defaults();
2075
2577
  PipelineAssembler = class {
2076
2578
  /**
2077
2579
  * Assembles a navigation pipeline from strategy documents.
@@ -2110,9 +2612,11 @@ var init_PipelineAssembler = __esm({
2110
2612
  if (generatorStrategies.length === 0) {
2111
2613
  if (filterStrategies.length > 0) {
2112
2614
  logger.debug(
2113
- "[PipelineAssembler] No generator found, using default ELO with configured filters"
2615
+ "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2114
2616
  );
2115
- generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
2617
+ const courseId = course.getCourseID();
2618
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
2619
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
2116
2620
  } else {
2117
2621
  warnings.push("No generator strategy found");
2118
2622
  return {
@@ -2173,75 +2677,10 @@ var init_PipelineAssembler = __esm({
2173
2677
  warnings
2174
2678
  };
2175
2679
  }
2176
- /**
2177
- * Creates a default ELO generator strategy.
2178
- * Used when filters are configured but no generator is specified.
2179
- */
2180
- makeDefaultEloStrategy(courseId) {
2181
- return {
2182
- _id: "NAVIGATION_STRATEGY-ELO-default",
2183
- course: courseId,
2184
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2185
- name: "ELO (default)",
2186
- description: "Default ELO-based generator",
2187
- implementingClass: "elo" /* ELO */,
2188
- serializedData: ""
2189
- };
2190
- }
2191
2680
  };
2192
2681
  }
2193
2682
  });
2194
2683
 
2195
- // src/core/navigators/defaults.ts
2196
- var defaults_exports = {};
2197
- __export(defaults_exports, {
2198
- createDefaultEloStrategy: () => createDefaultEloStrategy,
2199
- createDefaultPipeline: () => createDefaultPipeline,
2200
- createDefaultSrsStrategy: () => createDefaultSrsStrategy
2201
- });
2202
- function createDefaultEloStrategy(courseId) {
2203
- return {
2204
- _id: "NAVIGATION_STRATEGY-ELO-default",
2205
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2206
- name: "ELO (default)",
2207
- description: "Default ELO-based navigation strategy for new cards",
2208
- implementingClass: "elo" /* ELO */,
2209
- course: courseId,
2210
- serializedData: ""
2211
- };
2212
- }
2213
- function createDefaultSrsStrategy(courseId) {
2214
- return {
2215
- _id: "NAVIGATION_STRATEGY-SRS-default",
2216
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2217
- name: "SRS (default)",
2218
- description: "Default SRS-based navigation strategy for reviews",
2219
- implementingClass: "srs" /* SRS */,
2220
- course: courseId,
2221
- serializedData: ""
2222
- };
2223
- }
2224
- function createDefaultPipeline(user, course) {
2225
- const courseId = course.getCourseID();
2226
- const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
2227
- const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
2228
- const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
2229
- const eloDistanceFilter = createEloDistanceFilter();
2230
- return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
2231
- }
2232
- var init_defaults = __esm({
2233
- "src/core/navigators/defaults.ts"() {
2234
- "use strict";
2235
- init_navigators();
2236
- init_Pipeline();
2237
- init_CompositeGenerator();
2238
- init_elo();
2239
- init_srs();
2240
- init_eloDistance();
2241
- init_types_legacy();
2242
- }
2243
- });
2244
-
2245
2684
  // import("./**/*") in src/core/navigators/index.ts
2246
2685
  var globImport;
2247
2686
  var init_3 = __esm({
@@ -2249,6 +2688,7 @@ var init_3 = __esm({
2249
2688
  globImport = __glob({
2250
2689
  "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
2251
2690
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
2691
+ "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
2252
2692
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
2253
2693
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
2254
2694
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
@@ -2284,6 +2724,8 @@ __export(navigators_exports, {
2284
2724
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
2285
2725
  isFilter: () => isFilter,
2286
2726
  isGenerator: () => isGenerator,
2727
+ mountPipelineDebugger: () => mountPipelineDebugger,
2728
+ pipelineDebugAPI: () => pipelineDebugAPI,
2287
2729
  registerNavigator: () => registerNavigator
2288
2730
  });
2289
2731
  function registerNavigator(implementingClass, constructor) {
@@ -2350,6 +2792,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
2350
2792
  var init_navigators = __esm({
2351
2793
  "src/core/navigators/index.ts"() {
2352
2794
  "use strict";
2795
+ init_PipelineDebugger();
2353
2796
  init_logger();
2354
2797
  init_();
2355
2798
  init_2();