@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
@@ -471,6 +471,15 @@ var init_user_course_relDB = __esm({
471
471
  void this.user.updateCourseSettings(this._courseId, updates);
472
472
  }
473
473
  }
474
+ async getStrategyState(strategyKey) {
475
+ return this.user.getStrategyState(this._courseId, strategyKey);
476
+ }
477
+ async putStrategyState(strategyKey, data) {
478
+ return this.user.putStrategyState(this._courseId, strategyKey, data);
479
+ }
480
+ async deleteStrategyState(strategyKey) {
481
+ return this.user.deleteStrategyState(this._courseId, strategyKey);
482
+ }
474
483
  async getReviewstoDate(targetDate) {
475
484
  const allReviews = await this.user.getPendingReviews(this._courseId);
476
485
  logger.debug(
@@ -710,6 +719,271 @@ var init_courseLookupDB = __esm({
710
719
  }
711
720
  });
712
721
 
722
+ // src/core/navigators/PipelineDebugger.ts
723
+ var PipelineDebugger_exports = {};
724
+ __export(PipelineDebugger_exports, {
725
+ buildRunReport: () => buildRunReport,
726
+ captureRun: () => captureRun,
727
+ mountPipelineDebugger: () => mountPipelineDebugger,
728
+ pipelineDebugAPI: () => pipelineDebugAPI
729
+ });
730
+ function getOrigin(card) {
731
+ const firstEntry = card.provenance[0];
732
+ if (!firstEntry) return "unknown";
733
+ const reason = firstEntry.reason?.toLowerCase() || "";
734
+ if (reason.includes("new card")) return "new";
735
+ if (reason.includes("review")) return "review";
736
+ return "unknown";
737
+ }
738
+ function captureRun(report) {
739
+ const fullReport = {
740
+ ...report,
741
+ runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
742
+ timestamp: /* @__PURE__ */ new Date()
743
+ };
744
+ runHistory.unshift(fullReport);
745
+ if (runHistory.length > MAX_RUNS) {
746
+ runHistory.pop();
747
+ }
748
+ }
749
+ function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards) {
750
+ const selectedIds = new Set(selectedCards.map((c) => c.cardId));
751
+ const cards = allCards.map((card) => ({
752
+ cardId: card.cardId,
753
+ courseId: card.courseId,
754
+ origin: getOrigin(card),
755
+ finalScore: card.score,
756
+ provenance: card.provenance,
757
+ selected: selectedIds.has(card.cardId)
758
+ }));
759
+ const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
760
+ const newSelected = selectedCards.filter((c) => getOrigin(c) === "new").length;
761
+ return {
762
+ courseId,
763
+ courseName,
764
+ generatorName,
765
+ generators,
766
+ generatedCount,
767
+ filters,
768
+ finalCount: selectedCards.length,
769
+ reviewsSelected,
770
+ newSelected,
771
+ cards
772
+ };
773
+ }
774
+ function formatProvenance(provenance) {
775
+ return provenance.map((p) => {
776
+ const actionSymbol = p.action === "generated" ? "\u{1F3B2}" : p.action === "boosted" ? "\u2B06\uFE0F" : p.action === "penalized" ? "\u2B07\uFE0F" : "\u27A1\uFE0F";
777
+ return ` ${actionSymbol} ${p.strategyName}: ${p.score.toFixed(3)} - ${p.reason}`;
778
+ }).join("\n");
779
+ }
780
+ function printRunSummary(run) {
781
+ console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
782
+ logger.info(`Run ID: ${run.runId}`);
783
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
784
+ logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
785
+ if (run.generators && run.generators.length > 0) {
786
+ console.group("Generator breakdown:");
787
+ for (const g of run.generators) {
788
+ logger.info(
789
+ ` ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews, top: ${g.topScore.toFixed(2)})`
790
+ );
791
+ }
792
+ console.groupEnd();
793
+ }
794
+ if (run.filters.length > 0) {
795
+ console.group("Filter impact:");
796
+ for (const f of run.filters) {
797
+ logger.info(` ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
798
+ }
799
+ console.groupEnd();
800
+ }
801
+ logger.info(
802
+ `Result: ${run.finalCount} cards selected (${run.newSelected} new, ${run.reviewsSelected} reviews)`
803
+ );
804
+ console.groupEnd();
805
+ }
806
+ function mountPipelineDebugger() {
807
+ if (typeof window === "undefined") return;
808
+ const win = window;
809
+ win.skuilder = win.skuilder || {};
810
+ win.skuilder.pipeline = pipelineDebugAPI;
811
+ }
812
+ var MAX_RUNS, runHistory, pipelineDebugAPI;
813
+ var init_PipelineDebugger = __esm({
814
+ "src/core/navigators/PipelineDebugger.ts"() {
815
+ "use strict";
816
+ init_logger();
817
+ MAX_RUNS = 10;
818
+ runHistory = [];
819
+ pipelineDebugAPI = {
820
+ /**
821
+ * Get raw run history for programmatic access.
822
+ */
823
+ get runs() {
824
+ return [...runHistory];
825
+ },
826
+ /**
827
+ * Show summary of a specific pipeline run.
828
+ */
829
+ showRun(idOrIndex = 0) {
830
+ if (runHistory.length === 0) {
831
+ logger.info("[Pipeline Debug] No runs captured yet.");
832
+ return;
833
+ }
834
+ let run;
835
+ if (typeof idOrIndex === "number") {
836
+ run = runHistory[idOrIndex];
837
+ if (!run) {
838
+ logger.info(
839
+ `[Pipeline Debug] No run found at index ${idOrIndex}. History length: ${runHistory.length}`
840
+ );
841
+ return;
842
+ }
843
+ } else {
844
+ run = runHistory.find((r) => r.runId.endsWith(idOrIndex));
845
+ if (!run) {
846
+ logger.info(`[Pipeline Debug] No run found matching ID '${idOrIndex}'.`);
847
+ return;
848
+ }
849
+ }
850
+ printRunSummary(run);
851
+ },
852
+ /**
853
+ * Show summary of the last pipeline run.
854
+ */
855
+ showLastRun() {
856
+ this.showRun(0);
857
+ },
858
+ /**
859
+ * Show detailed provenance for a specific card.
860
+ */
861
+ showCard(cardId) {
862
+ for (const run of runHistory) {
863
+ const card = run.cards.find((c) => c.cardId === cardId);
864
+ if (card) {
865
+ console.group(`\u{1F3B4} Card: ${cardId}`);
866
+ logger.info(`Course: ${card.courseId}`);
867
+ logger.info(`Origin: ${card.origin}`);
868
+ logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
869
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
870
+ logger.info("Provenance:");
871
+ logger.info(formatProvenance(card.provenance));
872
+ console.groupEnd();
873
+ return;
874
+ }
875
+ }
876
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
877
+ },
878
+ /**
879
+ * Explain why reviews may or may not have been selected.
880
+ */
881
+ explainReviews() {
882
+ if (runHistory.length === 0) {
883
+ logger.info("[Pipeline Debug] No runs captured yet.");
884
+ return;
885
+ }
886
+ console.group("\u{1F4CB} Review Selection Analysis");
887
+ for (const run of runHistory) {
888
+ console.group(`Run: ${run.courseId} @ ${run.timestamp.toLocaleTimeString()}`);
889
+ const allReviews = run.cards.filter((c) => c.origin === "review");
890
+ const selectedReviews = allReviews.filter((c) => c.selected);
891
+ if (allReviews.length === 0) {
892
+ logger.info("\u274C No reviews were generated. Check SRS logs for why.");
893
+ } else if (selectedReviews.length === 0) {
894
+ logger.info(`\u26A0\uFE0F ${allReviews.length} reviews generated but none selected.`);
895
+ logger.info("Possible reasons:");
896
+ const topNewScore = Math.max(
897
+ ...run.cards.filter((c) => c.origin === "new" && c.selected).map((c) => c.finalScore),
898
+ 0
899
+ );
900
+ const topReviewScore = Math.max(...allReviews.map((c) => c.finalScore), 0);
901
+ if (topReviewScore < topNewScore) {
902
+ logger.info(
903
+ ` - New cards scored higher (top new: ${topNewScore.toFixed(2)}, top review: ${topReviewScore.toFixed(2)})`
904
+ );
905
+ }
906
+ const topReview = allReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
907
+ if (topReview) {
908
+ logger.info(` - Top review score: ${topReview.finalScore.toFixed(3)}`);
909
+ logger.info(" - Its provenance:");
910
+ logger.info(formatProvenance(topReview.provenance));
911
+ }
912
+ } else {
913
+ logger.info(`\u2705 ${selectedReviews.length}/${allReviews.length} reviews selected.`);
914
+ logger.info("Top selected review:");
915
+ const topSelected = selectedReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
916
+ logger.info(formatProvenance(topSelected.provenance));
917
+ }
918
+ console.groupEnd();
919
+ }
920
+ console.groupEnd();
921
+ },
922
+ /**
923
+ * Show all runs in compact format.
924
+ */
925
+ listRuns() {
926
+ if (runHistory.length === 0) {
927
+ logger.info("[Pipeline Debug] No runs captured yet.");
928
+ return;
929
+ }
930
+ console.table(
931
+ runHistory.map((r) => ({
932
+ id: r.runId.slice(-8),
933
+ time: r.timestamp.toLocaleTimeString(),
934
+ course: r.courseName || r.courseId.slice(0, 8),
935
+ generated: r.generatedCount,
936
+ selected: r.finalCount,
937
+ new: r.newSelected,
938
+ reviews: r.reviewsSelected
939
+ }))
940
+ );
941
+ },
942
+ /**
943
+ * Export run history as JSON for bug reports.
944
+ */
945
+ export() {
946
+ const json = JSON.stringify(runHistory, null, 2);
947
+ logger.info("[Pipeline Debug] Run history exported. Copy the returned string or use:");
948
+ logger.info(" copy(window.skuilder.pipeline.export())");
949
+ return json;
950
+ },
951
+ /**
952
+ * Clear run history.
953
+ */
954
+ clear() {
955
+ runHistory.length = 0;
956
+ logger.info("[Pipeline Debug] Run history cleared.");
957
+ },
958
+ /**
959
+ * Show help.
960
+ */
961
+ help() {
962
+ logger.info(`
963
+ \u{1F527} Pipeline Debug API
964
+
965
+ Commands:
966
+ .showLastRun() Show summary of most recent pipeline run
967
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
968
+ .showCard(cardId) Show provenance trail for a specific card
969
+ .explainReviews() Analyze why reviews were/weren't selected
970
+ .listRuns() List all captured runs in table format
971
+ .export() Export run history as JSON for bug reports
972
+ .clear() Clear run history
973
+ .runs Access raw run history array
974
+ .help() Show this help message
975
+
976
+ Example:
977
+ window.skuilder.pipeline.showLastRun()
978
+ window.skuilder.pipeline.showRun(1)
979
+ window.skuilder.pipeline.showCard('abc123')
980
+ `);
981
+ }
982
+ };
983
+ mountPipelineDebugger();
984
+ }
985
+ });
986
+
713
987
  // src/core/navigators/generators/CompositeGenerator.ts
714
988
  var CompositeGenerator_exports = {};
715
989
  __export(CompositeGenerator_exports, {
@@ -778,6 +1052,24 @@ var init_CompositeGenerator = __esm({
778
1052
  const results = await Promise.all(
779
1053
  this.generators.map((g) => g.getWeightedCards(limit, context))
780
1054
  );
1055
+ const generatorSummaries = [];
1056
+ results.forEach((cards, index) => {
1057
+ const gen = this.generators[index];
1058
+ const genName = gen.name || `Generator ${index}`;
1059
+ const newCards = cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
1060
+ const reviewCards = cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
1061
+ if (cards.length > 0) {
1062
+ const topScore = Math.max(...cards.map((c) => c.score)).toFixed(2);
1063
+ const parts = [];
1064
+ if (newCards.length > 0) parts.push(`${newCards.length} new`);
1065
+ if (reviewCards.length > 0) parts.push(`${reviewCards.length} reviews`);
1066
+ const breakdown = parts.length > 0 ? parts.join(", ") : `${cards.length} cards`;
1067
+ generatorSummaries.push(`${genName}: ${breakdown} (top: ${topScore})`);
1068
+ } else {
1069
+ generatorSummaries.push(`${genName}: 0 cards`);
1070
+ }
1071
+ });
1072
+ logger.info(`[Composite] Generator breakdown: ${generatorSummaries.join(" | ")}`);
781
1073
  const byCardId = /* @__PURE__ */ new Map();
782
1074
  results.forEach((cards, index) => {
783
1075
  const gen = this.generators[index];
@@ -895,6 +1187,7 @@ var init_elo = __esm({
895
1187
  "use strict";
896
1188
  init_navigators();
897
1189
  import_common5 = require("@vue-skuilder/common");
1190
+ init_logger();
898
1191
  ELONavigator = class extends ContentNavigator {
899
1192
  /** Human-readable name for CardGenerator interface */
900
1193
  name;
@@ -954,7 +1247,16 @@ var init_elo = __esm({
954
1247
  };
955
1248
  });
956
1249
  scored.sort((a, b) => b.score - a.score);
957
- return scored.slice(0, limit);
1250
+ const result = scored.slice(0, limit);
1251
+ if (result.length > 0) {
1252
+ const topScores = result.slice(0, 3).map((c) => c.score.toFixed(2)).join(", ");
1253
+ logger.info(
1254
+ `[ELO] Course ${this.course.getCourseID()}: ${result.length} new cards (top scores: ${topScores})`
1255
+ );
1256
+ } else {
1257
+ logger.info(`[ELO] Course ${this.course.getCourseID()}: No new cards available`);
1258
+ }
1259
+ return result;
958
1260
  }
959
1261
  };
960
1262
  }
@@ -973,19 +1275,37 @@ var srs_exports = {};
973
1275
  __export(srs_exports, {
974
1276
  default: () => SRSNavigator
975
1277
  });
976
- var import_moment3, SRSNavigator;
1278
+ var import_moment3, DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_PRESSURE, SRSNavigator;
977
1279
  var init_srs = __esm({
978
1280
  "src/core/navigators/generators/srs.ts"() {
979
1281
  "use strict";
980
1282
  import_moment3 = __toESM(require("moment"), 1);
981
1283
  init_navigators();
982
1284
  init_logger();
1285
+ DEFAULT_HEALTHY_BACKLOG = 20;
1286
+ MAX_BACKLOG_PRESSURE = 0.5;
983
1287
  SRSNavigator = class extends ContentNavigator {
984
1288
  /** Human-readable name for CardGenerator interface */
985
1289
  name;
1290
+ /** Healthy backlog threshold - when exceeded, backlog pressure kicks in */
1291
+ healthyBacklog;
986
1292
  constructor(user, course, strategyData) {
987
1293
  super(user, course, strategyData);
988
1294
  this.name = strategyData?.name || "SRS";
1295
+ const config = this.parseConfig(strategyData?.serializedData);
1296
+ this.healthyBacklog = config.healthyBacklog ?? DEFAULT_HEALTHY_BACKLOG;
1297
+ }
1298
+ /**
1299
+ * Parse configuration from serialized JSON.
1300
+ */
1301
+ parseConfig(serializedData) {
1302
+ if (!serializedData) return {};
1303
+ try {
1304
+ return JSON.parse(serializedData);
1305
+ } catch {
1306
+ logger.warn("[SRS] Failed to parse strategy config, using defaults");
1307
+ return {};
1308
+ }
989
1309
  }
990
1310
  /**
991
1311
  * Get review cards scored by urgency.
@@ -993,6 +1313,7 @@ var init_srs = __esm({
993
1313
  * Score formula combines:
994
1314
  * - Relative overdueness: hoursOverdue / intervalHours
995
1315
  * - Interval recency: exponential decay favoring shorter intervals
1316
+ * - Backlog pressure: boost when due reviews exceed healthy threshold
996
1317
  *
997
1318
  * Cards not yet due are excluded (not scored as 0).
998
1319
  *
@@ -1006,11 +1327,32 @@ var init_srs = __esm({
1006
1327
  if (!this.user || !this.course) {
1007
1328
  throw new Error("SRSNavigator requires user and course to be set");
1008
1329
  }
1009
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1330
+ const courseId = this.course.getCourseID();
1331
+ const reviews = await this.user.getPendingReviews(courseId);
1010
1332
  const now = import_moment3.default.utc();
1011
1333
  const dueReviews = reviews.filter((r) => now.isAfter(import_moment3.default.utc(r.reviewTime)));
1334
+ const backlogPressure = this.computeBacklogPressure(dueReviews.length);
1335
+ if (dueReviews.length > 0) {
1336
+ const pressureNote = backlogPressure > 0 ? ` [backlog pressure: +${backlogPressure.toFixed(2)}]` : ` [healthy backlog]`;
1337
+ logger.info(
1338
+ `[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
1339
+ );
1340
+ } else if (reviews.length > 0) {
1341
+ const sortedByDue = [...reviews].sort(
1342
+ (a, b) => import_moment3.default.utc(a.reviewTime).diff(import_moment3.default.utc(b.reviewTime))
1343
+ );
1344
+ const nextDue = sortedByDue[0];
1345
+ const nextDueTime = import_moment3.default.utc(nextDue.reviewTime);
1346
+ const untilDue = import_moment3.default.duration(nextDueTime.diff(now));
1347
+ const untilDueStr = untilDue.asHours() < 1 ? `${Math.round(untilDue.asMinutes())}m` : untilDue.asHours() < 24 ? `${Math.round(untilDue.asHours())}h` : `${Math.round(untilDue.asDays())}d`;
1348
+ logger.info(
1349
+ `[SRS] Course ${courseId}: 0 reviews due now (${reviews.length} scheduled, next in ${untilDueStr})`
1350
+ );
1351
+ } else {
1352
+ logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
1353
+ }
1012
1354
  const scored = dueReviews.map((review) => {
1013
- const { score, reason } = this.computeUrgencyScore(review, now);
1355
+ const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
1014
1356
  return {
1015
1357
  cardId: review.cardId,
1016
1358
  courseId: review.courseId,
@@ -1028,13 +1370,35 @@ var init_srs = __esm({
1028
1370
  ]
1029
1371
  };
1030
1372
  });
1031
- logger.debug(`[srsNav] got ${scored.length} weighted cards`);
1032
1373
  return scored.sort((a, b) => b.score - a.score).slice(0, limit);
1033
1374
  }
1375
+ /**
1376
+ * Compute backlog pressure based on number of due reviews.
1377
+ *
1378
+ * Backlog pressure is 0 when at or below healthy threshold,
1379
+ * and increases linearly above it, maxing out at MAX_BACKLOG_PRESSURE.
1380
+ *
1381
+ * Examples (with default healthyBacklog=20):
1382
+ * - 10 due reviews → 0.00 (healthy)
1383
+ * - 20 due reviews → 0.00 (at threshold)
1384
+ * - 40 due reviews → 0.25 (2x threshold)
1385
+ * - 60 due reviews → 0.50 (3x threshold, maxed)
1386
+ *
1387
+ * @param dueCount - Number of reviews currently due
1388
+ * @returns Backlog pressure score to add to urgency (0 to MAX_BACKLOG_PRESSURE)
1389
+ */
1390
+ computeBacklogPressure(dueCount) {
1391
+ if (dueCount <= this.healthyBacklog) {
1392
+ return 0;
1393
+ }
1394
+ const excess = dueCount - this.healthyBacklog;
1395
+ const pressure = excess / this.healthyBacklog * (MAX_BACKLOG_PRESSURE / 2);
1396
+ return Math.min(MAX_BACKLOG_PRESSURE, pressure);
1397
+ }
1034
1398
  /**
1035
1399
  * Compute urgency score for a review card.
1036
1400
  *
1037
- * Two factors:
1401
+ * Three factors:
1038
1402
  * 1. Relative overdueness = hoursOverdue / intervalHours
1039
1403
  * - 2 days overdue on 3-day interval = 0.67 (urgent)
1040
1404
  * - 2 days overdue on 180-day interval = 0.01 (not urgent)
@@ -1044,10 +1408,19 @@ var init_srs = __esm({
1044
1408
  * - 30 days (720h) → ~0.56
1045
1409
  * - 180 days → ~0.30
1046
1410
  *
1047
- * Combined: base 0.5 + weighted average of factors * 0.45
1048
- * Result range: approximately 0.5 to 0.95
1411
+ * 3. Backlog pressure = global boost when review backlog exceeds healthy threshold
1412
+ * - At healthy backlog: 0
1413
+ * - At 2x healthy: +0.25
1414
+ * - At 3x+ healthy: +0.50 (max)
1415
+ *
1416
+ * Combined: base 0.5 + (urgency factors * 0.45) + backlog pressure
1417
+ * Result range: 0.5 to 1.0 (uncapped to allow high-urgency reviews to compete with new cards)
1418
+ *
1419
+ * @param review - The scheduled card to score
1420
+ * @param now - Current time
1421
+ * @param backlogPressure - Pre-computed backlog pressure (0 to 0.5)
1049
1422
  */
1050
- computeUrgencyScore(review, now) {
1423
+ computeUrgencyScore(review, now, backlogPressure) {
1051
1424
  const scheduledAt = import_moment3.default.utc(review.scheduledAt);
1052
1425
  const due = import_moment3.default.utc(review.reviewTime);
1053
1426
  const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
@@ -1056,8 +1429,19 @@ var init_srs = __esm({
1056
1429
  const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
1057
1430
  const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
1058
1431
  const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
1059
- const score = Math.min(0.95, 0.5 + urgency * 0.45);
1060
- const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
1432
+ const baseScore = 0.5 + urgency * 0.45;
1433
+ const score = Math.min(1, baseScore + backlogPressure);
1434
+ const reasonParts = [
1435
+ `${Math.round(hoursOverdue)}h overdue`,
1436
+ `interval: ${Math.round(intervalHours)}h`,
1437
+ `relative: ${relativeOverdue.toFixed(2)}`,
1438
+ `recency: ${recencyFactor.toFixed(2)}`
1439
+ ];
1440
+ if (backlogPressure > 0) {
1441
+ reasonParts.push(`backlog: +${backlogPressure.toFixed(2)}`);
1442
+ }
1443
+ reasonParts.push("review");
1444
+ const reason = reasonParts.join(", ");
1061
1445
  return { score, reason };
1062
1446
  }
1063
1447
  };
@@ -2333,10 +2717,23 @@ function logTagHydration(cards, tagsByCard) {
2333
2717
  `[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
2334
2718
  );
2335
2719
  }
2336
- function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
2720
+ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores, filterImpacts) {
2337
2721
  const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
2722
+ let filterSummary = "";
2723
+ if (filterImpacts.length > 0) {
2724
+ const impacts = filterImpacts.map((f) => {
2725
+ const parts = [];
2726
+ if (f.boosted > 0) parts.push(`+${f.boosted}`);
2727
+ if (f.penalized > 0) parts.push(`-${f.penalized}`);
2728
+ if (f.passed > 0) parts.push(`=${f.passed}`);
2729
+ return `${f.name}: ${parts.join("/")}`;
2730
+ });
2731
+ filterSummary = `
2732
+ Filter impact: ${impacts.join(", ")}`;
2733
+ }
2338
2734
  logger.info(
2339
- `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
2735
+ `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})` + filterSummary + `
2736
+ \u{1F4A1} Inspect: window.skuilder.pipeline`
2340
2737
  );
2341
2738
  }
2342
2739
  function logCardProvenance(cards, maxCards = 3) {
@@ -2361,6 +2758,7 @@ var init_Pipeline = __esm({
2361
2758
  init_navigators();
2362
2759
  init_logger();
2363
2760
  init_orchestration();
2761
+ init_PipelineDebugger();
2364
2762
  Pipeline = class extends ContentNavigator {
2365
2763
  generator;
2366
2764
  filters;
@@ -2408,12 +2806,49 @@ var init_Pipeline = __esm({
2408
2806
  );
2409
2807
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2410
2808
  const generatedCount = cards.length;
2809
+ let generatorSummaries;
2810
+ if (this.generator.generators) {
2811
+ const genMap = /* @__PURE__ */ new Map();
2812
+ for (const card of cards) {
2813
+ const firstProv = card.provenance[0];
2814
+ if (firstProv) {
2815
+ const genName = firstProv.strategyName;
2816
+ if (!genMap.has(genName)) {
2817
+ genMap.set(genName, { cards: [] });
2818
+ }
2819
+ genMap.get(genName).cards.push(card);
2820
+ }
2821
+ }
2822
+ generatorSummaries = Array.from(genMap.entries()).map(([name, data]) => {
2823
+ const newCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
2824
+ const reviewCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
2825
+ return {
2826
+ name,
2827
+ cardCount: data.cards.length,
2828
+ newCount: newCards.length,
2829
+ reviewCount: reviewCards.length,
2830
+ topScore: Math.max(...data.cards.map((c) => c.score), 0)
2831
+ };
2832
+ });
2833
+ }
2411
2834
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2412
2835
  cards = await this.hydrateTags(cards);
2836
+ const allCardsBeforeFiltering = [...cards];
2837
+ const filterImpacts = [];
2413
2838
  for (const filter of this.filters) {
2414
2839
  const beforeCount = cards.length;
2840
+ const beforeScores = new Map(cards.map((c) => [c.cardId, c.score]));
2415
2841
  cards = await filter.transform(cards, context);
2416
- logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
2842
+ let boosted = 0, penalized = 0, passed = 0;
2843
+ const removed = beforeCount - cards.length;
2844
+ for (const card of cards) {
2845
+ const before = beforeScores.get(card.cardId) ?? 0;
2846
+ if (card.score > before) boosted++;
2847
+ else if (card.score < before) penalized++;
2848
+ else passed++;
2849
+ }
2850
+ filterImpacts.push({ name: filter.name, boosted, penalized, passed, removed });
2851
+ logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2417
2852
  }
2418
2853
  cards = cards.filter((c) => c.score > 0);
2419
2854
  cards.sort((a, b) => b.score - a.score);
@@ -2424,9 +2859,26 @@ var init_Pipeline = __esm({
2424
2859
  generatedCount,
2425
2860
  this.filters.length,
2426
2861
  result.length,
2427
- topScores
2862
+ topScores,
2863
+ filterImpacts
2428
2864
  );
2429
2865
  logCardProvenance(result, 3);
2866
+ try {
2867
+ const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
2868
+ const report = buildRunReport(
2869
+ this.course?.getCourseID() || "unknown",
2870
+ courseName,
2871
+ this.generator.name,
2872
+ generatorSummaries,
2873
+ generatedCount,
2874
+ filterImpacts,
2875
+ allCardsBeforeFiltering,
2876
+ result
2877
+ );
2878
+ captureRun(report);
2879
+ } catch (e) {
2880
+ logger.debug(`[Pipeline] Failed to capture debug run: ${e}`);
2881
+ }
2430
2882
  return result;
2431
2883
  }
2432
2884
  /**
@@ -2516,6 +2968,56 @@ var init_Pipeline = __esm({
2516
2968
  }
2517
2969
  });
2518
2970
 
2971
+ // src/core/navigators/defaults.ts
2972
+ var defaults_exports = {};
2973
+ __export(defaults_exports, {
2974
+ createDefaultEloStrategy: () => createDefaultEloStrategy,
2975
+ createDefaultPipeline: () => createDefaultPipeline,
2976
+ createDefaultSrsStrategy: () => createDefaultSrsStrategy
2977
+ });
2978
+ function createDefaultEloStrategy(courseId) {
2979
+ return {
2980
+ _id: "NAVIGATION_STRATEGY-ELO-default",
2981
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2982
+ name: "ELO (default)",
2983
+ description: "Default ELO-based navigation strategy for new cards",
2984
+ implementingClass: "elo" /* ELO */,
2985
+ course: courseId,
2986
+ serializedData: ""
2987
+ };
2988
+ }
2989
+ function createDefaultSrsStrategy(courseId) {
2990
+ return {
2991
+ _id: "NAVIGATION_STRATEGY-SRS-default",
2992
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2993
+ name: "SRS (default)",
2994
+ description: "Default SRS-based navigation strategy for reviews",
2995
+ implementingClass: "srs" /* SRS */,
2996
+ course: courseId,
2997
+ serializedData: ""
2998
+ };
2999
+ }
3000
+ function createDefaultPipeline(user, course) {
3001
+ const courseId = course.getCourseID();
3002
+ const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
3003
+ const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
3004
+ const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
3005
+ const eloDistanceFilter = createEloDistanceFilter();
3006
+ return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
3007
+ }
3008
+ var init_defaults = __esm({
3009
+ "src/core/navigators/defaults.ts"() {
3010
+ "use strict";
3011
+ init_navigators();
3012
+ init_Pipeline();
3013
+ init_CompositeGenerator();
3014
+ init_elo();
3015
+ init_srs();
3016
+ init_eloDistance();
3017
+ init_types_legacy();
3018
+ }
3019
+ });
3020
+
2519
3021
  // src/core/navigators/PipelineAssembler.ts
2520
3022
  var PipelineAssembler_exports = {};
2521
3023
  __export(PipelineAssembler_exports, {
@@ -2528,9 +3030,9 @@ var init_PipelineAssembler = __esm({
2528
3030
  init_navigators();
2529
3031
  init_WeightedFilter();
2530
3032
  init_Pipeline();
2531
- init_types_legacy();
2532
3033
  init_logger();
2533
3034
  init_CompositeGenerator();
3035
+ init_defaults();
2534
3036
  PipelineAssembler = class {
2535
3037
  /**
2536
3038
  * Assembles a navigation pipeline from strategy documents.
@@ -2569,9 +3071,11 @@ var init_PipelineAssembler = __esm({
2569
3071
  if (generatorStrategies.length === 0) {
2570
3072
  if (filterStrategies.length > 0) {
2571
3073
  logger.debug(
2572
- "[PipelineAssembler] No generator found, using default ELO with configured filters"
3074
+ "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2573
3075
  );
2574
- generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
3076
+ const courseId = course.getCourseID();
3077
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3078
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
2575
3079
  } else {
2576
3080
  warnings.push("No generator strategy found");
2577
3081
  return {
@@ -2632,75 +3136,10 @@ var init_PipelineAssembler = __esm({
2632
3136
  warnings
2633
3137
  };
2634
3138
  }
2635
- /**
2636
- * Creates a default ELO generator strategy.
2637
- * Used when filters are configured but no generator is specified.
2638
- */
2639
- makeDefaultEloStrategy(courseId) {
2640
- return {
2641
- _id: "NAVIGATION_STRATEGY-ELO-default",
2642
- course: courseId,
2643
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2644
- name: "ELO (default)",
2645
- description: "Default ELO-based generator",
2646
- implementingClass: "elo" /* ELO */,
2647
- serializedData: ""
2648
- };
2649
- }
2650
3139
  };
2651
3140
  }
2652
3141
  });
2653
3142
 
2654
- // src/core/navigators/defaults.ts
2655
- var defaults_exports = {};
2656
- __export(defaults_exports, {
2657
- createDefaultEloStrategy: () => createDefaultEloStrategy,
2658
- createDefaultPipeline: () => createDefaultPipeline,
2659
- createDefaultSrsStrategy: () => createDefaultSrsStrategy
2660
- });
2661
- function createDefaultEloStrategy(courseId) {
2662
- return {
2663
- _id: "NAVIGATION_STRATEGY-ELO-default",
2664
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2665
- name: "ELO (default)",
2666
- description: "Default ELO-based navigation strategy for new cards",
2667
- implementingClass: "elo" /* ELO */,
2668
- course: courseId,
2669
- serializedData: ""
2670
- };
2671
- }
2672
- function createDefaultSrsStrategy(courseId) {
2673
- return {
2674
- _id: "NAVIGATION_STRATEGY-SRS-default",
2675
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2676
- name: "SRS (default)",
2677
- description: "Default SRS-based navigation strategy for reviews",
2678
- implementingClass: "srs" /* SRS */,
2679
- course: courseId,
2680
- serializedData: ""
2681
- };
2682
- }
2683
- function createDefaultPipeline(user, course) {
2684
- const courseId = course.getCourseID();
2685
- const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
2686
- const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
2687
- const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
2688
- const eloDistanceFilter = createEloDistanceFilter();
2689
- return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
2690
- }
2691
- var init_defaults = __esm({
2692
- "src/core/navigators/defaults.ts"() {
2693
- "use strict";
2694
- init_navigators();
2695
- init_Pipeline();
2696
- init_CompositeGenerator();
2697
- init_elo();
2698
- init_srs();
2699
- init_eloDistance();
2700
- init_types_legacy();
2701
- }
2702
- });
2703
-
2704
3143
  // import("./**/*") in src/core/navigators/index.ts
2705
3144
  var globImport;
2706
3145
  var init_3 = __esm({
@@ -2708,6 +3147,7 @@ var init_3 = __esm({
2708
3147
  globImport = __glob({
2709
3148
  "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
2710
3149
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
3150
+ "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
2711
3151
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
2712
3152
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
2713
3153
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
@@ -2743,6 +3183,8 @@ __export(navigators_exports, {
2743
3183
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
2744
3184
  isFilter: () => isFilter,
2745
3185
  isGenerator: () => isGenerator,
3186
+ mountPipelineDebugger: () => mountPipelineDebugger,
3187
+ pipelineDebugAPI: () => pipelineDebugAPI,
2746
3188
  registerNavigator: () => registerNavigator
2747
3189
  });
2748
3190
  function registerNavigator(implementingClass, constructor) {
@@ -2809,6 +3251,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
2809
3251
  var init_navigators = __esm({
2810
3252
  "src/core/navigators/index.ts"() {
2811
3253
  "use strict";
3254
+ init_PipelineDebugger();
2812
3255
  init_logger();
2813
3256
  init_();
2814
3257
  init_2();
@@ -5395,7 +5838,9 @@ __export(core_exports, {
5395
5838
  isQuestionRecord: () => isQuestionRecord,
5396
5839
  isReview: () => isReview,
5397
5840
  log: () => log,
5841
+ mountPipelineDebugger: () => mountPipelineDebugger,
5398
5842
  parseCardHistoryID: () => parseCardHistoryID,
5843
+ pipelineDebugAPI: () => pipelineDebugAPI,
5399
5844
  recordUserOutcome: () => recordUserOutcome,
5400
5845
  registerNavigator: () => registerNavigator,
5401
5846
  runPeriodUpdate: () => runPeriodUpdate,
@@ -5454,7 +5899,9 @@ init_core();
5454
5899
  isQuestionRecord,
5455
5900
  isReview,
5456
5901
  log,
5902
+ mountPipelineDebugger,
5457
5903
  parseCardHistoryID,
5904
+ pipelineDebugAPI,
5458
5905
  recordUserOutcome,
5459
5906
  registerNavigator,
5460
5907
  runPeriodUpdate,