@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
package/dist/index.mjs CHANGED
@@ -490,6 +490,15 @@ var init_user_course_relDB = __esm({
490
490
  void this.user.updateCourseSettings(this._courseId, updates);
491
491
  }
492
492
  }
493
+ async getStrategyState(strategyKey) {
494
+ return this.user.getStrategyState(this._courseId, strategyKey);
495
+ }
496
+ async putStrategyState(strategyKey, data) {
497
+ return this.user.putStrategyState(this._courseId, strategyKey, data);
498
+ }
499
+ async deleteStrategyState(strategyKey) {
500
+ return this.user.deleteStrategyState(this._courseId, strategyKey);
501
+ }
493
502
  async getReviewstoDate(targetDate) {
494
503
  const allReviews = await this.user.getPendingReviews(this._courseId);
495
504
  logger.debug(
@@ -840,6 +849,271 @@ var init_courseLookupDB = __esm({
840
849
  }
841
850
  });
842
851
 
852
+ // src/core/navigators/PipelineDebugger.ts
853
+ var PipelineDebugger_exports = {};
854
+ __export(PipelineDebugger_exports, {
855
+ buildRunReport: () => buildRunReport,
856
+ captureRun: () => captureRun,
857
+ mountPipelineDebugger: () => mountPipelineDebugger,
858
+ pipelineDebugAPI: () => pipelineDebugAPI
859
+ });
860
+ function getOrigin(card) {
861
+ const firstEntry = card.provenance[0];
862
+ if (!firstEntry) return "unknown";
863
+ const reason = firstEntry.reason?.toLowerCase() || "";
864
+ if (reason.includes("new card")) return "new";
865
+ if (reason.includes("review")) return "review";
866
+ return "unknown";
867
+ }
868
+ function captureRun(report) {
869
+ const fullReport = {
870
+ ...report,
871
+ runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
872
+ timestamp: /* @__PURE__ */ new Date()
873
+ };
874
+ runHistory.unshift(fullReport);
875
+ if (runHistory.length > MAX_RUNS) {
876
+ runHistory.pop();
877
+ }
878
+ }
879
+ function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards) {
880
+ const selectedIds = new Set(selectedCards.map((c) => c.cardId));
881
+ const cards = allCards.map((card) => ({
882
+ cardId: card.cardId,
883
+ courseId: card.courseId,
884
+ origin: getOrigin(card),
885
+ finalScore: card.score,
886
+ provenance: card.provenance,
887
+ selected: selectedIds.has(card.cardId)
888
+ }));
889
+ const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
890
+ const newSelected = selectedCards.filter((c) => getOrigin(c) === "new").length;
891
+ return {
892
+ courseId,
893
+ courseName,
894
+ generatorName,
895
+ generators,
896
+ generatedCount,
897
+ filters,
898
+ finalCount: selectedCards.length,
899
+ reviewsSelected,
900
+ newSelected,
901
+ cards
902
+ };
903
+ }
904
+ function formatProvenance(provenance) {
905
+ return provenance.map((p) => {
906
+ const actionSymbol = p.action === "generated" ? "\u{1F3B2}" : p.action === "boosted" ? "\u2B06\uFE0F" : p.action === "penalized" ? "\u2B07\uFE0F" : "\u27A1\uFE0F";
907
+ return ` ${actionSymbol} ${p.strategyName}: ${p.score.toFixed(3)} - ${p.reason}`;
908
+ }).join("\n");
909
+ }
910
+ function printRunSummary(run) {
911
+ console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
912
+ logger.info(`Run ID: ${run.runId}`);
913
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
914
+ logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
915
+ if (run.generators && run.generators.length > 0) {
916
+ console.group("Generator breakdown:");
917
+ for (const g of run.generators) {
918
+ logger.info(
919
+ ` ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews, top: ${g.topScore.toFixed(2)})`
920
+ );
921
+ }
922
+ console.groupEnd();
923
+ }
924
+ if (run.filters.length > 0) {
925
+ console.group("Filter impact:");
926
+ for (const f of run.filters) {
927
+ logger.info(` ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
928
+ }
929
+ console.groupEnd();
930
+ }
931
+ logger.info(
932
+ `Result: ${run.finalCount} cards selected (${run.newSelected} new, ${run.reviewsSelected} reviews)`
933
+ );
934
+ console.groupEnd();
935
+ }
936
+ function mountPipelineDebugger() {
937
+ if (typeof window === "undefined") return;
938
+ const win = window;
939
+ win.skuilder = win.skuilder || {};
940
+ win.skuilder.pipeline = pipelineDebugAPI;
941
+ }
942
+ var MAX_RUNS, runHistory, pipelineDebugAPI;
943
+ var init_PipelineDebugger = __esm({
944
+ "src/core/navigators/PipelineDebugger.ts"() {
945
+ "use strict";
946
+ init_logger();
947
+ MAX_RUNS = 10;
948
+ runHistory = [];
949
+ pipelineDebugAPI = {
950
+ /**
951
+ * Get raw run history for programmatic access.
952
+ */
953
+ get runs() {
954
+ return [...runHistory];
955
+ },
956
+ /**
957
+ * Show summary of a specific pipeline run.
958
+ */
959
+ showRun(idOrIndex = 0) {
960
+ if (runHistory.length === 0) {
961
+ logger.info("[Pipeline Debug] No runs captured yet.");
962
+ return;
963
+ }
964
+ let run;
965
+ if (typeof idOrIndex === "number") {
966
+ run = runHistory[idOrIndex];
967
+ if (!run) {
968
+ logger.info(
969
+ `[Pipeline Debug] No run found at index ${idOrIndex}. History length: ${runHistory.length}`
970
+ );
971
+ return;
972
+ }
973
+ } else {
974
+ run = runHistory.find((r) => r.runId.endsWith(idOrIndex));
975
+ if (!run) {
976
+ logger.info(`[Pipeline Debug] No run found matching ID '${idOrIndex}'.`);
977
+ return;
978
+ }
979
+ }
980
+ printRunSummary(run);
981
+ },
982
+ /**
983
+ * Show summary of the last pipeline run.
984
+ */
985
+ showLastRun() {
986
+ this.showRun(0);
987
+ },
988
+ /**
989
+ * Show detailed provenance for a specific card.
990
+ */
991
+ showCard(cardId) {
992
+ for (const run of runHistory) {
993
+ const card = run.cards.find((c) => c.cardId === cardId);
994
+ if (card) {
995
+ console.group(`\u{1F3B4} Card: ${cardId}`);
996
+ logger.info(`Course: ${card.courseId}`);
997
+ logger.info(`Origin: ${card.origin}`);
998
+ logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
999
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
1000
+ logger.info("Provenance:");
1001
+ logger.info(formatProvenance(card.provenance));
1002
+ console.groupEnd();
1003
+ return;
1004
+ }
1005
+ }
1006
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
1007
+ },
1008
+ /**
1009
+ * Explain why reviews may or may not have been selected.
1010
+ */
1011
+ explainReviews() {
1012
+ if (runHistory.length === 0) {
1013
+ logger.info("[Pipeline Debug] No runs captured yet.");
1014
+ return;
1015
+ }
1016
+ console.group("\u{1F4CB} Review Selection Analysis");
1017
+ for (const run of runHistory) {
1018
+ console.group(`Run: ${run.courseId} @ ${run.timestamp.toLocaleTimeString()}`);
1019
+ const allReviews = run.cards.filter((c) => c.origin === "review");
1020
+ const selectedReviews = allReviews.filter((c) => c.selected);
1021
+ if (allReviews.length === 0) {
1022
+ logger.info("\u274C No reviews were generated. Check SRS logs for why.");
1023
+ } else if (selectedReviews.length === 0) {
1024
+ logger.info(`\u26A0\uFE0F ${allReviews.length} reviews generated but none selected.`);
1025
+ logger.info("Possible reasons:");
1026
+ const topNewScore = Math.max(
1027
+ ...run.cards.filter((c) => c.origin === "new" && c.selected).map((c) => c.finalScore),
1028
+ 0
1029
+ );
1030
+ const topReviewScore = Math.max(...allReviews.map((c) => c.finalScore), 0);
1031
+ if (topReviewScore < topNewScore) {
1032
+ logger.info(
1033
+ ` - New cards scored higher (top new: ${topNewScore.toFixed(2)}, top review: ${topReviewScore.toFixed(2)})`
1034
+ );
1035
+ }
1036
+ const topReview = allReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
1037
+ if (topReview) {
1038
+ logger.info(` - Top review score: ${topReview.finalScore.toFixed(3)}`);
1039
+ logger.info(" - Its provenance:");
1040
+ logger.info(formatProvenance(topReview.provenance));
1041
+ }
1042
+ } else {
1043
+ logger.info(`\u2705 ${selectedReviews.length}/${allReviews.length} reviews selected.`);
1044
+ logger.info("Top selected review:");
1045
+ const topSelected = selectedReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
1046
+ logger.info(formatProvenance(topSelected.provenance));
1047
+ }
1048
+ console.groupEnd();
1049
+ }
1050
+ console.groupEnd();
1051
+ },
1052
+ /**
1053
+ * Show all runs in compact format.
1054
+ */
1055
+ listRuns() {
1056
+ if (runHistory.length === 0) {
1057
+ logger.info("[Pipeline Debug] No runs captured yet.");
1058
+ return;
1059
+ }
1060
+ console.table(
1061
+ runHistory.map((r) => ({
1062
+ id: r.runId.slice(-8),
1063
+ time: r.timestamp.toLocaleTimeString(),
1064
+ course: r.courseName || r.courseId.slice(0, 8),
1065
+ generated: r.generatedCount,
1066
+ selected: r.finalCount,
1067
+ new: r.newSelected,
1068
+ reviews: r.reviewsSelected
1069
+ }))
1070
+ );
1071
+ },
1072
+ /**
1073
+ * Export run history as JSON for bug reports.
1074
+ */
1075
+ export() {
1076
+ const json = JSON.stringify(runHistory, null, 2);
1077
+ logger.info("[Pipeline Debug] Run history exported. Copy the returned string or use:");
1078
+ logger.info(" copy(window.skuilder.pipeline.export())");
1079
+ return json;
1080
+ },
1081
+ /**
1082
+ * Clear run history.
1083
+ */
1084
+ clear() {
1085
+ runHistory.length = 0;
1086
+ logger.info("[Pipeline Debug] Run history cleared.");
1087
+ },
1088
+ /**
1089
+ * Show help.
1090
+ */
1091
+ help() {
1092
+ logger.info(`
1093
+ \u{1F527} Pipeline Debug API
1094
+
1095
+ Commands:
1096
+ .showLastRun() Show summary of most recent pipeline run
1097
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
1098
+ .showCard(cardId) Show provenance trail for a specific card
1099
+ .explainReviews() Analyze why reviews were/weren't selected
1100
+ .listRuns() List all captured runs in table format
1101
+ .export() Export run history as JSON for bug reports
1102
+ .clear() Clear run history
1103
+ .runs Access raw run history array
1104
+ .help() Show this help message
1105
+
1106
+ Example:
1107
+ window.skuilder.pipeline.showLastRun()
1108
+ window.skuilder.pipeline.showRun(1)
1109
+ window.skuilder.pipeline.showCard('abc123')
1110
+ `);
1111
+ }
1112
+ };
1113
+ mountPipelineDebugger();
1114
+ }
1115
+ });
1116
+
843
1117
  // src/core/navigators/generators/CompositeGenerator.ts
844
1118
  var CompositeGenerator_exports = {};
845
1119
  __export(CompositeGenerator_exports, {
@@ -908,6 +1182,24 @@ var init_CompositeGenerator = __esm({
908
1182
  const results = await Promise.all(
909
1183
  this.generators.map((g) => g.getWeightedCards(limit, context))
910
1184
  );
1185
+ const generatorSummaries = [];
1186
+ results.forEach((cards, index) => {
1187
+ const gen = this.generators[index];
1188
+ const genName = gen.name || `Generator ${index}`;
1189
+ const newCards = cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
1190
+ const reviewCards = cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
1191
+ if (cards.length > 0) {
1192
+ const topScore = Math.max(...cards.map((c) => c.score)).toFixed(2);
1193
+ const parts = [];
1194
+ if (newCards.length > 0) parts.push(`${newCards.length} new`);
1195
+ if (reviewCards.length > 0) parts.push(`${reviewCards.length} reviews`);
1196
+ const breakdown = parts.length > 0 ? parts.join(", ") : `${cards.length} cards`;
1197
+ generatorSummaries.push(`${genName}: ${breakdown} (top: ${topScore})`);
1198
+ } else {
1199
+ generatorSummaries.push(`${genName}: 0 cards`);
1200
+ }
1201
+ });
1202
+ logger.info(`[Composite] Generator breakdown: ${generatorSummaries.join(" | ")}`);
911
1203
  const byCardId = /* @__PURE__ */ new Map();
912
1204
  results.forEach((cards, index) => {
913
1205
  const gen = this.generators[index];
@@ -1025,6 +1317,7 @@ var init_elo = __esm({
1025
1317
  "src/core/navigators/generators/elo.ts"() {
1026
1318
  "use strict";
1027
1319
  init_navigators();
1320
+ init_logger();
1028
1321
  ELONavigator = class extends ContentNavigator {
1029
1322
  /** Human-readable name for CardGenerator interface */
1030
1323
  name;
@@ -1084,7 +1377,16 @@ var init_elo = __esm({
1084
1377
  };
1085
1378
  });
1086
1379
  scored.sort((a, b) => b.score - a.score);
1087
- return scored.slice(0, limit);
1380
+ const result = scored.slice(0, limit);
1381
+ if (result.length > 0) {
1382
+ const topScores = result.slice(0, 3).map((c) => c.score.toFixed(2)).join(", ");
1383
+ logger.info(
1384
+ `[ELO] Course ${this.course.getCourseID()}: ${result.length} new cards (top scores: ${topScores})`
1385
+ );
1386
+ } else {
1387
+ logger.info(`[ELO] Course ${this.course.getCourseID()}: No new cards available`);
1388
+ }
1389
+ return result;
1088
1390
  }
1089
1391
  };
1090
1392
  }
@@ -1104,18 +1406,36 @@ __export(srs_exports, {
1104
1406
  default: () => SRSNavigator
1105
1407
  });
1106
1408
  import moment3 from "moment";
1107
- var SRSNavigator;
1409
+ var DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_PRESSURE, SRSNavigator;
1108
1410
  var init_srs = __esm({
1109
1411
  "src/core/navigators/generators/srs.ts"() {
1110
1412
  "use strict";
1111
1413
  init_navigators();
1112
1414
  init_logger();
1415
+ DEFAULT_HEALTHY_BACKLOG = 20;
1416
+ MAX_BACKLOG_PRESSURE = 0.5;
1113
1417
  SRSNavigator = class extends ContentNavigator {
1114
1418
  /** Human-readable name for CardGenerator interface */
1115
1419
  name;
1420
+ /** Healthy backlog threshold - when exceeded, backlog pressure kicks in */
1421
+ healthyBacklog;
1116
1422
  constructor(user, course, strategyData) {
1117
1423
  super(user, course, strategyData);
1118
1424
  this.name = strategyData?.name || "SRS";
1425
+ const config = this.parseConfig(strategyData?.serializedData);
1426
+ this.healthyBacklog = config.healthyBacklog ?? DEFAULT_HEALTHY_BACKLOG;
1427
+ }
1428
+ /**
1429
+ * Parse configuration from serialized JSON.
1430
+ */
1431
+ parseConfig(serializedData) {
1432
+ if (!serializedData) return {};
1433
+ try {
1434
+ return JSON.parse(serializedData);
1435
+ } catch {
1436
+ logger.warn("[SRS] Failed to parse strategy config, using defaults");
1437
+ return {};
1438
+ }
1119
1439
  }
1120
1440
  /**
1121
1441
  * Get review cards scored by urgency.
@@ -1123,6 +1443,7 @@ var init_srs = __esm({
1123
1443
  * Score formula combines:
1124
1444
  * - Relative overdueness: hoursOverdue / intervalHours
1125
1445
  * - Interval recency: exponential decay favoring shorter intervals
1446
+ * - Backlog pressure: boost when due reviews exceed healthy threshold
1126
1447
  *
1127
1448
  * Cards not yet due are excluded (not scored as 0).
1128
1449
  *
@@ -1136,11 +1457,32 @@ var init_srs = __esm({
1136
1457
  if (!this.user || !this.course) {
1137
1458
  throw new Error("SRSNavigator requires user and course to be set");
1138
1459
  }
1139
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1460
+ const courseId = this.course.getCourseID();
1461
+ const reviews = await this.user.getPendingReviews(courseId);
1140
1462
  const now = moment3.utc();
1141
1463
  const dueReviews = reviews.filter((r) => now.isAfter(moment3.utc(r.reviewTime)));
1464
+ const backlogPressure = this.computeBacklogPressure(dueReviews.length);
1465
+ if (dueReviews.length > 0) {
1466
+ const pressureNote = backlogPressure > 0 ? ` [backlog pressure: +${backlogPressure.toFixed(2)}]` : ` [healthy backlog]`;
1467
+ logger.info(
1468
+ `[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
1469
+ );
1470
+ } else if (reviews.length > 0) {
1471
+ const sortedByDue = [...reviews].sort(
1472
+ (a, b) => moment3.utc(a.reviewTime).diff(moment3.utc(b.reviewTime))
1473
+ );
1474
+ const nextDue = sortedByDue[0];
1475
+ const nextDueTime = moment3.utc(nextDue.reviewTime);
1476
+ const untilDue = moment3.duration(nextDueTime.diff(now));
1477
+ const untilDueStr = untilDue.asHours() < 1 ? `${Math.round(untilDue.asMinutes())}m` : untilDue.asHours() < 24 ? `${Math.round(untilDue.asHours())}h` : `${Math.round(untilDue.asDays())}d`;
1478
+ logger.info(
1479
+ `[SRS] Course ${courseId}: 0 reviews due now (${reviews.length} scheduled, next in ${untilDueStr})`
1480
+ );
1481
+ } else {
1482
+ logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
1483
+ }
1142
1484
  const scored = dueReviews.map((review) => {
1143
- const { score, reason } = this.computeUrgencyScore(review, now);
1485
+ const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
1144
1486
  return {
1145
1487
  cardId: review.cardId,
1146
1488
  courseId: review.courseId,
@@ -1158,13 +1500,35 @@ var init_srs = __esm({
1158
1500
  ]
1159
1501
  };
1160
1502
  });
1161
- logger.debug(`[srsNav] got ${scored.length} weighted cards`);
1162
1503
  return scored.sort((a, b) => b.score - a.score).slice(0, limit);
1163
1504
  }
1505
+ /**
1506
+ * Compute backlog pressure based on number of due reviews.
1507
+ *
1508
+ * Backlog pressure is 0 when at or below healthy threshold,
1509
+ * and increases linearly above it, maxing out at MAX_BACKLOG_PRESSURE.
1510
+ *
1511
+ * Examples (with default healthyBacklog=20):
1512
+ * - 10 due reviews → 0.00 (healthy)
1513
+ * - 20 due reviews → 0.00 (at threshold)
1514
+ * - 40 due reviews → 0.25 (2x threshold)
1515
+ * - 60 due reviews → 0.50 (3x threshold, maxed)
1516
+ *
1517
+ * @param dueCount - Number of reviews currently due
1518
+ * @returns Backlog pressure score to add to urgency (0 to MAX_BACKLOG_PRESSURE)
1519
+ */
1520
+ computeBacklogPressure(dueCount) {
1521
+ if (dueCount <= this.healthyBacklog) {
1522
+ return 0;
1523
+ }
1524
+ const excess = dueCount - this.healthyBacklog;
1525
+ const pressure = excess / this.healthyBacklog * (MAX_BACKLOG_PRESSURE / 2);
1526
+ return Math.min(MAX_BACKLOG_PRESSURE, pressure);
1527
+ }
1164
1528
  /**
1165
1529
  * Compute urgency score for a review card.
1166
1530
  *
1167
- * Two factors:
1531
+ * Three factors:
1168
1532
  * 1. Relative overdueness = hoursOverdue / intervalHours
1169
1533
  * - 2 days overdue on 3-day interval = 0.67 (urgent)
1170
1534
  * - 2 days overdue on 180-day interval = 0.01 (not urgent)
@@ -1174,10 +1538,19 @@ var init_srs = __esm({
1174
1538
  * - 30 days (720h) → ~0.56
1175
1539
  * - 180 days → ~0.30
1176
1540
  *
1177
- * Combined: base 0.5 + weighted average of factors * 0.45
1178
- * Result range: approximately 0.5 to 0.95
1541
+ * 3. Backlog pressure = global boost when review backlog exceeds healthy threshold
1542
+ * - At healthy backlog: 0
1543
+ * - At 2x healthy: +0.25
1544
+ * - At 3x+ healthy: +0.50 (max)
1545
+ *
1546
+ * Combined: base 0.5 + (urgency factors * 0.45) + backlog pressure
1547
+ * Result range: 0.5 to 1.0 (uncapped to allow high-urgency reviews to compete with new cards)
1548
+ *
1549
+ * @param review - The scheduled card to score
1550
+ * @param now - Current time
1551
+ * @param backlogPressure - Pre-computed backlog pressure (0 to 0.5)
1179
1552
  */
1180
- computeUrgencyScore(review, now) {
1553
+ computeUrgencyScore(review, now, backlogPressure) {
1181
1554
  const scheduledAt = moment3.utc(review.scheduledAt);
1182
1555
  const due = moment3.utc(review.reviewTime);
1183
1556
  const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
@@ -1186,8 +1559,19 @@ var init_srs = __esm({
1186
1559
  const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
1187
1560
  const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
1188
1561
  const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
1189
- const score = Math.min(0.95, 0.5 + urgency * 0.45);
1190
- const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
1562
+ const baseScore = 0.5 + urgency * 0.45;
1563
+ const score = Math.min(1, baseScore + backlogPressure);
1564
+ const reasonParts = [
1565
+ `${Math.round(hoursOverdue)}h overdue`,
1566
+ `interval: ${Math.round(intervalHours)}h`,
1567
+ `relative: ${relativeOverdue.toFixed(2)}`,
1568
+ `recency: ${recencyFactor.toFixed(2)}`
1569
+ ];
1570
+ if (backlogPressure > 0) {
1571
+ reasonParts.push(`backlog: +${backlogPressure.toFixed(2)}`);
1572
+ }
1573
+ reasonParts.push("review");
1574
+ const reason = reasonParts.join(", ");
1191
1575
  return { score, reason };
1192
1576
  }
1193
1577
  };
@@ -2464,10 +2848,23 @@ function logTagHydration(cards, tagsByCard) {
2464
2848
  `[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
2465
2849
  );
2466
2850
  }
2467
- function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
2851
+ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores, filterImpacts) {
2468
2852
  const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
2853
+ let filterSummary = "";
2854
+ if (filterImpacts.length > 0) {
2855
+ const impacts = filterImpacts.map((f) => {
2856
+ const parts = [];
2857
+ if (f.boosted > 0) parts.push(`+${f.boosted}`);
2858
+ if (f.penalized > 0) parts.push(`-${f.penalized}`);
2859
+ if (f.passed > 0) parts.push(`=${f.passed}`);
2860
+ return `${f.name}: ${parts.join("/")}`;
2861
+ });
2862
+ filterSummary = `
2863
+ Filter impact: ${impacts.join(", ")}`;
2864
+ }
2469
2865
  logger.info(
2470
- `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
2866
+ `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})` + filterSummary + `
2867
+ \u{1F4A1} Inspect: window.skuilder.pipeline`
2471
2868
  );
2472
2869
  }
2473
2870
  function logCardProvenance(cards, maxCards = 3) {
@@ -2491,6 +2888,7 @@ var init_Pipeline = __esm({
2491
2888
  init_navigators();
2492
2889
  init_logger();
2493
2890
  init_orchestration();
2891
+ init_PipelineDebugger();
2494
2892
  Pipeline = class extends ContentNavigator {
2495
2893
  generator;
2496
2894
  filters;
@@ -2538,12 +2936,49 @@ var init_Pipeline = __esm({
2538
2936
  );
2539
2937
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2540
2938
  const generatedCount = cards.length;
2939
+ let generatorSummaries;
2940
+ if (this.generator.generators) {
2941
+ const genMap = /* @__PURE__ */ new Map();
2942
+ for (const card of cards) {
2943
+ const firstProv = card.provenance[0];
2944
+ if (firstProv) {
2945
+ const genName = firstProv.strategyName;
2946
+ if (!genMap.has(genName)) {
2947
+ genMap.set(genName, { cards: [] });
2948
+ }
2949
+ genMap.get(genName).cards.push(card);
2950
+ }
2951
+ }
2952
+ generatorSummaries = Array.from(genMap.entries()).map(([name, data]) => {
2953
+ const newCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
2954
+ const reviewCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
2955
+ return {
2956
+ name,
2957
+ cardCount: data.cards.length,
2958
+ newCount: newCards.length,
2959
+ reviewCount: reviewCards.length,
2960
+ topScore: Math.max(...data.cards.map((c) => c.score), 0)
2961
+ };
2962
+ });
2963
+ }
2541
2964
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2542
2965
  cards = await this.hydrateTags(cards);
2966
+ const allCardsBeforeFiltering = [...cards];
2967
+ const filterImpacts = [];
2543
2968
  for (const filter of this.filters) {
2544
2969
  const beforeCount = cards.length;
2970
+ const beforeScores = new Map(cards.map((c) => [c.cardId, c.score]));
2545
2971
  cards = await filter.transform(cards, context);
2546
- logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
2972
+ let boosted = 0, penalized = 0, passed = 0;
2973
+ const removed = beforeCount - cards.length;
2974
+ for (const card of cards) {
2975
+ const before = beforeScores.get(card.cardId) ?? 0;
2976
+ if (card.score > before) boosted++;
2977
+ else if (card.score < before) penalized++;
2978
+ else passed++;
2979
+ }
2980
+ filterImpacts.push({ name: filter.name, boosted, penalized, passed, removed });
2981
+ logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2547
2982
  }
2548
2983
  cards = cards.filter((c) => c.score > 0);
2549
2984
  cards.sort((a, b) => b.score - a.score);
@@ -2554,9 +2989,26 @@ var init_Pipeline = __esm({
2554
2989
  generatedCount,
2555
2990
  this.filters.length,
2556
2991
  result.length,
2557
- topScores
2992
+ topScores,
2993
+ filterImpacts
2558
2994
  );
2559
2995
  logCardProvenance(result, 3);
2996
+ try {
2997
+ const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
2998
+ const report = buildRunReport(
2999
+ this.course?.getCourseID() || "unknown",
3000
+ courseName,
3001
+ this.generator.name,
3002
+ generatorSummaries,
3003
+ generatedCount,
3004
+ filterImpacts,
3005
+ allCardsBeforeFiltering,
3006
+ result
3007
+ );
3008
+ captureRun(report);
3009
+ } catch (e) {
3010
+ logger.debug(`[Pipeline] Failed to capture debug run: ${e}`);
3011
+ }
2560
3012
  return result;
2561
3013
  }
2562
3014
  /**
@@ -2646,6 +3098,56 @@ var init_Pipeline = __esm({
2646
3098
  }
2647
3099
  });
2648
3100
 
3101
+ // src/core/navigators/defaults.ts
3102
+ var defaults_exports = {};
3103
+ __export(defaults_exports, {
3104
+ createDefaultEloStrategy: () => createDefaultEloStrategy,
3105
+ createDefaultPipeline: () => createDefaultPipeline,
3106
+ createDefaultSrsStrategy: () => createDefaultSrsStrategy
3107
+ });
3108
+ function createDefaultEloStrategy(courseId) {
3109
+ return {
3110
+ _id: "NAVIGATION_STRATEGY-ELO-default",
3111
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
3112
+ name: "ELO (default)",
3113
+ description: "Default ELO-based navigation strategy for new cards",
3114
+ implementingClass: "elo" /* ELO */,
3115
+ course: courseId,
3116
+ serializedData: ""
3117
+ };
3118
+ }
3119
+ function createDefaultSrsStrategy(courseId) {
3120
+ return {
3121
+ _id: "NAVIGATION_STRATEGY-SRS-default",
3122
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
3123
+ name: "SRS (default)",
3124
+ description: "Default SRS-based navigation strategy for reviews",
3125
+ implementingClass: "srs" /* SRS */,
3126
+ course: courseId,
3127
+ serializedData: ""
3128
+ };
3129
+ }
3130
+ function createDefaultPipeline(user, course) {
3131
+ const courseId = course.getCourseID();
3132
+ const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
3133
+ const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
3134
+ const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
3135
+ const eloDistanceFilter = createEloDistanceFilter();
3136
+ return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
3137
+ }
3138
+ var init_defaults = __esm({
3139
+ "src/core/navigators/defaults.ts"() {
3140
+ "use strict";
3141
+ init_navigators();
3142
+ init_Pipeline();
3143
+ init_CompositeGenerator();
3144
+ init_elo();
3145
+ init_srs();
3146
+ init_eloDistance();
3147
+ init_types_legacy();
3148
+ }
3149
+ });
3150
+
2649
3151
  // src/core/navigators/PipelineAssembler.ts
2650
3152
  var PipelineAssembler_exports = {};
2651
3153
  __export(PipelineAssembler_exports, {
@@ -2658,9 +3160,9 @@ var init_PipelineAssembler = __esm({
2658
3160
  init_navigators();
2659
3161
  init_WeightedFilter();
2660
3162
  init_Pipeline();
2661
- init_types_legacy();
2662
3163
  init_logger();
2663
3164
  init_CompositeGenerator();
3165
+ init_defaults();
2664
3166
  PipelineAssembler = class {
2665
3167
  /**
2666
3168
  * Assembles a navigation pipeline from strategy documents.
@@ -2699,9 +3201,11 @@ var init_PipelineAssembler = __esm({
2699
3201
  if (generatorStrategies.length === 0) {
2700
3202
  if (filterStrategies.length > 0) {
2701
3203
  logger.debug(
2702
- "[PipelineAssembler] No generator found, using default ELO with configured filters"
3204
+ "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2703
3205
  );
2704
- generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
3206
+ const courseId = course.getCourseID();
3207
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3208
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
2705
3209
  } else {
2706
3210
  warnings.push("No generator strategy found");
2707
3211
  return {
@@ -2762,75 +3266,10 @@ var init_PipelineAssembler = __esm({
2762
3266
  warnings
2763
3267
  };
2764
3268
  }
2765
- /**
2766
- * Creates a default ELO generator strategy.
2767
- * Used when filters are configured but no generator is specified.
2768
- */
2769
- makeDefaultEloStrategy(courseId) {
2770
- return {
2771
- _id: "NAVIGATION_STRATEGY-ELO-default",
2772
- course: courseId,
2773
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2774
- name: "ELO (default)",
2775
- description: "Default ELO-based generator",
2776
- implementingClass: "elo" /* ELO */,
2777
- serializedData: ""
2778
- };
2779
- }
2780
3269
  };
2781
3270
  }
2782
3271
  });
2783
3272
 
2784
- // src/core/navigators/defaults.ts
2785
- var defaults_exports = {};
2786
- __export(defaults_exports, {
2787
- createDefaultEloStrategy: () => createDefaultEloStrategy,
2788
- createDefaultPipeline: () => createDefaultPipeline,
2789
- createDefaultSrsStrategy: () => createDefaultSrsStrategy
2790
- });
2791
- function createDefaultEloStrategy(courseId) {
2792
- return {
2793
- _id: "NAVIGATION_STRATEGY-ELO-default",
2794
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2795
- name: "ELO (default)",
2796
- description: "Default ELO-based navigation strategy for new cards",
2797
- implementingClass: "elo" /* ELO */,
2798
- course: courseId,
2799
- serializedData: ""
2800
- };
2801
- }
2802
- function createDefaultSrsStrategy(courseId) {
2803
- return {
2804
- _id: "NAVIGATION_STRATEGY-SRS-default",
2805
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2806
- name: "SRS (default)",
2807
- description: "Default SRS-based navigation strategy for reviews",
2808
- implementingClass: "srs" /* SRS */,
2809
- course: courseId,
2810
- serializedData: ""
2811
- };
2812
- }
2813
- function createDefaultPipeline(user, course) {
2814
- const courseId = course.getCourseID();
2815
- const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
2816
- const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
2817
- const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
2818
- const eloDistanceFilter = createEloDistanceFilter();
2819
- return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
2820
- }
2821
- var init_defaults = __esm({
2822
- "src/core/navigators/defaults.ts"() {
2823
- "use strict";
2824
- init_navigators();
2825
- init_Pipeline();
2826
- init_CompositeGenerator();
2827
- init_elo();
2828
- init_srs();
2829
- init_eloDistance();
2830
- init_types_legacy();
2831
- }
2832
- });
2833
-
2834
3273
  // import("./**/*") in src/core/navigators/index.ts
2835
3274
  var globImport;
2836
3275
  var init_3 = __esm({
@@ -2838,6 +3277,7 @@ var init_3 = __esm({
2838
3277
  globImport = __glob({
2839
3278
  "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
2840
3279
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
3280
+ "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
2841
3281
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
2842
3282
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
2843
3283
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
@@ -2873,6 +3313,8 @@ __export(navigators_exports, {
2873
3313
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
2874
3314
  isFilter: () => isFilter,
2875
3315
  isGenerator: () => isGenerator,
3316
+ mountPipelineDebugger: () => mountPipelineDebugger,
3317
+ pipelineDebugAPI: () => pipelineDebugAPI,
2876
3318
  registerNavigator: () => registerNavigator
2877
3319
  });
2878
3320
  function registerNavigator(implementingClass, constructor) {
@@ -2939,6 +3381,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
2939
3381
  var init_navigators = __esm({
2940
3382
  "src/core/navigators/index.ts"() {
2941
3383
  "use strict";
3384
+ init_PipelineDebugger();
2942
3385
  init_logger();
2943
3386
  init_();
2944
3387
  init_2();
@@ -7555,6 +7998,14 @@ var SrsService = class {
7555
7998
  constructor(user) {
7556
7999
  this.user = user;
7557
8000
  }
8001
+ /**
8002
+ * Remove a scheduled review from the user's database.
8003
+ * Used to clean up orphaned reviews (e.g., card deleted from course DB).
8004
+ */
8005
+ removeReview(reviewID) {
8006
+ logger.info(`[SrsService] Removing orphaned scheduled review: ${reviewID}`);
8007
+ void this.user.removeScheduledCardReview(reviewID);
8008
+ }
7558
8009
  /**
7559
8010
  * Calculates the next review time for a card based on its history and
7560
8011
  * schedules it in the user's database.
@@ -7581,7 +8032,11 @@ var SrsService = class {
7581
8032
 
7582
8033
  // src/study/services/EloService.ts
7583
8034
  init_logger();
7584
- import { adjustCourseScores, toCourseElo as toCourseElo7 } from "@vue-skuilder/common";
8035
+ import {
8036
+ adjustCourseScores,
8037
+ adjustCourseScoresPerTag,
8038
+ toCourseElo as toCourseElo7
8039
+ } from "@vue-skuilder/common";
7585
8040
  var EloService = class {
7586
8041
  dataLayer;
7587
8042
  user;
@@ -7593,7 +8048,7 @@ var EloService = class {
7593
8048
  * Updates both user and card ELO ratings based on user performance.
7594
8049
  * @param userScore Score between 0-1 representing user performance
7595
8050
  * @param course_id Course identifier
7596
- * @param card_id Card identifier
8051
+ * @param card_id Card identifier
7597
8052
  * @param userCourseRegDoc User's course registration document (will be mutated)
7598
8053
  * @param currentCard Current card session record
7599
8054
  * @param k Optional K-factor for ELO calculation
@@ -7603,7 +8058,9 @@ var EloService = class {
7603
8058
  logger.warn(`k value interpretation not currently implemented`);
7604
8059
  }
7605
8060
  const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
7606
- const userElo = toCourseElo7(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
8061
+ const userElo = toCourseElo7(
8062
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
8063
+ );
7607
8064
  const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
7608
8065
  if (cardElo && userElo) {
7609
8066
  const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
@@ -7640,11 +8097,64 @@ var EloService = class {
7640
8097
  }
7641
8098
  }
7642
8099
  }
8100
+ /**
8101
+ * Updates both user and card ELO ratings with per-tag granularity.
8102
+ * Tags in taggedPerformance but not on card will be created dynamically.
8103
+ *
8104
+ * @param taggedPerformance Performance object with _global and per-tag scores
8105
+ * @param course_id Course identifier
8106
+ * @param card_id Card identifier
8107
+ * @param userCourseRegDoc User's course registration document (will be mutated)
8108
+ * @param currentCard Current card session record
8109
+ */
8110
+ async updateUserAndCardEloPerTag(taggedPerformance, course_id, card_id, userCourseRegDoc, currentCard) {
8111
+ const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
8112
+ const userElo = toCourseElo7(
8113
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
8114
+ );
8115
+ const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
8116
+ if (cardElo && userElo) {
8117
+ const eloUpdate = adjustCourseScoresPerTag(userElo, cardElo, taggedPerformance);
8118
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
8119
+ const results = await Promise.allSettled([
8120
+ this.user.updateUserElo(course_id, eloUpdate.userElo),
8121
+ courseDB.updateCardElo(card_id, eloUpdate.cardElo)
8122
+ ]);
8123
+ const userEloStatus = results[0].status === "fulfilled";
8124
+ const cardEloStatus = results[1].status === "fulfilled";
8125
+ if (userEloStatus && cardEloStatus) {
8126
+ const user = results[0].value;
8127
+ const card = results[1].value;
8128
+ if (user.ok && card && card.ok) {
8129
+ const tagCount = Object.keys(taggedPerformance).length - 1;
8130
+ logger.info(
8131
+ `[EloService] Updated ELOS (per-tag, ${tagCount} tags):
8132
+ User: ${JSON.stringify(eloUpdate.userElo)})
8133
+ Card: ${JSON.stringify(eloUpdate.cardElo)})
8134
+ `
8135
+ );
8136
+ }
8137
+ } else {
8138
+ logger.warn(
8139
+ `[EloService] Partial ELO update (per-tag):
8140
+ User ELO update: ${userEloStatus ? "SUCCESS" : "FAILED"}
8141
+ Card ELO update: ${cardEloStatus ? "SUCCESS" : "FAILED"}`
8142
+ );
8143
+ if (!userEloStatus && results[0].status === "rejected") {
8144
+ logger.error("[EloService] User ELO update error:", results[0].reason);
8145
+ }
8146
+ if (!cardEloStatus && results[1].status === "rejected") {
8147
+ logger.error("[EloService] Card ELO update error:", results[1].reason);
8148
+ }
8149
+ }
8150
+ }
8151
+ }
7643
8152
  };
7644
8153
 
7645
8154
  // src/study/services/ResponseProcessor.ts
7646
8155
  init_core();
7647
8156
  init_logger();
8157
+ import { isTaggedPerformance } from "@vue-skuilder/common";
7648
8158
  var ResponseProcessor = class {
7649
8159
  srsService;
7650
8160
  eloService;
@@ -7652,6 +8162,33 @@ var ResponseProcessor = class {
7652
8162
  this.srsService = srsService;
7653
8163
  this.eloService = eloService;
7654
8164
  }
8165
+ /**
8166
+ * Parses performance data into global score and optional per-tag scores.
8167
+ *
8168
+ * @param performance - Numeric or structured performance from QuestionRecord
8169
+ * @returns Parsed performance with global score and optional tag scores
8170
+ */
8171
+ parsePerformance(performance2) {
8172
+ if (typeof performance2 === "number") {
8173
+ return {
8174
+ globalScore: performance2,
8175
+ taggedPerformance: null
8176
+ };
8177
+ }
8178
+ if (isTaggedPerformance(performance2)) {
8179
+ return {
8180
+ globalScore: performance2._global,
8181
+ taggedPerformance: performance2
8182
+ };
8183
+ }
8184
+ logger.warn("[ResponseProcessor] Unexpected performance structure, using neutral score", {
8185
+ performance: performance2
8186
+ });
8187
+ return {
8188
+ globalScore: 0.5,
8189
+ taggedPerformance: null
8190
+ };
8191
+ }
7655
8192
  /**
7656
8193
  * Processes a user's response to a card, handling SRS scheduling and ELO updates.
7657
8194
  * @param cardRecord User's response record
@@ -7712,46 +8249,60 @@ var ResponseProcessor = class {
7712
8249
  processCorrectResponse(cardRecord, history, studySessionItem, courseRegistrationDoc, currentCard, courseId, cardId) {
7713
8250
  if (cardRecord.priorAttemps === 0) {
7714
8251
  void this.srsService.scheduleReview(history, studySessionItem);
7715
- if (history.records.length === 1) {
7716
- const userScore = 0.5 + cardRecord.performance / 2;
7717
- void this.eloService.updateUserAndCardElo(
7718
- userScore,
8252
+ const { globalScore, taggedPerformance } = this.parsePerformance(cardRecord.performance);
8253
+ if (taggedPerformance) {
8254
+ void this.eloService.updateUserAndCardEloPerTag(
8255
+ taggedPerformance,
7719
8256
  courseId,
7720
8257
  cardId,
7721
8258
  courseRegistrationDoc,
7722
8259
  currentCard
7723
8260
  );
8261
+ logger.info(
8262
+ `[ResponseProcessor] Processed correct response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
8263
+ );
7724
8264
  } else {
7725
- const k = Math.ceil(32 / history.records.length);
7726
- const userScore = 0.5 + cardRecord.performance / 2;
7727
- void this.eloService.updateUserAndCardElo(
7728
- userScore,
7729
- courseId,
7730
- cardId,
7731
- courseRegistrationDoc,
7732
- currentCard,
7733
- k
8265
+ const userScore = 0.5 + globalScore / 2;
8266
+ if (history.records.length === 1) {
8267
+ void this.eloService.updateUserAndCardElo(
8268
+ userScore,
8269
+ courseId,
8270
+ cardId,
8271
+ courseRegistrationDoc,
8272
+ currentCard
8273
+ );
8274
+ } else {
8275
+ const k = Math.ceil(32 / history.records.length);
8276
+ void this.eloService.updateUserAndCardElo(
8277
+ userScore,
8278
+ courseId,
8279
+ cardId,
8280
+ courseRegistrationDoc,
8281
+ currentCard,
8282
+ k
8283
+ );
8284
+ }
8285
+ logger.info(
8286
+ "[ResponseProcessor] Processed correct response with SRS scheduling and ELO update"
7734
8287
  );
7735
8288
  }
7736
- logger.info(
7737
- "[ResponseProcessor] Processed correct response with SRS scheduling and ELO update"
7738
- );
7739
8289
  return {
7740
8290
  nextCardAction: "dismiss-success",
7741
8291
  shouldLoadNextCard: true,
7742
8292
  isCorrect: true,
7743
- performanceScore: cardRecord.performance,
8293
+ performanceScore: globalScore,
7744
8294
  shouldClearFeedbackShadow: true
7745
8295
  };
7746
8296
  } else {
7747
8297
  logger.info(
7748
8298
  "[ResponseProcessor] Processed correct response (retry attempt - no scheduling/ELO)"
7749
8299
  );
8300
+ const { globalScore } = this.parsePerformance(cardRecord.performance);
7750
8301
  return {
7751
8302
  nextCardAction: "marked-failed",
7752
8303
  shouldLoadNextCard: true,
7753
8304
  isCorrect: true,
7754
- performanceScore: cardRecord.performance,
8305
+ performanceScore: globalScore,
7755
8306
  shouldClearFeedbackShadow: true
7756
8307
  };
7757
8308
  }
@@ -7760,28 +8311,52 @@ var ResponseProcessor = class {
7760
8311
  * Handles processing for incorrect responses: ELO updates only.
7761
8312
  */
7762
8313
  processIncorrectResponse(cardRecord, history, courseRegistrationDoc, currentCard, courseId, cardId, maxAttemptsPerView, maxSessionViews, sessionViews) {
8314
+ const { taggedPerformance } = this.parsePerformance(cardRecord.performance);
7763
8315
  if (history.records.length !== 1 && cardRecord.priorAttemps === 0) {
7764
- void this.eloService.updateUserAndCardElo(
7765
- 0,
7766
- // Failed response = 0 score
7767
- courseId,
7768
- cardId,
7769
- courseRegistrationDoc,
7770
- currentCard
7771
- );
7772
- logger.info("[ResponseProcessor] Processed incorrect response with ELO update");
7773
- } else {
7774
- logger.info("[ResponseProcessor] Processed incorrect response (no ELO update needed)");
7775
- }
7776
- if (currentCard.records.length >= maxAttemptsPerView) {
7777
- if (sessionViews >= maxSessionViews) {
8316
+ if (taggedPerformance) {
8317
+ void this.eloService.updateUserAndCardEloPerTag(
8318
+ taggedPerformance,
8319
+ courseId,
8320
+ cardId,
8321
+ courseRegistrationDoc,
8322
+ currentCard
8323
+ );
8324
+ logger.info(
8325
+ `[ResponseProcessor] Processed incorrect response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
8326
+ );
8327
+ } else {
7778
8328
  void this.eloService.updateUserAndCardElo(
7779
8329
  0,
8330
+ // Failed response = 0 score
7780
8331
  courseId,
7781
8332
  cardId,
7782
8333
  courseRegistrationDoc,
7783
8334
  currentCard
7784
8335
  );
8336
+ logger.info("[ResponseProcessor] Processed incorrect response with ELO update");
8337
+ }
8338
+ } else {
8339
+ logger.info("[ResponseProcessor] Processed incorrect response (no ELO update needed)");
8340
+ }
8341
+ if (currentCard.records.length >= maxAttemptsPerView) {
8342
+ if (sessionViews >= maxSessionViews) {
8343
+ if (taggedPerformance) {
8344
+ void this.eloService.updateUserAndCardEloPerTag(
8345
+ taggedPerformance,
8346
+ courseId,
8347
+ cardId,
8348
+ courseRegistrationDoc,
8349
+ currentCard
8350
+ );
8351
+ } else {
8352
+ void this.eloService.updateUserAndCardElo(
8353
+ 0,
8354
+ courseId,
8355
+ cardId,
8356
+ courseRegistrationDoc,
8357
+ currentCard
8358
+ );
8359
+ }
7785
8360
  return {
7786
8361
  nextCardAction: "dismiss-failed",
7787
8362
  shouldLoadNextCard: true,
@@ -9467,16 +10042,645 @@ var QuotaRoundRobinMixer = class {
9467
10042
  return [];
9468
10043
  }
9469
10044
  const quotaPerSource = Math.ceil(limit / batches.length);
9470
- const mixed = [];
9471
- for (const batch of batches) {
9472
- const sortedBatch = [...batch.weighted].sort((a, b) => b.score - a.score);
9473
- const topFromSource = sortedBatch.slice(0, quotaPerSource);
9474
- mixed.push(...topFromSource);
10045
+ const sourceStacks = batches.map((batch) => {
10046
+ return [...batch.weighted].sort((a, b) => b.score - a.score).slice(0, quotaPerSource);
10047
+ });
10048
+ for (let i = sourceStacks.length - 1; i > 0; i--) {
10049
+ const j = Math.floor(Math.random() * (i + 1));
10050
+ [sourceStacks[i], sourceStacks[j]] = [sourceStacks[j], sourceStacks[i]];
10051
+ }
10052
+ const result = [];
10053
+ let exhausted = 0;
10054
+ const cursors = new Array(sourceStacks.length).fill(0);
10055
+ while (result.length < limit && exhausted < sourceStacks.length) {
10056
+ exhausted = 0;
10057
+ for (let s = 0; s < sourceStacks.length; s++) {
10058
+ if (result.length >= limit) break;
10059
+ if (cursors[s] < sourceStacks[s].length) {
10060
+ result.push(sourceStacks[s][cursors[s]]);
10061
+ cursors[s]++;
10062
+ } else {
10063
+ exhausted++;
10064
+ }
10065
+ }
9475
10066
  }
9476
- return mixed.sort((a, b) => b.score - a.score).slice(0, limit);
10067
+ return result;
9477
10068
  }
9478
10069
  };
9479
10070
 
10071
+ // src/study/MixerDebugger.ts
10072
+ init_logger();
10073
+ init_navigators();
10074
+ var MAX_RUNS2 = 10;
10075
+ var runHistory2 = [];
10076
+ function buildSourceSummary(batch, sourceId, sourceName) {
10077
+ const scores = batch.weighted.map((c) => c.score);
10078
+ const reviewCount = batch.weighted.filter((c) => getCardOrigin(c) === "review").length;
10079
+ const newCount = batch.weighted.filter((c) => getCardOrigin(c) === "new").length;
10080
+ return {
10081
+ sourceIndex: batch.sourceIndex,
10082
+ sourceId,
10083
+ sourceName,
10084
+ totalCards: batch.weighted.length,
10085
+ reviewCount,
10086
+ newCount,
10087
+ topScore: scores.length > 0 ? Math.max(...scores) : 0,
10088
+ bottomScore: scores.length > 0 ? Math.min(...scores) : 0,
10089
+ scoreRange: scores.length > 0 ? [Math.min(...scores), Math.max(...scores)] : [0, 0],
10090
+ avgScore: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0
10091
+ };
10092
+ }
10093
+ function buildSourceBreakdown(sourceId, sourceName, allCards) {
10094
+ const sourceCards = allCards.filter((c) => c.courseId === sourceId);
10095
+ const selectedCards = sourceCards.filter((c) => c.selected);
10096
+ const reviewsProvided = sourceCards.filter((c) => c.origin === "review").length;
10097
+ const newProvided = sourceCards.filter((c) => c.origin === "new").length;
10098
+ const reviewsSelected = selectedCards.filter((c) => c.origin === "review").length;
10099
+ const newSelected = selectedCards.filter((c) => c.origin === "new").length;
10100
+ return {
10101
+ sourceId,
10102
+ sourceName,
10103
+ reviewsProvided,
10104
+ newProvided,
10105
+ reviewsSelected,
10106
+ newSelected,
10107
+ totalSelected: selectedCards.length,
10108
+ selectionRate: sourceCards.length > 0 ? selectedCards.length / sourceCards.length * 100 : 0
10109
+ };
10110
+ }
10111
+ function captureMixerRun(mixerType, batches, sourceIds, sourceNames, requestedLimit, quotaPerSource, mixedResult) {
10112
+ const sourceSummaries = batches.map(
10113
+ (batch, idx) => buildSourceSummary(batch, sourceIds[idx] || `source-${idx}`, sourceNames[idx])
10114
+ );
10115
+ const selectedIds = new Set(mixedResult.map((c) => c.cardId));
10116
+ const sourceRankings = /* @__PURE__ */ new Map();
10117
+ batches.forEach((batch) => {
10118
+ const sorted = [...batch.weighted].sort((a, b) => b.score - a.score);
10119
+ const rankings = /* @__PURE__ */ new Map();
10120
+ sorted.forEach((card, idx) => {
10121
+ rankings.set(card.cardId, idx + 1);
10122
+ });
10123
+ sourceRankings.set(sourceIds[batch.sourceIndex] || `source-${batch.sourceIndex}`, rankings);
10124
+ });
10125
+ const mixRankings = /* @__PURE__ */ new Map();
10126
+ mixedResult.forEach((card, idx) => {
10127
+ mixRankings.set(card.cardId, idx + 1);
10128
+ });
10129
+ const allCardsMap = /* @__PURE__ */ new Map();
10130
+ batches.forEach((batch) => {
10131
+ batch.weighted.forEach((card) => {
10132
+ allCardsMap.set(card.cardId, card);
10133
+ });
10134
+ });
10135
+ const cards = Array.from(allCardsMap.values()).map((card) => ({
10136
+ cardId: card.cardId,
10137
+ courseId: card.courseId,
10138
+ origin: getCardOrigin(card),
10139
+ score: card.score,
10140
+ sourceIndex: batches.findIndex((b) => b.weighted.some((c) => c.cardId === card.cardId)),
10141
+ selected: selectedIds.has(card.cardId),
10142
+ rankInSource: sourceRankings.get(card.courseId)?.get(card.cardId),
10143
+ rankInMix: mixRankings.get(card.cardId)
10144
+ }));
10145
+ const uniqueSourceIds = Array.from(new Set(sourceIds.filter((id) => id)));
10146
+ const sourceBreakdowns = uniqueSourceIds.map(
10147
+ (sourceId, idx) => buildSourceBreakdown(sourceId, sourceNames[idx], cards)
10148
+ );
10149
+ const report = {
10150
+ runId: `mix-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
10151
+ timestamp: /* @__PURE__ */ new Date(),
10152
+ mixerType,
10153
+ requestedLimit,
10154
+ quotaPerSource,
10155
+ sourceSummaries,
10156
+ cards,
10157
+ finalCount: mixedResult.length,
10158
+ reviewsSelected: mixedResult.filter((c) => getCardOrigin(c) === "review").length,
10159
+ newSelected: mixedResult.filter((c) => getCardOrigin(c) === "new").length,
10160
+ sourceBreakdowns
10161
+ };
10162
+ runHistory2.unshift(report);
10163
+ if (runHistory2.length > MAX_RUNS2) {
10164
+ runHistory2.pop();
10165
+ }
10166
+ }
10167
+ function printMixerSummary(run) {
10168
+ console.group(`\u{1F3A8} Mixer Run: ${run.mixerType}`);
10169
+ logger.info(`Run ID: ${run.runId}`);
10170
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
10171
+ logger.info(
10172
+ `Config: limit=${run.requestedLimit}${run.quotaPerSource ? `, quota/source=${run.quotaPerSource}` : ""}`
10173
+ );
10174
+ console.group(`\u{1F4E5} Input: ${run.sourceSummaries.length} sources`);
10175
+ for (const src of run.sourceSummaries) {
10176
+ logger.info(
10177
+ ` ${src.sourceName || src.sourceId}: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`
10178
+ );
10179
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}], avg: ${src.avgScore.toFixed(2)}`);
10180
+ }
10181
+ console.groupEnd();
10182
+ console.group(`\u{1F4E4} Output: ${run.finalCount} cards selected (${run.reviewsSelected} reviews, ${run.newSelected} new)`);
10183
+ for (const breakdown of run.sourceBreakdowns) {
10184
+ const name = breakdown.sourceName || breakdown.sourceId;
10185
+ logger.info(
10186
+ ` ${name}: ${breakdown.totalSelected} selected (${breakdown.reviewsSelected} reviews, ${breakdown.newSelected} new) - ${breakdown.selectionRate.toFixed(1)}% selection rate`
10187
+ );
10188
+ }
10189
+ console.groupEnd();
10190
+ console.groupEnd();
10191
+ }
10192
+ var mixerDebugAPI = {
10193
+ /**
10194
+ * Get raw run history for programmatic access.
10195
+ */
10196
+ get runs() {
10197
+ return [...runHistory2];
10198
+ },
10199
+ /**
10200
+ * Show summary of a specific mixer run.
10201
+ */
10202
+ showRun(idOrIndex = 0) {
10203
+ if (runHistory2.length === 0) {
10204
+ logger.info("[Mixer Debug] No runs captured yet.");
10205
+ return;
10206
+ }
10207
+ let run;
10208
+ if (typeof idOrIndex === "number") {
10209
+ run = runHistory2[idOrIndex];
10210
+ if (!run) {
10211
+ logger.info(`[Mixer Debug] No run found at index ${idOrIndex}. History length: ${runHistory2.length}`);
10212
+ return;
10213
+ }
10214
+ } else {
10215
+ run = runHistory2.find((r) => r.runId.endsWith(idOrIndex));
10216
+ if (!run) {
10217
+ logger.info(`[Mixer Debug] No run found matching ID '${idOrIndex}'.`);
10218
+ return;
10219
+ }
10220
+ }
10221
+ printMixerSummary(run);
10222
+ },
10223
+ /**
10224
+ * Show summary of the last mixer run.
10225
+ */
10226
+ showLastMix() {
10227
+ this.showRun(0);
10228
+ },
10229
+ /**
10230
+ * Explain source balance in the last run.
10231
+ */
10232
+ explainSourceBalance() {
10233
+ if (runHistory2.length === 0) {
10234
+ logger.info("[Mixer Debug] No runs captured yet.");
10235
+ return;
10236
+ }
10237
+ const run = runHistory2[0];
10238
+ console.group("\u2696\uFE0F Source Balance Analysis");
10239
+ logger.info(`Mixer: ${run.mixerType}`);
10240
+ logger.info(`Requested limit: ${run.requestedLimit}`);
10241
+ if (run.quotaPerSource) {
10242
+ logger.info(`Quota per source: ${run.quotaPerSource}`);
10243
+ }
10244
+ console.group("Input Distribution:");
10245
+ for (const src of run.sourceSummaries) {
10246
+ const name = src.sourceName || src.sourceId;
10247
+ logger.info(`${name}:`);
10248
+ logger.info(` Provided: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`);
10249
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}]`);
10250
+ }
10251
+ console.groupEnd();
10252
+ console.group("Selection Results:");
10253
+ for (const breakdown of run.sourceBreakdowns) {
10254
+ const name = breakdown.sourceName || breakdown.sourceId;
10255
+ logger.info(`${name}:`);
10256
+ logger.info(
10257
+ ` Selected: ${breakdown.totalSelected}/${breakdown.reviewsProvided + breakdown.newProvided} (${breakdown.selectionRate.toFixed(1)}%)`
10258
+ );
10259
+ logger.info(` Reviews: ${breakdown.reviewsSelected}/${breakdown.reviewsProvided}`);
10260
+ logger.info(` New: ${breakdown.newSelected}/${breakdown.newProvided}`);
10261
+ if (breakdown.reviewsProvided > 0 && breakdown.reviewsSelected === 0) {
10262
+ logger.info(` \u26A0\uFE0F Had reviews but none selected!`);
10263
+ }
10264
+ if (breakdown.totalSelected === 0 && breakdown.reviewsProvided + breakdown.newProvided > 0) {
10265
+ logger.info(` \u26A0\uFE0F Had cards but none selected!`);
10266
+ }
10267
+ }
10268
+ console.groupEnd();
10269
+ const selectionRates = run.sourceBreakdowns.map((b) => b.selectionRate);
10270
+ const avgRate = selectionRates.reduce((a, b) => a + b, 0) / selectionRates.length;
10271
+ const maxDeviation = Math.max(...selectionRates.map((r) => Math.abs(r - avgRate)));
10272
+ if (maxDeviation > 20) {
10273
+ logger.info(`
10274
+ \u26A0\uFE0F Significant imbalance detected (max deviation: ${maxDeviation.toFixed(1)}%)`);
10275
+ logger.info("Possible causes:");
10276
+ logger.info(" - Score range differences between sources");
10277
+ logger.info(" - One source has much better quality cards");
10278
+ logger.info(" - Different card availability (reviews vs new)");
10279
+ }
10280
+ console.groupEnd();
10281
+ },
10282
+ /**
10283
+ * Compare score distributions across sources.
10284
+ */
10285
+ compareScores() {
10286
+ if (runHistory2.length === 0) {
10287
+ logger.info("[Mixer Debug] No runs captured yet.");
10288
+ return;
10289
+ }
10290
+ const run = runHistory2[0];
10291
+ console.group("\u{1F4CA} Score Distribution Comparison");
10292
+ console.table(
10293
+ run.sourceSummaries.map((src) => ({
10294
+ source: src.sourceName || src.sourceId,
10295
+ cards: src.totalCards,
10296
+ min: src.bottomScore.toFixed(3),
10297
+ max: src.topScore.toFixed(3),
10298
+ avg: src.avgScore.toFixed(3),
10299
+ range: (src.topScore - src.bottomScore).toFixed(3)
10300
+ }))
10301
+ );
10302
+ const ranges = run.sourceSummaries.map((s) => s.topScore - s.bottomScore);
10303
+ const avgScores = run.sourceSummaries.map((s) => s.avgScore);
10304
+ const rangeDiff = Math.max(...ranges) - Math.min(...ranges);
10305
+ const avgDiff = Math.max(...avgScores) - Math.min(...avgScores);
10306
+ if (rangeDiff > 0.3 || avgDiff > 0.2) {
10307
+ logger.info("\n\u26A0\uFE0F Significant score distribution differences detected");
10308
+ logger.info(
10309
+ "This may cause one source to dominate selection if using global sorting (not quota-based)"
10310
+ );
10311
+ }
10312
+ console.groupEnd();
10313
+ },
10314
+ /**
10315
+ * Show detailed information for a specific card.
10316
+ */
10317
+ showCard(cardId) {
10318
+ for (const run of runHistory2) {
10319
+ const card = run.cards.find((c) => c.cardId === cardId);
10320
+ if (card) {
10321
+ const source = run.sourceSummaries.find((s) => s.sourceIndex === card.sourceIndex);
10322
+ console.group(`\u{1F3B4} Card: ${cardId}`);
10323
+ logger.info(`Course: ${card.courseId}`);
10324
+ logger.info(`Source: ${source?.sourceName || source?.sourceId || "unknown"}`);
10325
+ logger.info(`Origin: ${card.origin}`);
10326
+ logger.info(`Score: ${card.score.toFixed(3)}`);
10327
+ if (card.rankInSource) {
10328
+ logger.info(`Rank in source: #${card.rankInSource}`);
10329
+ }
10330
+ if (card.rankInMix) {
10331
+ logger.info(`Rank in mixed results: #${card.rankInMix}`);
10332
+ }
10333
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
10334
+ if (!card.selected && card.rankInSource) {
10335
+ logger.info("\nWhy not selected:");
10336
+ if (run.quotaPerSource && card.rankInSource > run.quotaPerSource) {
10337
+ logger.info(` - Ranked #${card.rankInSource} in source, but quota was ${run.quotaPerSource}`);
10338
+ }
10339
+ logger.info(" - Check score compared to selected cards using .showRun()");
10340
+ }
10341
+ console.groupEnd();
10342
+ return;
10343
+ }
10344
+ }
10345
+ logger.info(`[Mixer Debug] Card '${cardId}' not found in recent runs.`);
10346
+ },
10347
+ /**
10348
+ * Show all runs in compact format.
10349
+ */
10350
+ listRuns() {
10351
+ if (runHistory2.length === 0) {
10352
+ logger.info("[Mixer Debug] No runs captured yet.");
10353
+ return;
10354
+ }
10355
+ console.table(
10356
+ runHistory2.map((r) => ({
10357
+ id: r.runId.slice(-8),
10358
+ time: r.timestamp.toLocaleTimeString(),
10359
+ mixer: r.mixerType,
10360
+ sources: r.sourceSummaries.length,
10361
+ selected: r.finalCount,
10362
+ reviews: r.reviewsSelected,
10363
+ new: r.newSelected
10364
+ }))
10365
+ );
10366
+ },
10367
+ /**
10368
+ * Export run history as JSON for bug reports.
10369
+ */
10370
+ export() {
10371
+ const json = JSON.stringify(runHistory2, null, 2);
10372
+ logger.info("[Mixer Debug] Run history exported. Copy the returned string or use:");
10373
+ logger.info(" copy(window.skuilder.mixer.export())");
10374
+ return json;
10375
+ },
10376
+ /**
10377
+ * Clear run history.
10378
+ */
10379
+ clear() {
10380
+ runHistory2.length = 0;
10381
+ logger.info("[Mixer Debug] Run history cleared.");
10382
+ },
10383
+ /**
10384
+ * Show help.
10385
+ */
10386
+ help() {
10387
+ logger.info(`
10388
+ \u{1F3A8} Mixer Debug API
10389
+
10390
+ Commands:
10391
+ .showLastMix() Show summary of most recent mixer run
10392
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
10393
+ .explainSourceBalance() Analyze source balance and selection patterns
10394
+ .compareScores() Compare score distributions across sources
10395
+ .showCard(cardId) Show mixer decisions for a specific card
10396
+ .listRuns() List all captured runs in table format
10397
+ .export() Export run history as JSON for bug reports
10398
+ .clear() Clear run history
10399
+ .runs Access raw run history array
10400
+ .help() Show this help message
10401
+
10402
+ Example:
10403
+ window.skuilder.mixer.showLastMix()
10404
+ window.skuilder.mixer.explainSourceBalance()
10405
+ window.skuilder.mixer.compareScores()
10406
+ `);
10407
+ }
10408
+ };
10409
+ function mountMixerDebugger() {
10410
+ if (typeof window === "undefined") return;
10411
+ const win = window;
10412
+ win.skuilder = win.skuilder || {};
10413
+ win.skuilder.mixer = mixerDebugAPI;
10414
+ }
10415
+ mountMixerDebugger();
10416
+
10417
+ // src/study/SessionDebugger.ts
10418
+ init_logger();
10419
+ var activeSession = null;
10420
+ var sessionHistory = [];
10421
+ var MAX_HISTORY = 5;
10422
+ function startSessionTracking(reviewQLength, newQLength, failedQLength) {
10423
+ const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10424
+ activeSession = {
10425
+ sessionId,
10426
+ startTime: /* @__PURE__ */ new Date(),
10427
+ initialQueues: {
10428
+ timestamp: /* @__PURE__ */ new Date(),
10429
+ reviewQLength,
10430
+ newQLength,
10431
+ failedQLength
10432
+ },
10433
+ presentations: [],
10434
+ queueSnapshots: []
10435
+ };
10436
+ logger.debug(`[SessionDebugger] Started tracking session: ${sessionId}`);
10437
+ }
10438
+ function recordCardPresentation(cardId, courseId, courseName, origin, queueSource, score) {
10439
+ if (!activeSession) {
10440
+ logger.warn("[SessionDebugger] No active session to record presentation");
10441
+ return;
10442
+ }
10443
+ activeSession.presentations.push({
10444
+ timestamp: /* @__PURE__ */ new Date(),
10445
+ sequenceNumber: activeSession.presentations.length + 1,
10446
+ cardId,
10447
+ courseId,
10448
+ courseName,
10449
+ origin,
10450
+ queueSource,
10451
+ score
10452
+ });
10453
+ }
10454
+ function snapshotQueues(reviewQLength, newQLength, failedQLength, reviewQNext3, newQNext3) {
10455
+ if (!activeSession) {
10456
+ return;
10457
+ }
10458
+ activeSession.queueSnapshots.push({
10459
+ timestamp: /* @__PURE__ */ new Date(),
10460
+ reviewQLength,
10461
+ newQLength,
10462
+ failedQLength,
10463
+ reviewQNext3,
10464
+ newQNext3
10465
+ });
10466
+ }
10467
+ function endSessionTracking() {
10468
+ if (!activeSession) {
10469
+ return;
10470
+ }
10471
+ activeSession.endTime = /* @__PURE__ */ new Date();
10472
+ sessionHistory.unshift(activeSession);
10473
+ if (sessionHistory.length > MAX_HISTORY) {
10474
+ sessionHistory.pop();
10475
+ }
10476
+ logger.debug(`[SessionDebugger] Ended session: ${activeSession.sessionId}`);
10477
+ activeSession = null;
10478
+ }
10479
+ function showCurrentQueue() {
10480
+ if (!activeSession) {
10481
+ logger.info("[Session Debug] No active session.");
10482
+ return;
10483
+ }
10484
+ const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
10485
+ console.group("\u{1F4CA} Current Queue State");
10486
+ logger.info(`Review Queue: ${latest.reviewQLength} cards`);
10487
+ if (latest.reviewQNext3 && latest.reviewQNext3.length > 0) {
10488
+ logger.info(` Next: ${latest.reviewQNext3.join(", ")}`);
10489
+ }
10490
+ logger.info(`New Queue: ${latest.newQLength} cards`);
10491
+ if (latest.newQNext3 && latest.newQNext3.length > 0) {
10492
+ logger.info(` Next: ${latest.newQNext3.join(", ")}`);
10493
+ }
10494
+ logger.info(`Failed Queue: ${latest.failedQLength} cards`);
10495
+ console.groupEnd();
10496
+ }
10497
+ function showPresentationHistory(sessionIndex = 0) {
10498
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
10499
+ if (!session) {
10500
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
10501
+ return;
10502
+ }
10503
+ console.group(`\u{1F4DC} Session History: ${session.sessionId}`);
10504
+ logger.info(`Started: ${session.startTime.toLocaleTimeString()}`);
10505
+ if (session.endTime) {
10506
+ logger.info(`Ended: ${session.endTime.toLocaleTimeString()}`);
10507
+ }
10508
+ logger.info(`Cards presented: ${session.presentations.length}`);
10509
+ if (session.presentations.length > 0) {
10510
+ console.table(
10511
+ session.presentations.map((p) => ({
10512
+ "#": p.sequenceNumber,
10513
+ course: p.courseName || p.courseId.slice(0, 8),
10514
+ origin: p.origin,
10515
+ queue: p.queueSource,
10516
+ score: p.score?.toFixed(3) || "-",
10517
+ time: p.timestamp.toLocaleTimeString()
10518
+ }))
10519
+ );
10520
+ }
10521
+ console.groupEnd();
10522
+ }
10523
+ function showInterleaving(sessionIndex = 0) {
10524
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
10525
+ if (!session) {
10526
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
10527
+ return;
10528
+ }
10529
+ console.group("\u{1F500} Interleaving Analysis");
10530
+ const courseCounts = /* @__PURE__ */ new Map();
10531
+ const courseOrigins = /* @__PURE__ */ new Map();
10532
+ session.presentations.forEach((p) => {
10533
+ const name = p.courseName || p.courseId;
10534
+ courseCounts.set(name, (courseCounts.get(name) || 0) + 1);
10535
+ if (!courseOrigins.has(name)) {
10536
+ courseOrigins.set(name, { review: 0, new: 0, failed: 0 });
10537
+ }
10538
+ const origins = courseOrigins.get(name);
10539
+ origins[p.origin]++;
10540
+ });
10541
+ logger.info("Course distribution:");
10542
+ console.table(
10543
+ Array.from(courseCounts.entries()).map(([course, count]) => {
10544
+ const origins = courseOrigins.get(course);
10545
+ return {
10546
+ course,
10547
+ total: count,
10548
+ reviews: origins.review,
10549
+ new: origins.new,
10550
+ failed: origins.failed,
10551
+ percentage: (count / session.presentations.length * 100).toFixed(1) + "%"
10552
+ };
10553
+ })
10554
+ );
10555
+ if (session.presentations.length > 0) {
10556
+ logger.info("\nPresentation sequence (first 20):");
10557
+ const sequence = session.presentations.slice(0, 20).map((p, idx) => `${idx + 1}. ${p.courseName || p.courseId.slice(0, 8)} (${p.origin})`).join("\n");
10558
+ logger.info(sequence);
10559
+ }
10560
+ let maxCluster = 0;
10561
+ let currentCluster = 1;
10562
+ let currentCourse = session.presentations[0]?.courseId;
10563
+ for (let i = 1; i < session.presentations.length; i++) {
10564
+ if (session.presentations[i].courseId === currentCourse) {
10565
+ currentCluster++;
10566
+ maxCluster = Math.max(maxCluster, currentCluster);
10567
+ } else {
10568
+ currentCourse = session.presentations[i].courseId;
10569
+ currentCluster = 1;
10570
+ }
10571
+ }
10572
+ if (maxCluster > 3) {
10573
+ logger.info(`
10574
+ \u26A0\uFE0F Detected clustering: max ${maxCluster} cards from same course in a row`);
10575
+ logger.info("This suggests cards are sorted by score rather than round-robin by course.");
10576
+ }
10577
+ console.groupEnd();
10578
+ }
10579
+ var sessionDebugAPI = {
10580
+ /**
10581
+ * Get raw session history for programmatic access.
10582
+ */
10583
+ get sessions() {
10584
+ return [...sessionHistory];
10585
+ },
10586
+ /**
10587
+ * Get active session if any.
10588
+ */
10589
+ get active() {
10590
+ return activeSession;
10591
+ },
10592
+ /**
10593
+ * Show current queue state.
10594
+ */
10595
+ showQueue() {
10596
+ showCurrentQueue();
10597
+ },
10598
+ /**
10599
+ * Show presentation history for current or past session.
10600
+ */
10601
+ showHistory(sessionIndex = 0) {
10602
+ showPresentationHistory(sessionIndex);
10603
+ },
10604
+ /**
10605
+ * Analyze course interleaving pattern.
10606
+ */
10607
+ showInterleaving(sessionIndex = 0) {
10608
+ showInterleaving(sessionIndex);
10609
+ },
10610
+ /**
10611
+ * List all tracked sessions.
10612
+ */
10613
+ listSessions() {
10614
+ if (activeSession) {
10615
+ logger.info(`Active session: ${activeSession.sessionId} (${activeSession.presentations.length} cards presented)`);
10616
+ }
10617
+ if (sessionHistory.length === 0) {
10618
+ logger.info("[Session Debug] No completed sessions in history.");
10619
+ return;
10620
+ }
10621
+ console.table(
10622
+ sessionHistory.map((s, idx) => ({
10623
+ index: idx,
10624
+ id: s.sessionId.slice(-8),
10625
+ started: s.startTime.toLocaleTimeString(),
10626
+ ended: s.endTime?.toLocaleTimeString() || "incomplete",
10627
+ cards: s.presentations.length
10628
+ }))
10629
+ );
10630
+ },
10631
+ /**
10632
+ * Export session history as JSON for bug reports.
10633
+ */
10634
+ export() {
10635
+ const data = {
10636
+ active: activeSession,
10637
+ history: sessionHistory
10638
+ };
10639
+ const json = JSON.stringify(data, null, 2);
10640
+ logger.info("[Session Debug] Session data exported. Copy the returned string or use:");
10641
+ logger.info(" copy(window.skuilder.session.export())");
10642
+ return json;
10643
+ },
10644
+ /**
10645
+ * Clear session history.
10646
+ */
10647
+ clear() {
10648
+ sessionHistory.length = 0;
10649
+ logger.info("[Session Debug] Session history cleared.");
10650
+ },
10651
+ /**
10652
+ * Show help.
10653
+ */
10654
+ help() {
10655
+ logger.info(`
10656
+ \u{1F3AF} Session Debug API
10657
+
10658
+ Commands:
10659
+ .showQueue() Show current queue state (active session only)
10660
+ .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
10661
+ .showInterleaving(index?) Analyze course interleaving pattern
10662
+ .listSessions() List all tracked sessions
10663
+ .export() Export session data as JSON for bug reports
10664
+ .clear() Clear session history
10665
+ .sessions Access raw session history array
10666
+ .active Access active session (if any)
10667
+ .help() Show this help message
10668
+
10669
+ Example:
10670
+ window.skuilder.session.showHistory()
10671
+ window.skuilder.session.showInterleaving()
10672
+ window.skuilder.session.showQueue()
10673
+ `);
10674
+ }
10675
+ };
10676
+ function mountSessionDebugger() {
10677
+ if (typeof window === "undefined") return;
10678
+ const win = window;
10679
+ win.skuilder = win.skuilder || {};
10680
+ win.skuilder.session = sessionDebugAPI;
10681
+ }
10682
+ mountSessionDebugger();
10683
+
9480
10684
  // src/study/SessionController.ts
9481
10685
  init_logger();
9482
10686
  var SessionController = class extends Loggable {
@@ -9486,6 +10690,8 @@ var SessionController = class extends Loggable {
9486
10690
  eloService;
9487
10691
  hydrationService;
9488
10692
  mixer;
10693
+ dataLayer;
10694
+ courseNameCache = /* @__PURE__ */ new Map();
9489
10695
  sources;
9490
10696
  // dataLayer and getViewComponent now injected into CardHydrationService
9491
10697
  _sessionRecord = [];
@@ -9505,7 +10711,11 @@ var SessionController = class extends Loggable {
9505
10711
  return this._secondsRemaining;
9506
10712
  }
9507
10713
  get report() {
9508
- return `${this.reviewQ.dequeueCount} reviews, ${this.newQ.dequeueCount} new cards`;
10714
+ const reviewCount = this.reviewQ.dequeueCount;
10715
+ const newCount = this.newQ.dequeueCount;
10716
+ const reviewWord = reviewCount === 1 ? "review" : "reviews";
10717
+ const newCardWord = newCount === 1 ? "new card" : "new cards";
10718
+ return `${reviewCount} ${reviewWord}, ${newCount} ${newCardWord}`;
9509
10719
  }
9510
10720
  get detailedReport() {
9511
10721
  return this.newQ.toString + "\n" + this.reviewQ.toString + "\n" + this.failedQ.toString;
@@ -9521,6 +10731,7 @@ var SessionController = class extends Loggable {
9521
10731
  */
9522
10732
  constructor(sources, time, dataLayer, getViewComponent, mixer) {
9523
10733
  super();
10734
+ this.dataLayer = dataLayer;
9524
10735
  this.mixer = mixer || new QuotaRoundRobinMixer();
9525
10736
  this.srsService = new SrsService(dataLayer.getUserDB());
9526
10737
  this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
@@ -9588,6 +10799,7 @@ var SessionController = class extends Loggable {
9588
10799
  }
9589
10800
  await this.getWeightedContent();
9590
10801
  await this.hydrationService.ensureHydratedCards();
10802
+ startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
9591
10803
  this._intervalHandle = setInterval(() => {
9592
10804
  this.tick();
9593
10805
  }, 1e3);
@@ -9683,6 +10895,30 @@ var SessionController = class extends Loggable {
9683
10895
  );
9684
10896
  }
9685
10897
  const mixedWeighted = this.mixer.mix(batches, limit * this.sources.length);
10898
+ const sourceIds = batches.map((b) => {
10899
+ const firstCard = b.weighted[0];
10900
+ return firstCard?.courseId || `source-${b.sourceIndex}`;
10901
+ });
10902
+ await Promise.all(
10903
+ sourceIds.map(async (id) => {
10904
+ try {
10905
+ const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
10906
+ this.courseNameCache.set(id, config.name);
10907
+ } catch {
10908
+ }
10909
+ })
10910
+ );
10911
+ const sourceNames = sourceIds.map((id) => this.courseNameCache.get(id));
10912
+ const quotaPerSource = this.mixer instanceof QuotaRoundRobinMixer ? Math.ceil(limit * this.sources.length / batches.length) : void 0;
10913
+ captureMixerRun(
10914
+ this.mixer.constructor.name,
10915
+ batches,
10916
+ sourceIds,
10917
+ sourceNames,
10918
+ limit * this.sources.length,
10919
+ quotaPerSource,
10920
+ mixedWeighted
10921
+ );
9686
10922
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
9687
10923
  const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
9688
10924
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
@@ -9791,21 +11027,46 @@ var SessionController = class extends Loggable {
9791
11027
  this.dismissCurrentCard(action);
9792
11028
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
9793
11029
  this._currentCard = null;
11030
+ endSessionTracking();
9794
11031
  return null;
9795
11032
  }
9796
- const nextItem = this._selectNextItemToHydrate();
9797
- if (!nextItem) {
9798
- this._currentCard = null;
9799
- return null;
9800
- }
9801
- let card = this.hydrationService.getHydratedCard(nextItem.cardID);
9802
- if (!card) {
9803
- card = await this.hydrationService.waitForCard(nextItem.cardID);
11033
+ const MAX_SKIP = 20;
11034
+ for (let attempt = 0; attempt < MAX_SKIP; attempt++) {
11035
+ const nextItem = this._selectNextItemToHydrate();
11036
+ if (!nextItem) {
11037
+ this._currentCard = null;
11038
+ endSessionTracking();
11039
+ return null;
11040
+ }
11041
+ let card = this.hydrationService.getHydratedCard(nextItem.cardID);
11042
+ if (!card) {
11043
+ card = await this.hydrationService.waitForCard(nextItem.cardID);
11044
+ }
11045
+ this.removeItemFromQueue(nextItem);
11046
+ if (card) {
11047
+ await this.hydrationService.ensureHydratedCards();
11048
+ this._currentCard = card;
11049
+ const origin = nextItem.status === "review" || nextItem.status === "failed-review" ? "review" : nextItem.status === "new" || nextItem.status === "failed-new" ? "new" : "failed";
11050
+ const queueSource = nextItem.status.startsWith("failed") ? "failedQ" : nextItem.status === "review" ? "reviewQ" : "newQ";
11051
+ recordCardPresentation(
11052
+ nextItem.cardID,
11053
+ nextItem.courseID,
11054
+ this.courseNameCache.get(nextItem.courseID),
11055
+ origin,
11056
+ queueSource
11057
+ );
11058
+ snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
11059
+ return card;
11060
+ }
11061
+ this.log(`Skipping card ${nextItem.cardID}: hydration failed, trying next`);
11062
+ if (isReview(nextItem)) {
11063
+ this.srsService.removeReview(nextItem.reviewID);
11064
+ }
9804
11065
  }
9805
- this.removeItemFromQueue(nextItem);
9806
- await this.hydrationService.ensureHydratedCards();
9807
- this._currentCard = card;
9808
- return card;
11066
+ this.log(`Exhausted ${MAX_SKIP} skip attempts finding a hydratable card`);
11067
+ this._currentCard = null;
11068
+ endSessionTracking();
11069
+ return null;
9809
11070
  }
9810
11071
  /**
9811
11072
  * Public API for processing user responses to cards.
@@ -9953,6 +11214,7 @@ export {
9953
11214
  aggregateOutcomesForGradient,
9954
11215
  areQuestionRecords,
9955
11216
  buildStrategyStateId,
11217
+ captureMixerRun,
9956
11218
  computeDeviation,
9957
11219
  computeEffectiveWeight,
9958
11220
  computeOutcomeSignal,
@@ -9960,6 +11222,7 @@ export {
9960
11222
  computeStrategyGradient,
9961
11223
  createOrchestrationContext,
9962
11224
  docIsDeleted,
11225
+ endSessionTracking,
9963
11226
  ensureAppDataDirectory,
9964
11227
  getAppDataDirectory,
9965
11228
  getCardHistoryID,
@@ -9983,9 +11246,15 @@ export {
9983
11246
  isQuestionTypeRegistered,
9984
11247
  isReview,
9985
11248
  log,
11249
+ mixerDebugAPI,
11250
+ mountMixerDebugger,
11251
+ mountPipelineDebugger,
11252
+ mountSessionDebugger,
9986
11253
  newInterval,
9987
11254
  parseCardHistoryID,
11255
+ pipelineDebugAPI,
9988
11256
  processCustomQuestionsData,
11257
+ recordCardPresentation,
9989
11258
  recordUserOutcome,
9990
11259
  registerBlanksCard,
9991
11260
  registerCustomQuestionTypes,
@@ -9998,6 +11267,9 @@ export {
9998
11267
  removeQuestionType,
9999
11268
  runPeriodUpdate,
10000
11269
  scoreAccuracyInZone,
11270
+ sessionDebugAPI,
11271
+ snapshotQueues,
11272
+ startSessionTracking,
10001
11273
  updateLearningState,
10002
11274
  updateStrategyWeight,
10003
11275
  validateMigration,