@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.js CHANGED
@@ -513,6 +513,15 @@ var init_user_course_relDB = __esm({
513
513
  void this.user.updateCourseSettings(this._courseId, updates);
514
514
  }
515
515
  }
516
+ async getStrategyState(strategyKey) {
517
+ return this.user.getStrategyState(this._courseId, strategyKey);
518
+ }
519
+ async putStrategyState(strategyKey, data) {
520
+ return this.user.putStrategyState(this._courseId, strategyKey, data);
521
+ }
522
+ async deleteStrategyState(strategyKey) {
523
+ return this.user.deleteStrategyState(this._courseId, strategyKey);
524
+ }
516
525
  async getReviewstoDate(targetDate) {
517
526
  const allReviews = await this.user.getPendingReviews(this._courseId);
518
527
  logger.debug(
@@ -863,6 +872,271 @@ var init_courseLookupDB = __esm({
863
872
  }
864
873
  });
865
874
 
875
+ // src/core/navigators/PipelineDebugger.ts
876
+ var PipelineDebugger_exports = {};
877
+ __export(PipelineDebugger_exports, {
878
+ buildRunReport: () => buildRunReport,
879
+ captureRun: () => captureRun,
880
+ mountPipelineDebugger: () => mountPipelineDebugger,
881
+ pipelineDebugAPI: () => pipelineDebugAPI
882
+ });
883
+ function getOrigin(card) {
884
+ const firstEntry = card.provenance[0];
885
+ if (!firstEntry) return "unknown";
886
+ const reason = firstEntry.reason?.toLowerCase() || "";
887
+ if (reason.includes("new card")) return "new";
888
+ if (reason.includes("review")) return "review";
889
+ return "unknown";
890
+ }
891
+ function captureRun(report) {
892
+ const fullReport = {
893
+ ...report,
894
+ runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
895
+ timestamp: /* @__PURE__ */ new Date()
896
+ };
897
+ runHistory.unshift(fullReport);
898
+ if (runHistory.length > MAX_RUNS) {
899
+ runHistory.pop();
900
+ }
901
+ }
902
+ function buildRunReport(courseId, courseName, generatorName, generators, generatedCount, filters, allCards, selectedCards) {
903
+ const selectedIds = new Set(selectedCards.map((c) => c.cardId));
904
+ const cards = allCards.map((card) => ({
905
+ cardId: card.cardId,
906
+ courseId: card.courseId,
907
+ origin: getOrigin(card),
908
+ finalScore: card.score,
909
+ provenance: card.provenance,
910
+ selected: selectedIds.has(card.cardId)
911
+ }));
912
+ const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === "review").length;
913
+ const newSelected = selectedCards.filter((c) => getOrigin(c) === "new").length;
914
+ return {
915
+ courseId,
916
+ courseName,
917
+ generatorName,
918
+ generators,
919
+ generatedCount,
920
+ filters,
921
+ finalCount: selectedCards.length,
922
+ reviewsSelected,
923
+ newSelected,
924
+ cards
925
+ };
926
+ }
927
+ function formatProvenance(provenance) {
928
+ return provenance.map((p) => {
929
+ const actionSymbol = p.action === "generated" ? "\u{1F3B2}" : p.action === "boosted" ? "\u2B06\uFE0F" : p.action === "penalized" ? "\u2B07\uFE0F" : "\u27A1\uFE0F";
930
+ return ` ${actionSymbol} ${p.strategyName}: ${p.score.toFixed(3)} - ${p.reason}`;
931
+ }).join("\n");
932
+ }
933
+ function printRunSummary(run) {
934
+ console.group(`\u{1F50D} Pipeline Run: ${run.courseId} (${run.courseName || "unnamed"})`);
935
+ logger.info(`Run ID: ${run.runId}`);
936
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
937
+ logger.info(`Generator: ${run.generatorName} \u2192 ${run.generatedCount} candidates`);
938
+ if (run.generators && run.generators.length > 0) {
939
+ console.group("Generator breakdown:");
940
+ for (const g of run.generators) {
941
+ logger.info(
942
+ ` ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews, top: ${g.topScore.toFixed(2)})`
943
+ );
944
+ }
945
+ console.groupEnd();
946
+ }
947
+ if (run.filters.length > 0) {
948
+ console.group("Filter impact:");
949
+ for (const f of run.filters) {
950
+ logger.info(` ${f.name}: \u2191${f.boosted} \u2193${f.penalized} =${f.passed} \u2715${f.removed}`);
951
+ }
952
+ console.groupEnd();
953
+ }
954
+ logger.info(
955
+ `Result: ${run.finalCount} cards selected (${run.newSelected} new, ${run.reviewsSelected} reviews)`
956
+ );
957
+ console.groupEnd();
958
+ }
959
+ function mountPipelineDebugger() {
960
+ if (typeof window === "undefined") return;
961
+ const win = window;
962
+ win.skuilder = win.skuilder || {};
963
+ win.skuilder.pipeline = pipelineDebugAPI;
964
+ }
965
+ var MAX_RUNS, runHistory, pipelineDebugAPI;
966
+ var init_PipelineDebugger = __esm({
967
+ "src/core/navigators/PipelineDebugger.ts"() {
968
+ "use strict";
969
+ init_logger();
970
+ MAX_RUNS = 10;
971
+ runHistory = [];
972
+ pipelineDebugAPI = {
973
+ /**
974
+ * Get raw run history for programmatic access.
975
+ */
976
+ get runs() {
977
+ return [...runHistory];
978
+ },
979
+ /**
980
+ * Show summary of a specific pipeline run.
981
+ */
982
+ showRun(idOrIndex = 0) {
983
+ if (runHistory.length === 0) {
984
+ logger.info("[Pipeline Debug] No runs captured yet.");
985
+ return;
986
+ }
987
+ let run;
988
+ if (typeof idOrIndex === "number") {
989
+ run = runHistory[idOrIndex];
990
+ if (!run) {
991
+ logger.info(
992
+ `[Pipeline Debug] No run found at index ${idOrIndex}. History length: ${runHistory.length}`
993
+ );
994
+ return;
995
+ }
996
+ } else {
997
+ run = runHistory.find((r) => r.runId.endsWith(idOrIndex));
998
+ if (!run) {
999
+ logger.info(`[Pipeline Debug] No run found matching ID '${idOrIndex}'.`);
1000
+ return;
1001
+ }
1002
+ }
1003
+ printRunSummary(run);
1004
+ },
1005
+ /**
1006
+ * Show summary of the last pipeline run.
1007
+ */
1008
+ showLastRun() {
1009
+ this.showRun(0);
1010
+ },
1011
+ /**
1012
+ * Show detailed provenance for a specific card.
1013
+ */
1014
+ showCard(cardId) {
1015
+ for (const run of runHistory) {
1016
+ const card = run.cards.find((c) => c.cardId === cardId);
1017
+ if (card) {
1018
+ console.group(`\u{1F3B4} Card: ${cardId}`);
1019
+ logger.info(`Course: ${card.courseId}`);
1020
+ logger.info(`Origin: ${card.origin}`);
1021
+ logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
1022
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
1023
+ logger.info("Provenance:");
1024
+ logger.info(formatProvenance(card.provenance));
1025
+ console.groupEnd();
1026
+ return;
1027
+ }
1028
+ }
1029
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
1030
+ },
1031
+ /**
1032
+ * Explain why reviews may or may not have been selected.
1033
+ */
1034
+ explainReviews() {
1035
+ if (runHistory.length === 0) {
1036
+ logger.info("[Pipeline Debug] No runs captured yet.");
1037
+ return;
1038
+ }
1039
+ console.group("\u{1F4CB} Review Selection Analysis");
1040
+ for (const run of runHistory) {
1041
+ console.group(`Run: ${run.courseId} @ ${run.timestamp.toLocaleTimeString()}`);
1042
+ const allReviews = run.cards.filter((c) => c.origin === "review");
1043
+ const selectedReviews = allReviews.filter((c) => c.selected);
1044
+ if (allReviews.length === 0) {
1045
+ logger.info("\u274C No reviews were generated. Check SRS logs for why.");
1046
+ } else if (selectedReviews.length === 0) {
1047
+ logger.info(`\u26A0\uFE0F ${allReviews.length} reviews generated but none selected.`);
1048
+ logger.info("Possible reasons:");
1049
+ const topNewScore = Math.max(
1050
+ ...run.cards.filter((c) => c.origin === "new" && c.selected).map((c) => c.finalScore),
1051
+ 0
1052
+ );
1053
+ const topReviewScore = Math.max(...allReviews.map((c) => c.finalScore), 0);
1054
+ if (topReviewScore < topNewScore) {
1055
+ logger.info(
1056
+ ` - New cards scored higher (top new: ${topNewScore.toFixed(2)}, top review: ${topReviewScore.toFixed(2)})`
1057
+ );
1058
+ }
1059
+ const topReview = allReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
1060
+ if (topReview) {
1061
+ logger.info(` - Top review score: ${topReview.finalScore.toFixed(3)}`);
1062
+ logger.info(" - Its provenance:");
1063
+ logger.info(formatProvenance(topReview.provenance));
1064
+ }
1065
+ } else {
1066
+ logger.info(`\u2705 ${selectedReviews.length}/${allReviews.length} reviews selected.`);
1067
+ logger.info("Top selected review:");
1068
+ const topSelected = selectedReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
1069
+ logger.info(formatProvenance(topSelected.provenance));
1070
+ }
1071
+ console.groupEnd();
1072
+ }
1073
+ console.groupEnd();
1074
+ },
1075
+ /**
1076
+ * Show all runs in compact format.
1077
+ */
1078
+ listRuns() {
1079
+ if (runHistory.length === 0) {
1080
+ logger.info("[Pipeline Debug] No runs captured yet.");
1081
+ return;
1082
+ }
1083
+ console.table(
1084
+ runHistory.map((r) => ({
1085
+ id: r.runId.slice(-8),
1086
+ time: r.timestamp.toLocaleTimeString(),
1087
+ course: r.courseName || r.courseId.slice(0, 8),
1088
+ generated: r.generatedCount,
1089
+ selected: r.finalCount,
1090
+ new: r.newSelected,
1091
+ reviews: r.reviewsSelected
1092
+ }))
1093
+ );
1094
+ },
1095
+ /**
1096
+ * Export run history as JSON for bug reports.
1097
+ */
1098
+ export() {
1099
+ const json = JSON.stringify(runHistory, null, 2);
1100
+ logger.info("[Pipeline Debug] Run history exported. Copy the returned string or use:");
1101
+ logger.info(" copy(window.skuilder.pipeline.export())");
1102
+ return json;
1103
+ },
1104
+ /**
1105
+ * Clear run history.
1106
+ */
1107
+ clear() {
1108
+ runHistory.length = 0;
1109
+ logger.info("[Pipeline Debug] Run history cleared.");
1110
+ },
1111
+ /**
1112
+ * Show help.
1113
+ */
1114
+ help() {
1115
+ logger.info(`
1116
+ \u{1F527} Pipeline Debug API
1117
+
1118
+ Commands:
1119
+ .showLastRun() Show summary of most recent pipeline run
1120
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
1121
+ .showCard(cardId) Show provenance trail for a specific card
1122
+ .explainReviews() Analyze why reviews were/weren't selected
1123
+ .listRuns() List all captured runs in table format
1124
+ .export() Export run history as JSON for bug reports
1125
+ .clear() Clear run history
1126
+ .runs Access raw run history array
1127
+ .help() Show this help message
1128
+
1129
+ Example:
1130
+ window.skuilder.pipeline.showLastRun()
1131
+ window.skuilder.pipeline.showRun(1)
1132
+ window.skuilder.pipeline.showCard('abc123')
1133
+ `);
1134
+ }
1135
+ };
1136
+ mountPipelineDebugger();
1137
+ }
1138
+ });
1139
+
866
1140
  // src/core/navigators/generators/CompositeGenerator.ts
