@vue-skuilder/db 0.1.24 → 0.1.26

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
@@ -448,6 +448,15 @@ var init_user_course_relDB = __esm({
448
448
  void this.user.updateCourseSettings(this._courseId, updates);
449
449
  }
450
450
  }
451
+ async getStrategyState(strategyKey) {
452
+ return this.user.getStrategyState(this._courseId, strategyKey);
453
+ }
454
+ async putStrategyState(strategyKey, data) {
455
+ return this.user.putStrategyState(this._courseId, strategyKey, data);
456
+ }
457
+ async deleteStrategyState(strategyKey) {
458
+ return this.user.deleteStrategyState(this._courseId, strategyKey);
459
+ }
451
460
  async getReviewstoDate(targetDate) {
452
461
  const allReviews = await this.user.getPendingReviews(this._courseId);
453
462
  logger.debug(
@@ -687,6 +696,271 @@ var init_courseLookupDB = __esm({
687
696
  }
688
697
  });
689
698
 
699
+ // src/core/navigators/PipelineDebugger.ts
700
+ var PipelineDebugger_exports = {};
701
+ __export(PipelineDebugger_exports, {
702
+ buildRunReport: () => buildRunReport,
703
+ captureRun: () => captureRun,
704
+ mountPipelineDebugger: () => mountPipelineDebugger,
705
+ pipelineDebugAPI: () => pipelineDebugAPI
706
+ });
707
+ function getOrigin(card) {
708
+ const firstEntry = card.provenance[0];
709
+ if (!firstEntry) return "unknown";
710
+ const reason = firstEntry.reason?.toLowerCase() || "";
711
+ if (reason.includes("new card")) return "new";
712
+ if (reason.includes("review")) return "review";
713
+ return "unknown";
714
+ }
715
+ function captureRun(report) {
716
+ const fullReport = {
717
+ ...report,
718
+ runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
719
+ timestamp: /* @__PURE__ */ new Date()
720
+ };
721
+ runHistory.unshift(fullReport);
722
+ if (runHistory.length > MAX_RUNS) {
723
+ runHistory.pop();
724
+ }
725
+ }
726
+ function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards) {
727
+ const selectedIds = new Set(selectedCards.map((c) => c.cardId));
728
+ const cards = allCards.map((card) => ({
729
+ cardId: card.cardId,
730
+ courseId: card.courseId,
731
+ origin: getOrigin(card),
732
+ finalScore: card.score,
733
+ provenance: card.provenance,
734
+ selected: selectedIds.has(card.cardId)
735
+ }));
736
+ const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
737
+ const newSelected = selectedCards.filter((c) => getOrigin(c) === "new").length;
738
+ return {
739
+ courseId,
740
+ courseName,
741
+ generatorName,
742
+ generators,
743
+ generatedCount,
744
+ filters,
745
+ finalCount: selectedCards.length,
746
+ reviewsSelected,
747
+ newSelected,
748
+ cards
749
+ };
750
+ }
751
+ function formatProvenance(provenance) {
752
+ return provenance.map((p) => {
753
+ const actionSymbol = p.action === "generated" ? "\u{1F3B2}" : p.action === "boosted" ? "\u2B06\uFE0F" : p.action === "penalized" ? "\u2B07\uFE0F" : "\u27A1\uFE0F";
754
+ return ` ${actionSymbol} ${p.strategyName}: ${p.score.toFixed(3)} - ${p.reason}`;
755
+ }).join("\n");
756
+ }
757
+ function printRunSummary(run) {
758
+ console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
759
+ logger.info(`Run ID: ${run.runId}`);
760
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
761
+ logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
762
+ if (run.generators && run.generators.length > 0) {
763
+ console.group("Generator breakdown:");
764
+ for (const g of run.generators) {
765
+ logger.info(
766
+ ` ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews, top: ${g.topScore.toFixed(2)})`
767
+ );
768
+ }
769
+ console.groupEnd();
770
+ }
771
+ if (run.filters.length > 0) {
772
+ console.group("Filter impact:");
773
+ for (const f of run.filters) {
774
+ logger.info(` ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
775
+ }
776
+ console.groupEnd();
777
+ }
778
+ logger.info(
779
+ `Result: ${run.finalCount} cards selected (${run.newSelected} new, ${run.reviewsSelected} reviews)`
780
+ );
781
+ console.groupEnd();
782
+ }
783
+ function mountPipelineDebugger() {
784
+ if (typeof window === "undefined") return;
785
+ const win = window;
786
+ win.skuilder = win.skuilder || {};
787
+ win.skuilder.pipeline = pipelineDebugAPI;
788
+ }
789
+ var MAX_RUNS, runHistory, pipelineDebugAPI;
790
+ var init_PipelineDebugger = __esm({
791
+ "src/core/navigators/PipelineDebugger.ts"() {
792
+ "use strict";
793
+ init_logger();
794
+ MAX_RUNS = 10;
795
+ runHistory = [];
796
+ pipelineDebugAPI = {
797
+ /**
798
+ * Get raw run history for programmatic access.
799
+ */
800
+ get runs() {
801
+ return [...runHistory];
802
+ },
803
+ /**
804
+ * Show summary of a specific pipeline run.
805
+ */
806
+ showRun(idOrIndex = 0) {
807
+ if (runHistory.length === 0) {
808
+ logger.info("[Pipeline Debug] No runs captured yet.");
809
+ return;
810
+ }
811
+ let run;
812
+ if (typeof idOrIndex === "number") {
813
+ run = runHistory[idOrIndex];
814
+ if (!run) {
815
+ logger.info(
816
+ `[Pipeline Debug] No run found at index ${idOrIndex}. History length: ${runHistory.length}`
817
+ );
818
+ return;
819
+ }
820
+ } else {
821
+ run = runHistory.find((r) => r.runId.endsWith(idOrIndex));
822
+ if (!run) {
823
+ logger.info(`[Pipeline Debug] No run found matching ID '${idOrIndex}'.`);
824
+ return;
825
+ }
826
+ }
827
+ printRunSummary(run);
828
+ },
829
+ /**
830
+ * Show summary of the last pipeline run.
831
+ */
832
+ showLastRun() {
833
+ this.showRun(0);
834
+ },
835
+ /**
836
+ * Show detailed provenance for a specific card.
837
+ */
838
+ showCard(cardId) {
839
+ for (const run of runHistory) {
840
+ const card = run.cards.find((c) => c.cardId === cardId);
841
+ if (card) {
842
+ console.group(`\u{1F3B4} Card: ${cardId}`);
843
+ logger.info(`Course: ${card.courseId}`);
844
+ logger.info(`Origin: ${card.origin}`);
845
+ logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
846
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
847
+ logger.info("Provenance:");
848
+ logger.info(formatProvenance(card.provenance));
849
+ console.groupEnd();
850
+ return;
851
+ }
852
+ }
853
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
854
+ },
855
+ /**
856
+ * Explain why reviews may or may not have been selected.
857
+ */
858
+ explainReviews() {
859
+ if (runHistory.length === 0) {
860
+ logger.info("[Pipeline Debug] No runs captured yet.");
861
+ return;
862
+ }
863
+ console.group("\u{1F4CB} Review Selection Analysis");
864
+ for (const run of runHistory) {
865
+ console.group(`Run: ${run.courseId} @ ${run.timestamp.toLocaleTimeString()}`);
866
+ const allReviews = run.cards.filter((c) => c.origin === "review");
867
+ const selectedReviews = allReviews.filter((c) => c.selected);
868
+ if (allReviews.length === 0) {
869
+ logger.info("\u274C No reviews were generated. Check SRS logs for why.");
870
+ } else if (selectedReviews.length === 0) {
871
+ logger.info(`\u26A0\uFE0F ${allReviews.length} reviews generated but none selected.`);
872
+ logger.info("Possible reasons:");
873
+ const topNewScore = Math.max(
874
+ ...run.cards.filter((c) => c.origin === "new" && c.selected).map((c) => c.finalScore),
875
+ 0
876
+ );
877
+ const topReviewScore = Math.max(...allReviews.map((c) => c.finalScore), 0);
878
+ if (topReviewScore < topNewScore) {
879
+ logger.info(
880
+ ` - New cards scored higher (top new: ${topNewScore.toFixed(2)}, top review: ${topReviewScore.toFixed(2)})`
881
+ );
882
+ }
883
+ const topReview = allReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
884
+ if (topReview) {
885
+ logger.info(` - Top review score: ${topReview.finalScore.toFixed(3)}`);
886
+ logger.info(" - Its provenance:");
887
+ logger.info(formatProvenance(topReview.provenance));
888
+ }
889
+ } else {
890
+ logger.info(`\u2705 ${selectedReviews.length}/${allReviews.length} reviews selected.`);
891
+ logger.info("Top selected review:");
892
+ const topSelected = selectedReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
893
+ logger.info(formatProvenance(topSelected.provenance));
894
+ }
895
+ console.groupEnd();
896
+ }
897
+ console.groupEnd();
898
+ },
899
+ /**
900
+ * Show all runs in compact format.
901
+ */
902
+ listRuns() {
903
+ if (runHistory.length === 0) {
904
+ logger.info("[Pipeline Debug] No runs captured yet.");
905
+ return;
906
+ }
907
+ console.table(
908
+ runHistory.map((r) => ({
909
+ id: r.runId.slice(-8),
910
+ time: r.timestamp.toLocaleTimeString(),
911
+ course: r.courseName || r.courseId.slice(0, 8),
912
+ generated: r.generatedCount,
913
+ selected: r.finalCount,
914
+ new: r.newSelected,
915
+ reviews: r.reviewsSelected
916
+ }))
917
+ );
918
+ },
919
+ /**
920
+ * Export run history as JSON for bug reports.
921
+ */
922
+ export() {
923
+ const json = JSON.stringify(runHistory, null, 2);
924
+ logger.info("[Pipeline Debug] Run history exported. Copy the returned string or use:");
925
+ logger.info(" copy(window.skuilder.pipeline.export())");
926
+ return json;
927
+ },
928
+ /**
929
+ * Clear run history.
930
+ */
931
+ clear() {
932
+ runHistory.length = 0;
933
+ logger.info("[Pipeline Debug] Run history cleared.");
934
+ },
935
+ /**
936
+ * Show help.
937
+ */
938
+ help() {
939
+ logger.info(`
940
+ \u{1F527} Pipeline Debug API
941
+
942
+ Commands:
943
+ .showLastRun() Show summary of most recent pipeline run
944
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
945
+ .showCard(cardId) Show provenance trail for a specific card
946
+ .explainReviews() Analyze why reviews were/weren't selected
947
+ .listRuns() List all captured runs in table format
948
+ .export() Export run history as JSON for bug reports
949
+ .clear() Clear run history
950
+ .runs Access raw run history array
951
+ .help() Show this help message
952
+
953
+ Example:
954
+ window.skuilder.pipeline.showLastRun()
955
+ window.skuilder.pipeline.showRun(1)
956
+ window.skuilder.pipeline.showCard('abc123')
957
+ `);
958
+ }
959
+ };
960
+ mountPipelineDebugger();
961
+ }
962
+ });
963
+
690
964
  // src/core/navigators/generators/CompositeGenerator.ts
691
965
  var CompositeGenerator_exports = {};
692
966
  __export(CompositeGenerator_exports, {
@@ -755,6 +1029,24 @@ var init_CompositeGenerator = __esm({
755
1029
  const results = await Promise.all(
756
1030
  this.generators.map((g) => g.getWeightedCards(limit, context))
757
1031
  );
1032
+ const generatorSummaries = [];
1033
+ results.forEach((cards, index) => {
1034
+ const gen = this.generators[index];
1035
+ const genName = gen.name || `Generator ${index}`;
1036
+ const newCards = cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
1037
+ const reviewCards = cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
1038
+ if (cards.length > 0) {
1039
+ const topScore = Math.max(...cards.map((c) => c.score)).toFixed(2);
1040
+ const parts = [];
1041
+ if (newCards.length > 0) parts.push(`${newCards.length} new`);
1042
+ if (reviewCards.length > 0) parts.push(`${reviewCards.length} reviews`);
1043
+ const breakdown = parts.length > 0 ? parts.join(", ") : `${cards.length} cards`;
1044
+ generatorSummaries.push(`${genName}: ${breakdown} (top: ${topScore})`);
1045
+ } else {
1046
+ generatorSummaries.push(`${genName}: 0 cards`);
1047
+ }
1048
+ });
1049
+ logger.info(`[Composite] Generator breakdown: ${generatorSummaries.join(" | ")}`);
758
1050
  const byCardId = /* @__PURE__ */ new Map();
759
1051
  results.forEach((cards, index) => {
760
1052
  const gen = this.generators[index];
@@ -872,6 +1164,7 @@ var init_elo = __esm({
872
1164
  "src/core/navigators/generators/elo.ts"() {
873
1165
  "use strict";
874
1166
  init_navigators();
1167
+ init_logger();
875
1168
  ELONavigator = class extends ContentNavigator {
876
1169
  /** Human-readable name for CardGenerator interface */
877
1170
  name;
@@ -931,7 +1224,16 @@ var init_elo = __esm({
931
1224
  };
932
1225
  });
933
1226
  scored.sort((a, b) => b.score - a.score);
934
- return scored.slice(0, limit);
1227
+ const result = scored.slice(0, limit);
1228
+ if (result.length > 0) {
1229
+ const topScores = result.slice(0, 3).map((c) => c.score.toFixed(2)).join(", ");
1230
+ logger.info(
1231
+ `[ELO] Course ${this.course.getCourseID()}: ${result.length} new cards (top scores: ${topScores})`
1232
+ );
1233
+ } else {
1234
+ logger.info(`[ELO] Course ${this.course.getCourseID()}: No new cards available`);
1235
+ }
1236
+ return result;
935
1237
  }
936
1238
  };
937
1239
  }
@@ -951,18 +1253,36 @@ __export(srs_exports, {
951
1253
  default: () => SRSNavigator
952
1254
  });
953
1255
  import moment3 from "moment";
954
- var SRSNavigator;
1256
+ var DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_PRESSURE, SRSNavigator;
955
1257
  var init_srs = __esm({
956
1258
  "src/core/navigators/generators/srs.ts"() {
957
1259
  "use strict";
958
1260
  init_navigators();
959
1261
  init_logger();
1262
+ DEFAULT_HEALTHY_BACKLOG = 20;
1263
+ MAX_BACKLOG_PRESSURE = 0.5;
960
1264
  SRSNavigator = class extends ContentNavigator {
961
1265
  /** Human-readable name for CardGenerator interface */
962
1266
  name;
1267
+ /** Healthy backlog threshold - when exceeded, backlog pressure kicks in */
1268
+ healthyBacklog;
963
1269
  constructor(user, course, strategyData) {
964
1270
  super(user, course, strategyData);
965
1271
  this.name = strategyData?.name || "SRS";
1272
+ const config = this.parseConfig(strategyData?.serializedData);
1273
+ this.healthyBacklog = config.healthyBacklog ?? DEFAULT_HEALTHY_BACKLOG;
1274
+ }
1275
+ /**
1276
+ * Parse configuration from serialized JSON.
1277
+ */
1278
+ parseConfig(serializedData) {
1279
+ if (!serializedData) return {};
1280
+ try {
1281
+ return JSON.parse(serializedData);
1282
+ } catch {
1283
+ logger.warn("[SRS] Failed to parse strategy config, using defaults");
1284
+ return {};
1285
+ }
966
1286
  }
967
1287
  /**
968
1288
  * Get review cards scored by urgency.
@@ -970,6 +1290,7 @@ var init_srs = __esm({
970
1290
  * Score formula combines:
971
1291
  * - Relative overdueness: hoursOverdue / intervalHours
972
1292
  * - Interval recency: exponential decay favoring shorter intervals
1293
+ * - Backlog pressure: boost when due reviews exceed healthy threshold
973
1294
  *
974
1295
  * Cards not yet due are excluded (not scored as 0).
975
1296
  *
@@ -983,11 +1304,32 @@ var init_srs = __esm({
983
1304
  if (!this.user || !this.course) {
984
1305
  throw new Error("SRSNavigator requires user and course to be set");
985
1306
  }
986
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1307
+ const courseId = this.course.getCourseID();
1308
+ const reviews = await this.user.getPendingReviews(courseId);
987
1309
  const now = moment3.utc();
988
1310
  const dueReviews = reviews.filter((r) => now.isAfter(moment3.utc(r.reviewTime)));
1311
+ const backlogPressure = this.computeBacklogPressure(dueReviews.length);
1312
+ if (dueReviews.length > 0) {
1313
+ const pressureNote = backlogPressure > 0 ? ` [backlog pressure: +${backlogPressure.toFixed(2)}]` : ` [healthy backlog]`;
1314
+ logger.info(
1315
+ `[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
1316
+ );
1317
+ } else if (reviews.length > 0) {
1318
+ const sortedByDue = [...reviews].sort(
1319
+ (a, b) => moment3.utc(a.reviewTime).diff(moment3.utc(b.reviewTime))
1320
+ );
1321
+ const nextDue = sortedByDue[0];
1322
+ const nextDueTime = moment3.utc(nextDue.reviewTime);
1323
+ const untilDue = moment3.duration(nextDueTime.diff(now));
1324
+ const untilDueStr = untilDue.asHours() < 1 ? `${Math.round(untilDue.asMinutes())}m` : untilDue.asHours() < 24 ? `${Math.round(untilDue.asHours())}h` : `${Math.round(untilDue.asDays())}d`;
1325
+ logger.info(
1326
+ `[SRS] Course ${courseId}: 0 reviews due now (${reviews.length} scheduled, next in ${untilDueStr})`
1327
+ );
1328
+ } else {
1329
+ logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
1330
+ }
989
1331
  const scored = dueReviews.map((review) => {
990
- const { score, reason } = this.computeUrgencyScore(review, now);
1332
+ const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
991
1333
  return {
992
1334
  cardId: review.cardId,
993
1335
  courseId: review.courseId,
@@ -1005,13 +1347,35 @@ var init_srs = __esm({
1005
1347
  ]
1006
1348
  };
1007
1349
  });
1008
- logger.debug(`[srsNav] got ${scored.length} weighted cards`);
1009
1350
  return scored.sort((a, b) => b.score - a.score).slice(0, limit);
1010
1351
  }
1352
+ /**
1353
+ * Compute backlog pressure based on number of due reviews.
1354
+ *
1355
+ * Backlog pressure is 0 when at or below healthy threshold,
1356
+ * and increases linearly above it, maxing out at MAX_BACKLOG_PRESSURE.
1357
+ *
1358
+ * Examples (with default healthyBacklog=20):
1359
+ * - 10 due reviews → 0.00 (healthy)
1360
+ * - 20 due reviews → 0.00 (at threshold)
1361
+ * - 40 due reviews → 0.25 (2x threshold)
1362
+ * - 60 due reviews → 0.50 (3x threshold, maxed)
1363
+ *
1364
+ * @param dueCount - Number of reviews currently due
1365
+ * @returns Backlog pressure score to add to urgency (0 to MAX_BACKLOG_PRESSURE)
1366
+ */
1367
+ computeBacklogPressure(dueCount) {
1368
+ if (dueCount <= this.healthyBacklog) {
1369
+ return 0;
1370
+ }
1371
+ const excess = dueCount - this.healthyBacklog;
1372
+ const pressure = excess / this.healthyBacklog * (MAX_BACKLOG_PRESSURE / 2);
1373
+ return Math.min(MAX_BACKLOG_PRESSURE, pressure);
1374
+ }
1011
1375
  /**
1012
1376
  * Compute urgency score for a review card.
1013
1377
  *
1014
- * Two factors:
1378
+ * Three factors:
1015
1379
  * 1. Relative overdueness = hoursOverdue / intervalHours
1016
1380
  * - 2 days overdue on 3-day interval = 0.67 (urgent)
1017
1381
  * - 2 days overdue on 180-day interval = 0.01 (not urgent)
@@ -1021,10 +1385,19 @@ var init_srs = __esm({
1021
1385
  * - 30 days (720h) → ~0.56
1022
1386
  * - 180 days → ~0.30
1023
1387
  *
1024
- * Combined: base 0.5 + weighted average of factors * 0.45
1025
- * Result range: approximately 0.5 to 0.95
1388
+ * 3. Backlog pressure = global boost when review backlog exceeds healthy threshold
1389
+ * - At healthy backlog: 0
1390
+ * - At 2x healthy: +0.25
1391
+ * - At 3x+ healthy: +0.50 (max)
1392
+ *
1393
+ * Combined: base 0.5 + (urgency factors * 0.45) + backlog pressure
1394
+ * Result range: 0.5 to 1.0 (uncapped to allow high-urgency reviews to compete with new cards)
1395
+ *
1396
+ * @param review - The scheduled card to score
1397
+ * @param now - Current time
1398
+ * @param backlogPressure - Pre-computed backlog pressure (0 to 0.5)
1026
1399
  */
1027
- computeUrgencyScore(review, now) {
1400
+ computeUrgencyScore(review, now, backlogPressure) {
1028
1401
  const scheduledAt = moment3.utc(review.scheduledAt);
1029
1402
  const due = moment3.utc(review.reviewTime);
1030
1403
  const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
@@ -1033,8 +1406,19 @@ var init_srs = __esm({
1033
1406
  const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
1034
1407
  const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
1035
1408
  const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
1036
- const score = Math.min(0.95, 0.5 + urgency * 0.45);
1037
- const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
1409
+ const baseScore = 0.5 + urgency * 0.45;
1410
+ const score = Math.min(1, baseScore + backlogPressure);
1411
+ const reasonParts = [
1412
+ `${Math.round(hoursOverdue)}h overdue`,
1413
+ `interval: ${Math.round(intervalHours)}h`,
1414
+ `relative: ${relativeOverdue.toFixed(2)}`,
1415
+ `recency: ${recencyFactor.toFixed(2)}`
1416
+ ];
1417
+ if (backlogPressure > 0) {
1418
+ reasonParts.push(`backlog: +${backlogPressure.toFixed(2)}`);
1419
+ }
1420
+ reasonParts.push("review");
1421
+ const reason = reasonParts.join(", ");
1038
1422
  return { score, reason };
1039
1423
  }
1040
1424
  };
@@ -2311,10 +2695,23 @@ function logTagHydration(cards, tagsByCard) {
2311
2695
  `[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
2312
2696
  );
2313
2697
  }
2314
- function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
2698
+ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores, filterImpacts) {
2315
2699
  const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
2700
+ let filterSummary = "";
2701
+ if (filterImpacts.length > 0) {
2702
+ const impacts = filterImpacts.map((f) => {
2703
+ const parts = [];
2704
+ if (f.boosted > 0) parts.push(`+${f.boosted}`);
2705
+ if (f.penalized > 0) parts.push(`-${f.penalized}`);
2706
+ if (f.passed > 0) parts.push(`=${f.passed}`);
2707
+ return `${f.name}: ${parts.join("/")}`;
2708
+ });
2709
+ filterSummary = `
2710
+ Filter impact: ${impacts.join(", ")}`;
2711
+ }
2316
2712
  logger.info(
2317
- `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
2713
+ `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})` + filterSummary + `
2714
+ \u{1F4A1} Inspect: window.skuilder.pipeline`
2318
2715
  );
2319
2716
  }
2320
2717
  function logCardProvenance(cards, maxCards = 3) {
@@ -2338,6 +2735,7 @@ var init_Pipeline = __esm({
2338
2735
  init_navigators();
2339
2736
  init_logger();
2340
2737
  init_orchestration();
2738
+ init_PipelineDebugger();
2341
2739
  Pipeline = class extends ContentNavigator {
2342
2740
  generator;
2343
2741
  filters;
@@ -2385,12 +2783,49 @@ var init_Pipeline = __esm({
2385
2783
  );
2386
2784
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2387
2785
  const generatedCount = cards.length;
2786
+ let generatorSummaries;
2787
+ if (this.generator.generators) {
2788
+ const genMap = /* @__PURE__ */ new Map();
2789
+ for (const card of cards) {
2790
+ const firstProv = card.provenance[0];
2791
+ if (firstProv) {
2792
+ const genName = firstProv.strategyName;
2793
+ if (!genMap.has(genName)) {
2794
+ genMap.set(genName, { cards: [] });
2795
+ }
2796
+ genMap.get(genName).cards.push(card);
2797
+ }
2798
+ }
2799
+ generatorSummaries = Array.from(genMap.entries()).map(([name, data]) => {
2800
+ const newCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
2801
+ const reviewCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
2802
+ return {
2803
+ name,
2804
+ cardCount: data.cards.length,
2805
+ newCount: newCards.length,
2806
+ reviewCount: reviewCards.length,
2807
+ topScore: Math.max(...data.cards.map((c) => c.score), 0)
2808
+ };
2809
+ });
2810
+ }
2388
2811
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2389
2812
  cards = await this.hydrateTags(cards);
2813
+ const allCardsBeforeFiltering = [...cards];
2814
+ const filterImpacts = [];
2390
2815
  for (const filter of this.filters) {
2391
2816
  const beforeCount = cards.length;
2817
+ const beforeScores = new Map(cards.map((c) => [c.cardId, c.score]));
2392
2818
  cards = await filter.transform(cards, context);
2393
- logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
2819
+ let boosted = 0, penalized = 0, passed = 0;
2820
+ const removed = beforeCount - cards.length;
2821
+ for (const card of cards) {
2822
+ const before = beforeScores.get(card.cardId) ?? 0;
2823
+ if (card.score > before) boosted++;
2824
+ else if (card.score < before) penalized++;
2825
+ else passed++;
2826
+ }
2827
+ filterImpacts.push({ name: filter.name, boosted, penalized, passed, removed });
2828
+ logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2394
2829
  }
2395
2830
  cards = cards.filter((c) => c.score > 0);
2396
2831
  cards.sort((a, b) => b.score - a.score);
@@ -2401,9 +2836,26 @@ var init_Pipeline = __esm({
2401
2836
  generatedCount,
2402
2837
  this.filters.length,
2403
2838
  result.length,
2404
- topScores
2839
+ topScores,
2840
+ filterImpacts
2405
2841
  );
2406
2842
  logCardProvenance(result, 3);
2843
+ try {
2844
+ const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
2845
+ const report = buildRunReport(
2846
+ this.course?.getCourseID() || "unknown",
2847
+ courseName,
2848
+ this.generator.name,
2849
+ generatorSummaries,
2850
+ generatedCount,
2851
+ filterImpacts,
2852
+ allCardsBeforeFiltering,
2853
+ result
2854
+ );
2855
+ captureRun(report);
2856
+ } catch (e) {
2857
+ logger.debug(`[Pipeline] Failed to capture debug run: ${e}`);
2858
+ }
2407
2859
  return result;
2408
2860
  }
2409
2861
  /**
@@ -2493,6 +2945,56 @@ var init_Pipeline = __esm({
2493
2945
  }
2494
2946
  });
2495
2947
 
2948
+ // src/core/navigators/defaults.ts
2949
+ var defaults_exports = {};
2950
+ __export(defaults_exports, {
2951
+ createDefaultEloStrategy: () => createDefaultEloStrategy,
2952
+ createDefaultPipeline: () => createDefaultPipeline,
2953
+ createDefaultSrsStrategy: () => createDefaultSrsStrategy
2954
+ });
2955
+ function createDefaultEloStrategy(courseId) {
2956
+ return {
2957
+ _id: "NAVIGATION_STRATEGY-ELO-default",
2958
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2959
+ name: "ELO (default)",
2960
+ description: "Default ELO-based navigation strategy for new cards",
2961
+ implementingClass: "elo" /* ELO */,
2962
+ course: courseId,
2963
+ serializedData: ""
2964
+ };
2965
+ }
2966
+ function createDefaultSrsStrategy(courseId) {
2967
+ return {
2968
+ _id: "NAVIGATION_STRATEGY-SRS-default",
2969
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2970
+ name: "SRS (default)",
2971
+ description: "Default SRS-based navigation strategy for reviews",
2972
+ implementingClass: "srs" /* SRS */,
2973
+ course: courseId,
2974
+ serializedData: ""
2975
+ };
2976
+ }
2977
+ function createDefaultPipeline(user, course) {
2978
+ const courseId = course.getCourseID();
2979
+ const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
2980
+ const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
2981
+ const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
2982
+ const eloDistanceFilter = createEloDistanceFilter();
2983
+ return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
2984
+ }
2985
+ var init_defaults = __esm({
2986
+ "src/core/navigators/defaults.ts"() {
2987
+ "use strict";
2988
+ init_navigators();
2989
+ init_Pipeline();
2990
+ init_CompositeGenerator();
2991
+ init_elo();
2992
+ init_srs();
2993
+ init_eloDistance();
2994
+ init_types_legacy();
2995
+ }
2996
+ });
2997
+
2496
2998
  // src/core/navigators/PipelineAssembler.ts
2497
2999
  var PipelineAssembler_exports = {};
2498
3000
  __export(PipelineAssembler_exports, {
@@ -2505,9 +3007,9 @@ var init_PipelineAssembler = __esm({
2505
3007
  init_navigators();
2506
3008
  init_WeightedFilter();
2507
3009
  init_Pipeline();
2508
- init_types_legacy();
2509
3010
  init_logger();
2510
3011
  init_CompositeGenerator();
3012
+ init_defaults();
2511
3013
  PipelineAssembler = class {
2512
3014
  /**
2513
3015
  * Assembles a navigation pipeline from strategy documents.
@@ -2546,9 +3048,11 @@ var init_PipelineAssembler = __esm({
2546
3048
  if (generatorStrategies.length === 0) {
2547
3049
  if (filterStrategies.length > 0) {
2548
3050
  logger.debug(
2549
- "[PipelineAssembler] No generator found, using default ELO with configured filters"
3051
+ "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2550
3052
  );
2551
- generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
3053
+ const courseId = course.getCourseID();
3054
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3055
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
2552
3056
  } else {
2553
3057
  warnings.push("No generator strategy found");
2554
3058
  return {
@@ -2609,75 +3113,10 @@ var init_PipelineAssembler = __esm({
2609
3113
  warnings
2610
3114
  };
2611
3115
  }
2612
- /**
2613
- * Creates a default ELO generator strategy.
2614
- * Used when filters are configured but no generator is specified.
2615
- */
2616
- makeDefaultEloStrategy(courseId) {
2617
- return {
2618
- _id: "NAVIGATION_STRATEGY-ELO-default",
2619
- course: courseId,
2620
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2621
- name: "ELO (default)",
2622
- description: "Default ELO-based generator",
2623
- implementingClass: "elo" /* ELO */,
2624
- serializedData: ""
2625
- };
2626
- }
2627
3116
  };
2628
3117
  }
2629
3118
  });
2630
3119
 
2631
- // src/core/navigators/defaults.ts
2632
- var defaults_exports = {};
2633
- __export(defaults_exports, {
2634
- createDefaultEloStrategy: () => createDefaultEloStrategy,
2635
- createDefaultPipeline: () => createDefaultPipeline,
2636
- createDefaultSrsStrategy: () => createDefaultSrsStrategy
2637
- });
2638
- function createDefaultEloStrategy(courseId) {
2639
- return {
2640
- _id: "NAVIGATION_STRATEGY-ELO-default",
2641
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2642
- name: "ELO (default)",
2643
- description: "Default ELO-based navigation strategy for new cards",
2644
- implementingClass: "elo" /* ELO */,
2645
- course: courseId,
2646
- serializedData: ""
2647
- };
2648
- }
2649
- function createDefaultSrsStrategy(courseId) {
2650
- return {
2651
- _id: "NAVIGATION_STRATEGY-SRS-default",
2652
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2653
- name: "SRS (default)",
2654
- description: "Default SRS-based navigation strategy for reviews",
2655
- implementingClass: "srs" /* SRS */,
2656
- course: courseId,
2657
- serializedData: ""
2658
- };
2659
- }
2660
- function createDefaultPipeline(user, course) {
2661
- const courseId = course.getCourseID();
2662
- const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
2663
- const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
2664
- const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
2665
- const eloDistanceFilter = createEloDistanceFilter();
2666
- return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
2667
- }
2668
- var init_defaults = __esm({
2669
- "src/core/navigators/defaults.ts"() {
2670
- "use strict";
2671
- init_navigators();
2672
- init_Pipeline();
2673
- init_CompositeGenerator();
2674
- init_elo();
2675
- init_srs();
2676
- init_eloDistance();
2677
- init_types_legacy();
2678
- }
2679
- });
2680
-
2681
3120
  // import("./**/*") in src/core/navigators/index.ts
2682
3121
  var globImport;
2683
3122
  var init_3 = __esm({
@@ -2685,6 +3124,7 @@ var init_3 = __esm({
2685
3124
  globImport = __glob({
2686
3125
  "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
2687
3126
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
3127
+ "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
2688
3128
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
2689
3129
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
2690
3130
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
@@ -2720,6 +3160,8 @@ __export(navigators_exports, {
2720
3160
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
2721
3161
  isFilter: () => isFilter,
2722
3162
  isGenerator: () => isGenerator,
3163
+ mountPipelineDebugger: () => mountPipelineDebugger,
3164
+ pipelineDebugAPI: () => pipelineDebugAPI,
2723
3165
  registerNavigator: () => registerNavigator
2724
3166
  });
2725
3167
  function registerNavigator(implementingClass, constructor) {
@@ -2786,6 +3228,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
2786
3228
  var init_navigators = __esm({
2787
3229
  "src/core/navigators/index.ts"() {
2788
3230
  "use strict";
3231
+ init_PipelineDebugger();
2789
3232
  init_logger();
2790
3233
  init_();
2791
3234
  init_2();
@@ -5387,7 +5830,9 @@ export {
5387
5830
  isQuestionRecord,
5388
5831
  isReview,
5389
5832
  log,
5833
+ mountPipelineDebugger,
5390
5834
  parseCardHistoryID,
5835
+ pipelineDebugAPI,
5391
5836
  recordUserOutcome,
5392
5837
  registerNavigator,
5393
5838
  runPeriodUpdate,