@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
@@ -605,6 +605,271 @@ var init_courseLookupDB = __esm({
605
605
  }
606
606
  });
607
607
 
608
+ // src/core/navigators/PipelineDebugger.ts
609
+ var PipelineDebugger_exports = {};
610
+ __export(PipelineDebugger_exports, {
611
+ buildRunReport: () => buildRunReport,
612
+ captureRun: () => captureRun,
613
+ mountPipelineDebugger: () => mountPipelineDebugger,
614
+ pipelineDebugAPI: () => pipelineDebugAPI
615
+ });
616
+ function getOrigin(card) {
617
+ const firstEntry = card.provenance[0];
618
+ if (!firstEntry) return "unknown";
619
+ const reason = firstEntry.reason?.toLowerCase() || "";
620
+ if (reason.includes("new card")) return "new";
621
+ if (reason.includes("review")) return "review";
622
+ return "unknown";
623
+ }
624
+ function captureRun(report) {
625
+ const fullReport = {
626
+ ...report,
627
+ runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
628
+ timestamp: /* @__PURE__ */ new Date()
629
+ };
630
+ runHistory.unshift(fullReport);
631
+ if (runHistory.length > MAX_RUNS) {
632
+ runHistory.pop();
633
+ }
634
+ }
635
+ function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards) {
636
+ const selectedIds = new Set(selectedCards.map((c) => c.cardId));
637
+ const cards = allCards.map((card) => ({
638
+ cardId: card.cardId,
639
+ courseId: card.courseId,
640
+ origin: getOrigin(card),
641
+ finalScore: card.score,
642
+ provenance: card.provenance,
643
+ selected: selectedIds.has(card.cardId)
644
+ }));
645
+ const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
646
+ const newSelected = selectedCards.filter((c) => getOrigin(c) === "new").length;
647
+ return {
648
+ courseId,
649
+ courseName,
650
+ generatorName,
651
+ generators,
652
+ generatedCount,
653
+ filters,
654
+ finalCount: selectedCards.length,
655
+ reviewsSelected,
656
+ newSelected,
657
+ cards
658
+ };
659
+ }
660
+ function formatProvenance(provenance) {
661
+ return provenance.map((p) => {
662
+ const actionSymbol = p.action === "generated" ? "\u{1F3B2}" : p.action === "boosted" ? "\u2B06\uFE0F" : p.action === "penalized" ? "\u2B07\uFE0F" : "\u27A1\uFE0F";
663
+ return ` ${actionSymbol} ${p.strategyName}: ${p.score.toFixed(3)} - ${p.reason}`;
664
+ }).join("\n");
665
+ }
666
+ function printRunSummary(run) {
667
+ console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
668
+ logger.info(`Run ID: ${run.runId}`);
669
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
670
+ logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
671
+ if (run.generators && run.generators.length > 0) {
672
+ console.group("Generator breakdown:");
673
+ for (const g of run.generators) {
674
+ logger.info(
675
+ ` ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews, top: ${g.topScore.toFixed(2)})`
676
+ );
677
+ }
678
+ console.groupEnd();
679
+ }
680
+ if (run.filters.length > 0) {
681
+ console.group("Filter impact:");
682
+ for (const f of run.filters) {
683
+ logger.info(` ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
684
+ }
685
+ console.groupEnd();
686
+ }
687
+ logger.info(
688
+ `Result: ${run.finalCount} cards selected (${run.newSelected} new, ${run.reviewsSelected} reviews)`
689
+ );
690
+ console.groupEnd();
691
+ }
692
+ function mountPipelineDebugger() {
693
+ if (typeof window === "undefined") return;
694
+ const win = window;
695
+ win.skuilder = win.skuilder || {};
696
+ win.skuilder.pipeline = pipelineDebugAPI;
697
+ }
698
+ var MAX_RUNS, runHistory, pipelineDebugAPI;
699
+ var init_PipelineDebugger = __esm({
700
+ "src/core/navigators/PipelineDebugger.ts"() {
701
+ "use strict";
702
+ init_logger();
703
+ MAX_RUNS = 10;
704
+ runHistory = [];
705
+ pipelineDebugAPI = {
706
+ /**
707
+ * Get raw run history for programmatic access.
708
+ */
709
+ get runs() {
710
+ return [...runHistory];
711
+ },
712
+ /**
713
+ * Show summary of a specific pipeline run.
714
+ */
715
+ showRun(idOrIndex = 0) {
716
+ if (runHistory.length === 0) {
717
+ logger.info("[Pipeline Debug] No runs captured yet.");
718
+ return;
719
+ }
720
+ let run;
721
+ if (typeof idOrIndex === "number") {
722
+ run = runHistory[idOrIndex];
723
+ if (!run) {
724
+ logger.info(
725
+ `[Pipeline Debug] No run found at index ${idOrIndex}. History length: ${runHistory.length}`
726
+ );
727
+ return;
728
+ }
729
+ } else {
730
+ run = runHistory.find((r) => r.runId.endsWith(idOrIndex));
731
+ if (!run) {
732
+ logger.info(`[Pipeline Debug] No run found matching ID '${idOrIndex}'.`);
733
+ return;
734
+ }
735
+ }
736
+ printRunSummary(run);
737
+ },
738
+ /**
739
+ * Show summary of the last pipeline run.
740
+ */
741
+ showLastRun() {
742
+ this.showRun(0);
743
+ },
744
+ /**
745
+ * Show detailed provenance for a specific card.
746
+ */
747
+ showCard(cardId) {
748
+ for (const run of runHistory) {
749
+ const card = run.cards.find((c) => c.cardId === cardId);
750
+ if (card) {
751
+ console.group(`\u{1F3B4} Card: ${cardId}`);
752
+ logger.info(`Course: ${card.courseId}`);
753
+ logger.info(`Origin: ${card.origin}`);
754
+ logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
755
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
756
+ logger.info("Provenance:");
757
+ logger.info(formatProvenance(card.provenance));
758
+ console.groupEnd();
759
+ return;
760
+ }
761
+ }
762
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
763
+ },
764
+ /**
765
+ * Explain why reviews may or may not have been selected.
766
+ */
767
+ explainReviews() {
768
+ if (runHistory.length === 0) {
769
+ logger.info("[Pipeline Debug] No runs captured yet.");
770
+ return;
771
+ }
772
+ console.group("\u{1F4CB} Review Selection Analysis");
773
+ for (const run of runHistory) {
774
+ console.group(`Run: ${run.courseId} @ ${run.timestamp.toLocaleTimeString()}`);
775
+ const allReviews = run.cards.filter((c) => c.origin === "review");
776
+ const selectedReviews = allReviews.filter((c) => c.selected);
777
+ if (allReviews.length === 0) {
778
+ logger.info("\u274C No reviews were generated. Check SRS logs for why.");
779
+ } else if (selectedReviews.length === 0) {
780
+ logger.info(`\u26A0\uFE0F ${allReviews.length} reviews generated but none selected.`);
781
+ logger.info("Possible reasons:");
782
+ const topNewScore = Math.max(
783
+ ...run.cards.filter((c) => c.origin === "new" && c.selected).map((c) => c.finalScore),
784
+ 0
785
+ );
786
+ const topReviewScore = Math.max(...allReviews.map((c) => c.finalScore), 0);
787
+ if (topReviewScore < topNewScore) {
788
+ logger.info(
789
+ ` - New cards scored higher (top new: ${topNewScore.toFixed(2)}, top review: ${topReviewScore.toFixed(2)})`
790
+ );
791
+ }
792
+ const topReview = allReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
793
+ if (topReview) {
794
+ logger.info(` - Top review score: ${topReview.finalScore.toFixed(3)}`);
795
+ logger.info(" - Its provenance:");
796
+ logger.info(formatProvenance(topReview.provenance));
797
+ }
798
+ } else {
799
+ logger.info(`\u2705 ${selectedReviews.length}/${allReviews.length} reviews selected.`);
800
+ logger.info("Top selected review:");
801
+ const topSelected = selectedReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
802
+ logger.info(formatProvenance(topSelected.provenance));
803
+ }
804
+ console.groupEnd();
805
+ }
806
+ console.groupEnd();
807
+ },
808
+ /**
809
+ * Show all runs in compact format.
810
+ */
811
+ listRuns() {
812
+ if (runHistory.length === 0) {
813
+ logger.info("[Pipeline Debug] No runs captured yet.");
814
+ return;
815
+ }
816
+ console.table(
817
+ runHistory.map((r) => ({
818
+ id: r.runId.slice(-8),
819
+ time: r.timestamp.toLocaleTimeString(),
820
+ course: r.courseName || r.courseId.slice(0, 8),
821
+ generated: r.generatedCount,
822
+ selected: r.finalCount,
823
+ new: r.newSelected,
824
+ reviews: r.reviewsSelected
825
+ }))
826
+ );
827
+ },
828
+ /**
829
+ * Export run history as JSON for bug reports.
830
+ */
831
+ export() {
832
+ const json = JSON.stringify(runHistory, null, 2);
833
+ logger.info("[Pipeline Debug] Run history exported. Copy the returned string or use:");
834
+ logger.info(" copy(window.skuilder.pipeline.export())");
835
+ return json;
836
+ },
837
+ /**
838
+ * Clear run history.
839
+ */
840
+ clear() {
841
+ runHistory.length = 0;
842
+ logger.info("[Pipeline Debug] Run history cleared.");
843
+ },
844
+ /**
845
+ * Show help.
846
+ */
847
+ help() {
848
+ logger.info(`
849
+ \u{1F527} Pipeline Debug API
850
+
851
+ Commands:
852
+ .showLastRun() Show summary of most recent pipeline run
853
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
854
+ .showCard(cardId) Show provenance trail for a specific card
855
+ .explainReviews() Analyze why reviews were/weren't selected
856
+ .listRuns() List all captured runs in table format
857
+ .export() Export run history as JSON for bug reports
858
+ .clear() Clear run history
859
+ .runs Access raw run history array
860
+ .help() Show this help message
861
+
862
+ Example:
863
+ window.skuilder.pipeline.showLastRun()
864
+ window.skuilder.pipeline.showRun(1)
865
+ window.skuilder.pipeline.showCard('abc123')
866
+ `);
867
+ }
868
+ };
869
+ mountPipelineDebugger();
870
+ }
871
+ });
872
+
608
873
  // src/core/navigators/generators/CompositeGenerator.ts
609
874
  var CompositeGenerator_exports = {};
610
875
  __export(CompositeGenerator_exports, {
@@ -673,6 +938,24 @@ var init_CompositeGenerator = __esm({
673
938
  const results = await Promise.all(
674
939
  this.generators.map((g) => g.getWeightedCards(limit, context))
675
940
  );
941
+ const generatorSummaries = [];
942
+ results.forEach((cards, index) => {
943
+ const gen = this.generators[index];
944
+ const genName = gen.name || `Generator ${index}`;
945
+ const newCards = cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
946
+ const reviewCards = cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
947
+ if (cards.length > 0) {
948
+ const topScore = Math.max(...cards.map((c) => c.score)).toFixed(2);
949
+ const parts = [];
950
+ if (newCards.length > 0) parts.push(`${newCards.length} new`);
951
+ if (reviewCards.length > 0) parts.push(`${reviewCards.length} reviews`);
952
+ const breakdown = parts.length > 0 ? parts.join(", ") : `${cards.length} cards`;
953
+ generatorSummaries.push(`${genName}: ${breakdown} (top: ${topScore})`);
954
+ } else {
955
+ generatorSummaries.push(`${genName}: 0 cards`);
956
+ }
957
+ });
958
+ logger.info(`[Composite] Generator breakdown: ${generatorSummaries.join(" | ")}`);
676
959
  const byCardId = /* @__PURE__ */ new Map();
677
960
  results.forEach((cards, index) => {
678
961
  const gen = this.generators[index];
@@ -790,6 +1073,7 @@ var init_elo = __esm({
790
1073
  "src/core/navigators/generators/elo.ts"() {
791
1074
  "use strict";
792
1075
  init_navigators();
1076
+ init_logger();
793
1077
  ELONavigator = class extends ContentNavigator {
794
1078
  /** Human-readable name for CardGenerator interface */
795
1079
  name;
@@ -849,7 +1133,16 @@ var init_elo = __esm({
849
1133
  };
850
1134
  });
851
1135
  scored.sort((a, b) => b.score - a.score);
852
- return scored.slice(0, limit);
1136
+ const result = scored.slice(0, limit);
1137
+ if (result.length > 0) {
1138
+ const topScores = result.slice(0, 3).map((c) => c.score.toFixed(2)).join(", ");
1139
+ logger.info(
1140
+ `[ELO] Course ${this.course.getCourseID()}: ${result.length} new cards (top scores: ${topScores})`
1141
+ );
1142
+ } else {
1143
+ logger.info(`[ELO] Course ${this.course.getCourseID()}: No new cards available`);
1144
+ }
1145
+ return result;
853
1146
  }
854
1147
  };
855
1148
  }
@@ -869,18 +1162,36 @@ __export(srs_exports, {
869
1162
  default: () => SRSNavigator
870
1163
  });
871
1164
  import moment from "moment";
872
- var SRSNavigator;
1165
+ var DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_PRESSURE, SRSNavigator;
873
1166
  var init_srs = __esm({
874
1167
  "src/core/navigators/generators/srs.ts"() {
875
1168
  "use strict";
876
1169
  init_navigators();
877
1170
  init_logger();
1171
+ DEFAULT_HEALTHY_BACKLOG = 20;
1172
+ MAX_BACKLOG_PRESSURE = 0.5;
878
1173
  SRSNavigator = class extends ContentNavigator {
879
1174
  /** Human-readable name for CardGenerator interface */
880
1175
  name;
1176
+ /** Healthy backlog threshold - when exceeded, backlog pressure kicks in */
1177
+ healthyBacklog;
881
1178
  constructor(user, course, strategyData) {
882
1179
  super(user, course, strategyData);
883
1180
  this.name = strategyData?.name || "SRS";
1181
+ const config = this.parseConfig(strategyData?.serializedData);
1182
+ this.healthyBacklog = config.healthyBacklog ?? DEFAULT_HEALTHY_BACKLOG;
1183
+ }
1184
+ /**
1185
+ * Parse configuration from serialized JSON.
1186
+ */
1187
+ parseConfig(serializedData) {
1188
+ if (!serializedData) return {};
1189
+ try {
1190
+ return JSON.parse(serializedData);
1191
+ } catch {
1192
+ logger.warn("[SRS] Failed to parse strategy config, using defaults");
1193
+ return {};
1194
+ }
884
1195
  }
885
1196
  /**
886
1197
  * Get review cards scored by urgency.
@@ -888,6 +1199,7 @@ var init_srs = __esm({
888
1199
  * Score formula combines:
889
1200
  * - Relative overdueness: hoursOverdue / intervalHours
890
1201
  * - Interval recency: exponential decay favoring shorter intervals
1202
+ * - Backlog pressure: boost when due reviews exceed healthy threshold
891
1203
  *
892
1204
  * Cards not yet due are excluded (not scored as 0).
893
1205
  *
@@ -901,11 +1213,32 @@ var init_srs = __esm({
901
1213
  if (!this.user || !this.course) {
902
1214
  throw new Error("SRSNavigator requires user and course to be set");
903
1215
  }
904
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1216
+ const courseId = this.course.getCourseID();
1217
+ const reviews = await this.user.getPendingReviews(courseId);
905
1218
  const now = moment.utc();
906
1219
  const dueReviews = reviews.filter((r) => now.isAfter(moment.utc(r.reviewTime)));
1220
+ const backlogPressure = this.computeBacklogPressure(dueReviews.length);
1221
+ if (dueReviews.length > 0) {
1222
+ const pressureNote = backlogPressure > 0 ? ` [backlog pressure: +${backlogPressure.toFixed(2)}]` : ` [healthy backlog]`;
1223
+ logger.info(
1224
+ `[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
1225
+ );
1226
+ } else if (reviews.length > 0) {
1227
+ const sortedByDue = [...reviews].sort(
1228
+ (a, b) => moment.utc(a.reviewTime).diff(moment.utc(b.reviewTime))
1229
+ );
1230
+ const nextDue = sortedByDue[0];
1231
+ const nextDueTime = moment.utc(nextDue.reviewTime);
1232
+ const untilDue = moment.duration(nextDueTime.diff(now));
1233
+ const untilDueStr = untilDue.asHours() < 1 ? `${Math.round(untilDue.asMinutes())}m` : untilDue.asHours() < 24 ? `${Math.round(untilDue.asHours())}h` : `${Math.round(untilDue.asDays())}d`;
1234
+ logger.info(
1235
+ `[SRS] Course ${courseId}: 0 reviews due now (${reviews.length} scheduled, next in ${untilDueStr})`
1236
+ );
1237
+ } else {
1238
+ logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
1239
+ }
907
1240
  const scored = dueReviews.map((review) => {
908
- const { score, reason } = this.computeUrgencyScore(review, now);
1241
+ const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
909
1242
  return {
910
1243
  cardId: review.cardId,
911
1244
  courseId: review.courseId,
@@ -923,13 +1256,35 @@ var init_srs = __esm({
923
1256
  ]
924
1257
  };
925
1258
  });
926
- logger.debug(`[srsNav] got ${scored.length} weighted cards`);
927
1259
  return scored.sort((a, b) => b.score - a.score).slice(0, limit);
928
1260
  }
1261
+ /**
1262
+ * Compute backlog pressure based on number of due reviews.
1263
+ *
1264
+ * Backlog pressure is 0 when at or below healthy threshold,
1265
+ * and increases linearly above it, maxing out at MAX_BACKLOG_PRESSURE.
1266
+ *
1267
+ * Examples (with default healthyBacklog=20):
1268
+ * - 10 due reviews → 0.00 (healthy)
1269
+ * - 20 due reviews → 0.00 (at threshold)
1270
+ * - 40 due reviews → 0.25 (2x threshold)
1271
+ * - 60 due reviews → 0.50 (3x threshold, maxed)
1272
+ *
1273
+ * @param dueCount - Number of reviews currently due
1274
+ * @returns Backlog pressure score to add to urgency (0 to MAX_BACKLOG_PRESSURE)
1275
+ */
1276
+ computeBacklogPressure(dueCount) {
1277
+ if (dueCount <= this.healthyBacklog) {
1278
+ return 0;
1279
+ }
1280
+ const excess = dueCount - this.healthyBacklog;
1281
+ const pressure = excess / this.healthyBacklog * (MAX_BACKLOG_PRESSURE / 2);
1282
+ return Math.min(MAX_BACKLOG_PRESSURE, pressure);
1283
+ }
929
1284
  /**
930
1285
  * Compute urgency score for a review card.
931
1286
  *
932
- * Two factors:
1287
+ * Three factors:
933
1288
  * 1. Relative overdueness = hoursOverdue / intervalHours
934
1289
  * - 2 days overdue on 3-day interval = 0.67 (urgent)
935
1290
  * - 2 days overdue on 180-day interval = 0.01 (not urgent)
@@ -939,10 +1294,19 @@ var init_srs = __esm({
939
1294
  * - 30 days (720h) → ~0.56
940
1295
  * - 180 days → ~0.30
941
1296
  *
942
- * Combined: base 0.5 + weighted average of factors * 0.45
943
- * Result range: approximately 0.5 to 0.95
1297
+ * 3. Backlog pressure = global boost when review backlog exceeds healthy threshold
1298
+ * - At healthy backlog: 0
1299
+ * - At 2x healthy: +0.25
1300
+ * - At 3x+ healthy: +0.50 (max)
1301
+ *
1302
+ * Combined: base 0.5 + (urgency factors * 0.45) + backlog pressure
1303
+ * Result range: 0.5 to 1.0 (uncapped to allow high-urgency reviews to compete with new cards)
1304
+ *
1305
+ * @param review - The scheduled card to score
1306
+ * @param now - Current time
1307
+ * @param backlogPressure - Pre-computed backlog pressure (0 to 0.5)
944
1308
  */
945
- computeUrgencyScore(review, now) {
1309
+ computeUrgencyScore(review, now, backlogPressure) {
946
1310
  const scheduledAt = moment.utc(review.scheduledAt);
947
1311
  const due = moment.utc(review.reviewTime);
948
1312
  const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
@@ -951,8 +1315,19 @@ var init_srs = __esm({
951
1315
  const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
952
1316
  const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
953
1317
  const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
954
- const score = Math.min(0.95, 0.5 + urgency * 0.45);
955
- const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
1318
+ const baseScore = 0.5 + urgency * 0.45;
1319
+ const score = Math.min(1, baseScore + backlogPressure);
1320
+ const reasonParts = [
1321
+ `${Math.round(hoursOverdue)}h overdue`,
1322
+ `interval: ${Math.round(intervalHours)}h`,
1323
+ `relative: ${relativeOverdue.toFixed(2)}`,
1324
+ `recency: ${recencyFactor.toFixed(2)}`
1325
+ ];
1326
+ if (backlogPressure > 0) {
1327
+ reasonParts.push(`backlog: +${backlogPressure.toFixed(2)}`);
1328
+ }
1329
+ reasonParts.push("review");
1330
+ const reason = reasonParts.join(", ");
956
1331
  return { score, reason };
957
1332
  }
958
1333
  };
@@ -1983,10 +2358,23 @@ function logTagHydration(cards, tagsByCard) {
1983
2358
  `[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
1984
2359
  );
1985
2360
  }
1986
- function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
2361
+ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores, filterImpacts) {
1987
2362
  const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
2363
+ let filterSummary = "";
2364
+ if (filterImpacts.length > 0) {
2365
+ const impacts = filterImpacts.map((f) => {
2366
+ const parts = [];
2367
+ if (f.boosted > 0) parts.push(`+${f.boosted}`);
2368
+ if (f.penalized > 0) parts.push(`-${f.penalized}`);
2369
+ if (f.passed > 0) parts.push(`=${f.passed}`);
2370
+ return `${f.name}: ${parts.join("/")}`;
2371
+ });
2372
+ filterSummary = `
2373
+ Filter impact: ${impacts.join(", ")}`;
2374
+ }
1988
2375
  logger.info(
1989
- `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
2376
+ `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})` + filterSummary + `
2377
+ \u{1F4A1} Inspect: window.skuilder.pipeline`
1990
2378
  );
1991
2379
  }
1992
2380
  function logCardProvenance(cards, maxCards = 3) {
@@ -2010,6 +2398,7 @@ var init_Pipeline = __esm({
2010
2398
  init_navigators();
2011
2399
  init_logger();
2012
2400
  init_orchestration();
2401
+ init_PipelineDebugger();
2013
2402
  Pipeline = class extends ContentNavigator {
2014
2403
  generator;
2015
2404
  filters;
@@ -2057,12 +2446,49 @@ var init_Pipeline = __esm({
2057
2446
  );
2058
2447
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2059
2448
  const generatedCount = cards.length;
2449
+ let generatorSummaries;
2450
+ if (this.generator.generators) {
2451
+ const genMap = /* @__PURE__ */ new Map();
2452
+ for (const card of cards) {
2453
+ const firstProv = card.provenance[0];
2454
+ if (firstProv) {
2455
+ const genName = firstProv.strategyName;
2456
+ if (!genMap.has(genName)) {
2457
+ genMap.set(genName, { cards: [] });
2458
+ }
2459
+ genMap.get(genName).cards.push(card);
2460
+ }
2461
+ }
2462
+ generatorSummaries = Array.from(genMap.entries()).map(([name, data]) => {
2463
+ const newCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
2464
+ const reviewCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
2465
+ return {
2466
+ name,
2467
+ cardCount: data.cards.length,
2468
+ newCount: newCards.length,
2469
+ reviewCount: reviewCards.length,
2470
+ topScore: Math.max(...data.cards.map((c) => c.score), 0)
2471
+ };
2472
+ });
2473
+ }
2060
2474
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2061
2475
  cards = await this.hydrateTags(cards);
2476
+ const allCardsBeforeFiltering = [...cards];
2477
+ const filterImpacts = [];
2062
2478
  for (const filter of this.filters) {
2063
2479
  const beforeCount = cards.length;
2480
+ const beforeScores = new Map(cards.map((c) => [c.cardId, c.score]));
2064
2481
  cards = await filter.transform(cards, context);
2065
- logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
2482
+ let boosted = 0, penalized = 0, passed = 0;
2483
+ const removed = beforeCount - cards.length;
2484
+ for (const card of cards) {
2485
+ const before = beforeScores.get(card.cardId) ?? 0;
2486
+ if (card.score > before) boosted++;
2487
+ else if (card.score < before) penalized++;
2488
+ else passed++;
2489
+ }
2490
+ filterImpacts.push({ name: filter.name, boosted, penalized, passed, removed });
2491
+ logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2066
2492
  }
2067
2493
  cards = cards.filter((c) => c.score > 0);
2068
2494
  cards.sort((a, b) => b.score - a.score);
@@ -2073,9 +2499,26 @@ var init_Pipeline = __esm({
2073
2499
  generatedCount,
2074
2500
  this.filters.length,
2075
2501
  result.length,
2076
- topScores
2502
+ topScores,
2503
+ filterImpacts
2077
2504
  );
2078
2505
  logCardProvenance(result, 3);
2506
+ try {
2507
+ const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
2508
+ const report = buildRunReport(
2509
+ this.course?.getCourseID() || "unknown",
2510
+ courseName,
2511
+ this.generator.name,
2512
+ generatorSummaries,
2513
+ generatedCount,
2514
+ filterImpacts,
2515
+ allCardsBeforeFiltering,
2516
+ result
2517
+ );
2518
+ captureRun(report);
2519
+ } catch (e) {
2520
+ logger.debug(`[Pipeline] Failed to capture debug run: ${e}`);
2521
+ }
2079
2522
  return result;
2080
2523
  }
2081
2524
  /**
@@ -2165,6 +2608,56 @@ var init_Pipeline = __esm({
2165
2608
  }
2166
2609
  });
2167
2610
 
2611
+ // src/core/navigators/defaults.ts
2612
+ var defaults_exports = {};
2613
+ __export(defaults_exports, {
2614
+ createDefaultEloStrategy: () => createDefaultEloStrategy,
2615
+ createDefaultPipeline: () => createDefaultPipeline,
2616
+ createDefaultSrsStrategy: () => createDefaultSrsStrategy
2617
+ });
2618
+ function createDefaultEloStrategy(courseId) {
2619
+ return {
2620
+ _id: "NAVIGATION_STRATEGY-ELO-default",
2621
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2622
+ name: "ELO (default)",
2623
+ description: "Default ELO-based navigation strategy for new cards",
2624
+ implementingClass: "elo" /* ELO */,
2625
+ course: courseId,
2626
+ serializedData: ""
2627
+ };
2628
+ }
2629
+ function createDefaultSrsStrategy(courseId) {
2630
+ return {
2631
+ _id: "NAVIGATION_STRATEGY-SRS-default",
2632
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2633
+ name: "SRS (default)",
2634
+ description: "Default SRS-based navigation strategy for reviews",
2635
+ implementingClass: "srs" /* SRS */,
2636
+ course: courseId,
2637
+ serializedData: ""
2638
+ };
2639
+ }
2640
+ function createDefaultPipeline(user, course) {
2641
+ const courseId = course.getCourseID();
2642
+ const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
2643
+ const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
2644
+ const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
2645
+ const eloDistanceFilter = createEloDistanceFilter();
2646
+ return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
2647
+ }
2648
+ var init_defaults = __esm({
2649
+ "src/core/navigators/defaults.ts"() {
2650
+ "use strict";
2651
+ init_navigators();
2652
+ init_Pipeline();
2653
+ init_CompositeGenerator();
2654
+ init_elo();
2655
+ init_srs();
2656
+ init_eloDistance();
2657
+ init_types_legacy();
2658
+ }
2659
+ });
2660
+
2168
2661
  // src/core/navigators/PipelineAssembler.ts
2169
2662
  var PipelineAssembler_exports = {};
2170
2663
  __export(PipelineAssembler_exports, {
@@ -2177,9 +2670,9 @@ var init_PipelineAssembler = __esm({
2177
2670
  init_navigators();
2178
2671
  init_WeightedFilter();
2179
2672
  init_Pipeline();
2180
- init_types_legacy();
2181
2673
  init_logger();
2182
2674
  init_CompositeGenerator();
2675
+ init_defaults();
2183
2676
  PipelineAssembler = class {
2184
2677
  /**
2185
2678
  * Assembles a navigation pipeline from strategy documents.
@@ -2218,9 +2711,11 @@ var init_PipelineAssembler = __esm({
2218
2711
  if (generatorStrategies.length === 0) {
2219
2712
  if (filterStrategies.length > 0) {
2220
2713
  logger.debug(
2221
- "[PipelineAssembler] No generator found, using default ELO with configured filters"
2714
+ "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2222
2715
  );
2223
- generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
2716
+ const courseId = course.getCourseID();
2717
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
2718
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
2224
2719
  } else {
2225
2720
  warnings.push("No generator strategy found");
2226
2721
  return {
@@ -2281,75 +2776,10 @@ var init_PipelineAssembler = __esm({
2281
2776
  warnings
2282
2777
  };
2283
2778
  }
2284
- /**
2285
- * Creates a default ELO generator strategy.
2286
- * Used when filters are configured but no generator is specified.
2287
- */
2288
- makeDefaultEloStrategy(courseId) {
2289
- return {
2290
- _id: "NAVIGATION_STRATEGY-ELO-default",
2291
- course: courseId,
2292
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2293
- name: "ELO (default)",
2294
- description: "Default ELO-based generator",
2295
- implementingClass: "elo" /* ELO */,
2296
- serializedData: ""
2297
- };
2298
- }
2299
2779
  };
2300
2780
  }
2301
2781
  });
2302
2782
 
2303
- // src/core/navigators/defaults.ts
2304
- var defaults_exports = {};
2305
- __export(defaults_exports, {
2306
- createDefaultEloStrategy: () => createDefaultEloStrategy,
2307
- createDefaultPipeline: () => createDefaultPipeline,
2308
- createDefaultSrsStrategy: () => createDefaultSrsStrategy
2309
- });
2310
- function createDefaultEloStrategy(courseId) {
2311
- return {
2312
- _id: "NAVIGATION_STRATEGY-ELO-default",
2313
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2314
- name: "ELO (default)",
2315
- description: "Default ELO-based navigation strategy for new cards",
2316
- implementingClass: "elo" /* ELO */,
2317
- course: courseId,
2318
- serializedData: ""
2319
- };
2320
- }
2321
- function createDefaultSrsStrategy(courseId) {
2322
- return {
2323
- _id: "NAVIGATION_STRATEGY-SRS-default",
2324
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2325
- name: "SRS (default)",
2326
- description: "Default SRS-based navigation strategy for reviews",
2327
- implementingClass: "srs" /* SRS */,
2328
- course: courseId,
2329
- serializedData: ""
2330
- };
2331
- }
2332
- function createDefaultPipeline(user, course) {
2333
- const courseId = course.getCourseID();
2334
- const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
2335
- const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
2336
- const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
2337
- const eloDistanceFilter = createEloDistanceFilter();
2338
- return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
2339
- }
2340
- var init_defaults = __esm({
2341
- "src/core/navigators/defaults.ts"() {
2342
- "use strict";
2343
- init_navigators();
2344
- init_Pipeline();
2345
- init_CompositeGenerator();
2346
- init_elo();
2347
- init_srs();
2348
- init_eloDistance();
2349
- init_types_legacy();
2350
- }
2351
- });
2352
-
2353
2783
  // import("./**/*") in src/core/navigators/index.ts
2354
2784
  var globImport;
2355
2785
  var init_3 = __esm({
@@ -2357,6 +2787,7 @@ var init_3 = __esm({
2357
2787
  globImport = __glob({
2358
2788
  "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
2359
2789
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
2790
+ "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
2360
2791
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
2361
2792
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
2362
2793
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
@@ -2392,6 +2823,8 @@ __export(navigators_exports, {
2392
2823
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
2393
2824
  isFilter: () => isFilter,
2394
2825
  isGenerator: () => isGenerator,
2826
+ mountPipelineDebugger: () => mountPipelineDebugger,
2827
+ pipelineDebugAPI: () => pipelineDebugAPI,
2395
2828
  registerNavigator: () => registerNavigator
2396
2829
  });
2397
2830
  function registerNavigator(implementingClass, constructor) {
@@ -2458,6 +2891,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
2458
2891
  var init_navigators = __esm({
2459
2892
  "src/core/navigators/index.ts"() {
2460
2893
  "use strict";
2894
+ init_PipelineDebugger();
2461
2895
  init_logger();
2462
2896
  init_();
2463
2897
  init_2();
@@ -4130,6 +4564,15 @@ var init_user_course_relDB = __esm({
4130
4564
  void this.user.updateCourseSettings(this._courseId, updates);
4131
4565
  }
4132
4566
  }
4567
+ async getStrategyState(strategyKey) {
4568
+ return this.user.getStrategyState(this._courseId, strategyKey);
4569
+ }
4570
+ async putStrategyState(strategyKey, data) {
4571
+ return this.user.putStrategyState(this._courseId, strategyKey, data);
4572
+ }
4573
+ async deleteStrategyState(strategyKey) {
4574
+ return this.user.deleteStrategyState(this._courseId, strategyKey);
4575
+ }
4133
4576
  async getReviewstoDate(targetDate) {
4134
4577
  const allReviews = await this.user.getPendingReviews(this._courseId);
4135
4578
  logger.debug(