@vue-skuilder/db 0.1.23 → 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 (80) hide show
  1. package/dist/{contentSource-BP9hznNV.d.ts → contentSource-BmnmvH8C.d.ts} +268 -3
  2. package/dist/{contentSource-DsJadoBU.d.cts → contentSource-DfBbaLA-.d.cts} +268 -3
  3. package/dist/core/index.d.cts +310 -6
  4. package/dist/core/index.d.ts +310 -6
  5. package/dist/core/index.js +2606 -666
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +2564 -639
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-CHYrQ5pB.d.cts → dataLayerProvider-BeRXVMs5.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-MDTxXq2l.d.ts → dataLayerProvider-CG9GfaAY.d.ts} +1 -1
  11. package/dist/impl/couch/index.d.cts +11 -3
  12. package/dist/impl/couch/index.d.ts +11 -3
  13. package/dist/impl/couch/index.js +2336 -656
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +2316 -631
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +4 -4
  18. package/dist/impl/static/index.d.ts +4 -4
  19. package/dist/impl/static/index.js +2312 -632
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +2315 -630
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-Dj0SEgk3.d.ts → index-BWvO-_rJ.d.ts} +1 -1
  24. package/dist/{index-B_j6u5E4.d.cts → index-Ba7hYbHj.d.cts} +1 -1
  25. package/dist/index.d.cts +278 -20
  26. package/dist/index.d.ts +278 -20
  27. package/dist/index.js +3603 -720
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +3529 -674
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/{types-DQaXnuoc.d.ts → types-CJrLM1Ew.d.ts} +1 -1
  32. package/dist/{types-Bn0itutr.d.cts → types-W8n-B6HG.d.cts} +1 -1
  33. package/dist/{types-legacy-DDY4N-Uq.d.cts → types-legacy-JXDxinpU.d.cts} +5 -1
  34. package/dist/{types-legacy-DDY4N-Uq.d.ts → types-legacy-JXDxinpU.d.ts} +5 -1
  35. package/dist/util/packer/index.d.cts +3 -3
  36. package/dist/util/packer/index.d.ts +3 -3
  37. package/docs/brainstorm-navigation-paradigm.md +40 -34
  38. package/docs/future-orchestration-vision.md +216 -0
  39. package/docs/navigators-architecture.md +210 -9
  40. package/docs/todo-review-urgency-adaptation.md +205 -0
  41. package/docs/todo-strategy-authoring.md +8 -6
  42. package/package.json +3 -3
  43. package/src/core/index.ts +2 -0
  44. package/src/core/interfaces/contentSource.ts +7 -0
  45. package/src/core/interfaces/userDB.ts +50 -0
  46. package/src/core/navigators/Pipeline.ts +132 -5
  47. package/src/core/navigators/PipelineAssembler.ts +21 -22
  48. package/src/core/navigators/PipelineDebugger.ts +426 -0
  49. package/src/core/navigators/filters/WeightedFilter.ts +141 -0
  50. package/src/core/navigators/filters/types.ts +4 -0
  51. package/src/core/navigators/generators/CompositeGenerator.ts +82 -19
  52. package/src/core/navigators/generators/elo.ts +14 -1
  53. package/src/core/navigators/generators/srs.ts +146 -18
  54. package/src/core/navigators/generators/types.ts +4 -0
  55. package/src/core/navigators/index.ts +203 -13
  56. package/src/core/orchestration/gradient.ts +133 -0
  57. package/src/core/orchestration/index.ts +210 -0
  58. package/src/core/orchestration/learning.ts +250 -0
  59. package/src/core/orchestration/recording.ts +92 -0
  60. package/src/core/orchestration/signal.ts +67 -0
  61. package/src/core/types/contentNavigationStrategy.ts +38 -0
  62. package/src/core/types/learningState.ts +77 -0
  63. package/src/core/types/types-legacy.ts +4 -0
  64. package/src/core/types/userOutcome.ts +51 -0
  65. package/src/courseConfigRegistration.ts +107 -0
  66. package/src/factory.ts +6 -0
  67. package/src/impl/common/BaseUserDB.ts +16 -0
  68. package/src/impl/couch/user-course-relDB.ts +12 -0
  69. package/src/study/MixerDebugger.ts +555 -0
  70. package/src/study/SessionController.ts +159 -20
  71. package/src/study/SessionDebugger.ts +442 -0
  72. package/src/study/SourceMixer.ts +36 -17
  73. package/src/study/TODO-session-scheduling.md +133 -0
  74. package/src/study/index.ts +2 -0
  75. package/src/study/services/EloService.ts +79 -4
  76. package/src/study/services/ResponseProcessor.ts +130 -72
  77. package/src/study/services/SrsService.ts +9 -0
  78. package/tests/core/navigators/Pipeline.test.ts +2 -0
  79. package/tests/core/navigators/PipelineAssembler.test.ts +4 -4
  80. package/docs/todo-evolutionary-orchestration.md +0 -310
package/dist/index.mjs CHANGED
@@ -1,5 +1,10 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __glob = (map) => (path2) => {
4
+ var fn = map[path2];
5
+ if (fn) return fn();
6
+ throw new Error("Module not found in bundle: " + path2);
7
+ };
3
8
  var __esm = (fn, res) => function __init() {
4
9
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
10
  };
@@ -96,6 +101,8 @@ var init_types_legacy = __esm({
96
101
  DocType3["TAG"] = "TAG";
97
102
  DocType3["NAVIGATION_STRATEGY"] = "NAVIGATION_STRATEGY";
98
103
  DocType3["STRATEGY_STATE"] = "STRATEGY_STATE";
104
+ DocType3["USER_OUTCOME"] = "USER_OUTCOME";
105
+ DocType3["STRATEGY_LEARNING_STATE"] = "STRATEGY_LEARNING_STATE";
99
106
  return DocType3;
100
107
  })(DocType || {});
101
108
  DocTypePrefixes = {
@@ -110,7 +117,9 @@ var init_types_legacy = __esm({
110
117
  ["VIEW" /* VIEW */]: "VIEW",
111
118
  ["PEDAGOGY" /* PEDAGOGY */]: "PEDAGOGY",
112
119
  ["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */]: "NAVIGATION_STRATEGY",
113
- ["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE"
120
+ ["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE",
121
+ ["USER_OUTCOME" /* USER_OUTCOME */]: "USER_OUTCOME",
122
+ ["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]: "STRATEGY_LEARNING_STATE"
114
123
  };
115
124
  }
116
125
  });
@@ -481,6 +490,15 @@ var init_user_course_relDB = __esm({
481
490
  void this.user.updateCourseSettings(this._courseId, updates);
482
491
  }
483
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
+ }
484
502
  async getReviewstoDate(targetDate) {
485
503
  const allReviews = await this.user.getPendingReviews(this._courseId);
486
504
  logger.debug(
@@ -831,359 +849,289 @@ var init_courseLookupDB = __esm({
831
849
  }
832
850
  });
833
851
 
834
- // src/core/navigators/index.ts
835
- function getCardOrigin(card) {
836
- if (card.provenance.length === 0) {
837
- throw new Error("Card has no provenance - cannot determine origin");
838
- }
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) {
839
861
  const firstEntry = card.provenance[0];
840
- const reason = firstEntry.reason.toLowerCase();
841
- if (reason.includes("failed")) {
842
- return "failed";
843
- }
844
- if (reason.includes("review")) {
845
- return "review";
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();
846
877
  }
847
- return "new";
848
878
  }
849
- function isGenerator(impl) {
850
- return NavigatorRoles[impl] === "generator" /* GENERATOR */;
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
+ };
851
903
  }
852
- function isFilter(impl) {
853
- return NavigatorRoles[impl] === "filter" /* FILTER */;
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");
854
909
  }
855
- var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
856
- var init_navigators = __esm({
857
- "src/core/navigators/index.ts"() {
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"() {
858
945
  "use strict";
859
946
  init_logger();
860
- Navigators = /* @__PURE__ */ ((Navigators2) => {
861
- Navigators2["ELO"] = "elo";
862
- Navigators2["SRS"] = "srs";
863
- Navigators2["HIERARCHY"] = "hierarchyDefinition";
864
- Navigators2["INTERFERENCE"] = "interferenceMitigator";
865
- Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
866
- Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
867
- return Navigators2;
868
- })(Navigators || {});
869
- NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
870
- NavigatorRole2["GENERATOR"] = "generator";
871
- NavigatorRole2["FILTER"] = "filter";
872
- return NavigatorRole2;
873
- })(NavigatorRole || {});
874
- NavigatorRoles = {
875
- ["elo" /* ELO */]: "generator" /* GENERATOR */,
876
- ["srs" /* SRS */]: "generator" /* GENERATOR */,
877
- ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
878
- ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
879
- ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
880
- ["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
881
- };
882
- ContentNavigator = class {
883
- /** User interface for this navigation session */
884
- user;
885
- /** Course interface for this navigation session */
886
- course;
887
- /** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
888
- strategyName;
889
- /** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
890
- strategyId;
947
+ MAX_RUNS = 10;
948
+ runHistory = [];
949
+ pipelineDebugAPI = {
891
950
  /**
892
- * Constructor for standard navigators.
893
- * Call this from subclass constructors to initialize common fields.
894
- *
895
- * Note: CompositeGenerator and Pipeline call super() without args, then set
896
- * user/course fields directly if needed.
951
+ * Get raw run history for programmatic access.
897
952
  */
898
- constructor(user, course, strategyData) {
899
- this.user = user;
900
- this.course = course;
901
- if (strategyData) {
902
- this.strategyName = strategyData.name;
903
- this.strategyId = strategyData._id;
904
- }
905
- }
906
- // ============================================================================
907
- // STRATEGY STATE HELPERS
908
- // ============================================================================
909
- //
910
- // These methods allow strategies to persist their own state (user preferences,
911
- // learned patterns, temporal tracking) in the user database.
912
- //
913
- // ============================================================================
953
+ get runs() {
954
+ return [...runHistory];
955
+ },
914
956
  /**
915
- * Unique key identifying this strategy for state storage.
916
- *
917
- * Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
918
- * Override in subclasses if multiple instances of the same strategy type
919
- * need separate state storage.
957
+ * Show summary of a specific pipeline run.
920
958
  */
921
- get strategyKey() {
922
- return this.constructor.name;
923
- }
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
+ },
924
982
  /**
925
- * Get this strategy's persisted state for the current course.
926
- *
927
- * @returns The strategy's data payload, or null if no state exists
928
- * @throws Error if user or course is not initialized
983
+ * Show summary of the last pipeline run.
929
984
  */
930
- async getStrategyState() {
931
- if (!this.user || !this.course) {
932
- throw new Error(
933
- `Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
934
- );
935
- }
936
- return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
937
- }
985
+ showLastRun() {
986
+ this.showRun(0);
987
+ },
938
988
  /**
939
- * Persist this strategy's state for the current course.
940
- *
941
- * @param data - The strategy's data payload to store
942
- * @throws Error if user or course is not initialized
989
+ * Show detailed provenance for a specific card.
943
990
  */
944
- async putStrategyState(data) {
945
- if (!this.user || !this.course) {
946
- throw new Error(
947
- `Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
948
- );
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
+ }
949
1005
  }
950
- return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
951
- }
1006
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
1007
+ },
952
1008
  /**
953
- * Factory method to create navigator instances dynamically.
954
- *
955
- * @param user - User interface
956
- * @param course - Course interface
957
- * @param strategyData - Strategy configuration document
958
- * @returns the runtime object used to steer a study session.
1009
+ * Explain why reviews may or may not have been selected.
959
1010
  */
960
- static async create(user, course, strategyData) {
961
- const implementingClass = strategyData.implementingClass;
962
- let NavigatorImpl;
963
- const variations = [".ts", ".js", ""];
964
- const dirs = ["filters", "generators"];
965
- for (const ext of variations) {
966
- for (const dir of dirs) {
967
- const loadFrom = `./${dir}/${implementingClass}${ext}`;
968
- try {
969
- const module = await import(loadFrom);
970
- NavigatorImpl = module.default;
971
- break;
972
- } catch (e) {
973
- logger.debug(`Failed to load extension from ${loadFrom}:`, e);
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));
974
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));
975
1047
  }
1048
+ console.groupEnd();
976
1049
  }
977
- if (!NavigatorImpl) {
978
- throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
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;
979
1059
  }
980
- return new NavigatorImpl(user, course, strategyData);
981
- }
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
+ },
982
1072
  /**
983
- * Get cards with suitability scores and provenance trails.
984
- *
985
- * **This is the PRIMARY API for navigation strategies.**
986
- *
987
- * Returns cards ranked by suitability score (0-1). Higher scores indicate
988
- * better candidates for presentation. Each card includes a provenance trail
989
- * documenting how strategies contributed to the final score.
990
- *
991
- * ## Implementation Required
992
- * All navigation strategies MUST override this method. The base class does
993
- * not provide a default implementation.
994
- *
995
- * ## For Generators
996
- * Override this method to generate candidates and compute scores based on
997
- * your strategy's logic (e.g., ELO proximity, review urgency). Create the
998
- * initial provenance entry with action='generated'.
999
- *
1000
- * ## For Filters
1001
- * Filters should implement the CardFilter interface instead and be composed
1002
- * via Pipeline. Filters do not directly implement getWeightedCards().
1003
- *
1004
- * @param limit - Maximum cards to return
1005
- * @returns Cards sorted by score descending, with provenance trails
1073
+ * Export run history as JSON for bug reports.
1006
1074
  */
