@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
@@ -627,6 +627,271 @@ var init_courseLookupDB = __esm({
627
627
  }
628
628
  });
629
629
 
630
+ // src/core/navigators/PipelineDebugger.ts
631
+ var PipelineDebugger_exports = {};
632
+ __export(PipelineDebugger_exports, {
633
+ buildRunReport: () => buildRunReport,
634
+ captureRun: () => captureRun,
635
+ mountPipelineDebugger: () => mountPipelineDebugger,
636
+ pipelineDebugAPI: () => pipelineDebugAPI
637
+ });
638
+ function getOrigin(card) {
639
+ const firstEntry = card.provenance[0];
640
+ if (!firstEntry) return "unknown";
641
+ const reason = firstEntry.reason?.toLowerCase() || "";
642
+ if (reason.includes("new card")) return "new";
643
+ if (reason.includes("review")) return "review";
644
+ return "unknown";
645
+ }
646
+ function captureRun(report) {
647
+ const fullReport = {
648
+ ...report,
649
+ runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
650
+ timestamp: /* @__PURE__ */ new Date()
651
+ };
652
+ runHistory.unshift(fullReport);
653
+ if (runHistory.length > MAX_RUNS) {
654
+ runHistory.pop();
655
+ }
656
+ }
657
+ function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards) {
658
+ const selectedIds = new Set(selectedCards.map((c) => c.cardId));
659
+ const cards = allCards.map((card) => ({
660
+ cardId: card.cardId,
661
+ courseId: card.courseId,
662
+ origin: getOrigin(card),
663
+ finalScore: card.score,
664
+ provenance: card.provenance,
665
+ selected: selectedIds.has(card.cardId)
666
+ }));
667
+ const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
668
+ const newSelected = selectedCards.filter((c) => getOrigin(c) === "new").length;
669
+ return {
670
+ courseId,
671
+ courseName,
672
+ generatorName,
673
+ generators,
674
+ generatedCount,
675
+ filters,
676
+ finalCount: selectedCards.length,
677
+ reviewsSelected,
678
+ newSelected,
679
+ cards
680
+ };
681
+ }
682
+ function formatProvenance(provenance) {
683
+ return provenance.map((p) => {
684
+ const actionSymbol = p.action === "generated" ? "\u{1F3B2}" : p.action === "boosted" ? "\u2B06\uFE0F" : p.action === "penalized" ? "\u2B07\uFE0F" : "\u27A1\uFE0F";
685
+ return ` ${actionSymbol} ${p.strategyName}: ${p.score.toFixed(3)} - ${p.reason}`;
686
+ }).join("\n");
687
+ }
688
+ function printRunSummary(run) {
689
+ console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
690
+ logger.info(`Run ID: ${run.runId}`);
691
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
692
+ logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
693
+ if (run.generators && run.generators.length > 0) {
694
+ console.group("Generator breakdown:");
695
+ for (const g of run.generators) {
696
+ logger.info(
697
+ ` ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews, top: ${g.topScore.toFixed(2)})`
698
+ );
699
+ }
700
+ console.groupEnd();
701
+ }
702
+ if (run.filters.length > 0) {
703
+ console.group("Filter impact:");
704
+ for (const f of run.filters) {
705
+ logger.info(` ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
706
+ }
707
+ console.groupEnd();
708
+ }
709
+ logger.info(
710
+ `Result: ${run.finalCount} cards selected (${run.newSelected} new, ${run.reviewsSelected} reviews)`
711
+ );
712
+ console.groupEnd();
713
+ }
714
+ function mountPipelineDebugger() {
715
+ if (typeof window === "undefined") return;
716
+ const win = window;
717
+ win.skuilder = win.skuilder || {};
718
+ win.skuilder.pipeline = pipelineDebugAPI;
719
+ }
720
+ var MAX_RUNS, runHistory, pipelineDebugAPI;
721
+ var init_PipelineDebugger = __esm({
722
+ "src/core/navigators/PipelineDebugger.ts"() {
723
+ "use strict";
724
+ init_logger();
725
+ MAX_RUNS = 10;
726
+ runHistory = [];
727
+ pipelineDebugAPI = {
728
+ /**
729
+ * Get raw run history for programmatic access.
730
+ */
731
+ get runs() {
732
+ return [...runHistory];
733
+ },
734
+ /**
735
+ * Show summary of a specific pipeline run.
736
+ */
737
+ showRun(idOrIndex = 0) {
738
+ if (runHistory.length === 0) {
739
+ logger.info("[Pipeline Debug] No runs captured yet.");
740
+ return;
741
+ }
742
+ let run;
743
+ if (typeof idOrIndex === "number") {
744
+ run = runHistory[idOrIndex];
745
+ if (!run) {
746
+ logger.info(
747
+ `[Pipeline Debug] No run found at index ${idOrIndex}. History length: ${runHistory.length}`
748
+ );
749
+ return;
750
+ }
751
+ } else {
752
+ run = runHistory.find((r) => r.runId.endsWith(idOrIndex));
753
+ if (!run) {
754
+ logger.info(`[Pipeline Debug] No run found matching ID '${idOrIndex}'.`);
755
+ return;
756
+ }
757
+ }
758
+ printRunSummary(run);
759
+ },
760
+ /**
761
+ * Show summary of the last pipeline run.
762
+ */
763
+ showLastRun() {
764
+ this.showRun(0);
765
+ },
766
+ /**
767
+ * Show detailed provenance for a specific card.
768
+ */
769
+ showCard(cardId) {
770
+ for (const run of runHistory) {
771
+ const card = run.cards.find((c) => c.cardId === cardId);
772
+ if (card) {
773
+ console.group(`\u{1F3B4} Card: ${cardId}`);
774
+ logger.info(`Course: ${card.courseId}`);
775
+ logger.info(`Origin: ${card.origin}`);
776
+ logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
777
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
778
+ logger.info("Provenance:");
779
+ logger.info(formatProvenance(card.provenance));
780
+ console.groupEnd();
781
+ return;
782
+ }
783
+ }
784
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
785
+ },
786
+ /**
787
+ * Explain why reviews may or may not have been selected.
788
+ */
789
+ explainReviews() {
790
+ if (runHistory.length === 0) {
791
+ logger.info("[Pipeline Debug] No runs captured yet.");
792
+ return;
793
+ }
794
+ console.group("\u{1F4CB} Review Selection Analysis");
795
+ for (const run of runHistory) {
796
+ console.group(`Run: ${run.courseId} @ ${run.timestamp.toLocaleTimeString()}`);
797
+ const allReviews = run.cards.filter((c) => c.origin === "review");
798
+ const selectedReviews = allReviews.filter((c) => c.selected);
799
+ if (allReviews.length === 0) {
800
+ logger.info("\u274C No reviews were generated. Check SRS logs for why.");
801
+ } else if (selectedReviews.length === 0) {
802
+ logger.info(`\u26A0\uFE0F ${allReviews.length} reviews generated but none selected.`);
803
+ logger.info("Possible reasons:");
804
+ const topNewScore = Math.max(
805
+ ...run.cards.filter((c) => c.origin === "new" && c.selected).map((c) => c.finalScore),
806
+ 0
807
+ );
808
+ const topReviewScore = Math.max(...allReviews.map((c) => c.finalScore), 0);
809
+ if (topReviewScore < topNewScore) {
810
+ logger.info(
811
+ ` - New cards scored higher (top new: ${topNewScore.toFixed(2)}, top review: ${topReviewScore.toFixed(2)})`
812
+ );
813
+ }
814
+ const topReview = allReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
815
+ if (topReview) {
816
+ logger.info(` - Top review score: ${topReview.finalScore.toFixed(3)}`);
817
+ logger.info(" - Its provenance:");
818
+ logger.info(formatProvenance(topReview.provenance));
819
+ }
820
+ } else {
821
+ logger.info(`\u2705 ${selectedReviews.length}/${allReviews.length} reviews selected.`);
822
+ logger.info("Top selected review:");
823
+ const topSelected = selectedReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
824
+ logger.info(formatProvenance(topSelected.provenance));
825
+ }
826
+ console.groupEnd();
827
+ }
828
+ console.groupEnd();
829
+ },
830
+ /**
831
+ * Show all runs in compact format.
832
+ */
833
+ listRuns() {
834
+ if (runHistory.length === 0) {
835
+ logger.info("[Pipeline Debug] No runs captured yet.");
836
+ return;
837
+ }
838
+ console.table(
839
+ runHistory.map((r) => ({
840
+ id: r.runId.slice(-8),
841
+ time: r.timestamp.toLocaleTimeString(),
842
+ course: r.courseName || r.courseId.slice(0, 8),
843
+ generated: r.generatedCount,
844
+ selected: r.finalCount,
845
+ new: r.newSelected,
846
+ reviews: r.reviewsSelected
847
+ }))
848
+ );
849
+ },
850
+ /**
851
+ * Export run history as JSON for bug reports.
852
+ */
853
+ export() {
854
+ const json = JSON.stringify(runHistory, null, 2);
855
+ logger.info("[Pipeline Debug] Run history exported. Copy the returned string or use:");
856
+ logger.info(" copy(window.skuilder.pipeline.export())");
857
+ return json;
858
+ },
859
+ /**
860
+ * Clear run history.
861
+ */
862
+ clear() {
863
+ runHistory.length = 0;
864
+ logger.info("[Pipeline Debug] Run history cleared.");
865
+ },
866
+ /**
867
+ * Show help.
868
+ */
869
+ help() {
870
+ logger.info(`
871
+ \u{1F527} Pipeline Debug API
872
+
873
+ Commands:
874
+ .showLastRun() Show summary of most recent pipeline run
875
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
876
+ .showCard(cardId) Show provenance trail for a specific card
877
+ .explainReviews() Analyze why reviews were/weren't selected
878
+ .listRuns() List all captured runs in table format
879
+ .export() Export run history as JSON for bug reports
880
+ .clear() Clear run history
881
+ .runs Access raw run history array
882
+ .help() Show this help message
883
+
884
+ Example:
885
+ window.skuilder.pipeline.showLastRun()
886
+ window.skuilder.pipeline.showRun(1)
887
+ window.skuilder.pipeline.showCard('abc123')
888
+ `);
889
+ }
890
+ };
891
+ mountPipelineDebugger();
892
+ }
893
+ });
894
+
630
895
  // src/core/navigators/generators/CompositeGenerator.ts
631
896
  var CompositeGenerator_exports = {};
632
897
  __export(CompositeGenerator_exports, {
@@ -695,6 +960,24 @@ var init_CompositeGenerator = __esm({
695
960
  const results = await Promise.all(
696
961
  this.generators.map((g) => g.getWeightedCards(limit, context))
697
962
  );
963
+ const generatorSummaries = [];
964
+ results.forEach((cards, index) => {
965
+ const gen = this.generators[index];
966
+ const genName = gen.name || `Generator ${index}`;
967
+ const newCards = cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
968
+ const reviewCards = cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
969
+ if (cards.length > 0) {
970
+ const topScore = Math.max(...cards.map((c) => c.score)).toFixed(2);
971
+ const parts = [];
972
+ if (newCards.length > 0) parts.push(`${newCards.length} new`);
973
+ if (reviewCards.length > 0) parts.push(`${reviewCards.length} reviews`);
974
+ const breakdown = parts.length > 0 ? parts.join(", ") : `${cards.length} cards`;
975
+ generatorSummaries.push(`${genName}: ${breakdown} (top: ${topScore})`);
976
+ } else {
977
+ generatorSummaries.push(`${genName}: 0 cards`);
978
+ }
979
+ });
980
+ logger.info(`[Composite] Generator breakdown: ${generatorSummaries.join(" | ")}`);
698
981
  const byCardId = /* @__PURE__ */ new Map();
699
982
  results.forEach((cards, index) => {
700
983
  const gen = this.generators[index];
@@ -812,6 +1095,7 @@ var init_elo = __esm({
812
1095
  "use strict";
813
1096
  init_navigators();
814
1097
  import_common5 = require("@vue-skuilder/common");
1098
+ init_logger();
815
1099
  ELONavigator = class extends ContentNavigator {
816
1100
  /** Human-readable name for CardGenerator interface */
817
1101
  name;
@@ -871,7 +1155,16 @@ var init_elo = __esm({
871
1155
  };
872
1156
  });
873
1157
  scored.sort((a, b) => b.score - a.score);
874
- return scored.slice(0, limit);
1158
+ const result = scored.slice(0, limit);
1159
+ if (result.length > 0) {
1160
+ const topScores = result.slice(0, 3).map((c) => c.score.toFixed(2)).join(", ");
1161
+ logger.info(
1162
+ `[ELO] Course ${this.course.getCourseID()}: ${result.length} new cards (top scores: ${topScores})`
1163
+ );
1164
+ } else {
1165
+ logger.info(`[ELO] Course ${this.course.getCourseID()}: No new cards available`);
1166
+ }
1167
+ return result;
875
1168
  }
876
1169
  };
877
1170
  }
@@ -890,19 +1183,37 @@ var srs_exports = {};
890
1183
  __export(srs_exports, {
891
1184
  default: () => SRSNavigator
892
1185
  });
893
- var import_moment, SRSNavigator;
1186
+ var import_moment, DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_PRESSURE, SRSNavigator;
894
1187
  var init_srs = __esm({
895
1188
  "src/core/navigators/generators/srs.ts"() {
896
1189
  "use strict";
897
1190
  import_moment = __toESM(require("moment"), 1);
898
1191
  init_navigators();
899
1192
  init_logger();
1193
+ DEFAULT_HEALTHY_BACKLOG = 20;
1194
+ MAX_BACKLOG_PRESSURE = 0.5;
900
1195
  SRSNavigator = class extends ContentNavigator {
901
1196
  /** Human-readable name for CardGenerator interface */
902
1197
  name;
1198
+ /** Healthy backlog threshold - when exceeded, backlog pressure kicks in */
1199
+ healthyBacklog;
903
1200
  constructor(user, course, strategyData) {
904
1201
  super(user, course, strategyData);
905
1202
  this.name = strategyData?.name || "SRS";
1203
+ const config = this.parseConfig(strategyData?.serializedData);
1204
+ this.healthyBacklog = config.healthyBacklog ?? DEFAULT_HEALTHY_BACKLOG;
1205
+ }
1206
+ /**
1207
+ * Parse configuration from serialized JSON.
1208
+ */
1209
+ parseConfig(serializedData) {
1210
+ if (!serializedData) return {};
1211
+ try {
1212
+ return JSON.parse(serializedData);
1213
+ } catch {
1214
+ logger.warn("[SRS] Failed to parse strategy config, using defaults");
1215
+ return {};
1216
+ }
906
1217
  }
907
1218
  /**
908
1219
  * Get review cards scored by urgency.
@@ -910,6 +1221,7 @@ var init_srs = __esm({
910
1221
  * Score formula combines:
911
1222
  * - Relative overdueness: hoursOverdue / intervalHours
912
1223
  * - Interval recency: exponential decay favoring shorter intervals
1224
+ * - Backlog pressure: boost when due reviews exceed healthy threshold
913
1225
  *
914
1226
  * Cards not yet due are excluded (not scored as 0).
915
1227
  *
@@ -923,11 +1235,32 @@ var init_srs = __esm({
923
1235
  if (!this.user || !this.course) {
924
1236
  throw new Error("SRSNavigator requires user and course to be set");
925
1237
  }
926
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1238
+ const courseId = this.course.getCourseID();
1239
+ const reviews = await this.user.getPendingReviews(courseId);
927
1240
  const now = import_moment.default.utc();
928
1241
  const dueReviews = reviews.filter((r) => now.isAfter(import_moment.default.utc(r.reviewTime)));
1242
+ const backlogPressure = this.computeBacklogPressure(dueReviews.length);
1243
+ if (dueReviews.length > 0) {
1244
+ const pressureNote = backlogPressure > 0 ? ` [backlog pressure: +${backlogPressure.toFixed(2)}]` : ` [healthy backlog]`;
1245
+ logger.info(
1246
+ `[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
1247
+ );
1248
+ } else if (reviews.length > 0) {
1249
+ const sortedByDue = [...reviews].sort(
1250
+ (a, b) => import_moment.default.utc(a.reviewTime).diff(import_moment.default.utc(b.reviewTime))
1251
+ );
1252
+ const nextDue = sortedByDue[0];
1253
+ const nextDueTime = import_moment.default.utc(nextDue.reviewTime);
1254
+ const untilDue = import_moment.default.duration(nextDueTime.diff(now));
1255
+ const untilDueStr = untilDue.asHours() < 1 ? `${Math.round(untilDue.asMinutes())}m` : untilDue.asHours() < 24 ? `${Math.round(untilDue.asHours())}h` : `${Math.round(untilDue.asDays())}d`;
1256
+ logger.info(
1257
+ `[SRS] Course ${courseId}: 0 reviews due now (${reviews.length} scheduled, next in ${untilDueStr})`
1258
+ );
1259
+ } else {
1260
+ logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
1261
+ }
929
1262
  const scored = dueReviews.map((review) => {
930
- const { score, reason } = this.computeUrgencyScore(review, now);
1263
+ const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
931
1264
  return {
932
1265
  cardId: review.cardId,
933
1266
  courseId: review.courseId,
@@ -945,13 +1278,35 @@ var init_srs = __esm({
945
1278
  ]
946
1279
  };
947
1280
  });
948
- logger.debug(`[srsNav] got ${scored.length} weighted cards`);
949
1281
  return scored.sort((a, b) => b.score - a.score).slice(0, limit);
950
1282
  }
1283
+ /**
1284
+ * Compute backlog pressure based on number of due reviews.
1285
+ *
1286
+ * Backlog pressure is 0 when at or below healthy threshold,
1287
+ * and increases linearly above it, maxing out at MAX_BACKLOG_PRESSURE.
1288
+ *
1289
+ * Examples (with default healthyBacklog=20):
1290
+ * - 10 due reviews → 0.00 (healthy)
1291
+ * - 20 due reviews → 0.00 (at threshold)
1292
+ * - 40 due reviews → 0.25 (2x threshold)
1293
+ * - 60 due reviews → 0.50 (3x threshold, maxed)
1294
+ *
1295
+ * @param dueCount - Number of reviews currently due
1296
+ * @returns Backlog pressure score to add to urgency (0 to MAX_BACKLOG_PRESSURE)
1297
+ */
1298
+ computeBacklogPressure(dueCount) {
1299
+ if (dueCount <= this.healthyBacklog) {
1300
+ return 0;
1301
+ }
1302
+ const excess = dueCount - this.healthyBacklog;
1303
+ const pressure = excess / this.healthyBacklog * (MAX_BACKLOG_PRESSURE / 2);
1304
+ return Math.min(MAX_BACKLOG_PRESSURE, pressure);
1305
+ }
951
1306
  /**
952
1307
  * Compute urgency score for a review card.
953
1308
  *
954
- * Two factors:
1309
+ * Three factors:
955
1310
  * 1. Relative overdueness = hoursOverdue / intervalHours
956
1311
  * - 2 days overdue on 3-day interval = 0.67 (urgent)
957
1312
  * - 2 days overdue on 180-day interval = 0.01 (not urgent)
@@ -961,10 +1316,19 @@ var init_srs = __esm({
961
1316
  * - 30 days (720h) → ~0.56
962
1317
  * - 180 days → ~0.30
963
1318
  *
964
- * Combined: base 0.5 + weighted average of factors * 0.45
965
- * Result range: approximately 0.5 to 0.95
1319
+ * 3. Backlog pressure = global boost when review backlog exceeds healthy threshold
1320
+ * - At healthy backlog: 0
1321
+ * - At 2x healthy: +0.25
1322
+ * - At 3x+ healthy: +0.50 (max)
1323
+ *
1324
+ * Combined: base 0.5 + (urgency factors * 0.45) + backlog pressure
1325
+ * Result range: 0.5 to 1.0 (uncapped to allow high-urgency reviews to compete with new cards)
1326
+ *
1327
+ * @param review - The scheduled card to score
1328
+ * @param now - Current time
1329
+ * @param backlogPressure - Pre-computed backlog pressure (0 to 0.5)
966
1330
  */
967
- computeUrgencyScore(review, now) {
1331
+ computeUrgencyScore(review, now, backlogPressure) {
968
1332
  const scheduledAt = import_moment.default.utc(review.scheduledAt);
969
1333
  const due = import_moment.default.utc(review.reviewTime);
970
1334
  const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
@@ -973,8 +1337,19 @@ var init_srs = __esm({
973
1337
  const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
974
1338
  const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
975
1339
  const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
976
- const score = Math.min(0.95, 0.5 + urgency * 0.45);
977
- const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
1340
+ const baseScore = 0.5 + urgency * 0.45;
1341
+ const score = Math.min(1, baseScore + backlogPressure);
1342
+ const reasonParts = [
1343
+ `${Math.round(hoursOverdue)}h overdue`,
1344
+ `interval: ${Math.round(intervalHours)}h`,
1345
+ `relative: ${relativeOverdue.toFixed(2)}`,
1346
+ `recency: ${recencyFactor.toFixed(2)}`
1347
+ ];
1348
+ if (backlogPressure > 0) {
1349
+ reasonParts.push(`backlog: +${backlogPressure.toFixed(2)}`);
1350
+ }
1351
+ reasonParts.push("review");
1352
+ const reason = reasonParts.join(", ");
978
1353
  return { score, reason };
979
1354
  }
980
1355
  };
@@ -2004,10 +2379,23 @@ function logTagHydration(cards, tagsByCard) {
2004
2379
  `[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
2005
2380
  );
2006
2381
  }
2007
- function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
2382
+ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores, filterImpacts) {
2008
2383
  const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
2384
+ let filterSummary = "";
2385
+ if (filterImpacts.length > 0) {
2386
+ const impacts = filterImpacts.map((f) => {
2387
+ const parts = [];
2388
+ if (f.boosted > 0) parts.push(`+${f.boosted}`);
2389
+ if (f.penalized > 0) parts.push(`-${f.penalized}`);
2390
+ if (f.passed > 0) parts.push(`=${f.passed}`);
2391
+ return `${f.name}: ${parts.join("/")}`;
2392
+ });
2393
+ filterSummary = `
2394
+ Filter impact: ${impacts.join(", ")}`;
2395
+ }
2009
2396
  logger.info(
2010
- `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
2397
+ `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})` + filterSummary + `
2398
+ \u{1F4A1} Inspect: window.skuilder.pipeline`
2011
2399
  );
2012
2400
  }
2013
2401
  function logCardProvenance(cards, maxCards = 3) {
@@ -2032,6 +2420,7 @@ var init_Pipeline = __esm({
2032
2420
  init_navigators();
2033
2421
  init_logger();
2034
2422
  init_orchestration();
2423
+ init_PipelineDebugger();
2035
2424
  Pipeline = class extends ContentNavigator {
2036
2425
  generator;
2037
2426
  filters;
@@ -2079,12 +2468,49 @@ var init_Pipeline = __esm({
2079
2468
  );
2080
2469
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2081
2470
  const generatedCount = cards.length;
2471
+ let generatorSummaries;
2472
+ if (this.generator.generators) {
2473
+ const genMap = /* @__PURE__ */ new Map();
2474
+ for (const card of cards) {
2475
+ const firstProv = card.provenance[0];
2476
+ if (firstProv) {
2477
+ const genName = firstProv.strategyName;
2478
+ if (!genMap.has(genName)) {
2479
+ genMap.set(genName, { cards: [] });
2480
+ }
2481
+ genMap.get(genName).cards.push(card);
2482
+ }
2483
+ }
2484
+ generatorSummaries = Array.from(genMap.entries()).map(([name, data]) => {
2485
+ const newCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
2486
+ const reviewCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
2487
+ return {
2488
+ name,
2489
+ cardCount: data.cards.length,
2490
+ newCount: newCards.length,
2491
+ reviewCount: reviewCards.length,
2492
+ topScore: Math.max(...data.cards.map((c) => c.score), 0)
2493
+ };
2494
+ });
2495
+ }
2082
2496
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2083
2497
  cards = await this.hydrateTags(cards);
2498
+ const allCardsBeforeFiltering = [...cards];
2499
+ const filterImpacts = [];
2084
2500
  for (const filter of this.filters) {
2085
2501
  const beforeCount = cards.length;
2502
+ const beforeScores = new Map(cards.map((c) => [c.cardId, c.score]));
2086
2503
  cards = await filter.transform(cards, context);
2087
- logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
2504
+ let boosted = 0, penalized = 0, passed = 0;
2505
+ const removed = beforeCount - cards.length;
2506
+ for (const card of cards) {
2507
+ const before = beforeScores.get(card.cardId) ?? 0;
2508
+ if (card.score > before) boosted++;
2509
+ else if (card.score < before) penalized++;
2510
+ else passed++;
2511
+ }
2512
+ filterImpacts.push({ name: filter.name, boosted, penalized, passed, removed });
2513
+ logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2088
2514
  }
2089
2515
  cards = cards.filter((c) => c.score > 0);
2090
2516
  cards.sort((a, b) => b.score - a.score);
@@ -2095,9 +2521,26 @@ var init_Pipeline = __esm({
2095
2521
  generatedCount,
2096
2522
  this.filters.length,
2097
2523
  result.length,
2098
- topScores
2524
+ topScores,
2525
+ filterImpacts
2099
2526
  );
2100
2527
  logCardProvenance(result, 3);
2528
+ try {
2529
+ const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
2530
+ const report = buildRunReport(
2531
+ this.course?.getCourseID() || "unknown",
2532
+ courseName,
2533
+ this.generator.name,
2534
+ generatorSummaries,
2535
+ generatedCount,
2536
+ filterImpacts,
2537
+ allCardsBeforeFiltering,
2538
+ result
2539
+ );
2540
+ captureRun(report);
2541
+ } catch (e) {
2542
+ logger.debug(`[Pipeline] Failed to capture debug run: ${e}`);
2543
+ }
2101
2544
  return result;
2102
2545
  }
2103
2546
  /**
@@ -2187,6 +2630,56 @@ var init_Pipeline = __esm({
2187
2630
  }
2188
2631
  });
2189
2632
 
2633
+ // src/core/navigators/defaults.ts
2634
+ var defaults_exports = {};
2635
+ __export(defaults_exports, {
2636
+ createDefaultEloStrategy: () => createDefaultEloStrategy,
2637
+ createDefaultPipeline: () => createDefaultPipeline,
2638
+ createDefaultSrsStrategy: () => createDefaultSrsStrategy
2639
+ });
2640
+ function createDefaultEloStrategy(courseId) {
2641
+ return {
2642
+ _id: "NAVIGATION_STRATEGY-ELO-default",
2643
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2644
+ name: "ELO (default)",
2645
+ description: "Default ELO-based navigation strategy for new cards",
2646
+ implementingClass: "elo" /* ELO */,
2647
+ course: courseId,
2648
+ serializedData: ""
2649
+ };
2650
+ }
2651
+ function createDefaultSrsStrategy(courseId) {
2652
+ return {
2653
+ _id: "NAVIGATION_STRATEGY-SRS-default",
2654
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2655
+ name: "SRS (default)",
2656
+ description: "Default SRS-based navigation strategy for reviews",
2657
+ implementingClass: "srs" /* SRS */,
2658
+ course: courseId,
2659
+ serializedData: ""
2660
+ };
2661
+ }
2662
+ function createDefaultPipeline(user, course) {
2663
+ const courseId = course.getCourseID();
2664
+ const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
2665
+ const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
2666
+ const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
2667
+ const eloDistanceFilter = createEloDistanceFilter();
2668
+ return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
2669
+ }
2670
+ var init_defaults = __esm({
2671
+ "src/core/navigators/defaults.ts"() {
2672
+ "use strict";
2673
+ init_navigators();
2674
+ init_Pipeline();
2675
+ init_CompositeGenerator();
2676
+ init_elo();
2677
+ init_srs();
2678
+ init_eloDistance();
2679
+ init_types_legacy();
2680
+ }
2681
+ });
2682
+
2190
2683
  // src/core/navigators/PipelineAssembler.ts
2191
2684
  var PipelineAssembler_exports = {};
2192
2685
  __export(PipelineAssembler_exports, {
@@ -2199,9 +2692,9 @@ var init_PipelineAssembler = __esm({
2199
2692
  init_navigators();
2200
2693
  init_WeightedFilter();
2201
2694
  init_Pipeline();
2202
- init_types_legacy();
2203
2695
  init_logger();
2204
2696
  init_CompositeGenerator();
2697
+ init_defaults();
2205
2698
  PipelineAssembler = class {
2206
2699
  /**
2207
2700
  * Assembles a navigation pipeline from strategy documents.
@@ -2240,9 +2733,11 @@ var init_PipelineAssembler = __esm({
2240
2733
  if (generatorStrategies.length === 0) {
2241
2734
  if (filterStrategies.length > 0) {
2242
2735
  logger.debug(
2243
- "[PipelineAssembler] No generator found, using default ELO with configured filters"
2736
+ "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2244
2737
  );
2245
- generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
2738
+ const courseId = course.getCourseID();
2739
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
2740
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
2246
2741
  } else {
2247
2742
  warnings.push("No generator strategy found");
2248
2743
  return {
@@ -2303,75 +2798,10 @@ var init_PipelineAssembler = __esm({
2303
2798
  warnings
2304
2799
  };
2305
2800
  }
2306
- /**
2307
- * Creates a default ELO generator strategy.
2308
- * Used when filters are configured but no generator is specified.
2309
- */
2310
- makeDefaultEloStrategy(courseId) {
2311
- return {
2312
- _id: "NAVIGATION_STRATEGY-ELO-default",
2313
- course: courseId,
2314
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2315
- name: "ELO (default)",
2316
- description: "Default ELO-based generator",
2317
- implementingClass: "elo" /* ELO */,
2318
- serializedData: ""
2319
- };
2320
- }
2321
2801
  };
2322
2802
  }
2323
2803
  });
2324
2804
 
2325
- // src/core/navigators/defaults.ts
2326
- var defaults_exports = {};
2327
- __export(defaults_exports, {
2328
- createDefaultEloStrategy: () => createDefaultEloStrategy,
2329
- createDefaultPipeline: () => createDefaultPipeline,
2330
- createDefaultSrsStrategy: () => createDefaultSrsStrategy
2331
- });
2332
- function createDefaultEloStrategy(courseId) {
2333
- return {
2334
- _id: "NAVIGATION_STRATEGY-ELO-default",
2335
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2336
- name: "ELO (default)",
2337
- description: "Default ELO-based navigation strategy for new cards",
2338
- implementingClass: "elo" /* ELO */,
2339
- course: courseId,
2340
- serializedData: ""
2341
- };
2342
- }
2343
- function createDefaultSrsStrategy(courseId) {
2344
- return {
2345
- _id: "NAVIGATION_STRATEGY-SRS-default",
2346
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2347
- name: "SRS (default)",
2348
- description: "Default SRS-based navigation strategy for reviews",
2349
- implementingClass: "srs" /* SRS */,
2350
- course: courseId,
2351
- serializedData: ""
2352
- };
2353
- }
2354
- function createDefaultPipeline(user, course) {
2355
- const courseId = course.getCourseID();
2356
- const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
2357
- const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
2358
- const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
2359
- const eloDistanceFilter = createEloDistanceFilter();
2360
- return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
2361
- }
2362
- var init_defaults = __esm({
2363
- "src/core/navigators/defaults.ts"() {
2364
- "use strict";
2365
- init_navigators();
2366
- init_Pipeline();
2367
- init_CompositeGenerator();
2368
- init_elo();
2369
- init_srs();
2370
- init_eloDistance();
2371
- init_types_legacy();
2372
- }
2373
- });
2374
-
2375
2805
  // import("./**/*") in src/core/navigators/index.ts
2376
2806
  var globImport;
2377
2807
  var init_3 = __esm({
@@ -2379,6 +2809,7 @@ var init_3 = __esm({
2379
2809
  globImport = __glob({
2380
2810
  "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
2381
2811
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
2812
+ "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
2382
2813
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
2383
2814
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
2384
2815
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
@@ -2414,6 +2845,8 @@ __export(navigators_exports, {
2414
2845
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
2415
2846
  isFilter: () => isFilter,
2416
2847
  isGenerator: () => isGenerator,
2848
+ mountPipelineDebugger: () => mountPipelineDebugger,
2849
+ pipelineDebugAPI: () => pipelineDebugAPI,
2417
2850
  registerNavigator: () => registerNavigator
2418
2851
  });
2419
2852
  function registerNavigator(implementingClass, constructor) {
@@ -2480,6 +2913,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
2480
2913
  var init_navigators = __esm({
2481
2914
  "src/core/navigators/index.ts"() {
2482
2915
  "use strict";
2916
+ init_PipelineDebugger();
2483
2917
  init_logger();
2484
2918
  init_();
2485
2919
  init_2();
@@ -4150,6 +4584,15 @@ var init_user_course_relDB = __esm({
4150
4584
  void this.user.updateCourseSettings(this._courseId, updates);
4151
4585
  }
4152
4586
  }
4587
+ async getStrategyState(strategyKey) {
4588
+ return this.user.getStrategyState(this._courseId, strategyKey);
4589
+ }
4590
+ async putStrategyState(strategyKey, data) {
4591
+ return this.user.putStrategyState(this._courseId, strategyKey, data);
4592
+ }
4593
+ async deleteStrategyState(strategyKey) {
4594
+ return this.user.deleteStrategyState(this._courseId, strategyKey);
4595
+ }
4153
4596
  async getReviewstoDate(targetDate) {
4154
4597
  const allReviews = await this.user.getPendingReviews(this._courseId);
4155
4598
  logger.debug(