867
1141
  var CompositeGenerator_exports = {};
868
1142
  __export(CompositeGenerator_exports, {
@@ -931,6 +1205,24 @@ var init_CompositeGenerator = __esm({
931
1205
  const results = await Promise.all(
932
1206
  this.generators.map((g) => g.getWeightedCards(limit, context))
933
1207
  );
1208
+ const generatorSummaries = [];
1209
+ results.forEach((cards, index) => {
1210
+ const gen = this.generators[index];
1211
+ const genName = gen.name || `Generator ${index}`;
1212
+ const newCards = cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
1213
+ const reviewCards = cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
1214
+ if (cards.length > 0) {
1215
+ const topScore = Math.max(...cards.map((c) => c.score)).toFixed(2);
1216
+ const parts = [];
1217
+ if (newCards.length > 0) parts.push(`${newCards.length} new`);
1218
+ if (reviewCards.length > 0) parts.push(`${reviewCards.length} reviews`);
1219
+ const breakdown = parts.length > 0 ? parts.join(", ") : `${cards.length} cards`;
1220
+ generatorSummaries.push(`${genName}: ${breakdown} (top: ${topScore})`);
1221
+ } else {
1222
+ generatorSummaries.push(`${genName}: 0 cards`);
1223
+ }
1224
+ });
1225
+ logger.info(`[Composite] Generator breakdown: ${generatorSummaries.join(" | ")}`);
934
1226
  const byCardId = /* @__PURE__ */ new Map();
935
1227
  results.forEach((cards, index) => {
936
1228
  const gen = this.generators[index];
@@ -1048,6 +1340,7 @@ var init_elo = __esm({
1048
1340
  "use strict";
1049
1341
  init_navigators();
1050
1342
  import_common5 = require("@vue-skuilder/common");
1343
+ init_logger();
1051
1344
  ELONavigator = class extends ContentNavigator {
1052
1345
  /** Human-readable name for CardGenerator interface */
1053
1346
  name;
@@ -1107,7 +1400,16 @@ var init_elo = __esm({
1107
1400
  };
1108
1401
  });
1109
1402
  scored.sort((a, b) => b.score - a.score);
1110
- return scored.slice(0, limit);
1403
+ const result = scored.slice(0, limit);
1404
+ if (result.length > 0) {
1405
+ const topScores = result.slice(0, 3).map((c) => c.score.toFixed(2)).join(", ");
1406
+ logger.info(
1407
+ `[ELO] Course ${this.course.getCourseID()}: ${result.length} new cards (top scores: ${topScores})`
1408
+ );
1409
+ } else {
1410
+ logger.info(`[ELO] Course ${this.course.getCourseID()}: No new cards available`);
1411
+ }
1412
+ return result;
1111
1413
  }
1112
1414
  };
1113
1415
  }
@@ -1126,19 +1428,37 @@ var srs_exports = {};
1126
1428
  __export(srs_exports, {
1127
1429
  default: () => SRSNavigator
1128
1430
  });
1129
- var import_moment3, SRSNavigator;
1431
+ var import_moment3, DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_PRESSURE, SRSNavigator;
1130
1432
  var init_srs = __esm({
1131
1433
  "src/core/navigators/generators/srs.ts"() {
1132
1434
  "use strict";
1133
1435
  import_moment3 = __toESM(require("moment"), 1);
1134
1436
  init_navigators();
1135
1437
  init_logger();
1438
+ DEFAULT_HEALTHY_BACKLOG = 20;
1439
+ MAX_BACKLOG_PRESSURE = 0.5;
1136
1440
  SRSNavigator = class extends ContentNavigator {
1137
1441
  /** Human-readable name for CardGenerator interface */
1138
1442
  name;
1443
+ /** Healthy backlog threshold - when exceeded, backlog pressure kicks in */
1444
+ healthyBacklog;
1139
1445
  constructor(user, course, strategyData) {
1140
1446
  super(user, course, strategyData);
1141
1447
  this.name = strategyData?.name || "SRS";
1448
+ const config = this.parseConfig(strategyData?.serializedData);
1449
+ this.healthyBacklog = config.healthyBacklog ?? DEFAULT_HEALTHY_BACKLOG;
1450
+ }
1451
+ /**
1452
+ * Parse configuration from serialized JSON.
1453
+ */
1454
+ parseConfig(serializedData) {
1455
+ if (!serializedData) return {};
1456
+ try {
1457
+ return JSON.parse(serializedData);
1458
+ } catch {
1459
+ logger.warn("[SRS] Failed to parse strategy config, using defaults");
1460
+ return {};
1461
+ }
1142
1462
  }
1143
1463
  /**
1144
1464
  * Get review cards scored by urgency.
@@ -1146,6 +1466,7 @@ var init_srs = __esm({
1146
1466
  * Score formula combines:
1147
1467
  * - Relative overdueness: hoursOverdue / intervalHours
1148
1468
  * - Interval recency: exponential decay favoring shorter intervals
1469
+ * - Backlog pressure: boost when due reviews exceed healthy threshold
1149
1470
  *
1150
1471
  * Cards not yet due are excluded (not scored as 0).
1151
1472
  *
@@ -1159,11 +1480,32 @@ var init_srs = __esm({
1159
1480
  if (!this.user || !this.course) {
1160
1481
  throw new Error("SRSNavigator requires user and course to be set");
1161
1482
  }
1162
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1483
+ const courseId = this.course.getCourseID();
1484
+ const reviews = await this.user.getPendingReviews(courseId);
1163
1485
  const now = import_moment3.default.utc();
1164
1486
  const dueReviews = reviews.filter((r) => now.isAfter(import_moment3.default.utc(r.reviewTime)));
1487
+ const backlogPressure = this.computeBacklogPressure(dueReviews.length);
1488
+ if (dueReviews.length > 0) {
1489
+ const pressureNote = backlogPressure > 0 ? ` [backlog pressure: +${backlogPressure.toFixed(2)}]` : ` [healthy backlog]`;
1490
+ logger.info(
1491
+ `[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
1492
+ );
1493
+ } else if (reviews.length > 0) {
1494
+ const sortedByDue = [...reviews].sort(
1495
+ (a, b) => import_moment3.default.utc(a.reviewTime).diff(import_moment3.default.utc(b.reviewTime))
1496
+ );
1497
+ const nextDue = sortedByDue[0];
1498
+ const nextDueTime = import_moment3.default.utc(nextDue.reviewTime);
1499
+ const untilDue = import_moment3.default.duration(nextDueTime.diff(now));
1500
+ const untilDueStr = untilDue.asHours() < 1 ? `${Math.round(untilDue.asMinutes())}m` : untilDue.asHours() < 24 ? `${Math.round(untilDue.asHours())}h` : `${Math.round(untilDue.asDays())}d`;
1501
+ logger.info(
1502
+ `[SRS] Course ${courseId}: 0 reviews due now (${reviews.length} scheduled, next in ${untilDueStr})`
1503
+ );
1504
+ } else {
1505
+ logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
1506
+ }
1165
1507
  const scored = dueReviews.map((review) => {
1166
- const { score, reason } = this.computeUrgencyScore(review, now);
1508
+ const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
1167
1509
  return {
1168
1510
  cardId: review.cardId,
1169
1511
  courseId: review.courseId,
@@ -1181,13 +1523,35 @@ var init_srs = __esm({
1181
1523
  ]
1182
1524
  };
1183
1525
  });
1184
- logger.debug(`[srsNav] got ${scored.length} weighted cards`);
1185
1526
  return scored.sort((a, b) => b.score - a.score).slice(0, limit);
1186
1527
  }
1528
+ /**
1529
+ * Compute backlog pressure based on number of due reviews.
1530
+ *
1531
+ * Backlog pressure is 0 when at or below healthy threshold,
1532
+ * and increases linearly above it, maxing out at MAX_BACKLOG_PRESSURE.
1533
+ *
1534
+ * Examples (with default healthyBacklog=20):
1535
+ * - 10 due reviews → 0.00 (healthy)
1536
+ * - 20 due reviews → 0.00 (at threshold)
1537
+ * - 40 due reviews → 0.25 (2x threshold)
1538
+ * - 60 due reviews → 0.50 (3x threshold, maxed)
1539
+ *
1540
+ * @param dueCount - Number of reviews currently due
1541
+ * @returns Backlog pressure score to add to urgency (0 to MAX_BACKLOG_PRESSURE)
1542
+ */
1543
+ computeBacklogPressure(dueCount) {
1544
+ if (dueCount <= this.healthyBacklog) {
1545
+ return 0;
1546
+ }
1547
+ const excess = dueCount - this.healthyBacklog;
1548
+ const pressure = excess / this.healthyBacklog * (MAX_BACKLOG_PRESSURE / 2);
1549
+ return Math.min(MAX_BACKLOG_PRESSURE, pressure);
1550
+ }
1187
1551
  /**
1188
1552
  * Compute urgency score for a review card.
1189
1553
  *
1190
- * Two factors:
1554
+ * Three factors:
1191
1555
  * 1. Relative overdueness = hoursOverdue / intervalHours
1192
1556
  * - 2 days overdue on 3-day interval = 0.67 (urgent)
1193
1557
  * - 2 days overdue on 180-day interval = 0.01 (not urgent)
@@ -1197,10 +1561,19 @@ var init_srs = __esm({
1197
1561
  * - 30 days (720h) → ~0.56
1198
1562
  * - 180 days → ~0.30
1199
1563
  *
1200
- * Combined: base 0.5 + weighted average of factors * 0.45
1201
- * Result range: approximately 0.5 to 0.95
1564
+ * 3. Backlog pressure = global boost when review backlog exceeds healthy threshold
1565
+ * - At healthy backlog: 0
1566
+ * - At 2x healthy: +0.25
1567
+ * - At 3x+ healthy: +0.50 (max)
1568
+ *
1569
+ * Combined: base 0.5 + (urgency factors * 0.45) + backlog pressure
1570
+ * Result range: 0.5 to 1.0 (uncapped to allow high-urgency reviews to compete with new cards)
1571
+ *
1572
+ * @param review - The scheduled card to score
1573
+ * @param now - Current time
1574
+ * @param backlogPressure - Pre-computed backlog pressure (0 to 0.5)
1202
1575
  */
1203
- computeUrgencyScore(review, now) {
1576
+ computeUrgencyScore(review, now, backlogPressure) {
1204
1577
  const scheduledAt = import_moment3.default.utc(review.scheduledAt);
1205
1578
  const due = import_moment3.default.utc(review.reviewTime);
1206
1579
  const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
@@ -1209,8 +1582,19 @@ var init_srs = __esm({
1209
1582
  const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
1210
1583
  const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
1211
1584
  const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
1212
- const score = Math.min(0.95, 0.5 + urgency * 0.45);
1213
- const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
1585
+ const baseScore = 0.5 + urgency * 0.45;
1586
+ const score = Math.min(1, baseScore + backlogPressure);
1587
+ const reasonParts = [
1588
+ `${Math.round(hoursOverdue)}h overdue`,
1589
+ `interval: ${Math.round(intervalHours)}h`,
1590
+ `relative: ${relativeOverdue.toFixed(2)}`,
1591
+ `recency: ${recencyFactor.toFixed(2)}`
1592
+ ];
1593
+ if (backlogPressure > 0) {
1594
+ reasonParts.push(`backlog: +${backlogPressure.toFixed(2)}`);
1595
+ }
1596
+ reasonParts.push("review");
1597
+ const reason = reasonParts.join(", ");
1214
1598
  return { score, reason };
1215
1599
  }
1216
1600
  };
@@ -2486,10 +2870,23 @@ function logTagHydration(cards, tagsByCard) {
2486
2870
  `[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
2487
2871
  );
2488
2872
  }
2489
- function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
2873
+ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores, filterImpacts) {
2490
2874
  const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
2875
+ let filterSummary = "";
2876
+ if (filterImpacts.length > 0) {
2877
+ const impacts = filterImpacts.map((f) => {
2878
+ const parts = [];
2879
+ if (f.boosted > 0) parts.push(`+${f.boosted}`);
2880
+ if (f.penalized > 0) parts.push(`-${f.penalized}`);
2881
+ if (f.passed > 0) parts.push(`=${f.passed}`);
2882
+ return `${f.name}: ${parts.join("/")}`;
2883
+ });
2884
+ filterSummary = `
2885
+ Filter impact: ${impacts.join(", ")}`;
2886
+ }
2491
2887
  logger.info(
2492
- `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
2888
+ `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})` + filterSummary + `
2889
+ \u{1F4A1} Inspect: window.skuilder.pipeline`
2493
2890
  );
2494
2891
  }
2495
2892
  function logCardProvenance(cards, maxCards = 3) {
@@ -2514,6 +2911,7 @@ var init_Pipeline = __esm({
2514
2911
  init_navigators();
2515
2912
  init_logger();
2516
2913
  init_orchestration();
2914
+ init_PipelineDebugger();
2517
2915
  Pipeline = class extends ContentNavigator {
2518
2916
  generator;
2519
2917
  filters;
@@ -2561,12 +2959,49 @@ var init_Pipeline = __esm({
2561
2959
  );
2562
2960
  let cards = await this.generator.getWeightedCards(fetchLimit, context);
2563
2961
  const generatedCount = cards.length;
2962
+ let generatorSummaries;
2963
+ if (this.generator.generators) {
2964
+ const genMap = /* @__PURE__ */ new Map();
2965
+ for (const card of cards) {
2966
+ const firstProv = card.provenance[0];
2967
+ if (firstProv) {
2968
+ const genName = firstProv.strategyName;
2969
+ if (!genMap.has(genName)) {
2970
+ genMap.set(genName, { cards: [] });
2971
+ }
2972
+ genMap.get(genName).cards.push(card);
2973
+ }
2974
+ }
2975
+ generatorSummaries = Array.from(genMap.entries()).map(([name, data]) => {
2976
+ const newCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("new card"));
2977
+ const reviewCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes("review"));
2978
+ return {
2979
+ name,
2980
+ cardCount: data.cards.length,
2981
+ newCount: newCards.length,
2982
+ reviewCount: reviewCards.length,
2983
+ topScore: Math.max(...data.cards.map((c) => c.score), 0)
2984
+ };
2985
+ });
2986
+ }
2564
2987
  logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2565
2988
  cards = await this.hydrateTags(cards);
2989
+ const allCardsBeforeFiltering = [...cards];
2990
+ const filterImpacts = [];
2566
2991
  for (const filter of this.filters) {
2567
2992
  const beforeCount = cards.length;
2993
+ const beforeScores = new Map(cards.map((c) => [c.cardId, c.score]));
2568
2994
  cards = await filter.transform(cards, context);
2569
- logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
2995
+ let boosted = 0, penalized = 0, passed = 0;
2996
+ const removed = beforeCount - cards.length;
2997
+ for (const card of cards) {
2998
+ const before = beforeScores.get(card.cardId) ?? 0;
2999
+ if (card.score > before) boosted++;
3000
+ else if (card.score < before) penalized++;
3001
+ else passed++;
3002
+ }
3003
+ filterImpacts.push({ name: filter.name, boosted, penalized, passed, removed });
3004
+ logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} \u2192 ${cards.length} cards (\u2191${boosted} \u2193${penalized} =${passed})`);
2570
3005
  }
2571
3006
  cards = cards.filter((c) => c.score > 0);
2572
3007
  cards.sort((a, b) => b.score - a.score);
@@ -2577,9 +3012,26 @@ var init_Pipeline = __esm({
2577
3012
  generatedCount,
2578
3013
  this.filters.length,
2579
3014
  result.length,
2580
- topScores
3015
+ topScores,
3016
+ filterImpacts
2581
3017
  );
2582
3018
  logCardProvenance(result, 3);
3019
+ try {
3020
+ const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => void 0);
3021
+ const report = buildRunReport(
3022
+ this.course?.getCourseID() || "unknown",
3023
+ courseName,
3024
+ this.generator.name,
3025
+ generatorSummaries,
3026
+ generatedCount,
3027
+ filterImpacts,
3028
+ allCardsBeforeFiltering,
3029
+ result
3030
+ );
3031
+ captureRun(report);
3032
+ } catch (e) {
3033
+ logger.debug(`[Pipeline] Failed to capture debug run: ${e}`);
3034
+ }
2583
3035
  return result;
2584
3036
  }
2585
3037
  /**
@@ -2669,6 +3121,56 @@ var init_Pipeline = __esm({
2669
3121
  }
2670
3122
  });
2671
3123
 
3124
+ // src/core/navigators/defaults.ts
3125
+ var defaults_exports = {};
3126
+ __export(defaults_exports, {
3127
+ createDefaultEloStrategy: () => createDefaultEloStrategy,
3128
+ createDefaultPipeline: () => createDefaultPipeline,
3129
+ createDefaultSrsStrategy: () => createDefaultSrsStrategy
3130
+ });
3131
+ function createDefaultEloStrategy(courseId) {
3132
+ return {
3133
+ _id: "NAVIGATION_STRATEGY-ELO-default",
3134
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
3135
+ name: "ELO (default)",
3136
+ description: "Default ELO-based navigation strategy for new cards",
3137
+ implementingClass: "elo" /* ELO */,
3138
+ course: courseId,
3139
+ serializedData: ""
3140
+ };
3141
+ }
3142
+ function createDefaultSrsStrategy(courseId) {
3143
+ return {
3144
+ _id: "NAVIGATION_STRATEGY-SRS-default",
3145
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
3146
+ name: "SRS (default)",
3147
+ description: "Default SRS-based navigation strategy for reviews",
3148
+ implementingClass: "srs" /* SRS */,
3149
+ course: courseId,
3150
+ serializedData: ""
3151
+ };
3152
+ }
3153
+ function createDefaultPipeline(user, course) {
3154
+ const courseId = course.getCourseID();
3155
+ const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
3156
+ const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
3157
+ const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
3158
+ const eloDistanceFilter = createEloDistanceFilter();
3159
+ return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
3160
+ }
3161
+ var init_defaults = __esm({
3162
+ "src/core/navigators/defaults.ts"() {
3163
+ "use strict";
3164
+ init_navigators();
3165
+ init_Pipeline();
3166
+ init_CompositeGenerator();
3167
+ init_elo();
3168
+ init_srs();
3169
+ init_eloDistance();
3170
+ init_types_legacy();
3171
+ }
3172
+ });
3173
+
2672
3174
  // src/core/navigators/PipelineAssembler.ts
2673
3175
  var PipelineAssembler_exports = {};
2674
3176
  __export(PipelineAssembler_exports, {
@@ -2681,9 +3183,9 @@ var init_PipelineAssembler = __esm({
2681
3183
  init_navigators();
2682
3184
  init_WeightedFilter();
2683
3185
  init_Pipeline();
2684
- init_types_legacy();
2685
3186
  init_logger();
2686
3187
  init_CompositeGenerator();
3188
+ init_defaults();
2687
3189
  PipelineAssembler = class {
2688
3190
  /**
2689
3191
  * Assembles a navigation pipeline from strategy documents.
@@ -2722,9 +3224,11 @@ var init_PipelineAssembler = __esm({
2722
3224
  if (generatorStrategies.length === 0) {
2723
3225
  if (filterStrategies.length > 0) {
2724
3226
  logger.debug(
2725
- "[PipelineAssembler] No generator found, using default ELO with configured filters"
3227
+ "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
2726
3228
  );
2727
- generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
3229
+ const courseId = course.getCourseID();
3230
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3231
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
2728
3232
  } else {
2729
3233
  warnings.push("No generator strategy found");
2730
3234
  return {
@@ -2785,75 +3289,10 @@ var init_PipelineAssembler = __esm({
2785
3289
  warnings
2786
3290
  };
2787
3291
  }
2788
- /**
2789
- * Creates a default ELO generator strategy.
2790
- * Used when filters are configured but no generator is specified.
2791
- */
2792
- makeDefaultEloStrategy(courseId) {
2793
- return {
2794
- _id: "NAVIGATION_STRATEGY-ELO-default",
2795
- course: courseId,
2796
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2797
- name: "ELO (default)",
2798
- description: "Default ELO-based generator",
2799
- implementingClass: "elo" /* ELO */,
2800
- serializedData: ""
2801
- };
2802
- }
2803
3292
  };
2804
3293
  }
2805
3294
  });
2806
3295
 
2807
- // src/core/navigators/defaults.ts
2808
- var defaults_exports = {};
2809
- __export(defaults_exports, {
2810
- createDefaultEloStrategy: () => createDefaultEloStrategy,
2811
- createDefaultPipeline: () => createDefaultPipeline,
2812
- createDefaultSrsStrategy: () => createDefaultSrsStrategy
2813
- });
2814
- function createDefaultEloStrategy(courseId) {
2815
- return {
2816
- _id: "NAVIGATION_STRATEGY-ELO-default",
2817
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2818
- name: "ELO (default)",
2819
- description: "Default ELO-based navigation strategy for new cards",
2820
- implementingClass: "elo" /* ELO */,
2821
- course: courseId,
2822
- serializedData: ""
2823
- };
2824
- }
2825
- function createDefaultSrsStrategy(courseId) {
2826
- return {
2827
- _id: "NAVIGATION_STRATEGY-SRS-default",
2828
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2829
- name: "SRS (default)",
2830
- description: "Default SRS-based navigation strategy for reviews",
2831
- implementingClass: "srs" /* SRS */,
2832
- course: courseId,
2833
- serializedData: ""
2834
- };
2835
- }
2836
- function createDefaultPipeline(user, course) {
2837
- const courseId = course.getCourseID();
2838
- const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
2839
- const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
2840
- const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
2841
- const eloDistanceFilter = createEloDistanceFilter();
2842
- return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
2843
- }
2844
- var init_defaults = __esm({
2845
- "src/core/navigators/defaults.ts"() {
2846
- "use strict";
2847
- init_navigators();
2848
- init_Pipeline();
2849
- init_CompositeGenerator();
2850
- init_elo();
2851
- init_srs();
2852
- init_eloDistance();
2853
- init_types_legacy();
2854
- }
2855
- });
2856
-
2857
3296
  // import("./**/*") in src/core/navigators/index.ts
2858
3297
  var globImport;
2859
3298
  var init_3 = __esm({
@@ -2861,6 +3300,7 @@ var init_3 = __esm({
2861
3300
  globImport = __glob({
2862
3301
  "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
2863
3302
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
3303
+ "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
2864
3304
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
2865
3305
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
2866
3306
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
@@ -2896,6 +3336,8 @@ __export(navigators_exports, {
2896
3336
  initializeNavigatorRegistry: () => initializeNavigatorRegistry,
2897
3337
  isFilter: () => isFilter,
2898
3338
  isGenerator: () => isGenerator,
3339
+ mountPipelineDebugger: () => mountPipelineDebugger,
3340
+ pipelineDebugAPI: () => pipelineDebugAPI,
2899
3341
  registerNavigator: () => registerNavigator
2900
3342
  });
2901
3343
  function registerNavigator(implementingClass, constructor) {
@@ -2962,6 +3404,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
2962
3404
  var init_navigators = __esm({
2963
3405
  "src/core/navigators/index.ts"() {
2964
3406
  "use strict";
3407
+ init_PipelineDebugger();
2965
3408
  init_logger();
2966
3409
  init_();
2967
3410
  init_2();
@@ -7206,6 +7649,7 @@ __export(index_exports, {
7206
7649
  aggregateOutcomesForGradient: () => aggregateOutcomesForGradient,
7207
7650
  areQuestionRecords: () => areQuestionRecords,
7208
7651
  buildStrategyStateId: () => buildStrategyStateId,
7652
+ captureMixerRun: () => captureMixerRun,
7209
7653
  computeDeviation: () => computeDeviation,
7210
7654
  computeEffectiveWeight: () => computeEffectiveWeight,
7211
7655
  computeOutcomeSignal: () => computeOutcomeSignal,
@@ -7213,6 +7657,7 @@ __export(index_exports, {
7213
7657
  computeStrategyGradient: () => computeStrategyGradient,
7214
7658
  createOrchestrationContext: () => createOrchestrationContext,
7215
7659
  docIsDeleted: () => docIsDeleted,
7660
+ endSessionTracking: () => endSessionTracking,
7216
7661
  ensureAppDataDirectory: () => ensureAppDataDirectory,
7217
7662
  getAppDataDirectory: () => getAppDataDirectory,
7218
7663
  getCardHistoryID: () => getCardHistoryID,
@@ -7236,9 +7681,15 @@ __export(index_exports, {
7236
7681
  isQuestionTypeRegistered: () => isQuestionTypeRegistered,
7237
7682
  isReview: () => isReview,
7238
7683
  log: () => log,
7684
+ mixerDebugAPI: () => mixerDebugAPI,
7685
+ mountMixerDebugger: () => mountMixerDebugger,
7686
+ mountPipelineDebugger: () => mountPipelineDebugger,
7687
+ mountSessionDebugger: () => mountSessionDebugger,
7239
7688
  newInterval: () => newInterval,
7240
7689
  parseCardHistoryID: () => parseCardHistoryID,
7690
+ pipelineDebugAPI: () => pipelineDebugAPI,
7241
7691
  processCustomQuestionsData: () => processCustomQuestionsData,
7692
+ recordCardPresentation: () => recordCardPresentation,
7242
7693
  recordUserOutcome: () => recordUserOutcome,
7243
7694
  registerBlanksCard: () => registerBlanksCard,
7244
7695
  registerCustomQuestionTypes: () => registerCustomQuestionTypes,
@@ -7251,6 +7702,9 @@ __export(index_exports, {
7251
7702
  removeQuestionType: () => removeQuestionType,
7252
7703
  runPeriodUpdate: () => runPeriodUpdate,
7253
7704
  scoreAccuracyInZone: () => scoreAccuracyInZone,
7705
+ sessionDebugAPI: () => sessionDebugAPI,
7706
+ snapshotQueues: () => snapshotQueues,
7707
+ startSessionTracking: () => startSessionTracking,
7254
7708
  updateLearningState: () => updateLearningState,
7255
7709
  updateStrategyWeight: () => updateStrategyWeight,
7256
7710
  validateMigration: () => validateMigration,
@@ -7651,6 +8105,14 @@ var SrsService = class {
7651
8105
  constructor(user) {
7652
8106
  this.user = user;
7653
8107
  }
8108
+ /**
8109
+ * Remove a scheduled review from the user's database.
8110
+ * Used to clean up orphaned reviews (e.g., card deleted from course DB).
8111
+ */
8112
+ removeReview(reviewID) {
8113
+ logger.info(`[SrsService] Removing orphaned scheduled review: ${reviewID}`);
8114
+ void this.user.removeScheduledCardReview(reviewID);
8115
+ }
7654
8116
  /**
7655
8117
  * Calculates the next review time for a card based on its history and
7656
8118
  * schedules it in the user's database.
@@ -7689,7 +8151,7 @@ var EloService = class {
7689
8151
  * Updates both user and card ELO ratings based on user performance.
7690
8152
  * @param userScore Score between 0-1 representing user performance
7691
8153
  * @param course_id Course identifier
7692
- * @param card_id Card identifier
8154
+ * @param card_id Card identifier
7693
8155
  * @param userCourseRegDoc User's course registration document (will be mutated)
7694
8156
  * @param currentCard Current card session record
7695
8157
  * @param k Optional K-factor for ELO calculation
@@ -7699,7 +8161,9 @@ var EloService = class {
7699
8161
  logger.warn(`k value interpretation not currently implemented`);
7700
8162
  }
7701
8163
  const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
7702
- const userElo = (0, import_common22.toCourseElo)(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
8164
+ const userElo = (0, import_common22.toCourseElo)(
8165
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
8166
+ );
7703
8167
  const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
7704
8168
  if (cardElo && userElo) {
7705
8169
  const eloUpdate = (0, import_common22.adjustCourseScores)(userElo, cardElo, userScore);
@@ -7736,11 +8200,64 @@ var EloService = class {
7736
8200
  }
7737
8201
  }
7738
8202
  }
8203
+ /**
8204
+ * Updates both user and card ELO ratings with per-tag granularity.
8205
+ * Tags in taggedPerformance but not on card will be created dynamically.
8206
+ *
8207
+ * @param taggedPerformance Performance object with _global and per-tag scores
8208
+ * @param course_id Course identifier
8209
+ * @param card_id Card identifier
8210
+ * @param userCourseRegDoc User's course registration document (will be mutated)
8211
+ * @param currentCard Current card session record
8212
+ */
8213
+ async updateUserAndCardEloPerTag(taggedPerformance, course_id, card_id, userCourseRegDoc, currentCard) {
8214
+ const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
8215
+ const userElo = (0, import_common22.toCourseElo)(
8216
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
8217
+ );
8218
+ const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
8219
+ if (cardElo && userElo) {
8220
+ const eloUpdate = (0, import_common22.adjustCourseScoresPerTag)(userElo, cardElo, taggedPerformance);
8221
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
8222
+ const results = await Promise.allSettled([
8223
+ this.user.updateUserElo(course_id, eloUpdate.userElo),
8224
+ courseDB.updateCardElo(card_id, eloUpdate.cardElo)
8225
+ ]);
8226
+ const userEloStatus = results[0].status === "fulfilled";
8227
+ const cardEloStatus = results[1].status === "fulfilled";
8228
+ if (userEloStatus && cardEloStatus) {
8229
+ const user = results[0].value;
8230
+ const card = results[1].value;
8231
+ if (user.ok && card && card.ok) {
8232
+ const tagCount = Object.keys(taggedPerformance).length - 1;
8233
+ logger.info(
8234
+ `[EloService] Updated ELOS (per-tag, ${tagCount} tags):
8235
+ User: ${JSON.stringify(eloUpdate.userElo)})
8236
+ Card: ${JSON.stringify(eloUpdate.cardElo)})
8237
+ `
8238
+ );
8239
+ }
8240
+ } else {
8241
+ logger.warn(
8242
+ `[EloService] Partial ELO update (per-tag):
8243
+ User ELO update: ${userEloStatus ? "SUCCESS" : "FAILED"}
8244
+ Card ELO update: ${cardEloStatus ? "SUCCESS" : "FAILED"}`
8245
+ );
8246
+ if (!userEloStatus && results[0].status === "rejected") {
8247
+ logger.error("[EloService] User ELO update error:", results[0].reason);
8248
+ }
8249
+ if (!cardEloStatus && results[1].status === "rejected") {
8250
+ logger.error("[EloService] Card ELO update error:", results[1].reason);
8251
+ }
8252
+ }
8253
+ }
8254
+ }
7739
8255
  };
7740
8256
 
7741
8257
  // src/study/services/ResponseProcessor.ts
7742
8258
  init_core();
7743
8259
  init_logger();
8260
+ var import_common23 = require("@vue-skuilder/common");
7744
8261
  var ResponseProcessor = class {
7745
8262
  srsService;
7746
8263
  eloService;
@@ -7748,6 +8265,33 @@ var ResponseProcessor = class {
7748
8265
  this.srsService = srsService;
7749
8266
  this.eloService = eloService;
7750
8267
  }
8268
+ /**
8269
+ * Parses performance data into global score and optional per-tag scores.
8270
+ *
8271
+ * @param performance - Numeric or structured performance from QuestionRecord
8272
+ * @returns Parsed performance with global score and optional tag scores
8273
+ */
8274
+ parsePerformance(performance2) {
8275
+ if (typeof performance2 === "number") {
8276
+ return {
8277
+ globalScore: performance2,
8278
+ taggedPerformance: null
8279
+ };
8280
+ }
8281
+ if ((0, import_common23.isTaggedPerformance)(performance2)) {
8282
+ return {
8283
+ globalScore: performance2._global,
8284
+ taggedPerformance: performance2
8285
+ };
8286
+ }
8287
+ logger.warn("[ResponseProcessor] Unexpected performance structure, using neutral score", {
8288
+ performance: performance2
8289
+ });
8290
+ return {
8291
+ globalScore: 0.5,
8292
+ taggedPerformance: null
8293
+ };
8294
+ }
7751
8295
  /**
7752
8296
  * Processes a user's response to a card, handling SRS scheduling and ELO updates.
7753
8297
  * @param cardRecord User's response record
@@ -7808,46 +8352,60 @@ var ResponseProcessor = class {
7808
8352
  processCorrectResponse(cardRecord, history, studySessionItem, courseRegistrationDoc, currentCard, courseId, cardId) {
7809
8353
  if (cardRecord.priorAttemps === 0) {
7810
8354
  void this.srsService.scheduleReview(history, studySessionItem);
7811
- if (history.records.length === 1) {
7812
- const userScore = 0.5 + cardRecord.performance / 2;
7813
- void this.eloService.updateUserAndCardElo(
7814
- userScore,
8355
+ const { globalScore, taggedPerformance } = this.parsePerformance(cardRecord.performance);
8356
+ if (taggedPerformance) {
8357
+ void this.eloService.updateUserAndCardEloPerTag(
8358
+ taggedPerformance,
7815
8359
  courseId,
7816
8360
  cardId,
7817
8361
  courseRegistrationDoc,
7818
8362
  currentCard
7819
8363
  );
8364
+ logger.info(
8365
+ `[ResponseProcessor] Processed correct response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
8366
+ );
7820
8367
  } else {
7821
- const k = Math.ceil(32 / history.records.length);
7822
- const userScore = 0.5 + cardRecord.performance / 2;
7823
- void this.eloService.updateUserAndCardElo(
7824
- userScore,
7825
- courseId,
7826
- cardId,
7827
- courseRegistrationDoc,
7828
- currentCard,
7829
- k
8368
+ const userScore = 0.5 + globalScore / 2;
8369
+ if (history.records.length === 1) {
8370
+ void this.eloService.updateUserAndCardElo(
8371
+ userScore,
8372
+ courseId,
8373
+ cardId,
8374
+ courseRegistrationDoc,
8375
+ currentCard
8376
+ );
8377
+ } else {
8378
+ const k = Math.ceil(32 / history.records.length);
8379
+ void this.eloService.updateUserAndCardElo(
8380
+ userScore,
8381
+ courseId,
8382
+ cardId,
8383
+ courseRegistrationDoc,
8384
+ currentCard,
8385
+ k
8386
+ );
8387
+ }
8388
+ logger.info(
8389
+ "[ResponseProcessor] Processed correct response with SRS scheduling and ELO update"
7830
8390
  );
7831
8391
  }
7832
- logger.info(
7833
- "[ResponseProcessor] Processed correct response with SRS scheduling and ELO update"
7834
- );
7835
8392
  return {
7836
8393
  nextCardAction: "dismiss-success",
7837
8394
  shouldLoadNextCard: true,
7838
8395
  isCorrect: true,
7839
- performanceScore: cardRecord.performance,
8396
+ performanceScore: globalScore,
7840
8397
  shouldClearFeedbackShadow: true
7841
8398
  };
7842
8399
  } else {
7843
8400
  logger.info(
7844
8401
  "[ResponseProcessor] Processed correct response (retry attempt - no scheduling/ELO)"
7845
8402
  );
8403
+ const { globalScore } = this.parsePerformance(cardRecord.performance);
7846
8404
  return {
7847
8405
  nextCardAction: "marked-failed",
7848
8406
  shouldLoadNextCard: true,
7849
8407
  isCorrect: true,
7850
- performanceScore: cardRecord.performance,
8408
+ performanceScore: globalScore,
7851
8409
  shouldClearFeedbackShadow: true
7852
8410
  };
7853
8411
  }
@@ -7856,28 +8414,52 @@ var ResponseProcessor = class {
7856
8414
  * Handles processing for incorrect responses: ELO updates only.
7857
8415
  */
7858
8416
  processIncorrectResponse(cardRecord, history, courseRegistrationDoc, currentCard, courseId, cardId, maxAttemptsPerView, maxSessionViews, sessionViews) {
8417
+ const { taggedPerformance } = this.parsePerformance(cardRecord.performance);
7859
8418
  if (history.records.length !== 1 && cardRecord.priorAttemps === 0) {
7860
- void this.eloService.updateUserAndCardElo(
7861
- 0,
7862
- // Failed response = 0 score
7863
- courseId,
7864
- cardId,
7865
- courseRegistrationDoc,
7866
- currentCard
7867
- );
7868
- logger.info("[ResponseProcessor] Processed incorrect response with ELO update");
7869
- } else {
7870
- logger.info("[ResponseProcessor] Processed incorrect response (no ELO update needed)");
7871
- }
7872
- if (currentCard.records.length >= maxAttemptsPerView) {
7873
- if (sessionViews >= maxSessionViews) {
8419
+ if (taggedPerformance) {
8420
+ void this.eloService.updateUserAndCardEloPerTag(
8421
+ taggedPerformance,
8422
+ courseId,
8423
+ cardId,
8424
+ courseRegistrationDoc,
8425
+ currentCard
8426
+ );
8427
+ logger.info(
8428
+ `[ResponseProcessor] Processed incorrect response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
8429
+ );
8430
+ } else {
7874
8431
  void this.eloService.updateUserAndCardElo(
7875
8432
  0,
8433
+ // Failed response = 0 score
7876
8434
  courseId,
7877
8435
  cardId,
7878
8436
  courseRegistrationDoc,
7879
8437
  currentCard
7880
8438
  );
8439
+ logger.info("[ResponseProcessor] Processed incorrect response with ELO update");
8440
+ }
8441
+ } else {
8442
+ logger.info("[ResponseProcessor] Processed incorrect response (no ELO update needed)");
8443
+ }
8444
+ if (currentCard.records.length >= maxAttemptsPerView) {
8445
+ if (sessionViews >= maxSessionViews) {
8446
+ if (taggedPerformance) {
8447
+ void this.eloService.updateUserAndCardEloPerTag(
8448
+ taggedPerformance,
8449
+ courseId,
8450
+ cardId,
8451
+ courseRegistrationDoc,
8452
+ currentCard
8453
+ );
8454
+ } else {
8455
+ void this.eloService.updateUserAndCardElo(
8456
+ 0,
8457
+ courseId,
8458
+ cardId,
8459
+ courseRegistrationDoc,
8460
+ currentCard
8461
+ );
8462
+ }
7881
8463
  return {
7882
8464
  nextCardAction: "dismiss-failed",
7883
8465
  shouldLoadNextCard: true,
@@ -7904,7 +8486,7 @@ var ResponseProcessor = class {
7904
8486
  };
7905
8487
 
7906
8488
  // src/study/services/CardHydrationService.ts
7907
- var import_common23 = require("@vue-skuilder/common");
8489
+ var import_common24 = require("@vue-skuilder/common");
7908
8490
  init_logger();
7909
8491
  function parseAudioURIs(data) {
7910
8492
  if (typeof data !== "string") return [];
@@ -8039,8 +8621,8 @@ var CardHydrationService = class {
8039
8621
  try {
8040
8622
  const courseDB = this.getCourseDB(item.courseID);
8041
8623
  const cardData = await courseDB.getCourseDoc(item.cardID);
8042
- if (!(0, import_common23.isCourseElo)(cardData.elo)) {
8043
- cardData.elo = (0, import_common23.toCourseElo)(cardData.elo);
8624
+ if (!(0, import_common24.isCourseElo)(cardData.elo)) {
8625
+ cardData.elo = (0, import_common24.toCourseElo)(cardData.elo);
8044
8626
  }
8045
8627
  const view = this.getViewComponent(cardData.id_view);
8046
8628
  const dataDocs = await Promise.all(
@@ -8064,7 +8646,7 @@ var CardHydrationService = class {
8064
8646
  );
8065
8647
  await Promise.allSettled(uniqueAudioUrls.map(prefetchAudio));
8066
8648
  }
8067
- const data = dataDocs.map(import_common23.displayableDataToViewData).reverse();
8649
+ const data = dataDocs.map(import_common24.displayableDataToViewData).reverse();
8068
8650
  this.hydratedCards.set(item.cardID, {
8069
8651
  item,
8070
8652
  view,
@@ -9559,16 +10141,645 @@ var QuotaRoundRobinMixer = class {
9559
10141
  return [];
9560
10142
  }
9561
10143
  const quotaPerSource = Math.ceil(limit / batches.length);
9562
- const mixed = [];
9563
- for (const batch of batches) {
9564
- const sortedBatch = [...batch.weighted].sort((a, b) => b.score - a.score);
9565
- const topFromSource = sortedBatch.slice(0, quotaPerSource);
9566
- mixed.push(...topFromSource);
10144
+ const sourceStacks = batches.map((batch) => {
10145
+ return [...batch.weighted].sort((a, b) => b.score - a.score).slice(0, quotaPerSource);
10146
+ });
10147
+ for (let i = sourceStacks.length - 1; i > 0; i--) {
10148
+ const j = Math.floor(Math.random() * (i + 1));
10149
+ [sourceStacks[i], sourceStacks[j]] = [sourceStacks[j], sourceStacks[i]];
10150
+ }
10151
+ const result = [];
10152
+ let exhausted = 0;
10153
+ const cursors = new Array(sourceStacks.length).fill(0);
10154
+ while (result.length < limit && exhausted < sourceStacks.length) {
10155
+ exhausted = 0;
10156
+ for (let s = 0; s < sourceStacks.length; s++) {
10157
+ if (result.length >= limit) break;
10158
+ if (cursors[s] < sourceStacks[s].length) {
10159
+ result.push(sourceStacks[s][cursors[s]]);
10160
+ cursors[s]++;
10161
+ } else {
10162
+ exhausted++;
10163
+ }
10164
+ }
9567
10165
  }
9568
- return mixed.sort((a, b) => b.score - a.score).slice(0, limit);
10166
+ return result;
9569
10167
  }
9570
10168
  };
9571
10169
 
10170
+ // src/study/MixerDebugger.ts
10171
+ init_logger();
10172
+ init_navigators();
10173
+ var MAX_RUNS2 = 10;
10174
+ var runHistory2 = [];
10175
+ function buildSourceSummary(batch, sourceId, sourceName) {
10176
+ const scores = batch.weighted.map((c) => c.score);
10177
+ const reviewCount = batch.weighted.filter((c) => getCardOrigin(c) === "review").length;
10178
+ const newCount = batch.weighted.filter((c) => getCardOrigin(c) === "new").length;
10179
+ return {
10180
+ sourceIndex: batch.sourceIndex,
10181
+ sourceId,
10182
+ sourceName,
10183
+ totalCards: batch.weighted.length,
10184
+ reviewCount,
10185
+ newCount,
10186
+ topScore: scores.length > 0 ? Math.max(...scores) : 0,
10187
+ bottomScore: scores.length > 0 ? Math.min(...scores) : 0,
10188
+ scoreRange: scores.length > 0 ? [Math.min(...scores), Math.max(...scores)] : [0, 0],
10189
+ avgScore: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0
10190
+ };
10191
+ }
10192
+ function buildSourceBreakdown(sourceId, sourceName, allCards) {
10193
+ const sourceCards = allCards.filter((c) => c.courseId === sourceId);
10194
+ const selectedCards = sourceCards.filter((c) => c.selected);
10195
+ const reviewsProvided = sourceCards.filter((c) => c.origin === "review").length;
10196
+ const newProvided = sourceCards.filter((c) => c.origin === "new").length;
10197
+ const reviewsSelected = selectedCards.filter((c) => c.origin === "review").length;
10198
+ const newSelected = selectedCards.filter((c) => c.origin === "new").length;
10199
+ return {
10200
+ sourceId,
10201
+ sourceName,
10202
+ reviewsProvided,
10203
+ newProvided,
10204
+ reviewsSelected,
10205
+ newSelected,
10206
+ totalSelected: selectedCards.length,
10207
+ selectionRate: sourceCards.length > 0 ? selectedCards.length / sourceCards.length * 100 : 0
10208
+ };
10209
+ }
10210
+ function captureMixerRun(mixerType, batches, sourceIds, sourceNames, requestedLimit, quotaPerSource, mixedResult) {
10211
+ const sourceSummaries = batches.map(
10212
+ (batch, idx) => buildSourceSummary(batch, sourceIds[idx] || `source-${idx}`, sourceNames[idx])
10213
+ );
10214
+ const selectedIds = new Set(mixedResult.map((c) => c.cardId));
10215
+ const sourceRankings = /* @__PURE__ */ new Map();
10216
+ batches.forEach((batch) => {
10217
+ const sorted = [...batch.weighted].sort((a, b) => b.score - a.score);
10218
+ const rankings = /* @__PURE__ */ new Map();
10219
+ sorted.forEach((card, idx) => {
10220
+ rankings.set(card.cardId, idx + 1);
10221
+ });
10222
+ sourceRankings.set(sourceIds[batch.sourceIndex] || `source-${batch.sourceIndex}`, rankings);
10223
+ });
10224
+ const mixRankings = /* @__PURE__ */ new Map();
10225
+ mixedResult.forEach((card, idx) => {
10226
+ mixRankings.set(card.cardId, idx + 1);
10227
+ });
10228
+ const allCardsMap = /* @__PURE__ */ new Map();
10229
+ batches.forEach((batch) => {
10230
+ batch.weighted.forEach((card) => {
10231
+ allCardsMap.set(card.cardId, card);
10232
+ });
10233
+ });
10234
+ const cards = Array.from(allCardsMap.values()).map((card) => ({
10235
+ cardId: card.cardId,
10236
+ courseId: card.courseId,
10237
+ origin: getCardOrigin(card),
10238
+ score: card.score,
10239
+ sourceIndex: batches.findIndex((b) => b.weighted.some((c) => c.cardId === card.cardId)),
10240
+ selected: selectedIds.has(card.cardId),
10241
+ rankInSource: sourceRankings.get(card.courseId)?.get(card.cardId),
10242
+ rankInMix: mixRankings.get(card.cardId)
10243
+ }));
10244
+ const uniqueSourceIds = Array.from(new Set(sourceIds.filter((id) => id)));
10245
+ const sourceBreakdowns = uniqueSourceIds.map(
10246
+ (sourceId, idx) => buildSourceBreakdown(sourceId, sourceNames[idx], cards)
10247
+ );
10248
+ const report = {
10249
+ runId: `mix-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
10250
+ timestamp: /* @__PURE__ */ new Date(),
10251
+ mixerType,
10252
+ requestedLimit,
10253
+ quotaPerSource,
10254
+ sourceSummaries,
10255
+ cards,
10256
+ finalCount: mixedResult.length,
10257
+ reviewsSelected: mixedResult.filter((c) => getCardOrigin(c) === "review").length,
10258
+ newSelected: mixedResult.filter((c) => getCardOrigin(c) === "new").length,
10259
+ sourceBreakdowns
10260
+ };
10261
+ runHistory2.unshift(report);
10262
+ if (runHistory2.length > MAX_RUNS2) {
10263
+ runHistory2.pop();
10264
+ }
10265
+ }
10266
+ function printMixerSummary(run) {
10267
+ console.group(`\u{1F3A8} Mixer Run: ${run.mixerType}`);
10268
+ logger.info(`Run ID: ${run.runId}`);
10269
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
10270
+ logger.info(
10271
+ `Config: limit=${run.requestedLimit}${run.quotaPerSource ? `, quota/source=${run.quotaPerSource}` : ""}`
10272
+ );
10273
+ console.group(`\u{1F4E5} Input: ${run.sourceSummaries.length} sources`);
10274
+ for (const src of run.sourceSummaries) {
10275
+ logger.info(
10276
+ ` ${src.sourceName || src.sourceId}: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`
10277
+ );
10278
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}], avg: ${src.avgScore.toFixed(2)}`);
10279
+ }
10280
+ console.groupEnd();
10281
+ console.group(`\u{1F4E4} Output: ${run.finalCount} cards selected (${run.reviewsSelected} reviews, ${run.newSelected} new)`);
10282
+ for (const breakdown of run.sourceBreakdowns) {
10283
+ const name = breakdown.sourceName || breakdown.sourceId;
10284
+ logger.info(
10285
+ ` ${name}: ${breakdown.totalSelected} selected (${breakdown.reviewsSelected} reviews, ${breakdown.newSelected} new) - ${breakdown.selectionRate.toFixed(1)}% selection rate`
10286
+ );
10287
+ }
10288
+ console.groupEnd();
10289
+ console.groupEnd();
10290
+ }
10291
+ var mixerDebugAPI = {
10292
+ /**
10293
+ * Get raw run history for programmatic access.
10294
+ */
10295
+ get runs() {
10296
+ return [...runHistory2];
10297
+ },
10298
+ /**
10299
+ * Show summary of a specific mixer run.
10300
+ */
10301
+ showRun(idOrIndex = 0) {
10302
+ if (runHistory2.length === 0) {
10303
+ logger.info("[Mixer Debug] No runs captured yet.");
10304
+ return;
10305
+ }
10306
+ let run;
10307
+ if (typeof idOrIndex === "number") {
10308
+ run = runHistory2[idOrIndex];
10309
+ if (!run) {
10310
+ logger.info(`[Mixer Debug] No run found at index ${idOrIndex}. History length: ${runHistory2.length}`);
10311
+ return;
10312
+ }
10313
+ } else {
10314
+ run = runHistory2.find((r) => r.runId.endsWith(idOrIndex));
10315
+ if (!run) {
10316
+ logger.info(`[Mixer Debug] No run found matching ID '${idOrIndex}'.`);
10317
+ return;
10318
+ }
10319
+ }
10320
+ printMixerSummary(run);
10321
+ },
10322
+ /**
10323
+ * Show summary of the last mixer run.
10324
+ */
10325
+ showLastMix() {
10326
+ this.showRun(0);
10327
+ },
10328
+ /**
10329
+ * Explain source balance in the last run.
10330
+ */
10331
+ explainSourceBalance() {
10332
+ if (runHistory2.length === 0) {
10333
+ logger.info("[Mixer Debug] No runs captured yet.");
10334
+ return;
10335
+ }
10336
+ const run = runHistory2[0];
10337
+ console.group("\u2696\uFE0F Source Balance Analysis");
10338
+ logger.info(`Mixer: ${run.mixerType}`);
10339
+ logger.info(`Requested limit: ${run.requestedLimit}`);
10340
+ if (run.quotaPerSource) {
10341
+ logger.info(`Quota per source: ${run.quotaPerSource}`);
10342
+ }
10343
+ console.group("Input Distribution:");
10344
+ for (const src of run.sourceSummaries) {
10345
+ const name = src.sourceName || src.sourceId;
10346
+ logger.info(`${name}:`);
10347
+ logger.info(` Provided: ${src.totalCards} cards (${src.reviewCount} reviews, ${src.newCount} new)`);
10348
+ logger.info(` Score range: [${src.scoreRange[0].toFixed(2)}, ${src.scoreRange[1].toFixed(2)}]`);
10349
+ }
10350
+ console.groupEnd();
10351
+ console.group("Selection Results:");
10352
+ for (const breakdown of run.sourceBreakdowns) {
10353
+ const name = breakdown.sourceName || breakdown.sourceId;
10354
+ logger.info(`${name}:`);
10355
+ logger.info(
10356
+ ` Selected: ${breakdown.totalSelected}/${breakdown.reviewsProvided + breakdown.newProvided} (${breakdown.selectionRate.toFixed(1)}%)`
10357
+ );
10358
+ logger.info(` Reviews: ${breakdown.reviewsSelected}/${breakdown.reviewsProvided}`);
10359
+ logger.info(` New: ${breakdown.newSelected}/${breakdown.newProvided}`);
10360
+ if (breakdown.reviewsProvided > 0 && breakdown.reviewsSelected === 0) {
10361
+ logger.info(` \u26A0\uFE0F Had reviews but none selected!`);
10362
+ }
10363
+ if (breakdown.totalSelected === 0 && breakdown.reviewsProvided + breakdown.newProvided > 0) {
10364
+ logger.info(` \u26A0\uFE0F Had cards but none selected!`);
10365
+ }
10366
+ }
10367
+ console.groupEnd();
10368
+ const selectionRates = run.sourceBreakdowns.map((b) => b.selectionRate);
10369
+ const avgRate = selectionRates.reduce((a, b) => a + b, 0) / selectionRates.length;
10370
+ const maxDeviation = Math.max(...selectionRates.map((r) => Math.abs(r - avgRate)));
10371
+ if (maxDeviation > 20) {
10372
+ logger.info(`
10373
+ \u26A0\uFE0F Significant imbalance detected (max deviation: ${maxDeviation.toFixed(1)}%)`);
10374
+ logger.info("Possible causes:");
10375
+ logger.info(" - Score range differences between sources");
10376
+ logger.info(" - One source has much better quality cards");
10377
+ logger.info(" - Different card availability (reviews vs new)");
10378
+ }
10379
+ console.groupEnd();
10380
+ },
10381
+ /**
10382
+ * Compare score distributions across sources.
10383
+ */
10384
+ compareScores() {
10385
+ if (runHistory2.length === 0) {
10386
+ logger.info("[Mixer Debug] No runs captured yet.");
10387
+ return;
10388
+ }
10389
+ const run = runHistory2[0];
10390
+ console.group("\u{1F4CA} Score Distribution Comparison");
10391
+ console.table(
10392
+ run.sourceSummaries.map((src) => ({
10393
+ source: src.sourceName || src.sourceId,
10394
+ cards: src.totalCards,
10395
+ min: src.bottomScore.toFixed(3),
10396
+ max: src.topScore.toFixed(3),
10397
+ avg: src.avgScore.toFixed(3),
10398
+ range: (src.topScore - src.bottomScore).toFixed(3)
10399
+ }))
10400
+ );
10401
+ const ranges = run.sourceSummaries.map((s) => s.topScore - s.bottomScore);
10402
+ const avgScores = run.sourceSummaries.map((s) => s.avgScore);
10403
+ const rangeDiff = Math.max(...ranges) - Math.min(...ranges);
10404
+ const avgDiff = Math.max(...avgScores) - Math.min(...avgScores);
10405
+ if (rangeDiff > 0.3 || avgDiff > 0.2) {
10406
+ logger.info("\n\u26A0\uFE0F Significant score distribution differences detected");
10407
+ logger.info(
10408
+ "This may cause one source to dominate selection if using global sorting (not quota-based)"
10409
+ );
10410
+ }
10411
+ console.groupEnd();
10412
+ },
10413
+ /**
10414
+ * Show detailed information for a specific card.
10415
+ */
10416
+ showCard(cardId) {
10417
+ for (const run of runHistory2) {
10418
+ const card = run.cards.find((c) => c.cardId === cardId);
10419
+ if (card) {
10420
+ const source = run.sourceSummaries.find((s) => s.sourceIndex === card.sourceIndex);
10421
+ console.group(`\u{1F3B4} Card: ${cardId}`);
10422
+ logger.info(`Course: ${card.courseId}`);
10423
+ logger.info(`Source: ${source?.sourceName || source?.sourceId || "unknown"}`);
10424
+ logger.info(`Origin: ${card.origin}`);
10425
+ logger.info(`Score: ${card.score.toFixed(3)}`);
10426
+ if (card.rankInSource) {
10427
+ logger.info(`Rank in source: #${card.rankInSource}`);
10428
+ }
10429
+ if (card.rankInMix) {
10430
+ logger.info(`Rank in mixed results: #${card.rankInMix}`);
10431
+ }
10432
+ logger.info(`Selected: ${card.selected ? "Yes \u2705" : "No \u274C"}`);
10433
+ if (!card.selected && card.rankInSource) {
10434
+ logger.info("\nWhy not selected:");
10435
+ if (run.quotaPerSource && card.rankInSource > run.quotaPerSource) {
10436
+ logger.info(` - Ranked #${card.rankInSource} in source, but quota was ${run.quotaPerSource}`);
10437
+ }
10438
+ logger.info(" - Check score compared to selected cards using .showRun()");
10439
+ }
10440
+ console.groupEnd();
10441
+ return;
10442
+ }
10443
+ }
10444
+ logger.info(`[Mixer Debug] Card '${cardId}' not found in recent runs.`);
10445
+ },
10446
+ /**
10447
+ * Show all runs in compact format.
10448
+ */
10449
+ listRuns() {
10450
+ if (runHistory2.length === 0) {
10451
+ logger.info("[Mixer Debug] No runs captured yet.");
10452
+ return;
10453
+ }
10454
+ console.table(
10455
+ runHistory2.map((r) => ({
10456
+ id: r.runId.slice(-8),
10457
+ time: r.timestamp.toLocaleTimeString(),
10458
+ mixer: r.mixerType,
10459
+ sources: r.sourceSummaries.length,
10460
+ selected: r.finalCount,
10461
+ reviews: r.reviewsSelected,
10462
+ new: r.newSelected
10463
+ }))
10464
+ );
10465
+ },
10466
+ /**
10467
+ * Export run history as JSON for bug reports.
10468
+ */
10469
+ export() {
10470
+ const json = JSON.stringify(runHistory2, null, 2);
10471
+ logger.info("[Mixer Debug] Run history exported. Copy the returned string or use:");
10472
+ logger.info(" copy(window.skuilder.mixer.export())");
10473
+ return json;
10474
+ },
10475
+ /**
10476
+ * Clear run history.
10477
+ */
10478
+ clear() {
10479
+ runHistory2.length = 0;
10480
+ logger.info("[Mixer Debug] Run history cleared.");
10481
+ },
10482
+ /**
10483
+ * Show help.
10484
+ */
10485
+ help() {
10486
+ logger.info(`
10487
+ \u{1F3A8} Mixer Debug API
10488
+
10489
+ Commands:
10490
+ .showLastMix() Show summary of most recent mixer run
10491
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
10492
+ .explainSourceBalance() Analyze source balance and selection patterns
10493
+ .compareScores() Compare score distributions across sources
10494
+ .showCard(cardId) Show mixer decisions for a specific card
10495
+ .listRuns() List all captured runs in table format
10496
+ .export() Export run history as JSON for bug reports
10497
+ .clear() Clear run history
10498
+ .runs Access raw run history array
10499
+ .help() Show this help message
10500
+
10501
+ Example:
10502
+ window.skuilder.mixer.showLastMix()
10503
+ window.skuilder.mixer.explainSourceBalance()
10504
+ window.skuilder.mixer.compareScores()
10505
+ `);
10506
+ }
10507
+ };
10508
+ function mountMixerDebugger() {
10509
+ if (typeof window === "undefined") return;
10510
+ const win = window;
10511
+ win.skuilder = win.skuilder || {};
10512
+ win.skuilder.mixer = mixerDebugAPI;
10513
+ }
10514
+ mountMixerDebugger();
10515
+
10516
+ // src/study/SessionDebugger.ts
10517
+ init_logger();
10518
+ var activeSession = null;
10519
+ var sessionHistory = [];
10520
+ var MAX_HISTORY = 5;
10521
+ function startSessionTracking(reviewQLength, newQLength, failedQLength) {
10522
+ const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
10523
+ activeSession = {
10524
+ sessionId,
10525
+ startTime: /* @__PURE__ */ new Date(),
10526
+ initialQueues: {
10527
+ timestamp: /* @__PURE__ */ new Date(),
10528
+ reviewQLength,
10529
+ newQLength,
10530
+ failedQLength
10531
+ },
10532
+ presentations: [],
10533
+ queueSnapshots: []
10534
+ };
10535
+ logger.debug(`[SessionDebugger] Started tracking session: ${sessionId}`);
10536
+ }
10537
+ function recordCardPresentation(cardId, courseId, courseName, origin, queueSource, score) {
10538
+ if (!activeSession) {
10539
+ logger.warn("[SessionDebugger] No active session to record presentation");
10540
+ return;
10541
+ }
10542
+ activeSession.presentations.push({
10543
+ timestamp: /* @__PURE__ */ new Date(),
10544
+ sequenceNumber: activeSession.presentations.length + 1,
10545
+ cardId,
10546
+ courseId,
10547
+ courseName,
10548
+ origin,
10549
+ queueSource,
10550
+ score
10551
+ });
10552
+ }
10553
+ function snapshotQueues(reviewQLength, newQLength, failedQLength, reviewQNext3, newQNext3) {
10554
+ if (!activeSession) {
10555
+ return;
10556
+ }
10557
+ activeSession.queueSnapshots.push({
10558
+ timestamp: /* @__PURE__ */ new Date(),
10559
+ reviewQLength,
10560
+ newQLength,
10561
+ failedQLength,
10562
+ reviewQNext3,
10563
+ newQNext3
10564
+ });
10565
+ }
10566
+ function endSessionTracking() {
10567
+ if (!activeSession) {
10568
+ return;
10569
+ }
10570
+ activeSession.endTime = /* @__PURE__ */ new Date();
10571
+ sessionHistory.unshift(activeSession);
10572
+ if (sessionHistory.length > MAX_HISTORY) {
10573
+ sessionHistory.pop();
10574
+ }
10575
+ logger.debug(`[SessionDebugger] Ended session: ${activeSession.sessionId}`);
10576
+ activeSession = null;
10577
+ }
10578
+ function showCurrentQueue() {
10579
+ if (!activeSession) {
10580
+ logger.info("[Session Debug] No active session.");
10581
+ return;
10582
+ }
10583
+ const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
10584
+ console.group("\u{1F4CA} Current Queue State");
10585
+ logger.info(`Review Queue: ${latest.reviewQLength} cards`);
10586
+ if (latest.reviewQNext3 && latest.reviewQNext3.length > 0) {
10587
+ logger.info(` Next: ${latest.reviewQNext3.join(", ")}`);
10588
+ }
10589
+ logger.info(`New Queue: ${latest.newQLength} cards`);
10590
+ if (latest.newQNext3 && latest.newQNext3.length > 0) {
10591
+ logger.info(` Next: ${latest.newQNext3.join(", ")}`);
10592
+ }
10593
+ logger.info(`Failed Queue: ${latest.failedQLength} cards`);
10594
+ console.groupEnd();
10595
+ }
10596
+ function showPresentationHistory(sessionIndex = 0) {
10597
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
10598
+ if (!session) {
10599
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
10600
+ return;
10601
+ }
10602
+ console.group(`\u{1F4DC} Session History: ${session.sessionId}`);
10603
+ logger.info(`Started: ${session.startTime.toLocaleTimeString()}`);
10604
+ if (session.endTime) {
10605
+ logger.info(`Ended: ${session.endTime.toLocaleTimeString()}`);
10606
+ }
10607
+ logger.info(`Cards presented: ${session.presentations.length}`);
10608
+ if (session.presentations.length > 0) {
10609
+ console.table(
10610
+ session.presentations.map((p) => ({
10611
+ "#": p.sequenceNumber,
10612
+ course: p.courseName || p.courseId.slice(0, 8),
10613
+ origin: p.origin,
10614
+ queue: p.queueSource,
10615
+ score: p.score?.toFixed(3) || "-",
10616
+ time: p.timestamp.toLocaleTimeString()
10617
+ }))
10618
+ );
10619
+ }
10620
+ console.groupEnd();
10621
+ }
10622
+ function showInterleaving(sessionIndex = 0) {
10623
+ const session = sessionIndex === 0 && activeSession ? activeSession : sessionHistory[sessionIndex];
10624
+ if (!session) {
10625
+ logger.info(`[Session Debug] No session found at index ${sessionIndex}`);
10626
+ return;
10627
+ }
10628
+ console.group("\u{1F500} Interleaving Analysis");
10629
+ const courseCounts = /* @__PURE__ */ new Map();
10630
+ const courseOrigins = /* @__PURE__ */ new Map();
10631
+ session.presentations.forEach((p) => {
10632
+ const name = p.courseName || p.courseId;
10633
+ courseCounts.set(name, (courseCounts.get(name) || 0) + 1);
10634
+ if (!courseOrigins.has(name)) {
10635
+ courseOrigins.set(name, { review: 0, new: 0, failed: 0 });
10636
+ }
10637
+ const origins = courseOrigins.get(name);
10638
+ origins[p.origin]++;
10639
+ });
10640
+ logger.info("Course distribution:");
10641
+ console.table(
10642
+ Array.from(courseCounts.entries()).map(([course, count]) => {
10643
+ const origins = courseOrigins.get(course);
10644
+ return {
10645
+ course,
10646
+ total: count,
10647
+ reviews: origins.review,
10648
+ new: origins.new,
10649
+ failed: origins.failed,
10650
+ percentage: (count / session.presentations.length * 100).toFixed(1) + "%"
10651
+ };
10652
+ })
10653
+ );
10654
+ if (session.presentations.length > 0) {
10655
+ logger.info("\nPresentation sequence (first 20):");
10656
+ const sequence = session.presentations.slice(0, 20).map((p, idx) => `${idx + 1}. ${p.courseName || p.courseId.slice(0, 8)} (${p.origin})`).join("\n");
10657
+ logger.info(sequence);
10658
+ }
10659
+ let maxCluster = 0;
10660
+ let currentCluster = 1;
10661
+ let currentCourse = session.presentations[0]?.courseId;
10662
+ for (let i = 1; i < session.presentations.length; i++) {
10663
+ if (session.presentations[i].courseId === currentCourse) {
10664
+ currentCluster++;
10665
+ maxCluster = Math.max(maxCluster, currentCluster);
10666
+ } else {
10667
+ currentCourse = session.presentations[i].courseId;
10668
+ currentCluster = 1;
10669
+ }
10670
+ }
10671
+ if (maxCluster > 3) {
10672
+ logger.info(`
10673
+ \u26A0\uFE0F Detected clustering: max ${maxCluster} cards from same course in a row`);
10674
+ logger.info("This suggests cards are sorted by score rather than round-robin by course.");
10675
+ }
10676
+ console.groupEnd();
10677
+ }
10678
+ var sessionDebugAPI = {
10679
+ /**
10680
+ * Get raw session history for programmatic access.
10681
+ */
10682
+ get sessions() {
10683
+ return [...sessionHistory];
10684
+ },
10685
+ /**
10686
+ * Get active session if any.
10687
+ */
10688
+ get active() {
10689
+ return activeSession;
10690
+ },
10691
+ /**
10692
+ * Show current queue state.
10693
+ */
10694
+ showQueue() {
10695
+ showCurrentQueue();
10696
+ },
10697
+ /**
10698
+ * Show presentation history for current or past session.
10699
+ */
10700
+ showHistory(sessionIndex = 0) {
10701
+ showPresentationHistory(sessionIndex);
10702
+ },
10703
+ /**
10704
+ * Analyze course interleaving pattern.
10705
+ */
10706
+ showInterleaving(sessionIndex = 0) {
10707
+ showInterleaving(sessionIndex);
10708
+ },
10709
+ /**
10710
+ * List all tracked sessions.
10711
+ */
10712
+ listSessions() {
10713
+ if (activeSession) {
10714
+ logger.info(`Active session: ${activeSession.sessionId} (${activeSession.presentations.length} cards presented)`);
10715
+ }
10716
+ if (sessionHistory.length === 0) {
10717
+ logger.info("[Session Debug] No completed sessions in history.");
10718
+ return;
10719
+ }
10720
+ console.table(
10721
+ sessionHistory.map((s, idx) => ({
10722
+ index: idx,
10723
+ id: s.sessionId.slice(-8),
10724
+ started: s.startTime.toLocaleTimeString(),
10725
+ ended: s.endTime?.toLocaleTimeString() || "incomplete",
10726
+ cards: s.presentations.length
10727
+ }))
10728
+ );
10729
+ },
10730
+ /**
10731
+ * Export session history as JSON for bug reports.
10732
+ */
10733
+ export() {
10734
+ const data = {
10735
+ active: activeSession,
10736
+ history: sessionHistory
10737
+ };
10738
+ const json = JSON.stringify(data, null, 2);
10739
+ logger.info("[Session Debug] Session data exported. Copy the returned string or use:");
10740
+ logger.info(" copy(window.skuilder.session.export())");
10741
+ return json;
10742
+ },
10743
+ /**
10744
+ * Clear session history.
10745
+ */
10746
+ clear() {
10747
+ sessionHistory.length = 0;
10748
+ logger.info("[Session Debug] Session history cleared.");
10749
+ },
10750
+ /**
10751
+ * Show help.
10752
+ */
10753
+ help() {
10754
+ logger.info(`
10755
+ \u{1F3AF} Session Debug API
10756
+
10757
+ Commands:
10758
+ .showQueue() Show current queue state (active session only)
10759
+ .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
10760
+ .showInterleaving(index?) Analyze course interleaving pattern
10761
+ .listSessions() List all tracked sessions
10762
+ .export() Export session data as JSON for bug reports
10763
+ .clear() Clear session history
10764
+ .sessions Access raw session history array
10765
+ .active Access active session (if any)
10766
+ .help() Show this help message
10767
+
10768
+ Example:
10769
+ window.skuilder.session.showHistory()
10770
+ window.skuilder.session.showInterleaving()
10771
+ window.skuilder.session.showQueue()
10772
+ `);
10773
+ }
10774
+ };
10775
+ function mountSessionDebugger() {
10776
+ if (typeof window === "undefined") return;
10777
+ const win = window;
10778
+ win.skuilder = win.skuilder || {};
10779
+ win.skuilder.session = sessionDebugAPI;
10780
+ }
10781
+ mountSessionDebugger();
10782
+
9572
10783
  // src/study/SessionController.ts
9573
10784
  init_logger();
9574
10785
  var SessionController = class extends Loggable {
@@ -9578,6 +10789,8 @@ var SessionController = class extends Loggable {
9578
10789
  eloService;
9579
10790
  hydrationService;
9580
10791
  mixer;
10792
+ dataLayer;
10793
+ courseNameCache = /* @__PURE__ */ new Map();
9581
10794
  sources;
9582
10795
  // dataLayer and getViewComponent now injected into CardHydrationService
9583
10796
  _sessionRecord = [];
@@ -9597,7 +10810,11 @@ var SessionController = class extends Loggable {
9597
10810
  return this._secondsRemaining;
9598
10811
  }
9599
10812
  get report() {
9600
- return `${this.reviewQ.dequeueCount} reviews, ${this.newQ.dequeueCount} new cards`;
10813
+ const reviewCount = this.reviewQ.dequeueCount;
10814
+ const newCount = this.newQ.dequeueCount;
10815
+ const reviewWord = reviewCount === 1 ? "review" : "reviews";
10816
+ const newCardWord = newCount === 1 ? "new card" : "new cards";
10817
+ return `${reviewCount} ${reviewWord}, ${newCount} ${newCardWord}`;
9601
10818
  }
9602
10819
  get detailedReport() {
9603
10820
  return this.newQ.toString + "\n" + this.reviewQ.toString + "\n" + this.failedQ.toString;
@@ -9613,6 +10830,7 @@ var SessionController = class extends Loggable {
9613
10830
  */
9614
10831
  constructor(sources, time, dataLayer, getViewComponent, mixer) {
9615
10832
  super();
10833
+ this.dataLayer = dataLayer;
9616
10834
  this.mixer = mixer || new QuotaRoundRobinMixer();
9617
10835
  this.srsService = new SrsService(dataLayer.getUserDB());
9618
10836
  this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
@@ -9680,6 +10898,7 @@ var SessionController = class extends Loggable {
9680
10898
  }
9681
10899
  await this.getWeightedContent();
9682
10900
  await this.hydrationService.ensureHydratedCards();
10901
+ startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
9683
10902
  this._intervalHandle = setInterval(() => {
9684
10903
  this.tick();
9685
10904
  }, 1e3);
@@ -9775,6 +10994,30 @@ var SessionController = class extends Loggable {
9775
10994
  );
9776
10995
  }
9777
10996
  const mixedWeighted = this.mixer.mix(batches, limit * this.sources.length);
10997
+ const sourceIds = batches.map((b) => {
10998
+ const firstCard = b.weighted[0];
10999
+ return firstCard?.courseId || `source-${b.sourceIndex}`;
11000
+ });
11001
+ await Promise.all(
11002
+ sourceIds.map(async (id) => {
11003
+ try {
11004
+ const config = await this.dataLayer.getCoursesDB().getCourseConfig(id);
11005
+ this.courseNameCache.set(id, config.name);
11006
+ } catch {
11007
+ }
11008
+ })
11009
+ );
11010
+ const sourceNames = sourceIds.map((id) => this.courseNameCache.get(id));
11011
+ const quotaPerSource = this.mixer instanceof QuotaRoundRobinMixer ? Math.ceil(limit * this.sources.length / batches.length) : void 0;
11012
+ captureMixerRun(
11013
+ this.mixer.constructor.name,
11014
+ batches,
11015
+ sourceIds,
11016
+ sourceNames,
11017
+ limit * this.sources.length,
11018
+ quotaPerSource,
11019
+ mixedWeighted
11020
+ );
9778
11021
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
9779
11022
  const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
9780
11023
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
@@ -9883,21 +11126,46 @@ var SessionController = class extends Loggable {
9883
11126
  this.dismissCurrentCard(action);
9884
11127
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
9885
11128
  this._currentCard = null;
11129
+ endSessionTracking();
9886
11130
  return null;
9887
11131
  }
9888
- const nextItem = this._selectNextItemToHydrate();
9889
- if (!nextItem) {
9890
- this._currentCard = null;
9891
- return null;
9892
- }
9893
- let card = this.hydrationService.getHydratedCard(nextItem.cardID);
9894
- if (!card) {
9895
- card = await this.hydrationService.waitForCard(nextItem.cardID);
11132
+ const MAX_SKIP = 20;
11133
+ for (let attempt = 0; attempt < MAX_SKIP; attempt++) {
11134
+ const nextItem = this._selectNextItemToHydrate();
11135
+ if (!nextItem) {
11136
+ this._currentCard = null;
11137
+ endSessionTracking();
11138
+ return null;
11139
+ }
11140
+ let card = this.hydrationService.getHydratedCard(nextItem.cardID);
11141
+ if (!card) {
11142
+ card = await this.hydrationService.waitForCard(nextItem.cardID);
11143
+ }
11144
+ this.removeItemFromQueue(nextItem);
11145
+ if (card) {
11146
+ await this.hydrationService.ensureHydratedCards();
11147
+ this._currentCard = card;
11148
+ const origin = nextItem.status === "review" || nextItem.status === "failed-review" ? "review" : nextItem.status === "new" || nextItem.status === "failed-new" ? "new" : "failed";
11149
+ const queueSource = nextItem.status.startsWith("failed") ? "failedQ" : nextItem.status === "review" ? "reviewQ" : "newQ";
11150
+ recordCardPresentation(
11151
+ nextItem.cardID,
11152
+ nextItem.courseID,
11153
+ this.courseNameCache.get(nextItem.courseID),
11154
+ origin,
11155
+ queueSource
11156
+ );
11157
+ snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
11158
+ return card;
11159
+ }
11160
+ this.log(`Skipping card ${nextItem.cardID}: hydration failed, trying next`);
11161
+ if (isReview(nextItem)) {
11162
+ this.srsService.removeReview(nextItem.reviewID);
11163
+ }
9896
11164
  }
9897
- this.removeItemFromQueue(nextItem);
9898
- await this.hydrationService.ensureHydratedCards();
9899
- this._currentCard = card;
9900
- return card;
11165
+ this.log(`Exhausted ${MAX_SKIP} skip attempts finding a hydratable card`);
11166
+ this._currentCard = null;
11167
+ endSessionTracking();
11168
+ return null;
9901
11169
  }
9902
11170
  /**
9903
11171
  * Public API for processing user responses to cards.
@@ -10046,6 +11314,7 @@ init_factory();
10046
11314
  aggregateOutcomesForGradient,
10047
11315
  areQuestionRecords,
10048
11316
  buildStrategyStateId,
11317
+ captureMixerRun,
10049
11318
  computeDeviation,
10050
11319
  computeEffectiveWeight,
10051
11320
  computeOutcomeSignal,
@@ -10053,6 +11322,7 @@ init_factory();
10053
11322
  computeStrategyGradient,
10054
11323
  createOrchestrationContext,
10055
11324
  docIsDeleted,
11325
+ endSessionTracking,
10056
11326
  ensureAppDataDirectory,
10057
11327
  getAppDataDirectory,
10058
11328
  getCardHistoryID,
@@ -10076,9 +11346,15 @@ init_factory();
10076
11346
  isQuestionTypeRegistered,
10077
11347
  isReview,
10078
11348
  log,
11349
+ mixerDebugAPI,
11350
+ mountMixerDebugger,
11351
+ mountPipelineDebugger,
11352
+ mountSessionDebugger,
10079
11353
  newInterval,
10080
11354
  parseCardHistoryID,
11355
+ pipelineDebugAPI,
10081
11356
  processCustomQuestionsData,
11357
+ recordCardPresentation,
10082
11358
  recordUserOutcome,
10083
11359
  registerBlanksCard,
10084
11360
  registerCustomQuestionTypes,
@@ -10091,6 +11367,9 @@ init_factory();
10091
11367
  removeQuestionType,
10092
11368
  runPeriodUpdate,
10093
11369
  scoreAccuracyInZone,
11370
+ sessionDebugAPI,
11371
+ snapshotQueues,
11372
+ startSessionTracking,
10094
11373
  updateLearningState,
10095
11374
  updateStrategyWeight,
10096
11375
  validateMigration,