1007
- async getWeightedCards(_limit) {
1008
- throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
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
+ `);
1009
1111
  }
1010
1112
  };
1113
+ mountPipelineDebugger();
1011
1114
  }
1012
1115
  });
1013
1116
 
1014
- // src/core/navigators/Pipeline.ts
1015
- import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
1016
- function logPipelineConfig(generator, filters) {
1017
- const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
1018
- logger.info(
1019
- `[Pipeline] Configuration:
1020
- Generator: ${generator.name}
1021
- Filters:${filterList}`
1022
- );
1023
- }
1024
- function logTagHydration(cards, tagsByCard) {
1025
- const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
1026
- const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length;
1027
- logger.debug(
1028
- `[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
1029
- );
1030
- }
1031
- function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
1032
- const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
1033
- logger.info(
1034
- `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
1035
- );
1036
- }
1037
- function logCardProvenance(cards, maxCards = 3) {
1038
- const cardsToLog = cards.slice(0, maxCards);
1039
- logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
1040
- for (const card of cardsToLog) {
1041
- logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
1042
- for (const entry of card.provenance) {
1043
- const scoreChange = entry.score.toFixed(3);
1044
- const action = entry.action.padEnd(9);
1045
- logger.debug(
1046
- `[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
1047
- );
1048
- }
1049
- }
1050
- }
1051
- var Pipeline;
1052
- var init_Pipeline = __esm({
1053
- "src/core/navigators/Pipeline.ts"() {
1054
- "use strict";
1055
- init_navigators();
1056
- init_logger();
1057
- Pipeline = class extends ContentNavigator {
1058
- generator;
1059
- filters;
1060
- /**
1061
- * Create a new pipeline.
1062
- *
1063
- * @param generator - The generator (or CompositeGenerator) that produces candidates
1064
- * @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
1065
- * @param user - User database interface
1066
- * @param course - Course database interface
1067
- */
1068
- constructor(generator, filters, user, course) {
1069
- super();
1070
- this.generator = generator;
1071
- this.filters = filters;
1072
- this.user = user;
1073
- this.course = course;
1074
- course.getCourseConfig().then((cfg) => {
1075
- logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
1076
- }).catch((e) => {
1077
- logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
1078
- });
1079
- logPipelineConfig(generator, filters);
1080
- }
1081
- /**
1082
- * Get weighted cards by running generator and applying filters.
1083
- *
1084
- * 1. Build shared context (user ELO, etc.)
1085
- * 2. Get candidates from generator (passing context)
1086
- * 3. Batch hydrate tags for all candidates
1087
- * 4. Apply each filter sequentially
1088
- * 5. Remove zero-score cards
1089
- * 6. Sort by score descending
1090
- * 7. Return top N
1091
- *
1092
- * @param limit - Maximum number of cards to return
1093
- * @returns Cards sorted by score descending
1094
- */
1095
- async getWeightedCards(limit) {
1096
- const context = await this.buildContext();
1097
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
1098
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
1099
- logger.debug(
1100
- `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
1101
- );
1102
- let cards = await this.generator.getWeightedCards(fetchLimit, context);
1103
- const generatedCount = cards.length;
1104
- logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
1105
- cards = await this.hydrateTags(cards);
1106
- for (const filter of this.filters) {
1107
- const beforeCount = cards.length;
1108
- cards = await filter.transform(cards, context);
1109
- logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
1110
- }
1111
- cards = cards.filter((c) => c.score > 0);
1112
- cards.sort((a, b) => b.score - a.score);
1113
- const result = cards.slice(0, limit);
1114
- const topScores = result.slice(0, 3).map((c) => c.score);
1115
- logExecutionSummary(
1116
- this.generator.name,
1117
- generatedCount,
1118
- this.filters.length,
1119
- result.length,
1120
- topScores
1121
- );
1122
- logCardProvenance(result, 3);
1123
- return result;
1124
- }
1125
- /**
1126
- * Batch hydrate tags for all cards.
1127
- *
1128
- * Fetches tags for all cards in a single database query and attaches them
1129
- * to the WeightedCard objects. Filters can then use card.tags instead of
1130
- * making individual getAppliedTags() calls.
1131
- *
1132
- * @param cards - Cards to hydrate
1133
- * @returns Cards with tags populated
1134
- */
1135
- async hydrateTags(cards) {
1136
- if (cards.length === 0) {
1137
- return cards;
1138
- }
1139
- const cardIds = cards.map((c) => c.cardId);
1140
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
1141
- logTagHydration(cards, tagsByCard);
1142
- return cards.map((card) => ({
1143
- ...card,
1144
- tags: tagsByCard.get(card.cardId) ?? []
1145
- }));
1146
- }
1147
- /**
1148
- * Build shared context for generator and filters.
1149
- *
1150
- * Called once per getWeightedCards() invocation.
1151
- * Contains data that the generator and multiple filters might need.
1152
- *
1153
- * The context satisfies both GeneratorContext and FilterContext interfaces.
1154
- */
1155
- async buildContext() {
1156
- let userElo = 1e3;
1157
- try {
1158
- const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
1159
- const courseElo = toCourseElo2(courseReg.elo);
1160
- userElo = courseElo.global.score;
1161
- } catch (e) {
1162
- logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
1163
- }
1164
- return {
1165
- user: this.user,
1166
- course: this.course,
1167
- userElo
1168
- };
1169
- }
1170
- /**
1171
- * Get the course ID for this pipeline.
1172
- */
1173
- getCourseID() {
1174
- return this.course.getCourseID();
1175
- }
1176
- };
1177
- }
1178
- });
1179
-
1180
- // src/core/navigators/generators/CompositeGenerator.ts
1181
- var DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
1182
- var init_CompositeGenerator = __esm({
1183
- "src/core/navigators/generators/CompositeGenerator.ts"() {
1117
+ // src/core/navigators/generators/CompositeGenerator.ts
1118
+ var CompositeGenerator_exports = {};
1119
+ __export(CompositeGenerator_exports, {
1120
+ AggregationMode: () => AggregationMode,
1121
+ default: () => CompositeGenerator
1122
+ });
1123
+ var AggregationMode, DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
1124
+ var init_CompositeGenerator = __esm({
1125
+ "src/core/navigators/generators/CompositeGenerator.ts"() {
1184
1126
  "use strict";
1185
1127
  init_navigators();
1186
1128
  init_logger();
1129
+ AggregationMode = /* @__PURE__ */ ((AggregationMode2) => {
1130
+ AggregationMode2["MAX"] = "max";
1131
+ AggregationMode2["AVERAGE"] = "average";
1132
+ AggregationMode2["FREQUENCY_BOOST"] = "frequencyBoost";
1133
+ return AggregationMode2;
1134
+ })(AggregationMode || {});
1187
1135
  DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
1188
1136
  FREQUENCY_BOOST_FACTOR = 0.1;
1189
1137
  CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
@@ -1234,22 +1182,55 @@ var init_CompositeGenerator = __esm({
1234
1182
  const results = await Promise.all(
1235
1183
  this.generators.map((g) => g.getWeightedCards(limit, context))
1236
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(" | ")}`);
1237
1203
  const byCardId = /* @__PURE__ */ new Map();
1238
- for (const cards of results) {
1204
+ results.forEach((cards, index) => {
1205
+ const gen = this.generators[index];
1206
+ let weight = gen.learnable?.weight ?? 1;
1207
+ let deviation;
1208
+ if (gen.learnable && !gen.staticWeight && context.orchestration) {
1209
+ const strategyId = gen.strategyId;
1210
+ if (strategyId) {
1211
+ weight = context.orchestration.getEffectiveWeight(strategyId, gen.learnable);
1212
+ deviation = context.orchestration.getDeviation(strategyId);
1213
+ }
1214
+ }
1239
1215
  for (const card of cards) {
1216
+ if (card.provenance.length > 0) {
1217
+ card.provenance[0].effectiveWeight = weight;
1218
+ card.provenance[0].deviation = deviation;
1219
+ }
1240
1220
  const existing = byCardId.get(card.cardId) || [];
1241
- existing.push(card);
1221
+ existing.push({ card, weight });
1242
1222
  byCardId.set(card.cardId, existing);
1243
1223
  }
1244
- }
1224
+ });
1245
1225
  const merged = [];
1246
- for (const [, cards] of byCardId) {
1247
- const aggregatedScore = this.aggregateScores(cards);
1226
+ for (const [, items] of byCardId) {
1227
+ const cards = items.map((i) => i.card);
1228
+ const aggregatedScore = this.aggregateScores(items);
1248
1229
  const finalScore = Math.min(1, aggregatedScore);
1249
1230
  const mergedProvenance = cards.flatMap((c) => c.provenance);
1250
1231
  const initialScore = cards[0].score;
1251
1232
  const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
1252
- const reason = this.buildAggregationReason(cards, finalScore);
1233
+ const reason = this.buildAggregationReason(items, finalScore);
1253
1234
  merged.push({
1254
1235
  ...cards[0],
1255
1236
  score: finalScore,
@@ -1271,22 +1252,26 @@ var init_CompositeGenerator = __esm({
1271
1252
  /**
1272
1253
  * Build human-readable reason for score aggregation.
1273
1254
  */
1274
- buildAggregationReason(cards, finalScore) {
1255
+ buildAggregationReason(items, finalScore) {
1256
+ const cards = items.map((i) => i.card);
1275
1257
  const count = cards.length;
1276
1258
  const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
1277
1259
  if (count === 1) {
1278
- return `Single generator, score ${finalScore.toFixed(2)}`;
1260
+ const weightMsg = Math.abs(items[0].weight - 1) > 1e-3 ? ` (w=${items[0].weight.toFixed(2)})` : "";
1261
+ return `Single generator, score ${finalScore.toFixed(2)}${weightMsg}`;
1279
1262
  }
1280
1263
  const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
1281
1264
  switch (this.aggregationMode) {
1282
1265
  case "max" /* MAX */:
1283
1266
  return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
1284
1267
  case "average" /* AVERAGE */:
1285
- return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
1268
+ return `Weighted Avg of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
1286
1269
  case "frequencyBoost" /* FREQUENCY_BOOST */: {
1287
- const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
1270
+ const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
1271
+ const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
1272
+ const avg = totalWeight > 0 ? weightedSum / totalWeight : 0;
1288
1273
  const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
1289
- return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
1274
+ return `Frequency boost from ${count} generators (${strategies}): w-avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
1290
1275
  }
1291
1276
  default:
1292
1277
  return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
@@ -1295,16 +1280,22 @@ var init_CompositeGenerator = __esm({
1295
1280
  /**
1296
1281
  * Aggregate scores from multiple generators for the same card.
1297
1282
  */
1298
- aggregateScores(cards) {
1299
- const scores = cards.map((c) => c.score);
1283
+ aggregateScores(items) {
1284
+ const scores = items.map((i) => i.card.score);
1300
1285
  switch (this.aggregationMode) {
1301
1286
  case "max" /* MAX */:
1302
1287
  return Math.max(...scores);
1303
- case "average" /* AVERAGE */:
1304
- return scores.reduce((sum, s) => sum + s, 0) / scores.length;
1288
+ case "average" /* AVERAGE */: {
1289
+ const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
1290
+ if (totalWeight === 0) return 0;
1291
+ const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
1292
+ return weightedSum / totalWeight;
1293
+ }
1305
1294
  case "frequencyBoost" /* FREQUENCY_BOOST */: {
1306
- const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
1307
- const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
1295
+ const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
1296
+ const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
1297
+ const avg = totalWeight > 0 ? weightedSum / totalWeight : 0;
1298
+ const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (items.length - 1);
1308
1299
  return avg * frequencyBoost;
1309
1300
  }
1310
1301
  default:
@@ -1315,134 +1306,18 @@ var init_CompositeGenerator = __esm({
1315
1306
  }
1316
1307
  });
1317
1308
 
1318
- // src/core/navigators/PipelineAssembler.ts
1319
- var PipelineAssembler;
1320
- var init_PipelineAssembler = __esm({
1321
- "src/core/navigators/PipelineAssembler.ts"() {
1322
- "use strict";
1323
- init_navigators();
1324
- init_Pipeline();
1325
- init_types_legacy();
1326
- init_logger();
1327
- init_CompositeGenerator();
1328
- PipelineAssembler = class {
1329
- /**
1330
- * Assembles a navigation pipeline from strategy documents.
1331
- *
1332
- * 1. Separates into generators and filters by role
1333
- * 2. Validates at least one generator exists (or creates default ELO)
1334
- * 3. Instantiates generators - wraps multiple in CompositeGenerator
1335
- * 4. Instantiates filters
1336
- * 5. Returns Pipeline(generator, filters)
1337
- *
1338
- * @param input - Strategy documents plus user/course interfaces
1339
- * @returns Assembled pipeline and any warnings
1340
- */
1341
- async assemble(input) {
1342
- const { strategies, user, course } = input;
1343
- const warnings = [];
1344
- if (strategies.length === 0) {
1345
- return {
1346
- pipeline: null,
1347
- generatorStrategies: [],
1348
- filterStrategies: [],
1349
- warnings
1350
- };
1351
- }
1352
- const generatorStrategies = [];
1353
- const filterStrategies = [];
1354
- for (const s of strategies) {
1355
- if (isGenerator(s.implementingClass)) {
1356
- generatorStrategies.push(s);
1357
- } else if (isFilter(s.implementingClass)) {
1358
- filterStrategies.push(s);
1359
- } else {
1360
- warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
1361
- }
1362
- }
1363
- if (generatorStrategies.length === 0) {
1364
- if (filterStrategies.length > 0) {
1365
- logger.debug(
1366
- "[PipelineAssembler] No generator found, using default ELO with configured filters"
1367
- );
1368
- generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
1369
- } else {
1370
- warnings.push("No generator strategy found");
1371
- return {
1372
- pipeline: null,
1373
- generatorStrategies: [],
1374
- filterStrategies: [],
1375
- warnings
1376
- };
1377
- }
1378
- }
1379
- let generator;
1380
- if (generatorStrategies.length === 1) {
1381
- const nav = await ContentNavigator.create(user, course, generatorStrategies[0]);
1382
- generator = nav;
1383
- logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
1384
- } else {
1385
- logger.debug(
1386
- `[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
1387
- );
1388
- generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
1389
- }
1390
- const filters = [];
1391
- const sortedFilterStrategies = [...filterStrategies].sort(
1392
- (a, b) => a.name.localeCompare(b.name)
1393
- );
1394
- for (const filterStrategy of sortedFilterStrategies) {
1395
- try {
1396
- const nav = await ContentNavigator.create(user, course, filterStrategy);
1397
- if ("transform" in nav && typeof nav.transform === "function") {
1398
- filters.push(nav);
1399
- logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
1400
- } else {
1401
- warnings.push(
1402
- `Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
1403
- );
1404
- }
1405
- } catch (e) {
1406
- warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
1407
- }
1408
- }
1409
- const pipeline = new Pipeline(generator, filters, user, course);
1410
- logger.debug(
1411
- `[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
1412
- );
1413
- return {
1414
- pipeline,
1415
- generatorStrategies,
1416
- filterStrategies: sortedFilterStrategies,
1417
- warnings
1418
- };
1419
- }
1420
- /**
1421
- * Creates a default ELO generator strategy.
1422
- * Used when filters are configured but no generator is specified.
1423
- */
1424
- makeDefaultEloStrategy(courseId) {
1425
- return {
1426
- _id: "NAVIGATION_STRATEGY-ELO-default",
1427
- course: courseId,
1428
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1429
- name: "ELO (default)",
1430
- description: "Default ELO-based generator",
1431
- implementingClass: "elo" /* ELO */,
1432
- serializedData: ""
1433
- };
1434
- }
1435
- };
1436
- }
1437
- });
1438
-
1439
1309
  // src/core/navigators/generators/elo.ts
1440
- import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
1310
+ var elo_exports = {};
1311
+ __export(elo_exports, {
1312
+ default: () => ELONavigator
1313
+ });
1314
+ import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
1441
1315
  var ELONavigator;
1442
1316
  var init_elo = __esm({
1443
1317
  "src/core/navigators/generators/elo.ts"() {
1444
1318
  "use strict";
1445
1319
  init_navigators();
1320
+ init_logger();
1446
1321
  ELONavigator = class extends ContentNavigator {
1447
1322
  /** Human-readable name for CardGenerator interface */
1448
1323
  name;
@@ -1471,7 +1346,7 @@ var init_elo = __esm({
1471
1346
  userGlobalElo = context.userElo;
1472
1347
  } else {
1473
1348
  const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
1474
- const userElo = toCourseElo3(courseReg.elo);
1349
+ const userElo = toCourseElo2(courseReg.elo);
1475
1350
  userGlobalElo = userElo.global.score;
1476
1351
  }
1477
1352
  const activeCards = await this.user.getActiveCards();
@@ -1502,26 +1377,65 @@ var init_elo = __esm({
1502
1377
  };
1503
1378
  });
1504
1379
  scored.sort((a, b) => b.score - a.score);
1505
- 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;
1506
1390
  }
1507
1391
  };
1508
1392
  }
1509
1393
  });
1510
1394
 
1395
+ // src/core/navigators/generators/index.ts
1396
+ var generators_exports = {};
1397
+ var init_generators = __esm({
1398
+ "src/core/navigators/generators/index.ts"() {
1399
+ "use strict";
1400
+ }
1401
+ });
1402
+
1511
1403
  // src/core/navigators/generators/srs.ts
1404
+ var srs_exports = {};
1405
+ __export(srs_exports, {
1406
+ default: () => SRSNavigator
1407
+ });
1512
1408
  import moment3 from "moment";
1513
- var SRSNavigator;
1409
+ var DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_PRESSURE, SRSNavigator;
1514
1410
  var init_srs = __esm({
1515
1411
  "src/core/navigators/generators/srs.ts"() {
1516
1412
  "use strict";
1517
1413
  init_navigators();
1518
1414
  init_logger();
1415
+ DEFAULT_HEALTHY_BACKLOG = 20;
1416
+ MAX_BACKLOG_PRESSURE = 0.5;
1519
1417
  SRSNavigator = class extends ContentNavigator {
1520
1418
  /** Human-readable name for CardGenerator interface */
1521
1419
  name;
1420
+ /** Healthy backlog threshold - when exceeded, backlog pressure kicks in */
1421
+ healthyBacklog;
1522
1422
  constructor(user, course, strategyData) {
1523
1423
  super(user, course, strategyData);
1524
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
+ }
1525
1439
  }
1526
1440
  /**
1527
1441
  * Get review cards scored by urgency.
@@ -1529,6 +1443,7 @@ var init_srs = __esm({
1529
1443
  * Score formula combines:
1530
1444
  * - Relative overdueness: hoursOverdue / intervalHours
1531
1445
  * - Interval recency: exponential decay favoring shorter intervals
1446
+ * - Backlog pressure: boost when due reviews exceed healthy threshold
1532
1447
  *
1533
1448
  * Cards not yet due are excluded (not scored as 0).
1534
1449
  *
@@ -1542,11 +1457,32 @@ var init_srs = __esm({
1542
1457
  if (!this.user || !this.course) {
1543
1458
  throw new Error("SRSNavigator requires user and course to be set");
1544
1459
  }
1545
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1460
+ const courseId = this.course.getCourseID();
1461
+ const reviews = await this.user.getPendingReviews(courseId);
1546
1462
  const now = moment3.utc();
1547
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
+ }
1548
1484
  const scored = dueReviews.map((review) => {
1549
- const { score, reason } = this.computeUrgencyScore(review, now);
1485
+ const { score, reason } = this.computeUrgencyScore(review, now, backlogPressure);
1550
1486
  return {
1551
1487
  cardId: review.cardId,
1552
1488
  courseId: review.courseId,
@@ -1564,143 +1500,2084 @@ var init_srs = __esm({
1564
1500
  ]
1565
1501
  };
1566
1502
  });
1567
- logger.debug(`[srsNav] got ${scored.length} weighted cards`);
1568
1503
  return scored.sort((a, b) => b.score - a.score).slice(0, limit);
1569
1504
  }
1570
1505
  /**
1571
- * Compute urgency score for a review card.
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
+ }
1528
+ /**
1529
+ * Compute urgency score for a review card.
1530
+ *
1531
+ * Three factors:
1532
+ * 1. Relative overdueness = hoursOverdue / intervalHours
1533
+ * - 2 days overdue on 3-day interval = 0.67 (urgent)
1534
+ * - 2 days overdue on 180-day interval = 0.01 (not urgent)
1535
+ *
1536
+ * 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
1537
+ * - 24h interval → ~1.0 (very recent learning)
1538
+ * - 30 days (720h) → ~0.56
1539
+ * - 180 days → ~0.30
1540
+ *
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)
1552
+ */
1553
+ computeUrgencyScore(review, now, backlogPressure) {
1554
+ const scheduledAt = moment3.utc(review.scheduledAt);
1555
+ const due = moment3.utc(review.reviewTime);
1556
+ const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
1557
+ const hoursOverdue = now.diff(due, "hours");
1558
+ const relativeOverdue = hoursOverdue / intervalHours;
1559
+ const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
1560
+ const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
1561
+ const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
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(", ");
1575
+ return { score, reason };
1576
+ }
1577
+ };
1578
+ }
1579
+ });
1580
+
1581
+ // src/core/navigators/generators/types.ts
1582
+ var types_exports = {};
1583
+ var init_types = __esm({
1584
+ "src/core/navigators/generators/types.ts"() {
1585
+ "use strict";
1586
+ }
1587
+ });
1588
+
1589
+ // import("./generators/**/*") in src/core/navigators/index.ts
1590
+ var globImport_generators;
1591
+ var init_ = __esm({
1592
+ 'import("./generators/**/*") in src/core/navigators/index.ts'() {
1593
+ globImport_generators = __glob({
1594
+ "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1595
+ "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1596
+ "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1597
+ "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1598
+ "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1599
+ });
1600
+ }
1601
+ });
1602
+
1603
+ // src/core/types/contentNavigationStrategy.ts
1604
+ var DEFAULT_LEARNABLE_WEIGHT;
1605
+ var init_contentNavigationStrategy = __esm({
1606
+ "src/core/types/contentNavigationStrategy.ts"() {
1607
+ "use strict";
1608
+ DEFAULT_LEARNABLE_WEIGHT = {
1609
+ weight: 1,
1610
+ confidence: 0.1,
1611
+ // Low confidence initially = wide exploration
1612
+ sampleSize: 0
1613
+ };
1614
+ }
1615
+ });
1616
+
1617
+ // src/core/navigators/filters/WeightedFilter.ts
1618
+ var WeightedFilter_exports = {};
1619
+ __export(WeightedFilter_exports, {
1620
+ WeightedFilter: () => WeightedFilter
1621
+ });
1622
+ var WeightedFilter;
1623
+ var init_WeightedFilter = __esm({
1624
+ "src/core/navigators/filters/WeightedFilter.ts"() {
1625
+ "use strict";
1626
+ init_contentNavigationStrategy();
1627
+ WeightedFilter = class {
1628
+ name;
1629
+ inner;
1630
+ learnable;
1631
+ staticWeight;
1632
+ strategyId;
1633
+ constructor(inner, learnable = DEFAULT_LEARNABLE_WEIGHT, staticWeight = false, strategyId) {
1634
+ this.inner = inner;
1635
+ this.name = inner.name;
1636
+ this.learnable = learnable;
1637
+ this.staticWeight = staticWeight;
1638
+ this.strategyId = strategyId;
1639
+ }
1640
+ /**
1641
+ * Apply the inner filter, then scale its effect by the configured weight.
1642
+ */
1643
+ async transform(cards, context) {
1644
+ let effectiveWeight = this.learnable.weight;
1645
+ let deviation;
1646
+ if (!this.staticWeight && context.orchestration) {
1647
+ const strategyId = this.strategyId || this.inner.strategyId || this.name;
1648
+ effectiveWeight = context.orchestration.getEffectiveWeight(strategyId, this.learnable);
1649
+ deviation = context.orchestration.getDeviation(strategyId);
1650
+ }
1651
+ if (Math.abs(effectiveWeight - 1) < 1e-3) {
1652
+ return this.inner.transform(cards, context);
1653
+ }
1654
+ const originalScores = /* @__PURE__ */ new Map();
1655
+ for (const card of cards) {
1656
+ originalScores.set(card.cardId, card.score);
1657
+ }
1658
+ const transformedCards = await this.inner.transform(cards, context);
1659
+ return transformedCards.map((card) => {
1660
+ const originalScore = originalScores.get(card.cardId);
1661
+ if (originalScore === void 0 || originalScore === 0 || card.score === 0) {
1662
+ return card;
1663
+ }
1664
+ const rawEffect = card.score / originalScore;
1665
+ if (Math.abs(rawEffect - 1) < 1e-4) {
1666
+ return card;
1667
+ }
1668
+ const weightedEffect = Math.pow(rawEffect, effectiveWeight);
1669
+ const newScore = originalScore * weightedEffect;
1670
+ const lastProvIndex = card.provenance.length - 1;
1671
+ const lastProv = card.provenance[lastProvIndex];
1672
+ if (lastProv) {
1673
+ const updatedProvenance = [...card.provenance];
1674
+ updatedProvenance[lastProvIndex] = {
1675
+ ...lastProv,
1676
+ score: newScore,
1677
+ effectiveWeight,
1678
+ deviation
1679
+ // We can optionally append to the reason, but the structured field is key
1680
+ };
1681
+ return {
1682
+ ...card,
1683
+ score: newScore,
1684
+ provenance: updatedProvenance
1685
+ };
1686
+ }
1687
+ return {
1688
+ ...card,
1689
+ score: newScore
1690
+ };
1691
+ });
1692
+ }
1693
+ };
1694
+ }
1695
+ });
1696
+
1697
+ // src/core/navigators/filters/eloDistance.ts
1698
+ var eloDistance_exports = {};
1699
+ __export(eloDistance_exports, {
1700
+ DEFAULT_HALF_LIFE: () => DEFAULT_HALF_LIFE,
1701
+ DEFAULT_MAX_MULTIPLIER: () => DEFAULT_MAX_MULTIPLIER,
1702
+ DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
1703
+ createEloDistanceFilter: () => createEloDistanceFilter
1704
+ });
1705
+ function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
1706
+ const normalizedDistance = distance / halfLife;
1707
+ const decay = Math.exp(-(normalizedDistance * normalizedDistance));
1708
+ return minMultiplier + (maxMultiplier - minMultiplier) * decay;
1709
+ }
1710
+ function createEloDistanceFilter(config) {
1711
+ const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
1712
+ const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
1713
+ const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
1714
+ return {
1715
+ name: "ELO Distance Filter",
1716
+ async transform(cards, context) {
1717
+ const { course, userElo } = context;
1718
+ const cardIds = cards.map((c) => c.cardId);
1719
+ const cardElos = await course.getCardEloData(cardIds);
1720
+ return cards.map((card, i) => {
1721
+ const cardElo = cardElos[i]?.global?.score ?? 1e3;
1722
+ const distance = Math.abs(cardElo - userElo);
1723
+ const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
1724
+ const newScore = card.score * multiplier;
1725
+ const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
1726
+ return {
1727
+ ...card,
1728
+ score: newScore,
1729
+ provenance: [
1730
+ ...card.provenance,
1731
+ {
1732
+ strategy: "eloDistance",
1733
+ strategyName: "ELO Distance Filter",
1734
+ strategyId: "ELO_DISTANCE_FILTER",
1735
+ action,
1736
+ score: newScore,
1737
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
1738
+ }
1739
+ ]
1740
+ };
1741
+ });
1742
+ }
1743
+ };
1744
+ }
1745
+ var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
1746
+ var init_eloDistance = __esm({
1747
+ "src/core/navigators/filters/eloDistance.ts"() {
1748
+ "use strict";
1749
+ DEFAULT_HALF_LIFE = 200;
1750
+ DEFAULT_MIN_MULTIPLIER = 0.3;
1751
+ DEFAULT_MAX_MULTIPLIER = 1;
1752
+ }
1753
+ });
1754
+
1755
+ // src/core/navigators/filters/hierarchyDefinition.ts
1756
+ var hierarchyDefinition_exports = {};
1757
+ __export(hierarchyDefinition_exports, {
1758
+ default: () => HierarchyDefinitionNavigator
1759
+ });
1760
+ import { toCourseElo as toCourseElo3 } from "@vue-skuilder/common";
1761
+ var DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
1762
+ var init_hierarchyDefinition = __esm({
1763
+ "src/core/navigators/filters/hierarchyDefinition.ts"() {
1764
+ "use strict";
1765
+ init_navigators();
1766
+ DEFAULT_MIN_COUNT = 3;
1767
+ HierarchyDefinitionNavigator = class extends ContentNavigator {
1768
+ config;
1769
+ /** Human-readable name for CardFilter interface */
1770
+ name;
1771
+ constructor(user, course, strategyData) {
1772
+ super(user, course, strategyData);
1773
+ this.config = this.parseConfig(strategyData.serializedData);
1774
+ this.name = strategyData.name || "Hierarchy Definition";
1775
+ }
1776
+ parseConfig(serializedData) {
1777
+ try {
1778
+ const parsed = JSON.parse(serializedData);
1779
+ return {
1780
+ prerequisites: parsed.prerequisites || {}
1781
+ };
1782
+ } catch {
1783
+ return {
1784
+ prerequisites: {}
1785
+ };
1786
+ }
1787
+ }
1788
+ /**
1789
+ * Check if a specific prerequisite is satisfied
1790
+ */
1791
+ isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1792
+ if (!userTagElo) return false;
1793
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
1794
+ if (userTagElo.count < minCount) return false;
1795
+ if (prereq.masteryThreshold?.minElo !== void 0) {
1796
+ return userTagElo.score >= prereq.masteryThreshold.minElo;
1797
+ } else {
1798
+ return userTagElo.score >= userGlobalElo;
1799
+ }
1800
+ }
1801
+ /**
1802
+ * Get the set of tags the user has mastered.
1803
+ * A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
1804
+ */
1805
+ async getMasteredTags(context) {
1806
+ const mastered = /* @__PURE__ */ new Set();
1807
+ try {
1808
+ const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
1809
+ const userElo = toCourseElo3(courseReg.elo);
1810
+ for (const prereqs of Object.values(this.config.prerequisites)) {
1811
+ for (const prereq of prereqs) {
1812
+ const tagElo = userElo.tags[prereq.tag];
1813
+ if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
1814
+ mastered.add(prereq.tag);
1815
+ }
1816
+ }
1817
+ }
1818
+ } catch {
1819
+ }
1820
+ return mastered;
1821
+ }
1822
+ /**
1823
+ * Get the set of tags that are unlocked (prerequisites met)
1824
+ */
1825
+ getUnlockedTags(masteredTags) {
1826
+ const unlocked = /* @__PURE__ */ new Set();
1827
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
1828
+ const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
1829
+ if (allPrereqsMet) {
1830
+ unlocked.add(tagId);
1831
+ }
1832
+ }
1833
+ return unlocked;
1834
+ }
1835
+ /**
1836
+ * Check if a tag has prerequisites defined in config
1837
+ */
1838
+ hasPrerequisites(tagId) {
1839
+ return tagId in this.config.prerequisites;
1840
+ }
1841
+ /**
1842
+ * Check if a card is unlocked and generate reason.
1843
+ */
1844
+ async checkCardUnlock(card, _course, unlockedTags, masteredTags) {
1845
+ try {
1846
+ const cardTags = card.tags ?? [];
1847
+ const lockedTags = cardTags.filter(
1848
+ (tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
1849
+ );
1850
+ if (lockedTags.length === 0) {
1851
+ const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
1852
+ return {
1853
+ isUnlocked: true,
1854
+ reason: `Prerequisites met, tags: ${tagList}`
1855
+ };
1856
+ }
1857
+ const missingPrereqs = lockedTags.flatMap((tag) => {
1858
+ const prereqs = this.config.prerequisites[tag] || [];
1859
+ return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
1860
+ });
1861
+ return {
1862
+ isUnlocked: false,
1863
+ reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
1864
+ };
1865
+ } catch {
1866
+ return {
1867
+ isUnlocked: true,
1868
+ reason: "Prerequisites check skipped (tag lookup failed)"
1869
+ };
1870
+ }
1871
+ }
1872
+ /**
1873
+ * CardFilter.transform implementation.
1874
+ *
1875
+ * Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
1876
+ */
1877
+ async transform(cards, context) {
1878
+ const masteredTags = await this.getMasteredTags(context);
1879
+ const unlockedTags = this.getUnlockedTags(masteredTags);
1880
+ const gated = [];
1881
+ for (const card of cards) {
1882
+ const { isUnlocked, reason } = await this.checkCardUnlock(
1883
+ card,
1884
+ context.course,
1885
+ unlockedTags,
1886
+ masteredTags
1887
+ );
1888
+ const finalScore = isUnlocked ? card.score : 0;
1889
+ const action = isUnlocked ? "passed" : "penalized";
1890
+ gated.push({
1891
+ ...card,
1892
+ score: finalScore,
1893
+ provenance: [
1894
+ ...card.provenance,
1895
+ {
1896
+ strategy: "hierarchyDefinition",
1897
+ strategyName: this.strategyName || this.name,
1898
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1899
+ action,
1900
+ score: finalScore,
1901
+ reason
1902
+ }
1903
+ ]
1904
+ });
1905
+ }
1906
+ return gated;
1907
+ }
1908
+ /**
1909
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
1910
+ *
1911
+ * Use transform() via Pipeline instead.
1912
+ */
1913
+ async getWeightedCards(_limit) {
1914
+ throw new Error(
1915
+ "HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
1916
+ );
1917
+ }
1918
+ };
1919
+ }
1920
+ });
1921
+
1922
+ // src/core/navigators/filters/userTagPreference.ts
1923
+ var userTagPreference_exports = {};
1924
+ __export(userTagPreference_exports, {
1925
+ default: () => UserTagPreferenceFilter
1926
+ });
1927
+ var UserTagPreferenceFilter;
1928
+ var init_userTagPreference = __esm({
1929
+ "src/core/navigators/filters/userTagPreference.ts"() {
1930
+ "use strict";
1931
+ init_navigators();
1932
+ UserTagPreferenceFilter = class extends ContentNavigator {
1933
+ _strategyData;
1934
+ /** Human-readable name for CardFilter interface */
1935
+ name;
1936
+ constructor(user, course, strategyData) {
1937
+ super(user, course, strategyData);
1938
+ this._strategyData = strategyData;
1939
+ this.name = strategyData.name || "User Tag Preferences";
1940
+ }
1941
+ /**
1942
+ * Compute multiplier for a card based on its tags and user preferences.
1943
+ * Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
1944
+ */
1945
+ computeMultiplier(cardTags, boostMap) {
1946
+ const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== void 0);
1947
+ if (multipliers.length === 0) {
1948
+ return 1;
1949
+ }
1950
+ return Math.max(...multipliers);
1951
+ }
1952
+ /**
1953
+ * Build human-readable reason for the filter's decision.
1954
+ */
1955
+ buildReason(cardTags, boostMap, multiplier) {
1956
+ const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
1957
+ if (multiplier === 0) {
1958
+ return `Excluded by user preference: ${matchingTags.join(", ")} (${multiplier}x)`;
1959
+ }
1960
+ if (multiplier < 1) {
1961
+ return `Penalized by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
1962
+ }
1963
+ if (multiplier > 1) {
1964
+ return `Boosted by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
1965
+ }
1966
+ return "No matching user preferences";
1967
+ }
1968
+ /**
1969
+ * CardFilter.transform implementation.
1970
+ *
1971
+ * Apply user tag preferences:
1972
+ * 1. Read preferences from strategy state
1973
+ * 2. If no preferences, pass through unchanged
1974
+ * 3. For each card:
1975
+ * - Look up tag in boost record
1976
+ * - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
1977
+ * - If multiple tags match: use max multiplier
1978
+ * - Append provenance with clear reason
1979
+ */
1980
+ async transform(cards, _context) {
1981
+ const prefs = await this.getStrategyState();
1982
+ if (!prefs || Object.keys(prefs.boost).length === 0) {
1983
+ return cards.map((card) => ({
1984
+ ...card,
1985
+ provenance: [
1986
+ ...card.provenance,
1987
+ {
1988
+ strategy: "userTagPreference",
1989
+ strategyName: this.strategyName || this.name,
1990
+ strategyId: this.strategyId || this._strategyData._id,
1991
+ action: "passed",
1992
+ score: card.score,
1993
+ reason: "No user tag preferences configured"
1994
+ }
1995
+ ]
1996
+ }));
1997
+ }
1998
+ const adjusted = await Promise.all(
1999
+ cards.map(async (card) => {
2000
+ const cardTags = card.tags ?? [];
2001
+ const multiplier = this.computeMultiplier(cardTags, prefs.boost);
2002
+ const finalScore = Math.min(1, card.score * multiplier);
2003
+ let action;
2004
+ if (multiplier === 0 || multiplier < 1) {
2005
+ action = "penalized";
2006
+ } else if (multiplier > 1) {
2007
+ action = "boosted";
2008
+ } else {
2009
+ action = "passed";
2010
+ }
2011
+ return {
2012
+ ...card,
2013
+ score: finalScore,
2014
+ provenance: [
2015
+ ...card.provenance,
2016
+ {
2017
+ strategy: "userTagPreference",
2018
+ strategyName: this.strategyName || this.name,
2019
+ strategyId: this.strategyId || this._strategyData._id,
2020
+ action,
2021
+ score: finalScore,
2022
+ reason: this.buildReason(cardTags, prefs.boost, multiplier)
2023
+ }
2024
+ ]
2025
+ };
2026
+ })
2027
+ );
2028
+ return adjusted;
2029
+ }
2030
+ /**
2031
+ * Legacy getWeightedCards - throws as filters should not be used as generators.
2032
+ */
2033
+ async getWeightedCards(_limit) {
2034
+ throw new Error(
2035
+ "UserTagPreferenceFilter is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2036
+ );
2037
+ }
2038
+ };
2039
+ }
2040
+ });
2041
+
2042
+ // src/core/navigators/filters/index.ts
2043
+ var filters_exports = {};
2044
+ __export(filters_exports, {
2045
+ UserTagPreferenceFilter: () => UserTagPreferenceFilter,
2046
+ createEloDistanceFilter: () => createEloDistanceFilter
2047
+ });
2048
+ var init_filters = __esm({
2049
+ "src/core/navigators/filters/index.ts"() {
2050
+ "use strict";
2051
+ init_eloDistance();
2052
+ init_userTagPreference();
2053
+ }
2054
+ });
2055
+
2056
+ // src/core/navigators/filters/inferredPreferenceStub.ts
2057
+ var inferredPreferenceStub_exports = {};
2058
+ __export(inferredPreferenceStub_exports, {
2059
+ INFERRED_PREFERENCE_NAVIGATOR_STUB: () => INFERRED_PREFERENCE_NAVIGATOR_STUB
2060
+ });
2061
+ var INFERRED_PREFERENCE_NAVIGATOR_STUB;
2062
+ var init_inferredPreferenceStub = __esm({
2063
+ "src/core/navigators/filters/inferredPreferenceStub.ts"() {
2064
+ "use strict";
2065
+ INFERRED_PREFERENCE_NAVIGATOR_STUB = true;
2066
+ }
2067
+ });
2068
+
2069
+ // src/core/navigators/filters/interferenceMitigator.ts
2070
+ var interferenceMitigator_exports = {};
2071
+ __export(interferenceMitigator_exports, {
2072
+ default: () => InterferenceMitigatorNavigator
2073
+ });
2074
+ import { toCourseElo as toCourseElo4 } from "@vue-skuilder/common";
2075
+ var DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
2076
+ var init_interferenceMitigator = __esm({
2077
+ "src/core/navigators/filters/interferenceMitigator.ts"() {
2078
+ "use strict";
2079
+ init_navigators();
2080
+ DEFAULT_MIN_COUNT2 = 10;
2081
+ DEFAULT_MIN_ELAPSED_DAYS = 3;
2082
+ DEFAULT_INTERFERENCE_DECAY = 0.8;
2083
+ InterferenceMitigatorNavigator = class extends ContentNavigator {
2084
+ config;
2085
+ /** Human-readable name for CardFilter interface */
2086
+ name;
2087
+ /** Precomputed map: tag -> set of { partner, decay } it interferes with */
2088
+ interferenceMap;
2089
+ constructor(user, course, strategyData) {
2090
+ super(user, course, strategyData);
2091
+ this.config = this.parseConfig(strategyData.serializedData);
2092
+ this.interferenceMap = this.buildInterferenceMap();
2093
+ this.name = strategyData.name || "Interference Mitigator";
2094
+ }
2095
+ parseConfig(serializedData) {
2096
+ try {
2097
+ const parsed = JSON.parse(serializedData);
2098
+ let sets = parsed.interferenceSets || [];
2099
+ if (sets.length > 0 && Array.isArray(sets[0])) {
2100
+ sets = sets.map((tags) => ({ tags }));
2101
+ }
2102
+ return {
2103
+ interferenceSets: sets,
2104
+ maturityThreshold: {
2105
+ minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
2106
+ minElo: parsed.maturityThreshold?.minElo,
2107
+ minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
2108
+ },
2109
+ defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
2110
+ };
2111
+ } catch {
2112
+ return {
2113
+ interferenceSets: [],
2114
+ maturityThreshold: {
2115
+ minCount: DEFAULT_MIN_COUNT2,
2116
+ minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
2117
+ },
2118
+ defaultDecay: DEFAULT_INTERFERENCE_DECAY
2119
+ };
2120
+ }
2121
+ }
2122
+ /**
2123
+ * Build a map from each tag to its interference partners with decay coefficients.
2124
+ * If tags A, B, C are in an interference group with decay 0.8, then:
2125
+ * - A interferes with B (decay 0.8) and C (decay 0.8)
2126
+ * - B interferes with A (decay 0.8) and C (decay 0.8)
2127
+ * - etc.
2128
+ */
2129
+ buildInterferenceMap() {
2130
+ const map = /* @__PURE__ */ new Map();
2131
+ for (const group of this.config.interferenceSets) {
2132
+ const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
2133
+ for (const tag of group.tags) {
2134
+ if (!map.has(tag)) {
2135
+ map.set(tag, []);
2136
+ }
2137
+ const partners = map.get(tag);
2138
+ for (const other of group.tags) {
2139
+ if (other !== tag) {
2140
+ const existing = partners.find((p) => p.partner === other);
2141
+ if (existing) {
2142
+ existing.decay = Math.max(existing.decay, decay);
2143
+ } else {
2144
+ partners.push({ partner: other, decay });
2145
+ }
2146
+ }
2147
+ }
2148
+ }
2149
+ }
2150
+ return map;
2151
+ }
2152
+ /**
2153
+ * Get the set of tags that are currently immature for this user.
2154
+ * A tag is immature if the user has interacted with it but hasn't
2155
+ * reached the maturity threshold.
2156
+ */
2157
+ async getImmatureTags(context) {
2158
+ const immature = /* @__PURE__ */ new Set();
2159
+ try {
2160
+ const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
2161
+ const userElo = toCourseElo4(courseReg.elo);
2162
+ const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
2163
+ const minElo = this.config.maturityThreshold?.minElo;
2164
+ const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
2165
+ const minCountForElapsed = minElapsedDays * 2;
2166
+ for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
2167
+ if (tagElo.count === 0) continue;
2168
+ const belowCount = tagElo.count < minCount;
2169
+ const belowElo = minElo !== void 0 && tagElo.score < minElo;
2170
+ const belowElapsed = tagElo.count < minCountForElapsed;
2171
+ if (belowCount || belowElo || belowElapsed) {
2172
+ immature.add(tagId);
2173
+ }
2174
+ }
2175
+ } catch {
2176
+ }
2177
+ return immature;
2178
+ }
2179
+ /**
2180
+ * Get all tags that interfere with any immature tag, along with their decay coefficients.
2181
+ * These are the tags we want to avoid introducing.
2182
+ */
2183
+ getTagsToAvoid(immatureTags) {
2184
+ const avoid = /* @__PURE__ */ new Map();
2185
+ for (const immatureTag of immatureTags) {
2186
+ const partners = this.interferenceMap.get(immatureTag);
2187
+ if (partners) {
2188
+ for (const { partner, decay } of partners) {
2189
+ if (!immatureTags.has(partner)) {
2190
+ const existing = avoid.get(partner) ?? 0;
2191
+ avoid.set(partner, Math.max(existing, decay));
2192
+ }
2193
+ }
2194
+ }
2195
+ }
2196
+ return avoid;
2197
+ }
2198
+ /**
2199
+ * Compute interference score reduction for a card.
2200
+ * Returns: { multiplier, interfering tags, reason }
2201
+ */
2202
+ computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
2203
+ if (tagsToAvoid.size === 0) {
2204
+ return {
2205
+ multiplier: 1,
2206
+ interferingTags: [],
2207
+ reason: "No interference detected"
2208
+ };
2209
+ }
2210
+ let multiplier = 1;
2211
+ const interferingTags = [];
2212
+ for (const tag of cardTags) {
2213
+ const decay = tagsToAvoid.get(tag);
2214
+ if (decay !== void 0) {
2215
+ interferingTags.push(tag);
2216
+ multiplier *= 1 - decay;
2217
+ }
2218
+ }
2219
+ if (interferingTags.length === 0) {
2220
+ return {
2221
+ multiplier: 1,
2222
+ interferingTags: [],
2223
+ reason: "No interference detected"
2224
+ };
2225
+ }
2226
+ const causingTags = /* @__PURE__ */ new Set();
2227
+ for (const tag of interferingTags) {
2228
+ for (const immatureTag of immatureTags) {
2229
+ const partners = this.interferenceMap.get(immatureTag);
2230
+ if (partners?.some((p) => p.partner === tag)) {
2231
+ causingTags.add(immatureTag);
2232
+ }
2233
+ }
2234
+ }
2235
+ const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
2236
+ return { multiplier, interferingTags, reason };
2237
+ }
2238
+ /**
2239
+ * CardFilter.transform implementation.
2240
+ *
2241
+ * Apply interference-aware scoring. Cards with tags that interfere with
2242
+ * immature learnings get reduced scores.
2243
+ */
2244
+ async transform(cards, context) {
2245
+ const immatureTags = await this.getImmatureTags(context);
2246
+ const tagsToAvoid = this.getTagsToAvoid(immatureTags);
2247
+ const adjusted = [];
2248
+ for (const card of cards) {
2249
+ const cardTags = card.tags ?? [];
2250
+ const { multiplier, reason } = this.computeInterferenceEffect(
2251
+ cardTags,
2252
+ tagsToAvoid,
2253
+ immatureTags
2254
+ );
2255
+ const finalScore = card.score * multiplier;
2256
+ const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
2257
+ adjusted.push({
2258
+ ...card,
2259
+ score: finalScore,
2260
+ provenance: [
2261
+ ...card.provenance,
2262
+ {
2263
+ strategy: "interferenceMitigator",
2264
+ strategyName: this.strategyName || this.name,
2265
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
2266
+ action,
2267
+ score: finalScore,
2268
+ reason
2269
+ }
2270
+ ]
2271
+ });
2272
+ }
2273
+ return adjusted;
2274
+ }
2275
+ /**
2276
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
2277
+ *
2278
+ * Use transform() via Pipeline instead.
2279
+ */
2280
+ async getWeightedCards(_limit) {
2281
+ throw new Error(
2282
+ "InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2283
+ );
2284
+ }
2285
+ };
2286
+ }
2287
+ });
2288
+
2289
+ // src/core/navigators/filters/relativePriority.ts
2290
+ var relativePriority_exports = {};
2291
+ __export(relativePriority_exports, {
2292
+ default: () => RelativePriorityNavigator
2293
+ });
2294
+ var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
2295
+ var init_relativePriority = __esm({
2296
+ "src/core/navigators/filters/relativePriority.ts"() {
2297
+ "use strict";
2298
+ init_navigators();
2299
+ DEFAULT_PRIORITY = 0.5;
2300
+ DEFAULT_PRIORITY_INFLUENCE = 0.5;
2301
+ DEFAULT_COMBINE_MODE = "max";
2302
+ RelativePriorityNavigator = class extends ContentNavigator {
2303
+ config;
2304
+ /** Human-readable name for CardFilter interface */
2305
+ name;
2306
+ constructor(user, course, strategyData) {
2307
+ super(user, course, strategyData);
2308
+ this.config = this.parseConfig(strategyData.serializedData);
2309
+ this.name = strategyData.name || "Relative Priority";
2310
+ }
2311
+ parseConfig(serializedData) {
2312
+ try {
2313
+ const parsed = JSON.parse(serializedData);
2314
+ return {
2315
+ tagPriorities: parsed.tagPriorities || {},
2316
+ defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
2317
+ combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
2318
+ priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
2319
+ };
2320
+ } catch {
2321
+ return {
2322
+ tagPriorities: {},
2323
+ defaultPriority: DEFAULT_PRIORITY,
2324
+ combineMode: DEFAULT_COMBINE_MODE,
2325
+ priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
2326
+ };
2327
+ }
2328
+ }
2329
+ /**
2330
+ * Look up the priority for a tag.
2331
+ */
2332
+ getTagPriority(tagId) {
2333
+ return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
2334
+ }
2335
+ /**
2336
+ * Compute combined priority for a card based on its tags.
2337
+ */
2338
+ computeCardPriority(cardTags) {
2339
+ if (cardTags.length === 0) {
2340
+ return this.config.defaultPriority ?? DEFAULT_PRIORITY;
2341
+ }
2342
+ const priorities = cardTags.map((tag) => this.getTagPriority(tag));
2343
+ switch (this.config.combineMode) {
2344
+ case "max":
2345
+ return Math.max(...priorities);
2346
+ case "min":
2347
+ return Math.min(...priorities);
2348
+ case "average":
2349
+ return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
2350
+ default:
2351
+ return Math.max(...priorities);
2352
+ }
2353
+ }
2354
+ /**
2355
+ * Compute boost factor based on priority.
2356
+ *
2357
+ * The formula: 1 + (priority - 0.5) * priorityInfluence
2358
+ *
2359
+ * This creates a multiplier centered around 1.0:
2360
+ * - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
2361
+ * - Priority 0.5 with any influence → 1.00 (neutral)
2362
+ * - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
2363
+ */
2364
+ computeBoostFactor(priority) {
2365
+ const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
2366
+ return 1 + (priority - 0.5) * influence;
2367
+ }
2368
+ /**
2369
+ * Build human-readable reason for priority adjustment.
2370
+ */
2371
+ buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
2372
+ if (cardTags.length === 0) {
2373
+ return `No tags, neutral priority (${priority.toFixed(2)})`;
2374
+ }
2375
+ const tagList = cardTags.slice(0, 3).join(", ");
2376
+ const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
2377
+ if (boostFactor === 1) {
2378
+ return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
2379
+ } else if (boostFactor > 1) {
2380
+ return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
2381
+ } else {
2382
+ return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
2383
+ }
2384
+ }
2385
+ /**
2386
+ * CardFilter.transform implementation.
2387
+ *
2388
+ * Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
2389
+ * cards with low-priority tags get reduced scores.
2390
+ */
2391
+ async transform(cards, _context) {
2392
+ const adjusted = await Promise.all(
2393
+ cards.map(async (card) => {
2394
+ const cardTags = card.tags ?? [];
2395
+ const priority = this.computeCardPriority(cardTags);
2396
+ const boostFactor = this.computeBoostFactor(priority);
2397
+ const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
2398
+ const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
2399
+ const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
2400
+ return {
2401
+ ...card,
2402
+ score: finalScore,
2403
+ provenance: [
2404
+ ...card.provenance,
2405
+ {
2406
+ strategy: "relativePriority",
2407
+ strategyName: this.strategyName || this.name,
2408
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
2409
+ action,
2410
+ score: finalScore,
2411
+ reason
2412
+ }
2413
+ ]
2414
+ };
2415
+ })
2416
+ );
2417
+ return adjusted;
2418
+ }
2419
+ /**
2420
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
2421
+ *
2422
+ * Use transform() via Pipeline instead.
2423
+ */
2424
+ async getWeightedCards(_limit) {
2425
+ throw new Error(
2426
+ "RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2427
+ );
2428
+ }
2429
+ };
2430
+ }
2431
+ });
2432
+
2433
+ // src/core/navigators/filters/types.ts
2434
+ var types_exports2 = {};
2435
+ var init_types2 = __esm({
2436
+ "src/core/navigators/filters/types.ts"() {
2437
+ "use strict";
2438
+ }
2439
+ });
2440
+
2441
+ // src/core/navigators/filters/userGoalStub.ts
2442
+ var userGoalStub_exports = {};
2443
+ __export(userGoalStub_exports, {
2444
+ USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
2445
+ });
2446
+ var USER_GOAL_NAVIGATOR_STUB;
2447
+ var init_userGoalStub = __esm({
2448
+ "src/core/navigators/filters/userGoalStub.ts"() {
2449
+ "use strict";
2450
+ USER_GOAL_NAVIGATOR_STUB = true;
2451
+ }
2452
+ });
2453
+
2454
+ // import("./filters/**/*") in src/core/navigators/index.ts
2455
+ var globImport_filters;
2456
+ var init_2 = __esm({
2457
+ 'import("./filters/**/*") in src/core/navigators/index.ts'() {
2458
+ globImport_filters = __glob({
2459
+ "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
2460
+ "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
2461
+ "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
2462
+ "./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
2463
+ "./filters/inferredPreferenceStub.ts": () => Promise.resolve().then(() => (init_inferredPreferenceStub(), inferredPreferenceStub_exports)),
2464
+ "./filters/interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
2465
+ "./filters/relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
2466
+ "./filters/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
2467
+ "./filters/userGoalStub.ts": () => Promise.resolve().then(() => (init_userGoalStub(), userGoalStub_exports)),
2468
+ "./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports))
2469
+ });
2470
+ }
2471
+ });
2472
+
2473
+ // src/core/orchestration/gradient.ts
2474
+ function aggregateOutcomesForGradient(outcomes, strategyId) {
2475
+ const observations = [];
2476
+ for (const outcome of outcomes) {
2477
+ const deviation = outcome.deviations[strategyId];
2478
+ if (deviation === void 0) {
2479
+ continue;
2480
+ }
2481
+ observations.push({
2482
+ deviation,
2483
+ outcomeValue: outcome.outcomeValue,
2484
+ weight: 1
2485
+ });
2486
+ }
2487
+ logger.debug(
2488
+ `[Orchestration] Aggregated ${observations.length} observations for strategy ${strategyId}`
2489
+ );
2490
+ return observations;
2491
+ }
2492
+ function computeStrategyGradient(observations) {
2493
+ const n = observations.length;
2494
+ if (n < 3) {
2495
+ logger.debug(`[Orchestration] Insufficient observations for gradient (${n} < 3)`);
2496
+ return null;
2497
+ }
2498
+ let sumX = 0;
2499
+ let sumY = 0;
2500
+ let sumW = 0;
2501
+ for (const obs of observations) {
2502
+ const w = obs.weight ?? 1;
2503
+ sumX += obs.deviation * w;
2504
+ sumY += obs.outcomeValue * w;
2505
+ sumW += w;
2506
+ }
2507
+ const meanX = sumX / sumW;
2508
+ const meanY = sumY / sumW;
2509
+ let numerator = 0;
2510
+ let denominator = 0;
2511
+ let ssTotal = 0;
2512
+ for (const obs of observations) {
2513
+ const w = obs.weight ?? 1;
2514
+ const dx = obs.deviation - meanX;
2515
+ const dy = obs.outcomeValue - meanY;
2516
+ numerator += w * dx * dy;
2517
+ denominator += w * dx * dx;
2518
+ ssTotal += w * dy * dy;
2519
+ }
2520
+ if (denominator < 1e-10) {
2521
+ logger.debug(`[Orchestration] No variance in deviations, cannot compute gradient`);
2522
+ return {
2523
+ gradient: 0,
2524
+ intercept: meanY,
2525
+ rSquared: 0,
2526
+ sampleSize: n
2527
+ };
2528
+ }
2529
+ const gradient = numerator / denominator;
2530
+ const intercept = meanY - gradient * meanX;
2531
+ let ssResidual = 0;
2532
+ for (const obs of observations) {
2533
+ const w = obs.weight ?? 1;
2534
+ const predicted = gradient * obs.deviation + intercept;
2535
+ const residual = obs.outcomeValue - predicted;
2536
+ ssResidual += w * residual * residual;
2537
+ }
2538
+ const rSquared = ssTotal > 1e-10 ? 1 - ssResidual / ssTotal : 0;
2539
+ logger.debug(
2540
+ `[Orchestration] Computed gradient: ${gradient.toFixed(4)}, intercept: ${intercept.toFixed(4)}, R\xB2: ${rSquared.toFixed(4)}, n=${n}`
2541
+ );
2542
+ return {
2543
+ gradient,
2544
+ intercept,
2545
+ rSquared: Math.max(0, Math.min(1, rSquared)),
2546
+ // Clamp to [0,1]
2547
+ sampleSize: n
2548
+ };
2549
+ }
2550
+ var init_gradient = __esm({
2551
+ "src/core/orchestration/gradient.ts"() {
2552
+ "use strict";
2553
+ init_logger();
2554
+ }
2555
+ });
2556
+
2557
+ // src/core/orchestration/learning.ts
2558
+ function updateStrategyWeight(current, gradient) {
2559
+ if (gradient.sampleSize < MIN_OBSERVATIONS_FOR_UPDATE) {
2560
+ logger.debug(
2561
+ `[Orchestration] Insufficient samples (${gradient.sampleSize} < ${MIN_OBSERVATIONS_FOR_UPDATE}), keeping current weight`
2562
+ );
2563
+ return {
2564
+ ...current,
2565
+ sampleSize: current.sampleSize + gradient.sampleSize
2566
+ };
2567
+ }
2568
+ const isReliable = gradient.rSquared >= MIN_R_SQUARED_FOR_GRADIENT;
2569
+ const isFlat = Math.abs(gradient.gradient) < FLAT_GRADIENT_THRESHOLD;
2570
+ let newWeight = current.weight;
2571
+ let newConfidence = current.confidence;
2572
+ if (!isReliable || isFlat) {
2573
+ const confidenceGain = 0.05 * (1 - current.confidence);
2574
+ newConfidence = Math.min(1, current.confidence + confidenceGain);
2575
+ logger.debug(
2576
+ `[Orchestration] Flat/unreliable gradient (|g|=${Math.abs(gradient.gradient).toFixed(4)}, R\xB2=${gradient.rSquared.toFixed(4)}). Increasing confidence: ${current.confidence.toFixed(3)} \u2192 ${newConfidence.toFixed(3)}`
2577
+ );
2578
+ } else {
2579
+ let delta = gradient.gradient * LEARNING_RATE;
2580
+ delta = Math.max(-MAX_WEIGHT_DELTA, Math.min(MAX_WEIGHT_DELTA, delta));
2581
+ newWeight = current.weight + delta;
2582
+ newWeight = Math.max(0.1, Math.min(3, newWeight));
2583
+ const confidenceGain = 0.02 * (1 - current.confidence);
2584
+ newConfidence = Math.min(1, current.confidence + confidenceGain);
2585
+ logger.debug(
2586
+ `[Orchestration] Adjusting weight: ${current.weight.toFixed(3)} \u2192 ${newWeight.toFixed(3)} (gradient=${gradient.gradient.toFixed(4)}, delta=${delta.toFixed(4)})`
2587
+ );
2588
+ }
2589
+ return {
2590
+ weight: newWeight,
2591
+ confidence: newConfidence,
2592
+ sampleSize: current.sampleSize + gradient.sampleSize
2593
+ };
2594
+ }
2595
+ function updateLearningState(courseId, strategyId, currentWeight, gradient, existing) {
2596
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2597
+ const id = `STRATEGY_LEARNING_STATE::${courseId}::${strategyId}`;
2598
+ const historyEntry = {
2599
+ timestamp: now,
2600
+ weight: currentWeight.weight,
2601
+ confidence: currentWeight.confidence,
2602
+ gradient: gradient.gradient
2603
+ };
2604
+ let history = existing?.history ?? [];
2605
+ history = [...history, historyEntry];
2606
+ if (history.length > MAX_HISTORY_LENGTH) {
2607
+ history = history.slice(history.length - MAX_HISTORY_LENGTH);
2608
+ }
2609
+ const state = {
2610
+ _id: id,
2611
+ _rev: existing?._rev,
2612
+ docType: "STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */,
2613
+ courseId,
2614
+ strategyId,
2615
+ currentWeight,
2616
+ regression: {
2617
+ gradient: gradient.gradient,
2618
+ intercept: gradient.intercept,
2619
+ rSquared: gradient.rSquared,
2620
+ sampleSize: gradient.sampleSize,
2621
+ computedAt: now
2622
+ },
2623
+ history,
2624
+ updatedAt: now
2625
+ };
2626
+ return state;
2627
+ }
2628
+ function runPeriodUpdate(input) {
2629
+ const { courseId, strategyId, currentWeight, gradient, existingState } = input;
2630
+ logger.info(
2631
+ `[Orchestration] Running period update for strategy ${strategyId} (${gradient.sampleSize} observations)`
2632
+ );
2633
+ const newWeight = updateStrategyWeight(currentWeight, gradient);
2634
+ const updated = newWeight.weight !== currentWeight.weight;
2635
+ const learningState = updateLearningState(
2636
+ courseId,
2637
+ strategyId,
2638
+ newWeight,
2639
+ gradient,
2640
+ existingState
2641
+ );
2642
+ logger.info(
2643
+ `[Orchestration] Period update complete for ${strategyId}: weight ${currentWeight.weight.toFixed(3)} \u2192 ${newWeight.weight.toFixed(3)}, confidence ${currentWeight.confidence.toFixed(3)} \u2192 ${newWeight.confidence.toFixed(3)}`
2644
+ );
2645
+ return {
2646
+ strategyId,
2647
+ previousWeight: currentWeight,
2648
+ newWeight,
2649
+ gradient,
2650
+ learningState,
2651
+ updated
2652
+ };
2653
+ }
2654
+ function getDefaultLearnableWeight() {
2655
+ return { ...DEFAULT_LEARNABLE_WEIGHT };
2656
+ }
2657
+ var MIN_OBSERVATIONS_FOR_UPDATE, LEARNING_RATE, MAX_WEIGHT_DELTA, MIN_R_SQUARED_FOR_GRADIENT, FLAT_GRADIENT_THRESHOLD, MAX_HISTORY_LENGTH;
2658
+ var init_learning = __esm({
2659
+ "src/core/orchestration/learning.ts"() {
2660
+ "use strict";
2661
+ init_contentNavigationStrategy();
2662
+ init_types_legacy();
2663
+ init_logger();
2664
+ MIN_OBSERVATIONS_FOR_UPDATE = 10;
2665
+ LEARNING_RATE = 0.1;
2666
+ MAX_WEIGHT_DELTA = 0.3;
2667
+ MIN_R_SQUARED_FOR_GRADIENT = 0.05;
2668
+ FLAT_GRADIENT_THRESHOLD = 0.02;
2669
+ MAX_HISTORY_LENGTH = 100;
2670
+ }
2671
+ });
2672
+
2673
+ // src/core/orchestration/signal.ts
2674
+ function computeOutcomeSignal(records, config = {}) {
2675
+ if (!records || records.length === 0) {
2676
+ return null;
2677
+ }
2678
+ const target = config.targetAccuracy ?? 0.85;
2679
+ const tolerance = config.tolerance ?? 0.05;
2680
+ let correct = 0;
2681
+ for (const r of records) {
2682
+ if (r.isCorrect) correct++;
2683
+ }
2684
+ const accuracy = correct / records.length;
2685
+ return scoreAccuracyInZone(accuracy, target, tolerance);
2686
+ }
2687
+ function scoreAccuracyInZone(accuracy, target, tolerance) {
2688
+ const dist = Math.abs(accuracy - target);
2689
+ if (dist <= tolerance) {
2690
+ return 1;
2691
+ }
2692
+ const excess = dist - tolerance;
2693
+ const slope = 2.5;
2694
+ return Math.max(0, 1 - excess * slope);
2695
+ }
2696
+ var init_signal = __esm({
2697
+ "src/core/orchestration/signal.ts"() {
2698
+ "use strict";
2699
+ }
2700
+ });
2701
+
2702
+ // src/core/orchestration/recording.ts
2703
+ async function recordUserOutcome(context, periodStart, periodEnd, records, activeStrategyIds, eloStart = 0, eloEnd = 0, config) {
2704
+ const { user, course, userId } = context;
2705
+ const courseId = course.getCourseID();
2706
+ const outcomeValue = computeOutcomeSignal(records, config);
2707
+ if (outcomeValue === null) {
2708
+ logger.debug(
2709
+ `[Orchestration] No outcome signal computed for ${userId} (insufficient data). Skipping record.`
2710
+ );
2711
+ return;
2712
+ }
2713
+ const deviations = {};
2714
+ for (const strategyId of activeStrategyIds) {
2715
+ deviations[strategyId] = context.getDeviation(strategyId);
2716
+ }
2717
+ const id = `USER_OUTCOME::${courseId}::${userId}::${periodEnd}`;
2718
+ const record = {
2719
+ _id: id,
2720
+ docType: "USER_OUTCOME" /* USER_OUTCOME */,
2721
+ courseId,
2722
+ userId,
2723
+ periodStart,
2724
+ periodEnd,
2725
+ outcomeValue,
2726
+ deviations,
2727
+ metadata: {
2728
+ sessionsCount: 1,
2729
+ // Assumes recording is triggered per-session currently
2730
+ cardsSeen: records.length,
2731
+ eloStart,
2732
+ eloEnd,
2733
+ signalType: "accuracy_in_zone"
2734
+ }
2735
+ };
2736
+ try {
2737
+ await user.putUserOutcome(record);
2738
+ logger.debug(
2739
+ `[Orchestration] Recorded outcome ${outcomeValue.toFixed(3)} for ${userId} (doc: ${id})`
2740
+ );
2741
+ } catch (e) {
2742
+ logger.error(`[Orchestration] Failed to record outcome: ${e}`);
2743
+ }
2744
+ }
2745
+ var init_recording = __esm({
2746
+ "src/core/orchestration/recording.ts"() {
2747
+ "use strict";
2748
+ init_signal();
2749
+ init_types_legacy();
2750
+ init_logger();
2751
+ }
2752
+ });
2753
+
2754
+ // src/core/orchestration/index.ts
2755
+ function fnv1a(str) {
2756
+ let hash = 2166136261;
2757
+ for (let i = 0; i < str.length; i++) {
2758
+ hash ^= str.charCodeAt(i);
2759
+ hash = Math.imul(hash, 16777619);
2760
+ }
2761
+ return hash >>> 0;
2762
+ }
2763
+ function computeDeviation(userId, strategyId, salt) {
2764
+ const input = `${userId}:${strategyId}:${salt}`;
2765
+ const hash = fnv1a(input);
2766
+ const normalized = hash / 4294967296;
2767
+ return normalized * 2 - 1;
2768
+ }
2769
+ function computeSpread(confidence) {
2770
+ const clampedConfidence = Math.max(0, Math.min(1, confidence));
2771
+ return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
2772
+ }
2773
+ function computeEffectiveWeight(learnable, userId, strategyId, salt) {
2774
+ const deviation = computeDeviation(userId, strategyId, salt);
2775
+ const spread = computeSpread(learnable.confidence);
2776
+ const adjustment = deviation * spread * learnable.weight;
2777
+ const effective = learnable.weight + adjustment;
2778
+ return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
2779
+ }
2780
+ async function createOrchestrationContext(user, course) {
2781
+ let courseConfig;
2782
+ try {
2783
+ courseConfig = await course.getCourseConfig();
2784
+ } catch (e) {
2785
+ logger.error(`[Orchestration] Failed to load course config: ${e}`);
2786
+ courseConfig = {
2787
+ name: "Unknown",
2788
+ description: "",
2789
+ public: false,
2790
+ deleted: false,
2791
+ creator: "",
2792
+ admins: [],
2793
+ moderators: [],
2794
+ dataShapes: [],
2795
+ questionTypes: [],
2796
+ orchestration: { salt: "default" }
2797
+ };
2798
+ }
2799
+ const userId = user.getUsername();
2800
+ const salt = courseConfig.orchestration?.salt || "default_salt";
2801
+ return {
2802
+ user,
2803
+ course,
2804
+ userId,
2805
+ courseConfig,
2806
+ getEffectiveWeight(strategyId, learnable) {
2807
+ return computeEffectiveWeight(learnable, userId, strategyId, salt);
2808
+ },
2809
+ getDeviation(strategyId) {
2810
+ return computeDeviation(userId, strategyId, salt);
2811
+ }
2812
+ };
2813
+ }
2814
+ var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
2815
+ var init_orchestration = __esm({
2816
+ "src/core/orchestration/index.ts"() {
2817
+ "use strict";
2818
+ init_logger();
2819
+ init_gradient();
2820
+ init_learning();
2821
+ init_signal();
2822
+ init_recording();
2823
+ MIN_SPREAD = 0.1;
2824
+ MAX_SPREAD = 0.5;
2825
+ MIN_WEIGHT = 0.1;
2826
+ MAX_WEIGHT = 3;
2827
+ }
2828
+ });
2829
+
2830
+ // src/core/navigators/Pipeline.ts
2831
+ var Pipeline_exports = {};
2832
+ __export(Pipeline_exports, {
2833
+ Pipeline: () => Pipeline
2834
+ });
2835
+ import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
2836
+ function logPipelineConfig(generator, filters) {
2837
+ const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2838
+ logger.info(
2839
+ `[Pipeline] Configuration:
2840
+ Generator: ${generator.name}
2841
+ Filters:${filterList}`
2842
+ );
2843
+ }
2844
+ function logTagHydration(cards, tagsByCard) {
2845
+ const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
2846
+ const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length;
2847
+ logger.debug(
2848
+ `[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
2849
+ );
2850
+ }
2851
+ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores, filterImpacts) {
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
+ }
2865
+ logger.info(
2866
+ `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})` + filterSummary + `
2867
+ \u{1F4A1} Inspect: window.skuilder.pipeline`
2868
+ );
2869
+ }
2870
+ function logCardProvenance(cards, maxCards = 3) {
2871
+ const cardsToLog = cards.slice(0, maxCards);
2872
+ logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
2873
+ for (const card of cardsToLog) {
2874
+ logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
2875
+ for (const entry of card.provenance) {
2876
+ const scoreChange = entry.score.toFixed(3);
2877
+ const action = entry.action.padEnd(9);
2878
+ logger.debug(
2879
+ `[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
2880
+ );
2881
+ }
2882
+ }
2883
+ }
2884
+ var Pipeline;
2885
+ var init_Pipeline = __esm({
2886
+ "src/core/navigators/Pipeline.ts"() {
2887
+ "use strict";
2888
+ init_navigators();
2889
+ init_logger();
2890
+ init_orchestration();
2891
+ init_PipelineDebugger();
2892
+ Pipeline = class extends ContentNavigator {
2893
+ generator;
2894
+ filters;
2895
+ /**
2896
+ * Create a new pipeline.
2897
+ *
2898
+ * @param generator - The generator (or CompositeGenerator) that produces candidates
2899
+ * @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
2900
+ * @param user - User database interface
2901
+ * @param course - Course database interface
2902
+ */
2903
+ constructor(generator, filters, user, course) {
2904
+ super();
2905
+ this.generator = generator;
2906
+ this.filters = filters;
2907
+ this.user = user;
2908
+ this.course = course;
2909
+ course.getCourseConfig().then((cfg) => {
2910
+ logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
2911
+ }).catch((e) => {
2912
+ logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2913
+ });
2914
+ logPipelineConfig(generator, filters);
2915
+ }
2916
+ /**
2917
+ * Get weighted cards by running generator and applying filters.
2918
+ *
2919
+ * 1. Build shared context (user ELO, etc.)
2920
+ * 2. Get candidates from generator (passing context)
2921
+ * 3. Batch hydrate tags for all candidates
2922
+ * 4. Apply each filter sequentially
2923
+ * 5. Remove zero-score cards
2924
+ * 6. Sort by score descending
2925
+ * 7. Return top N
2926
+ *
2927
+ * @param limit - Maximum number of cards to return
2928
+ * @returns Cards sorted by score descending
2929
+ */
2930
+ async getWeightedCards(limit) {
2931
+ const context = await this.buildContext();
2932
+ const overFetchMultiplier = 2 + this.filters.length * 0.5;
2933
+ const fetchLimit = Math.ceil(limit * overFetchMultiplier);
2934
+ logger.debug(
2935
+ `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2936
+ );
2937
+ let cards = await this.generator.getWeightedCards(fetchLimit, context);
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
+ }
2964
+ logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2965
+ cards = await this.hydrateTags(cards);
2966
+ const allCardsBeforeFiltering = [...cards];
2967
+ const filterImpacts = [];
2968
+ for (const filter of this.filters) {
2969
+ const beforeCount = cards.length;
2970
+ const beforeScores = new Map(cards.map((c) => [c.cardId, c.score]));
2971
+ cards = await filter.transform(cards, context);
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})`);
2982
+ }
2983
+ cards = cards.filter((c) => c.score > 0);
2984
+ cards.sort((a, b) => b.score - a.score);
2985
+ const result = cards.slice(0, limit);
2986
+ const topScores = result.slice(0, 3).map((c) => c.score);
2987
+ logExecutionSummary(
2988
+ this.generator.name,
2989
+ generatedCount,
2990
+ this.filters.length,
2991
+ result.length,
2992
+ topScores,
2993
+ filterImpacts
2994
+ );
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
+ }
3012
+ return result;
3013
+ }
3014
+ /**
3015
+ * Batch hydrate tags for all cards.
3016
+ *
3017
+ * Fetches tags for all cards in a single database query and attaches them
3018
+ * to the WeightedCard objects. Filters can then use card.tags instead of
3019
+ * making individual getAppliedTags() calls.
3020
+ *
3021
+ * @param cards - Cards to hydrate
3022
+ * @returns Cards with tags populated
3023
+ */
3024
+ async hydrateTags(cards) {
3025
+ if (cards.length === 0) {
3026
+ return cards;
3027
+ }
3028
+ const cardIds = cards.map((c) => c.cardId);
3029
+ const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
3030
+ logTagHydration(cards, tagsByCard);
3031
+ return cards.map((card) => ({
3032
+ ...card,
3033
+ tags: tagsByCard.get(card.cardId) ?? []
3034
+ }));
3035
+ }
3036
+ /**
3037
+ * Build shared context for generator and filters.
3038
+ *
3039
+ * Called once per getWeightedCards() invocation.
3040
+ * Contains data that the generator and multiple filters might need.
3041
+ *
3042
+ * The context satisfies both GeneratorContext and FilterContext interfaces.
3043
+ */
3044
+ async buildContext() {
3045
+ let userElo = 1e3;
3046
+ try {
3047
+ const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
3048
+ const courseElo = toCourseElo5(courseReg.elo);
3049
+ userElo = courseElo.global.score;
3050
+ } catch (e) {
3051
+ logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
3052
+ }
3053
+ const orchestration = await createOrchestrationContext(this.user, this.course);
3054
+ return {
3055
+ user: this.user,
3056
+ course: this.course,
3057
+ userElo,
3058
+ orchestration
3059
+ };
3060
+ }
3061
+ /**
3062
+ * Get the course ID for this pipeline.
3063
+ */
3064
+ getCourseID() {
3065
+ return this.course.getCourseID();
3066
+ }
3067
+ /**
3068
+ * Get orchestration context for outcome recording.
3069
+ */
3070
+ async getOrchestrationContext() {
3071
+ return createOrchestrationContext(this.user, this.course);
3072
+ }
3073
+ /**
3074
+ * Get IDs of all strategies in this pipeline.
3075
+ * Used to record which strategies contributed to an outcome.
3076
+ */
3077
+ getStrategyIds() {
3078
+ const ids = [];
3079
+ const extractId = (obj) => {
3080
+ if (obj.strategyId) return obj.strategyId;
3081
+ return null;
3082
+ };
3083
+ const genId = extractId(this.generator);
3084
+ if (genId) ids.push(genId);
3085
+ if (this.generator.generators && Array.isArray(this.generator.generators)) {
3086
+ this.generator.generators.forEach((g) => {
3087
+ const subId = extractId(g);
3088
+ if (subId) ids.push(subId);
3089
+ });
3090
+ }
3091
+ for (const filter of this.filters) {
3092
+ const fId = extractId(filter);
3093
+ if (fId) ids.push(fId);
3094
+ }
3095
+ return [...new Set(ids)];
3096
+ }
3097
+ };
3098
+ }
3099
+ });
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
+
3151
+ // src/core/navigators/PipelineAssembler.ts
3152
+ var PipelineAssembler_exports = {};
3153
+ __export(PipelineAssembler_exports, {
3154
+ PipelineAssembler: () => PipelineAssembler
3155
+ });
3156
+ var PipelineAssembler;
3157
+ var init_PipelineAssembler = __esm({
3158
+ "src/core/navigators/PipelineAssembler.ts"() {
3159
+ "use strict";
3160
+ init_navigators();
3161
+ init_WeightedFilter();
3162
+ init_Pipeline();
3163
+ init_logger();
3164
+ init_CompositeGenerator();
3165
+ init_defaults();
3166
+ PipelineAssembler = class {
3167
+ /**
3168
+ * Assembles a navigation pipeline from strategy documents.
3169
+ *
3170
+ * 1. Separates into generators and filters by role
3171
+ * 2. Validates at least one generator exists (or creates default ELO)
3172
+ * 3. Instantiates generators - wraps multiple in CompositeGenerator
3173
+ * 4. Instantiates filters
3174
+ * 5. Returns Pipeline(generator, filters)
3175
+ *
3176
+ * @param input - Strategy documents plus user/course interfaces
3177
+ * @returns Assembled pipeline and any warnings
3178
+ */
3179
+ async assemble(input) {
3180
+ const { strategies, user, course } = input;
3181
+ const warnings = [];
3182
+ if (strategies.length === 0) {
3183
+ return {
3184
+ pipeline: null,
3185
+ generatorStrategies: [],
3186
+ filterStrategies: [],
3187
+ warnings
3188
+ };
3189
+ }
3190
+ const generatorStrategies = [];
3191
+ const filterStrategies = [];
3192
+ for (const s of strategies) {
3193
+ if (isGenerator(s.implementingClass)) {
3194
+ generatorStrategies.push(s);
3195
+ } else if (isFilter(s.implementingClass)) {
3196
+ filterStrategies.push(s);
3197
+ } else {
3198
+ warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
3199
+ }
3200
+ }
3201
+ if (generatorStrategies.length === 0) {
3202
+ if (filterStrategies.length > 0) {
3203
+ logger.debug(
3204
+ "[PipelineAssembler] No generator found, using default ELO and SRS with configured filters"
3205
+ );
3206
+ const courseId = course.getCourseID();
3207
+ generatorStrategies.push(createDefaultEloStrategy(courseId));
3208
+ generatorStrategies.push(createDefaultSrsStrategy(courseId));
3209
+ } else {
3210
+ warnings.push("No generator strategy found");
3211
+ return {
3212
+ pipeline: null,
3213
+ generatorStrategies: [],
3214
+ filterStrategies: [],
3215
+ warnings
3216
+ };
3217
+ }
3218
+ }
3219
+ let generator;
3220
+ if (generatorStrategies.length === 1) {
3221
+ const nav = await ContentNavigator.create(user, course, generatorStrategies[0]);
3222
+ generator = nav;
3223
+ logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
3224
+ } else {
3225
+ logger.debug(
3226
+ `[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
3227
+ );
3228
+ generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
3229
+ }
3230
+ const filters = [];
3231
+ const sortedFilterStrategies = [...filterStrategies].sort(
3232
+ (a, b) => a.name.localeCompare(b.name)
3233
+ );
3234
+ for (const filterStrategy of sortedFilterStrategies) {
3235
+ try {
3236
+ const nav = await ContentNavigator.create(user, course, filterStrategy);
3237
+ if ("transform" in nav && typeof nav.transform === "function") {
3238
+ let filter = nav;
3239
+ if (filterStrategy.learnable) {
3240
+ filter = new WeightedFilter(
3241
+ filter,
3242
+ filterStrategy.learnable,
3243
+ filterStrategy.staticWeight,
3244
+ filterStrategy._id
3245
+ );
3246
+ }
3247
+ filters.push(filter);
3248
+ logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
3249
+ } else {
3250
+ warnings.push(
3251
+ `Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
3252
+ );
3253
+ }
3254
+ } catch (e) {
3255
+ warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
3256
+ }
3257
+ }
3258
+ const pipeline = new Pipeline(generator, filters, user, course);
3259
+ logger.debug(
3260
+ `[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
3261
+ );
3262
+ return {
3263
+ pipeline,
3264
+ generatorStrategies,
3265
+ filterStrategies: sortedFilterStrategies,
3266
+ warnings
3267
+ };
3268
+ }
3269
+ };
3270
+ }
3271
+ });
3272
+
3273
+ // import("./**/*") in src/core/navigators/index.ts
3274
+ var globImport;
3275
+ var init_3 = __esm({
3276
+ 'import("./**/*") in src/core/navigators/index.ts'() {
3277
+ globImport = __glob({
3278
+ "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
3279
+ "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
3280
+ "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
3281
+ "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
3282
+ "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
3283
+ "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
3284
+ "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
3285
+ "./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
3286
+ "./filters/inferredPreferenceStub.ts": () => Promise.resolve().then(() => (init_inferredPreferenceStub(), inferredPreferenceStub_exports)),
3287
+ "./filters/interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
3288
+ "./filters/relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
3289
+ "./filters/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
3290
+ "./filters/userGoalStub.ts": () => Promise.resolve().then(() => (init_userGoalStub(), userGoalStub_exports)),
3291
+ "./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports)),
3292
+ "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
3293
+ "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
3294
+ "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
3295
+ "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
3296
+ "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
3297
+ "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
3298
+ });
3299
+ }
3300
+ });
3301
+
3302
+ // src/core/navigators/index.ts
3303
+ var navigators_exports = {};
3304
+ __export(navigators_exports, {
3305
+ ContentNavigator: () => ContentNavigator,
3306
+ NavigatorRole: () => NavigatorRole,
3307
+ NavigatorRoles: () => NavigatorRoles,
3308
+ Navigators: () => Navigators,
3309
+ getCardOrigin: () => getCardOrigin,
3310
+ getRegisteredNavigator: () => getRegisteredNavigator,
3311
+ getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
3312
+ hasRegisteredNavigator: () => hasRegisteredNavigator,
3313
+ initializeNavigatorRegistry: () => initializeNavigatorRegistry,
3314
+ isFilter: () => isFilter,
3315
+ isGenerator: () => isGenerator,
3316
+ mountPipelineDebugger: () => mountPipelineDebugger,
3317
+ pipelineDebugAPI: () => pipelineDebugAPI,
3318
+ registerNavigator: () => registerNavigator
3319
+ });
3320
+ function registerNavigator(implementingClass, constructor) {
3321
+ navigatorRegistry.set(implementingClass, constructor);
3322
+ logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
3323
+ }
3324
+ function getRegisteredNavigator(implementingClass) {
3325
+ return navigatorRegistry.get(implementingClass);
3326
+ }
3327
+ function hasRegisteredNavigator(implementingClass) {
3328
+ return navigatorRegistry.has(implementingClass);
3329
+ }
3330
+ function getRegisteredNavigatorNames() {
3331
+ return Array.from(navigatorRegistry.keys());
3332
+ }
3333
+ async function initializeNavigatorRegistry() {
3334
+ logger.debug("[NavigatorRegistry] Initializing built-in navigators...");
3335
+ const [eloModule, srsModule] = await Promise.all([
3336
+ Promise.resolve().then(() => (init_elo(), elo_exports)),
3337
+ Promise.resolve().then(() => (init_srs(), srs_exports))
3338
+ ]);
3339
+ registerNavigator("elo", eloModule.default);
3340
+ registerNavigator("srs", srsModule.default);
3341
+ const [
3342
+ hierarchyModule,
3343
+ interferenceModule,
3344
+ relativePriorityModule,
3345
+ userTagPreferenceModule
3346
+ ] = await Promise.all([
3347
+ Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
3348
+ Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
3349
+ Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
3350
+ Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports))
3351
+ ]);
3352
+ registerNavigator("hierarchyDefinition", hierarchyModule.default);
3353
+ registerNavigator("interferenceMitigator", interferenceModule.default);
3354
+ registerNavigator("relativePriority", relativePriorityModule.default);
3355
+ registerNavigator("userTagPreference", userTagPreferenceModule.default);
3356
+ logger.debug(
3357
+ `[NavigatorRegistry] Initialized ${navigatorRegistry.size} navigators: ${getRegisteredNavigatorNames().join(", ")}`
3358
+ );
3359
+ }
3360
+ function getCardOrigin(card) {
3361
+ if (card.provenance.length === 0) {
3362
+ throw new Error("Card has no provenance - cannot determine origin");
3363
+ }
3364
+ const firstEntry = card.provenance[0];
3365
+ const reason = firstEntry.reason.toLowerCase();
3366
+ if (reason.includes("failed")) {
3367
+ return "failed";
3368
+ }
3369
+ if (reason.includes("review")) {
3370
+ return "review";
3371
+ }
3372
+ return "new";
3373
+ }
3374
+ function isGenerator(impl) {
3375
+ return NavigatorRoles[impl] === "generator" /* GENERATOR */;
3376
+ }
3377
+ function isFilter(impl) {
3378
+ return NavigatorRoles[impl] === "filter" /* FILTER */;
3379
+ }
3380
+ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
3381
+ var init_navigators = __esm({
3382
+ "src/core/navigators/index.ts"() {
3383
+ "use strict";
3384
+ init_PipelineDebugger();
3385
+ init_logger();
3386
+ init_();
3387
+ init_2();
3388
+ init_3();
3389
+ navigatorRegistry = /* @__PURE__ */ new Map();
3390
+ Navigators = /* @__PURE__ */ ((Navigators2) => {
3391
+ Navigators2["ELO"] = "elo";
3392
+ Navigators2["SRS"] = "srs";
3393
+ Navigators2["HIERARCHY"] = "hierarchyDefinition";
3394
+ Navigators2["INTERFERENCE"] = "interferenceMitigator";
3395
+ Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
3396
+ Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
3397
+ return Navigators2;
3398
+ })(Navigators || {});
3399
+ NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
3400
+ NavigatorRole2["GENERATOR"] = "generator";
3401
+ NavigatorRole2["FILTER"] = "filter";
3402
+ return NavigatorRole2;
3403
+ })(NavigatorRole || {});
3404
+ NavigatorRoles = {
3405
+ ["elo" /* ELO */]: "generator" /* GENERATOR */,
3406
+ ["srs" /* SRS */]: "generator" /* GENERATOR */,
3407
+ ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
3408
+ ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
3409
+ ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
3410
+ ["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
3411
+ };
3412
+ ContentNavigator = class {
3413
+ /** User interface for this navigation session */
3414
+ user;
3415
+ /** Course interface for this navigation session */
3416
+ course;
3417
+ /** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
3418
+ strategyName;
3419
+ /** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
3420
+ strategyId;
3421
+ /** Evolutionary weighting configuration */
3422
+ learnable;
3423
+ /** Whether to bypass deviation (manual/static weighting) */
3424
+ staticWeight;
3425
+ /**
3426
+ * Constructor for standard navigators.
3427
+ * Call this from subclass constructors to initialize common fields.
3428
+ *
3429
+ * Note: CompositeGenerator and Pipeline call super() without args, then set
3430
+ * user/course fields directly if needed.
3431
+ */
3432
+ constructor(user, course, strategyData) {
3433
+ this.user = user;
3434
+ this.course = course;
3435
+ if (strategyData) {
3436
+ this.strategyName = strategyData.name;
3437
+ this.strategyId = strategyData._id;
3438
+ this.learnable = strategyData.learnable;
3439
+ this.staticWeight = strategyData.staticWeight;
3440
+ }
3441
+ }
3442
+ // ============================================================================
3443
+ // STRATEGY STATE HELPERS
3444
+ // ============================================================================
3445
+ //
3446
+ // These methods allow strategies to persist their own state (user preferences,
3447
+ // learned patterns, temporal tracking) in the user database.
3448
+ //
3449
+ // ============================================================================
3450
+ /**
3451
+ * Unique key identifying this strategy for state storage.
3452
+ *
3453
+ * Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
3454
+ * Override in subclasses if multiple instances of the same strategy type
3455
+ * need separate state storage.
3456
+ */
3457
+ get strategyKey() {
3458
+ return this.constructor.name;
3459
+ }
3460
+ /**
3461
+ * Get this strategy's persisted state for the current course.
3462
+ *
3463
+ * @returns The strategy's data payload, or null if no state exists
3464
+ * @throws Error if user or course is not initialized
3465
+ */
3466
+ async getStrategyState() {
3467
+ if (!this.user || !this.course) {
3468
+ throw new Error(
3469
+ `Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
3470
+ );
3471
+ }
3472
+ return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
3473
+ }
3474
+ /**
3475
+ * Persist this strategy's state for the current course.
3476
+ *
3477
+ * @param data - The strategy's data payload to store
3478
+ * @throws Error if user or course is not initialized
3479
+ */
3480
+ async putStrategyState(data) {
3481
+ if (!this.user || !this.course) {
3482
+ throw new Error(
3483
+ `Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
3484
+ );
3485
+ }
3486
+ return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
3487
+ }
3488
+ /**
3489
+ * Factory method to create navigator instances.
3490
+ *
3491
+ * First checks the navigator registry for a pre-registered constructor.
3492
+ * If not found, falls back to dynamic import (for custom navigators).
3493
+ *
3494
+ * For reliable operation in test environments, call initializeNavigatorRegistry()
3495
+ * before using this method.
3496
+ *
3497
+ * @param user - User interface
3498
+ * @param course - Course interface
3499
+ * @param strategyData - Strategy configuration document
3500
+ * @returns the runtime object used to steer a study session.
3501
+ */
3502
+ static async create(user, course, strategyData) {
3503
+ const implementingClass = strategyData.implementingClass;
3504
+ const RegisteredImpl = getRegisteredNavigator(implementingClass);
3505
+ if (RegisteredImpl) {
3506
+ logger.debug(`[ContentNavigator.create] Using registered navigator: ${implementingClass}`);
3507
+ return new RegisteredImpl(user, course, strategyData);
3508
+ }
3509
+ logger.debug(
3510
+ `[ContentNavigator.create] Navigator not in registry, attempting dynamic import: ${implementingClass}`
3511
+ );
3512
+ let NavigatorImpl;
3513
+ const variations = [".ts", ".js", ""];
3514
+ for (const ext of variations) {
3515
+ try {
3516
+ const module = await globImport_generators(`./generators/${implementingClass}${ext}`);
3517
+ NavigatorImpl = module.default;
3518
+ if (NavigatorImpl) break;
3519
+ } catch (e) {
3520
+ logger.debug(`Failed to load generator ${implementingClass}${ext}:`, e);
3521
+ }
3522
+ try {
3523
+ const module = await globImport_filters(`./filters/${implementingClass}${ext}`);
3524
+ NavigatorImpl = module.default;
3525
+ if (NavigatorImpl) break;
3526
+ } catch (e) {
3527
+ logger.debug(`Failed to load filter ${implementingClass}${ext}:`, e);
3528
+ }
3529
+ try {
3530
+ const module = await globImport(`./${implementingClass}${ext}`);
3531
+ NavigatorImpl = module.default;
3532
+ if (NavigatorImpl) break;
3533
+ } catch (e) {
3534
+ logger.debug(`Failed to load legacy ${implementingClass}${ext}:`, e);
3535
+ }
3536
+ if (NavigatorImpl) break;
3537
+ }
3538
+ if (!NavigatorImpl) {
3539
+ throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
3540
+ }
3541
+ return new NavigatorImpl(user, course, strategyData);
3542
+ }
3543
+ /**
3544
+ * Get cards with suitability scores and provenance trails.
1572
3545
  *
1573
- * Two factors:
1574
- * 1. Relative overdueness = hoursOverdue / intervalHours
1575
- * - 2 days overdue on 3-day interval = 0.67 (urgent)
1576
- * - 2 days overdue on 180-day interval = 0.01 (not urgent)
3546
+ * **This is the PRIMARY API for navigation strategies.**
1577
3547
  *
1578
- * 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
1579
- * - 24h interval ~1.0 (very recent learning)
1580
- * - 30 days (720h) ~0.56
1581
- * - 180 days → ~0.30
3548
+ * Returns cards ranked by suitability score (0-1). Higher scores indicate
3549
+ * better candidates for presentation. Each card includes a provenance trail
3550
+ * documenting how strategies contributed to the final score.
3551
+ *
3552
+ * ## Implementation Required
3553
+ * All navigation strategies MUST override this method. The base class does
3554
+ * not provide a default implementation.
3555
+ *
3556
+ * ## For Generators
3557
+ * Override this method to generate candidates and compute scores based on
3558
+ * your strategy's logic (e.g., ELO proximity, review urgency). Create the
3559
+ * initial provenance entry with action='generated'.
1582
3560
  *
1583
- * Combined: base 0.5 + weighted average of factors * 0.45
1584
- * Result range: approximately 0.5 to 0.95
3561
+ * ## For Filters
3562
+ * Filters should implement the CardFilter interface instead and be composed
3563
+ * via Pipeline. Filters do not directly implement getWeightedCards().
3564
+ *
3565
+ * @param limit - Maximum cards to return
3566
+ * @returns Cards sorted by score descending, with provenance trails
1585
3567
  */
1586
- computeUrgencyScore(review, now) {
1587
- const scheduledAt = moment3.utc(review.scheduledAt);
1588
- const due = moment3.utc(review.reviewTime);
1589
- const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
1590
- const hoursOverdue = now.diff(due, "hours");
1591
- const relativeOverdue = hoursOverdue / intervalHours;
1592
- const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
1593
- const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
1594
- const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
1595
- const score = Math.min(0.95, 0.5 + urgency * 0.45);
1596
- const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
1597
- return { score, reason };
3568
+ async getWeightedCards(_limit) {
3569
+ throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
1598
3570
  }
1599
3571
  };
1600
3572
  }
1601
3573
  });
1602
3574
 
1603
- // src/core/navigators/filters/eloDistance.ts
1604
- function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
1605
- const normalizedDistance = distance / halfLife;
1606
- const decay = Math.exp(-(normalizedDistance * normalizedDistance));
1607
- return minMultiplier + (maxMultiplier - minMultiplier) * decay;
1608
- }
1609
- function createEloDistanceFilter(config) {
1610
- const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
1611
- const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
1612
- const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
1613
- return {
1614
- name: "ELO Distance Filter",
1615
- async transform(cards, context) {
1616
- const { course, userElo } = context;
1617
- const cardIds = cards.map((c) => c.cardId);
1618
- const cardElos = await course.getCardEloData(cardIds);
1619
- return cards.map((card, i) => {
1620
- const cardElo = cardElos[i]?.global?.score ?? 1e3;
1621
- const distance = Math.abs(cardElo - userElo);
1622
- const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
1623
- const newScore = card.score * multiplier;
1624
- const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
1625
- return {
1626
- ...card,
1627
- score: newScore,
1628
- provenance: [
1629
- ...card.provenance,
1630
- {
1631
- strategy: "eloDistance",
1632
- strategyName: "ELO Distance Filter",
1633
- strategyId: "ELO_DISTANCE_FILTER",
1634
- action,
1635
- score: newScore,
1636
- reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
1637
- }
1638
- ]
1639
- };
1640
- });
1641
- }
1642
- };
1643
- }
1644
- var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
1645
- var init_eloDistance = __esm({
1646
- "src/core/navigators/filters/eloDistance.ts"() {
1647
- "use strict";
1648
- DEFAULT_HALF_LIFE = 200;
1649
- DEFAULT_MIN_MULTIPLIER = 0.3;
1650
- DEFAULT_MAX_MULTIPLIER = 1;
1651
- }
1652
- });
1653
-
1654
- // src/core/navigators/defaults.ts
1655
- function createDefaultEloStrategy(courseId) {
1656
- return {
1657
- _id: "NAVIGATION_STRATEGY-ELO-default",
1658
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1659
- name: "ELO (default)",
1660
- description: "Default ELO-based navigation strategy for new cards",
1661
- implementingClass: "elo" /* ELO */,
1662
- course: courseId,
1663
- serializedData: ""
1664
- };
1665
- }
1666
- function createDefaultSrsStrategy(courseId) {
1667
- return {
1668
- _id: "NAVIGATION_STRATEGY-SRS-default",
1669
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1670
- name: "SRS (default)",
1671
- description: "Default SRS-based navigation strategy for reviews",
1672
- implementingClass: "srs" /* SRS */,
1673
- course: courseId,
1674
- serializedData: ""
1675
- };
1676
- }
1677
- function createDefaultPipeline(user, course) {
1678
- const courseId = course.getCourseID();
1679
- const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
1680
- const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
1681
- const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
1682
- const eloDistanceFilter = createEloDistanceFilter();
1683
- return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
1684
- }
1685
- var init_defaults = __esm({
1686
- "src/core/navigators/defaults.ts"() {
1687
- "use strict";
1688
- init_navigators();
1689
- init_Pipeline();
1690
- init_CompositeGenerator();
1691
- init_elo();
1692
- init_srs();
1693
- init_eloDistance();
1694
- init_types_legacy();
1695
- }
1696
- });
1697
-
1698
3575
  // src/impl/couch/courseDB.ts
1699
3576
  import {
1700
3577
  EloToNumber,
1701
3578
  Status,
1702
3579
  blankCourseElo as blankCourseElo2,
1703
- toCourseElo as toCourseElo4
3580
+ toCourseElo as toCourseElo6
1704
3581
  } from "@vue-skuilder/common";
1705
3582
  function randIntWeightedTowardZero(n) {
1706
3583
  return Math.floor(Math.random() * Math.random() * Math.random() * n);
@@ -1902,7 +3779,7 @@ var init_courseDB = __esm({
1902
3779
  docs.rows.forEach((r) => {
1903
3780
  if (isSuccessRow(r)) {
1904
3781
  if (r.doc && r.doc.elo) {
1905
- ret.push(toCourseElo4(r.doc.elo));
3782
+ ret.push(toCourseElo6(r.doc.elo));
1906
3783
  } else {
1907
3784
  logger.warn("no elo data for card: " + r.id);
1908
3785
  ret.push(blankCourseElo2());
@@ -4115,6 +5992,19 @@ Currently logged-in as ${this._username}.`
4115
5992
  };
4116
5993
  await this.localDB.put(doc);
4117
5994
  }
5995
+ async putUserOutcome(record) {
5996
+ try {
5997
+ await this.localDB.put(record);
5998
+ } catch (err) {
5999
+ if (err.status === 409) {
6000
+ const existing = await this.localDB.get(record._id);
6001
+ record._rev = existing._rev;
6002
+ await this.localDB.put(record);
6003
+ } else {
6004
+ throw err;
6005
+ }
6006
+ }
6007
+ }
4118
6008
  async deleteStrategyState(courseId, strategyKey) {
4119
6009
  const docId = buildStrategyStateId(courseId, strategyKey);
4120
6010
  try {
@@ -5242,6 +7132,7 @@ async function initializeDataLayer(config) {
5242
7132
  logger.warn("Data layer already initialized. Returning existing instance.");
5243
7133
  return dataLayerInstance;
5244
7134
  }
7135
+ await initializeNavigatorRegistry();
5245
7136
  if (config.options.localStoragePrefix) {
5246
7137
  ENV.LOCAL_STORAGE_PREFIX = config.options.localStoragePrefix;
5247
7138
  }
@@ -5296,6 +7187,7 @@ var init_factory = __esm({
5296
7187
  "use strict";
5297
7188
  init_common();
5298
7189
  init_logger();
7190
+ init_navigators();
5299
7191
  NOT_SET = "NOT_SET";
5300
7192
  ENV = {
5301
7193
  COUCHDB_SERVER_PROTOCOL: NOT_SET,
@@ -5552,6 +7444,13 @@ var init_strategyState = __esm({
5552
7444
  }
5553
7445
  });
5554
7446
 
7447
+ // src/core/types/userOutcome.ts
7448
+ var init_userOutcome = __esm({
7449
+ "src/core/types/userOutcome.ts"() {
7450
+ "use strict";
7451
+ }
7452
+ });
7453
+
5555
7454
  // src/core/bulkImport/cardProcessor.ts
5556
7455
  import { Status as Status5 } from "@vue-skuilder/common";
5557
7456
  async function importParsedCards(parsedCards, courseDB, config) {
@@ -5673,7 +7572,7 @@ var init_cardProcessor = __esm({
5673
7572
  });
5674
7573
 
5675
7574
  // src/core/bulkImport/types.ts
5676
- var init_types = __esm({
7575
+ var init_types3 = __esm({
5677
7576
  "src/core/bulkImport/types.ts"() {
5678
7577
  "use strict";
5679
7578
  }
@@ -5684,7 +7583,7 @@ var init_bulkImport = __esm({
5684
7583
  "src/core/bulkImport/index.ts"() {
5685
7584
  "use strict";
5686
7585
  init_cardProcessor();
5687
- init_types();
7586
+ init_types3();
5688
7587
  }
5689
7588
  });
5690
7589
 
@@ -5696,10 +7595,12 @@ var init_core = __esm({
5696
7595
  init_types_legacy();
5697
7596
  init_user();
5698
7597
  init_strategyState();
7598
+ init_userOutcome();
5699
7599
  init_Loggable();
5700
7600
  init_util();
5701
7601
  init_navigators();
5702
7602
  init_bulkImport();
7603
+ init_orchestration();
5703
7604
  }
5704
7605
  });
5705
7606
 
@@ -5846,6 +7747,66 @@ function registerQuestionType(question, courseConfig) {
5846
7747
  logger.info(`Registered QuestionType: ${namespacedQuestionName}`);
5847
7748
  return true;
5848
7749
  }
7750
+ function removeDataShape(dataShapeName, courseConfig) {
7751
+ const index = courseConfig.dataShapes.findIndex((ds) => ds.name === dataShapeName);
7752
+ if (index === -1) {
7753
+ logger.info(`DataShape '${dataShapeName}' not found in course config`);
7754
+ return false;
7755
+ }
7756
+ courseConfig.dataShapes.splice(index, 1);
7757
+ courseConfig.questionTypes.forEach((qt) => {
7758
+ const dsIndex = qt.dataShapeList.indexOf(dataShapeName);
7759
+ if (dsIndex !== -1) {
7760
+ qt.dataShapeList.splice(dsIndex, 1);
7761
+ }
7762
+ });
7763
+ logger.info(`Removed DataShape: ${dataShapeName}`);
7764
+ return true;
7765
+ }
7766
+ function removeQuestionType(questionTypeName, courseConfig) {
7767
+ const index = courseConfig.questionTypes.findIndex((qt) => qt.name === questionTypeName);
7768
+ if (index === -1) {
7769
+ logger.info(`QuestionType '${questionTypeName}' not found in course config`);
7770
+ return false;
7771
+ }
7772
+ courseConfig.questionTypes.splice(index, 1);
7773
+ courseConfig.dataShapes.forEach((ds) => {
7774
+ const qtIndex = ds.questionTypes.indexOf(questionTypeName);
7775
+ if (qtIndex !== -1) {
7776
+ ds.questionTypes.splice(qtIndex, 1);
7777
+ }
7778
+ });
7779
+ logger.info(`Removed QuestionType: ${questionTypeName}`);
7780
+ return true;
7781
+ }
7782
+ async function removeCustomQuestionTypes(dataShapeNames, questionTypeNames, courseConfig, courseDB) {
7783
+ try {
7784
+ logger.info("Beginning custom question removal");
7785
+ logger.info(`Removing ${dataShapeNames.length} data shapes and ${questionTypeNames.length} question types`);
7786
+ let removedCount = 0;
7787
+ for (const qtName of questionTypeNames) {
7788
+ if (removeQuestionType(qtName, courseConfig)) {
7789
+ removedCount++;
7790
+ }
7791
+ }
7792
+ for (const dsName of dataShapeNames) {
7793
+ if (removeDataShape(dsName, courseConfig)) {
7794
+ removedCount++;
7795
+ }
7796
+ }
7797
+ logger.info("Updating course configuration...");
7798
+ const updateResult = await courseDB.updateCourseConfig(courseConfig);
7799
+ if (!updateResult.ok) {
7800
+ throw new Error(`Failed to update course config: ${JSON.stringify(updateResult)}`);
7801
+ }
7802
+ logger.info(`Custom question removal complete: ${removedCount} items removed`);
7803
+ return { success: true, removedCount };
7804
+ } catch (error) {
7805
+ const errorMessage = error instanceof Error ? error.message : String(error);
7806
+ logger.error(`Custom question removal failed: ${errorMessage}`);
7807
+ return { success: false, removedCount: 0, errorMessage };
7808
+ }
7809
+ }
5849
7810
  async function registerSeedData(question, courseDB, username) {
5850
7811
  if (question.questionClass.seedData && Array.isArray(question.questionClass.seedData)) {
5851
7812
  logger.info(`Registering seed data for question: ${question.name}`);
@@ -6037,6 +7998,14 @@ var SrsService = class {
6037
7998
  constructor(user) {
6038
7999
  this.user = user;
6039
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
+ }
6040
8009
  /**
6041
8010
  * Calculates the next review time for a card based on its history and
6042
8011
  * schedules it in the user's database.
@@ -6050,45 +8019,102 @@ var SrsService = class {
6050
8019
  logger.info(`[SrsService] Removing previously scheduled review for: ${item.cardID}`);
6051
8020
  void this.user.removeScheduledCardReview(item.reviewID);
6052
8021
  }
6053
- void this.user.scheduleCardReview({
6054
- user: this.user.getUsername(),
6055
- course_id: history.courseID,
6056
- card_id: history.cardID,
6057
- time: nextReviewTime,
6058
- scheduledFor: item.contentSourceType,
6059
- schedulingAgentId: item.contentSourceID
6060
- });
6061
- }
6062
- };
6063
-
6064
- // src/study/services/EloService.ts
6065
- init_logger();
6066
- import { adjustCourseScores, toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
6067
- var EloService = class {
6068
- dataLayer;
6069
- user;
6070
- constructor(dataLayer, user) {
6071
- this.dataLayer = dataLayer;
6072
- this.user = user;
8022
+ void this.user.scheduleCardReview({
8023
+ user: this.user.getUsername(),
8024
+ course_id: history.courseID,
8025
+ card_id: history.cardID,
8026
+ time: nextReviewTime,
8027
+ scheduledFor: item.contentSourceType,
8028
+ schedulingAgentId: item.contentSourceID
8029
+ });
8030
+ }
8031
+ };
8032
+
8033
+ // src/study/services/EloService.ts
8034
+ init_logger();
8035
+ import {
8036
+ adjustCourseScores,
8037
+ adjustCourseScoresPerTag,
8038
+ toCourseElo as toCourseElo7
8039
+ } from "@vue-skuilder/common";
8040
+ var EloService = class {
8041
+ dataLayer;
8042
+ user;
8043
+ constructor(dataLayer, user) {
8044
+ this.dataLayer = dataLayer;
8045
+ this.user = user;
8046
+ }
8047
+ /**
8048
+ * Updates both user and card ELO ratings based on user performance.
8049
+ * @param userScore Score between 0-1 representing user performance
8050
+ * @param course_id Course identifier
8051
+ * @param card_id Card identifier
8052
+ * @param userCourseRegDoc User's course registration document (will be mutated)
8053
+ * @param currentCard Current card session record
8054
+ * @param k Optional K-factor for ELO calculation
8055
+ */
8056
+ async updateUserAndCardElo(userScore, course_id, card_id, userCourseRegDoc, currentCard, k) {
8057
+ if (k) {
8058
+ logger.warn(`k value interpretation not currently implemented`);
8059
+ }
8060
+ const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
8061
+ const userElo = toCourseElo7(
8062
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
8063
+ );
8064
+ const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
8065
+ if (cardElo && userElo) {
8066
+ const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
8067
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
8068
+ const results = await Promise.allSettled([
8069
+ this.user.updateUserElo(course_id, eloUpdate.userElo),
8070
+ courseDB.updateCardElo(card_id, eloUpdate.cardElo)
8071
+ ]);
8072
+ const userEloStatus = results[0].status === "fulfilled";
8073
+ const cardEloStatus = results[1].status === "fulfilled";
8074
+ if (userEloStatus && cardEloStatus) {
8075
+ const user = results[0].value;
8076
+ const card = results[1].value;
8077
+ if (user.ok && card && card.ok) {
8078
+ logger.info(
8079
+ `[EloService] Updated ELOS:
8080
+ User: ${JSON.stringify(eloUpdate.userElo)})
8081
+ Card: ${JSON.stringify(eloUpdate.cardElo)})
8082
+ `
8083
+ );
8084
+ }
8085
+ } else {
8086
+ logger.warn(
8087
+ `[EloService] Partial ELO update:
8088
+ User ELO update: ${userEloStatus ? "SUCCESS" : "FAILED"}
8089
+ Card ELO update: ${cardEloStatus ? "SUCCESS" : "FAILED"}`
8090
+ );
8091
+ if (!userEloStatus && results[0].status === "rejected") {
8092
+ logger.error("[EloService] User ELO update error:", results[0].reason);
8093
+ }
8094
+ if (!cardEloStatus && results[1].status === "rejected") {
8095
+ logger.error("[EloService] Card ELO update error:", results[1].reason);
8096
+ }
8097
+ }
8098
+ }
6073
8099
  }
6074
8100
  /**
6075
- * Updates both user and card ELO ratings based on user performance.
6076
- * @param userScore Score between 0-1 representing user performance
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
6077
8105
  * @param course_id Course identifier
6078
- * @param card_id Card identifier
8106
+ * @param card_id Card identifier
6079
8107
  * @param userCourseRegDoc User's course registration document (will be mutated)
6080
8108
  * @param currentCard Current card session record
6081
- * @param k Optional K-factor for ELO calculation
6082
8109
  */
6083
- async updateUserAndCardElo(userScore, course_id, card_id, userCourseRegDoc, currentCard, k) {
6084
- if (k) {
6085
- logger.warn(`k value interpretation not currently implemented`);
6086
- }
8110
+ async updateUserAndCardEloPerTag(taggedPerformance, course_id, card_id, userCourseRegDoc, currentCard) {
6087
8111
  const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
6088
- const userElo = toCourseElo5(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
8112
+ const userElo = toCourseElo7(
8113
+ userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo
8114
+ );
6089
8115
  const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
6090
8116
  if (cardElo && userElo) {
6091
- const eloUpdate = adjustCourseScores(userElo, cardElo, userScore);
8117
+ const eloUpdate = adjustCourseScoresPerTag(userElo, cardElo, taggedPerformance);
6092
8118
  userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
6093
8119
  const results = await Promise.allSettled([
6094
8120
  this.user.updateUserElo(course_id, eloUpdate.userElo),
@@ -6100,8 +8126,9 @@ var EloService = class {
6100
8126
  const user = results[0].value;
6101
8127
  const card = results[1].value;
6102
8128
  if (user.ok && card && card.ok) {
8129
+ const tagCount = Object.keys(taggedPerformance).length - 1;
6103
8130
  logger.info(
6104
- `[EloService] Updated ELOS:
8131
+ `[EloService] Updated ELOS (per-tag, ${tagCount} tags):
6105
8132
  User: ${JSON.stringify(eloUpdate.userElo)})
6106
8133
  Card: ${JSON.stringify(eloUpdate.cardElo)})
6107
8134
  `
@@ -6109,7 +8136,7 @@ var EloService = class {
6109
8136
  }
6110
8137
  } else {
6111
8138
  logger.warn(
6112
- `[EloService] Partial ELO update:
8139
+ `[EloService] Partial ELO update (per-tag):
6113
8140
  User ELO update: ${userEloStatus ? "SUCCESS" : "FAILED"}
6114
8141
  Card ELO update: ${cardEloStatus ? "SUCCESS" : "FAILED"}`
6115
8142
  );
@@ -6127,6 +8154,7 @@ var EloService = class {
6127
8154
  // src/study/services/ResponseProcessor.ts
6128
8155
  init_core();
6129
8156
  init_logger();
8157
+ import { isTaggedPerformance } from "@vue-skuilder/common";
6130
8158
  var ResponseProcessor = class {
6131
8159
  srsService;
6132
8160
  eloService;
@@ -6134,6 +8162,33 @@ var ResponseProcessor = class {
6134
8162
  this.srsService = srsService;
6135
8163
  this.eloService = eloService;
6136
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
+ }
6137
8192
  /**
6138
8193
  * Processes a user's response to a card, handling SRS scheduling and ELO updates.
6139
8194
  * @param cardRecord User's response record
@@ -6194,46 +8249,60 @@ var ResponseProcessor = class {
6194
8249
  processCorrectResponse(cardRecord, history, studySessionItem, courseRegistrationDoc, currentCard, courseId, cardId) {
6195
8250
  if (cardRecord.priorAttemps === 0) {
6196
8251
  void this.srsService.scheduleReview(history, studySessionItem);
6197
- if (history.records.length === 1) {
6198
- const userScore = 0.5 + cardRecord.performance / 2;
6199
- void this.eloService.updateUserAndCardElo(
6200
- userScore,
8252
+ const { globalScore, taggedPerformance } = this.parsePerformance(cardRecord.performance);
8253
+ if (taggedPerformance) {
8254
+ void this.eloService.updateUserAndCardEloPerTag(
8255
+ taggedPerformance,
6201
8256
  courseId,
6202
8257
  cardId,
6203
8258
  courseRegistrationDoc,
6204
8259
  currentCard
6205
8260
  );
8261
+ logger.info(
8262
+ `[ResponseProcessor] Processed correct response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
8263
+ );
6206
8264
  } else {
6207
- const k = Math.ceil(32 / history.records.length);
6208
- const userScore = 0.5 + cardRecord.performance / 2;
6209
- void this.eloService.updateUserAndCardElo(
6210
- userScore,
6211
- courseId,
6212
- cardId,
6213
- courseRegistrationDoc,
6214
- currentCard,
6215
- 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"
6216
8287
  );
6217
8288
  }
6218
- logger.info(
6219
- "[ResponseProcessor] Processed correct response with SRS scheduling and ELO update"
6220
- );
6221
8289
  return {
6222
8290
  nextCardAction: "dismiss-success",
6223
8291
  shouldLoadNextCard: true,
6224
8292
  isCorrect: true,
6225
- performanceScore: cardRecord.performance,
8293
+ performanceScore: globalScore,
6226
8294
  shouldClearFeedbackShadow: true
6227
8295
  };
6228
8296
  } else {
6229
8297
  logger.info(
6230
8298
  "[ResponseProcessor] Processed correct response (retry attempt - no scheduling/ELO)"
6231
8299
  );
8300
+ const { globalScore } = this.parsePerformance(cardRecord.performance);
6232
8301
  return {
6233
8302
  nextCardAction: "marked-failed",
6234
8303
  shouldLoadNextCard: true,
6235
8304
  isCorrect: true,
6236
- performanceScore: cardRecord.performance,
8305
+ performanceScore: globalScore,
6237
8306
  shouldClearFeedbackShadow: true
6238
8307
  };
6239
8308
  }
@@ -6242,28 +8311,52 @@ var ResponseProcessor = class {
6242
8311
  * Handles processing for incorrect responses: ELO updates only.
6243
8312
  */
6244
8313
  processIncorrectResponse(cardRecord, history, courseRegistrationDoc, currentCard, courseId, cardId, maxAttemptsPerView, maxSessionViews, sessionViews) {
8314
+ const { taggedPerformance } = this.parsePerformance(cardRecord.performance);
6245
8315
  if (history.records.length !== 1 && cardRecord.priorAttemps === 0) {
6246
- void this.eloService.updateUserAndCardElo(
6247
- 0,
6248
- // Failed response = 0 score
6249
- courseId,
6250
- cardId,
6251
- courseRegistrationDoc,
6252
- currentCard
6253
- );
6254
- logger.info("[ResponseProcessor] Processed incorrect response with ELO update");
6255
- } else {
6256
- logger.info("[ResponseProcessor] Processed incorrect response (no ELO update needed)");
6257
- }
6258
- if (currentCard.records.length >= maxAttemptsPerView) {
6259
- 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 {
6260
8328
  void this.eloService.updateUserAndCardElo(
6261
8329
  0,
8330
+ // Failed response = 0 score
6262
8331
  courseId,
6263
8332
  cardId,
6264
8333
  courseRegistrationDoc,
6265
8334
  currentCard
6266
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
+ }
6267
8360
  return {
6268
8361
  nextCardAction: "dismiss-failed",
6269
8362
  shouldLoadNextCard: true,
@@ -6294,7 +8387,7 @@ init_logger();
6294
8387
  import {
6295
8388
  displayableDataToViewData,
6296
8389
  isCourseElo,
6297
- toCourseElo as toCourseElo6
8390
+ toCourseElo as toCourseElo8
6298
8391
  } from "@vue-skuilder/common";
6299
8392
  function parseAudioURIs(data) {
6300
8393
  if (typeof data !== "string") return [];
@@ -6430,7 +8523,7 @@ var CardHydrationService = class {
6430
8523
  const courseDB = this.getCourseDB(item.courseID);
6431
8524
  const cardData = await courseDB.getCourseDoc(item.cardID);
6432
8525
  if (!isCourseElo(cardData.elo)) {
6433
- cardData.elo = toCourseElo6(cardData.elo);
8526
+ cardData.elo = toCourseElo8(cardData.elo);
6434
8527
  }
6435
8528
  const view = this.getViewComponent(cardData.id_view);
6436
8529
  const dataDocs = await Promise.all(
@@ -6515,6 +8608,7 @@ var ItemQueue = class {
6515
8608
 
6516
8609
  // src/study/SessionController.ts
6517
8610
  init_couch();
8611
+ init_recording();
6518
8612
 
6519
8613
  // src/util/index.ts
6520
8614
  init_Loggable();
@@ -7948,15 +10042,644 @@ var QuotaRoundRobinMixer = class {
7948
10042
  return [];
7949
10043
  }
7950
10044
  const quotaPerSource = Math.ceil(limit / batches.length);
7951
- const mixed = [];
7952
- for (const batch of batches) {
7953
- const sortedBatch = [...batch.weighted].sort((a, b) => b.score - a.score);
7954
- const topFromSource = sortedBatch.slice(0, quotaPerSource);
7955
- 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
+ }
10066
+ }
10067
+ return result;
10068
+ }
10069
+ };
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;
7956
10570
  }
7957
- return mixed.sort((a, b) => b.score - a.score).slice(0, limit);
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
+ `);
7958
10674
  }
7959
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();
7960
10683
 
7961
10684
  // src/study/SessionController.ts
7962
10685
  init_logger();
@@ -7967,6 +10690,8 @@ var SessionController = class extends Loggable {
7967
10690
  eloService;
7968
10691
  hydrationService;
7969
10692
  mixer;
10693
+ dataLayer;
10694
+ courseNameCache = /* @__PURE__ */ new Map();
7970
10695
  sources;
7971
10696
  // dataLayer and getViewComponent now injected into CardHydrationService
7972
10697
  _sessionRecord = [];
@@ -7986,7 +10711,11 @@ var SessionController = class extends Loggable {
7986
10711
  return this._secondsRemaining;
7987
10712
  }
7988
10713
  get report() {
7989
- 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}`;
7990
10719
  }
7991
10720
  get detailedReport() {
7992
10721
  return this.newQ.toString + "\n" + this.reviewQ.toString + "\n" + this.failedQ.toString;
@@ -8002,6 +10731,7 @@ var SessionController = class extends Loggable {
8002
10731
  */
8003
10732
  constructor(sources, time, dataLayer, getViewComponent, mixer) {
8004
10733
  super();
10734
+ this.dataLayer = dataLayer;
8005
10735
  this.mixer = mixer || new QuotaRoundRobinMixer();
8006
10736
  this.srsService = new SrsService(dataLayer.getUserDB());
8007
10737
  this.eloService = new EloService(dataLayer, dataLayer.getUserDB());
@@ -8069,6 +10799,7 @@ var SessionController = class extends Loggable {
8069
10799
  }
8070
10800
  await this.getWeightedContent();
8071
10801
  await this.hydrationService.ensureHydratedCards();
10802
+ startSessionTracking(this.reviewQ.length, this.newQ.length, this.failedQ.length);
8072
10803
  this._intervalHandle = setInterval(() => {
8073
10804
  this.tick();
8074
10805
  }, 1e3);
@@ -8164,6 +10895,30 @@ var SessionController = class extends Loggable {
8164
10895
  );
8165
10896
  }
8166
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
+ );
8167
10922
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review");
8168
10923
  const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new");
8169
10924
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
@@ -8272,21 +11027,46 @@ var SessionController = class extends Loggable {
8272
11027
  this.dismissCurrentCard(action);
8273
11028
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0) {
8274
11029
  this._currentCard = null;
11030
+ endSessionTracking();
8275
11031
  return null;
8276
11032
  }
8277
- const nextItem = this._selectNextItemToHydrate();
8278
- if (!nextItem) {
8279
- this._currentCard = null;
8280
- return null;
8281
- }
8282
- let card = this.hydrationService.getHydratedCard(nextItem.cardID);
8283
- if (!card) {
8284
- 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
+ }
8285
11065
  }
8286
- this.removeItemFromQueue(nextItem);
8287
- await this.hydrationService.ensureHydratedCards();
8288
- this._currentCard = card;
8289
- return card;
11066
+ this.log(`Exhausted ${MAX_SKIP} skip attempts finding a hydratable card`);
11067
+ this._currentCard = null;
11068
+ endSessionTracking();
11069
+ return null;
8290
11070
  }
8291
11071
  /**
8292
11072
  * Public API for processing user responses to cards.
@@ -8362,6 +11142,49 @@ var SessionController = class extends Loggable {
8362
11142
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
8363
11143
  }
8364
11144
  }
11145
+ /**
11146
+ * End the session and record learning outcomes.
11147
+ *
11148
+ * This method aggregates all responses from the session and records a
11149
+ * UserOutcomeRecord if evolutionary orchestration is enabled.
11150
+ */
11151
+ async endSession() {
11152
+ if (!this._sessionRecord || this._sessionRecord.length === 0) {
11153
+ return;
11154
+ }
11155
+ const questionRecords = this._sessionRecord.flatMap((r) => r.records).filter((r) => r.userAnswer !== void 0);
11156
+ if (questionRecords.length === 0) {
11157
+ return;
11158
+ }
11159
+ let orchestrationContext = null;
11160
+ const strategies = [];
11161
+ for (const source of this.sources) {
11162
+ if (source.getOrchestrationContext) {
11163
+ try {
11164
+ orchestrationContext = await source.getOrchestrationContext();
11165
+ if (source.getStrategyIds) {
11166
+ strategies.push(...source.getStrategyIds());
11167
+ }
11168
+ } catch (e) {
11169
+ logger.warn(`[SessionController] Failed to get orchestration context: ${e}`);
11170
+ }
11171
+ if (orchestrationContext) break;
11172
+ }
11173
+ }
11174
+ if (!orchestrationContext) {
11175
+ logger.debug("[SessionController] No orchestration context available, skipping outcome recording");
11176
+ return;
11177
+ }
11178
+ const periodEnd = (/* @__PURE__ */ new Date()).toISOString();
11179
+ const periodStart = new Date(this.startTime).toISOString();
11180
+ await recordUserOutcome(
11181
+ orchestrationContext,
11182
+ periodStart,
11183
+ periodEnd,
11184
+ questionRecords,
11185
+ strategies
11186
+ );
11187
+ }
8365
11188
  };
8366
11189
 
8367
11190
  // src/study/index.ts
@@ -8388,19 +11211,33 @@ export {
8388
11211
  StaticToCouchDBMigrator,
8389
11212
  TagFilteredContentSource,
8390
11213
  _resetDataLayer,
11214
+ aggregateOutcomesForGradient,
8391
11215
  areQuestionRecords,
8392
11216
  buildStrategyStateId,
11217
+ captureMixerRun,
11218
+ computeDeviation,
11219
+ computeEffectiveWeight,
11220
+ computeOutcomeSignal,
11221
+ computeSpread,
11222
+ computeStrategyGradient,
11223
+ createOrchestrationContext,
8393
11224
  docIsDeleted,
11225
+ endSessionTracking,
8394
11226
  ensureAppDataDirectory,
8395
11227
  getAppDataDirectory,
8396
11228
  getCardHistoryID,
8397
11229
  getCardOrigin,
8398
11230
  getDataLayer,
8399
11231
  getDbPath,
11232
+ getDefaultLearnableWeight,
11233
+ getRegisteredNavigator,
11234
+ getRegisteredNavigatorNames,
8400
11235
  getStudySource,
11236
+ hasRegisteredNavigator,
8401
11237
  importParsedCards,
8402
11238
  initializeDataDirectory,
8403
11239
  initializeDataLayer,
11240
+ initializeNavigatorRegistry,
8404
11241
  isDataShapeRegistered,
8405
11242
  isDataShapeSchemaAvailable,
8406
11243
  isFilter,
@@ -8409,14 +11246,32 @@ export {
8409
11246
  isQuestionTypeRegistered,
8410
11247
  isReview,
8411
11248
  log,
11249
+ mixerDebugAPI,
11250
+ mountMixerDebugger,
11251
+ mountPipelineDebugger,
11252
+ mountSessionDebugger,
8412
11253
  newInterval,
8413
11254
  parseCardHistoryID,
11255
+ pipelineDebugAPI,
8414
11256
  processCustomQuestionsData,
11257
+ recordCardPresentation,
11258
+ recordUserOutcome,
8415
11259
  registerBlanksCard,
8416
11260
  registerCustomQuestionTypes,
8417
11261
  registerDataShape,
11262
+ registerNavigator,
8418
11263
  registerQuestionType,
8419
11264
  registerSeedData,
11265
+ removeCustomQuestionTypes,
11266
+ removeDataShape,
11267
+ removeQuestionType,
11268
+ runPeriodUpdate,
11269
+ scoreAccuracyInZone,
11270
+ sessionDebugAPI,
11271
+ snapshotQueues,
11272
+ startSessionTracking,
11273
+ updateLearningState,
11274
+ updateStrategyWeight,
8420
11275
  validateMigration,
8421
11276
  validateProcessorConfig,
8422
11277
  validateStaticCourse