@vue-skuilder/db 0.1.23 → 0.1.24

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 (66) hide show
  1. package/dist/{contentSource-BP9hznNV.d.ts → contentSource-BotbOOfX.d.ts} +227 -3
  2. package/dist/{contentSource-DsJadoBU.d.cts → contentSource-C90LH-OH.d.cts} +227 -3
  3. package/dist/core/index.d.cts +220 -6
  4. package/dist/core/index.d.ts +220 -6
  5. package/dist/core/index.js +2052 -559
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +2035 -555
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-CHYrQ5pB.d.cts → dataLayerProvider-DGKp4zFB.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-MDTxXq2l.d.ts → dataLayerProvider-SBpz9jQf.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 +1811 -574
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +1792 -550
  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 +1797 -560
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +1789 -547
  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 +36 -11
  26. package/dist/index.d.ts +36 -11
  27. package/dist/index.js +2410 -806
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +2112 -529
  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 +188 -5
  40. package/docs/todo-strategy-authoring.md +8 -6
  41. package/package.json +3 -3
  42. package/src/core/index.ts +2 -0
  43. package/src/core/interfaces/contentSource.ts +7 -0
  44. package/src/core/interfaces/userDB.ts +6 -0
  45. package/src/core/navigators/Pipeline.ts +46 -0
  46. package/src/core/navigators/PipelineAssembler.ts +14 -1
  47. package/src/core/navigators/filters/WeightedFilter.ts +141 -0
  48. package/src/core/navigators/filters/types.ts +4 -0
  49. package/src/core/navigators/generators/CompositeGenerator.ts +61 -19
  50. package/src/core/navigators/generators/types.ts +4 -0
  51. package/src/core/navigators/index.ts +194 -13
  52. package/src/core/orchestration/gradient.ts +133 -0
  53. package/src/core/orchestration/index.ts +210 -0
  54. package/src/core/orchestration/learning.ts +250 -0
  55. package/src/core/orchestration/recording.ts +92 -0
  56. package/src/core/orchestration/signal.ts +67 -0
  57. package/src/core/types/contentNavigationStrategy.ts +38 -0
  58. package/src/core/types/learningState.ts +77 -0
  59. package/src/core/types/types-legacy.ts +4 -0
  60. package/src/core/types/userOutcome.ts +51 -0
  61. package/src/courseConfigRegistration.ts +107 -0
  62. package/src/factory.ts +6 -0
  63. package/src/impl/common/BaseUserDB.ts +16 -0
  64. package/src/study/SessionController.ts +64 -1
  65. package/tests/core/navigators/Pipeline.test.ts +2 -0
  66. package/docs/todo-evolutionary-orchestration.md +0 -310
package/dist/index.js CHANGED
@@ -5,6 +5,11 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __glob = (map) => (path2) => {
9
+ var fn = map[path2];
10
+ if (fn) return fn();
11
+ throw new Error("Module not found in bundle: " + path2);
12
+ };
8
13
  var __esm = (fn, res) => function __init() {
9
14
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
15
  };
@@ -118,6 +123,8 @@ var init_types_legacy = __esm({
118
123
  DocType3["TAG"] = "TAG";
119
124
  DocType3["NAVIGATION_STRATEGY"] = "NAVIGATION_STRATEGY";
120
125
  DocType3["STRATEGY_STATE"] = "STRATEGY_STATE";
126
+ DocType3["USER_OUTCOME"] = "USER_OUTCOME";
127
+ DocType3["STRATEGY_LEARNING_STATE"] = "STRATEGY_LEARNING_STATE";
121
128
  return DocType3;
122
129
  })(DocType || {});
123
130
  DocTypePrefixes = {
@@ -132,7 +139,9 @@ var init_types_legacy = __esm({
132
139
  ["VIEW" /* VIEW */]: "VIEW",
133
140
  ["PEDAGOGY" /* PEDAGOGY */]: "PEDAGOGY",
134
141
  ["NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */]: "NAVIGATION_STRATEGY",
135
- ["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE"
142
+ ["STRATEGY_STATE" /* STRATEGY_STATE */]: "STRATEGY_STATE",
143
+ ["USER_OUTCOME" /* USER_OUTCOME */]: "USER_OUTCOME",
144
+ ["STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */]: "STRATEGY_LEARNING_STATE"
136
145
  };
137
146
  }
138
147
  });
@@ -854,870 +863,2295 @@ var init_courseLookupDB = __esm({
854
863
  }
855
864
  });
856
865
 
857
- // src/core/navigators/index.ts
858
- function getCardOrigin(card) {
859
- if (card.provenance.length === 0) {
860
- throw new Error("Card has no provenance - cannot determine origin");
861
- }
862
- const firstEntry = card.provenance[0];
863
- const reason = firstEntry.reason.toLowerCase();
864
- if (reason.includes("failed")) {
865
- return "failed";
866
- }
867
- if (reason.includes("review")) {
868
- return "review";
869
- }
870
- return "new";
871
- }
872
- function isGenerator(impl) {
873
- return NavigatorRoles[impl] === "generator" /* GENERATOR */;
874
- }
875
- function isFilter(impl) {
876
- return NavigatorRoles[impl] === "filter" /* FILTER */;
877
- }
878
- var Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
879
- var init_navigators = __esm({
880
- "src/core/navigators/index.ts"() {
866
+ // src/core/navigators/generators/CompositeGenerator.ts
867
+ var CompositeGenerator_exports = {};
868
+ __export(CompositeGenerator_exports, {
869
+ AggregationMode: () => AggregationMode,
870
+ default: () => CompositeGenerator
871
+ });
872
+ var AggregationMode, DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
873
+ var init_CompositeGenerator = __esm({
874
+ "src/core/navigators/generators/CompositeGenerator.ts"() {
881
875
  "use strict";
876
+ init_navigators();
882
877
  init_logger();
883
- Navigators = /* @__PURE__ */ ((Navigators2) => {
884
- Navigators2["ELO"] = "elo";
885
- Navigators2["SRS"] = "srs";
886
- Navigators2["HIERARCHY"] = "hierarchyDefinition";
887
- Navigators2["INTERFERENCE"] = "interferenceMitigator";
888
- Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
889
- Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
890
- return Navigators2;
891
- })(Navigators || {});
892
- NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
893
- NavigatorRole2["GENERATOR"] = "generator";
894
- NavigatorRole2["FILTER"] = "filter";
895
- return NavigatorRole2;
896
- })(NavigatorRole || {});
897
- NavigatorRoles = {
898
- ["elo" /* ELO */]: "generator" /* GENERATOR */,
899
- ["srs" /* SRS */]: "generator" /* GENERATOR */,
900
- ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
901
- ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
902
- ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
903
- ["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
904
- };
905
- ContentNavigator = class {
906
- /** User interface for this navigation session */
907
- user;
908
- /** Course interface for this navigation session */
909
- course;
910
- /** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
911
- strategyName;
912
- /** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
913
- strategyId;
914
- /**
915
- * Constructor for standard navigators.
916
- * Call this from subclass constructors to initialize common fields.
917
- *
918
- * Note: CompositeGenerator and Pipeline call super() without args, then set
919
- * user/course fields directly if needed.
920
- */
921
- constructor(user, course, strategyData) {
922
- this.user = user;
923
- this.course = course;
924
- if (strategyData) {
925
- this.strategyName = strategyData.name;
926
- this.strategyId = strategyData._id;
878
+ AggregationMode = /* @__PURE__ */ ((AggregationMode2) => {
879
+ AggregationMode2["MAX"] = "max";
880
+ AggregationMode2["AVERAGE"] = "average";
881
+ AggregationMode2["FREQUENCY_BOOST"] = "frequencyBoost";
882
+ return AggregationMode2;
883
+ })(AggregationMode || {});
884
+ DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
885
+ FREQUENCY_BOOST_FACTOR = 0.1;
886
+ CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
887
+ /** Human-readable name for CardGenerator interface */
888
+ name = "Composite Generator";
889
+ generators;
890
+ aggregationMode;
891
+ constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
892
+ super();
893
+ this.generators = generators;
894
+ this.aggregationMode = aggregationMode;
895
+ if (generators.length === 0) {
896
+ throw new Error("CompositeGenerator requires at least one generator");
927
897
  }
898
+ logger.debug(
899
+ `[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
900
+ );
928
901
  }
929
- // ============================================================================
930
- // STRATEGY STATE HELPERS
931
- // ============================================================================
932
- //
933
- // These methods allow strategies to persist their own state (user preferences,
934
- // learned patterns, temporal tracking) in the user database.
935
- //
936
- // ============================================================================
937
902
  /**
938
- * Unique key identifying this strategy for state storage.
903
+ * Creates a CompositeGenerator from strategy data.
939
904
  *
940
- * Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
941
- * Override in subclasses if multiple instances of the same strategy type
942
- * need separate state storage.
905
+ * This is a convenience factory for use by PipelineAssembler.
943
906
  */
944
- get strategyKey() {
945
- return this.constructor.name;
907
+ static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
908
+ const generators = await Promise.all(
909
+ strategies.map((s) => ContentNavigator.create(user, course, s))
910
+ );
911
+ return new _CompositeGenerator(generators, aggregationMode);
946
912
  }
947
913
  /**
948
- * Get this strategy's persisted state for the current course.
914
+ * Get weighted cards from all generators, merge and deduplicate.
949
915
  *
950
- * @returns The strategy's data payload, or null if no state exists
951
- * @throws Error if user or course is not initialized
916
+ * Cards appearing in multiple generators receive a score boost.
917
+ * Provenance tracks which generators produced each card and how scores were aggregated.
918
+ *
919
+ * This method supports both the legacy signature (limit only) and the
920
+ * CardGenerator interface signature (limit, context).
921
+ *
922
+ * @param limit - Maximum number of cards to return
923
+ * @param context - GeneratorContext passed to child generators (required when called via Pipeline)
952
924
  */
953
- async getStrategyState() {
954
- if (!this.user || !this.course) {
925
+ async getWeightedCards(limit, context) {
926
+ if (!context) {
955
927
  throw new Error(
956
- `Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
928
+ "CompositeGenerator.getWeightedCards requires a GeneratorContext. It should be called via Pipeline, not directly."
957
929
  );
958
930
  }
959
- return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
931
+ const results = await Promise.all(
932
+ this.generators.map((g) => g.getWeightedCards(limit, context))
933
+ );
934
+ const byCardId = /* @__PURE__ */ new Map();
935
+ results.forEach((cards, index) => {
936
+ const gen = this.generators[index];
937
+ let weight = gen.learnable?.weight ?? 1;
938
+ let deviation;
939
+ if (gen.learnable && !gen.staticWeight && context.orchestration) {
940
+ const strategyId = gen.strategyId;
941
+ if (strategyId) {
942
+ weight = context.orchestration.getEffectiveWeight(strategyId, gen.learnable);
943
+ deviation = context.orchestration.getDeviation(strategyId);
944
+ }
945
+ }
946
+ for (const card of cards) {
947
+ if (card.provenance.length > 0) {
948
+ card.provenance[0].effectiveWeight = weight;
949
+ card.provenance[0].deviation = deviation;
950
+ }
951
+ const existing = byCardId.get(card.cardId) || [];
952
+ existing.push({ card, weight });
953
+ byCardId.set(card.cardId, existing);
954
+ }
955
+ });
956
+ const merged = [];
957
+ for (const [, items] of byCardId) {
958
+ const cards = items.map((i) => i.card);
959
+ const aggregatedScore = this.aggregateScores(items);
960
+ const finalScore = Math.min(1, aggregatedScore);
961
+ const mergedProvenance = cards.flatMap((c) => c.provenance);
962
+ const initialScore = cards[0].score;
963
+ const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
964
+ const reason = this.buildAggregationReason(items, finalScore);
965
+ merged.push({
966
+ ...cards[0],
967
+ score: finalScore,
968
+ provenance: [
969
+ ...mergedProvenance,
970
+ {
971
+ strategy: "composite",
972
+ strategyName: "Composite Generator",
973
+ strategyId: "COMPOSITE_GENERATOR",
974
+ action,
975
+ score: finalScore,
976
+ reason
977
+ }
978
+ ]
979
+ });
980
+ }
981
+ return merged.sort((a, b) => b.score - a.score).slice(0, limit);
960
982
  }
961
983
  /**
962
- * Persist this strategy's state for the current course.
963
- *
964
- * @param data - The strategy's data payload to store
965
- * @throws Error if user or course is not initialized
984
+ * Build human-readable reason for score aggregation.
966
985
  */
967
- async putStrategyState(data) {
968
- if (!this.user || !this.course) {
969
- throw new Error(
970
- `Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
971
- );
986
+ buildAggregationReason(items, finalScore) {
987
+ const cards = items.map((i) => i.card);
988
+ const count = cards.length;
989
+ const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
990
+ if (count === 1) {
991
+ const weightMsg = Math.abs(items[0].weight - 1) > 1e-3 ? ` (w=${items[0].weight.toFixed(2)})` : "";
992
+ return `Single generator, score ${finalScore.toFixed(2)}${weightMsg}`;
993
+ }
994
+ const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
995
+ switch (this.aggregationMode) {
996
+ case "max" /* MAX */:
997
+ return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
998
+ case "average" /* AVERAGE */:
999
+ return `Weighted Avg of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
1000
+ case "frequencyBoost" /* FREQUENCY_BOOST */: {
1001
+ const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
1002
+ const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
1003
+ const avg = totalWeight > 0 ? weightedSum / totalWeight : 0;
1004
+ const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
1005
+ return `Frequency boost from ${count} generators (${strategies}): w-avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
1006
+ }
1007
+ default:
1008
+ return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
972
1009
  }
973
- return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
974
1010
  }
975
1011
  /**
976
- * Factory method to create navigator instances dynamically.
977
- *
978
- * @param user - User interface
979
- * @param course - Course interface
980
- * @param strategyData - Strategy configuration document
981
- * @returns the runtime object used to steer a study session.
1012
+ * Aggregate scores from multiple generators for the same card.
982
1013
  */
983
- static async create(user, course, strategyData) {
984
- const implementingClass = strategyData.implementingClass;
985
- let NavigatorImpl;
986
- const variations = [".ts", ".js", ""];
987
- const dirs = ["filters", "generators"];
988
- for (const ext of variations) {
989
- for (const dir of dirs) {
990
- const loadFrom = `./${dir}/${implementingClass}${ext}`;
991
- try {
992
- const module2 = await import(loadFrom);
993
- NavigatorImpl = module2.default;
994
- break;
995
- } catch (e) {
996
- logger.debug(`Failed to load extension from ${loadFrom}:`, e);
997
- }
1014
+ aggregateScores(items) {
1015
+ const scores = items.map((i) => i.card.score);
1016
+ switch (this.aggregationMode) {
1017
+ case "max" /* MAX */:
1018
+ return Math.max(...scores);
1019
+ case "average" /* AVERAGE */: {
1020
+ const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
1021
+ if (totalWeight === 0) return 0;
1022
+ const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
1023
+ return weightedSum / totalWeight;
998
1024
  }
1025
+ case "frequencyBoost" /* FREQUENCY_BOOST */: {
1026
+ const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
1027
+ const weightedSum = items.reduce((sum, i) => sum + i.card.score * i.weight, 0);
1028
+ const avg = totalWeight > 0 ? weightedSum / totalWeight : 0;
1029
+ const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (items.length - 1);
1030
+ return avg * frequencyBoost;
1031
+ }
1032
+ default:
1033
+ return scores[0];
999
1034
  }
1000
- if (!NavigatorImpl) {
1001
- throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
1002
- }
1003
- return new NavigatorImpl(user, course, strategyData);
1035
+ }
1036
+ };
1037
+ }
1038
+ });
1039
+
1040
+ // src/core/navigators/generators/elo.ts
1041
+ var elo_exports = {};
1042
+ __export(elo_exports, {
1043
+ default: () => ELONavigator
1044
+ });
1045
+ var import_common5, ELONavigator;
1046
+ var init_elo = __esm({
1047
+ "src/core/navigators/generators/elo.ts"() {
1048
+ "use strict";
1049
+ init_navigators();
1050
+ import_common5 = require("@vue-skuilder/common");
1051
+ ELONavigator = class extends ContentNavigator {
1052
+ /** Human-readable name for CardGenerator interface */
1053
+ name;
1054
+ constructor(user, course, strategyData) {
1055
+ super(user, course, strategyData);
1056
+ this.name = strategyData?.name || "ELO";
1004
1057
  }
1005
1058
  /**
1006
- * Get cards with suitability scores and provenance trails.
1007
- *
1008
- * **This is the PRIMARY API for navigation strategies.**
1009
- *
1010
- * Returns cards ranked by suitability score (0-1). Higher scores indicate
1011
- * better candidates for presentation. Each card includes a provenance trail
1012
- * documenting how strategies contributed to the final score.
1059
+ * Get new cards with suitability scores based on ELO distance.
1013
1060
  *
1014
- * ## Implementation Required
1015
- * All navigation strategies MUST override this method. The base class does
1016
- * not provide a default implementation.
1061
+ * Cards closer to user's ELO get higher scores.
1062
+ * Score formula: max(0, 1 - distance / 500)
1017
1063
  *
1018
- * ## For Generators
1019
- * Override this method to generate candidates and compute scores based on
1020
- * your strategy's logic (e.g., ELO proximity, review urgency). Create the
1021
- * initial provenance entry with action='generated'.
1064
+ * NOTE: This generator only handles NEW cards. Reviews are handled by
1065
+ * SRSNavigator. Use CompositeGenerator to combine both.
1022
1066
  *
1023
- * ## For Filters
1024
- * Filters should implement the CardFilter interface instead and be composed
1025
- * via Pipeline. Filters do not directly implement getWeightedCards().
1067
+ * This method supports both the legacy signature (limit only) and the
1068
+ * CardGenerator interface signature (limit, context).
1026
1069
  *
1027
- * @param limit - Maximum cards to return
1028
- * @returns Cards sorted by score descending, with provenance trails
1070
+ * @param limit - Maximum number of cards to return
1071
+ * @param context - Optional GeneratorContext (used when called via Pipeline)
1029
1072
  */
1030
- async getWeightedCards(_limit) {
1031
- throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
1073
+ async getWeightedCards(limit, context) {
1074
+ let userGlobalElo;
1075
+ if (context?.userElo !== void 0) {
1076
+ userGlobalElo = context.userElo;
1077
+ } else {
1078
+ const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
1079
+ const userElo = (0, import_common5.toCourseElo)(courseReg.elo);
1080
+ userGlobalElo = userElo.global.score;
1081
+ }
1082
+ const activeCards = await this.user.getActiveCards();
1083
+ const newCards = (await this.course.getCardsCenteredAtELO(
1084
+ { limit, elo: "user" },
1085
+ (c) => !activeCards.some((ac) => c.cardID === ac.cardID)
1086
+ )).map((c) => ({ ...c, status: "new" }));
1087
+ const cardIds = newCards.map((c) => c.cardID);
1088
+ const cardEloData = await this.course.getCardEloData(cardIds);
1089
+ const scored = newCards.map((c, i) => {
1090
+ const cardElo = cardEloData[i]?.global?.score ?? 1e3;
1091
+ const distance = Math.abs(cardElo - userGlobalElo);
1092
+ const score = Math.max(0, 1 - distance / 500);
1093
+ return {
1094
+ cardId: c.cardID,
1095
+ courseId: c.courseID,
1096
+ score,
1097
+ provenance: [
1098
+ {
1099
+ strategy: "elo",
1100
+ strategyName: this.strategyName || this.name,
1101
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
1102
+ action: "generated",
1103
+ score,
1104
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), new card`
1105
+ }
1106
+ ]
1107
+ };
1108
+ });
1109
+ scored.sort((a, b) => b.score - a.score);
1110
+ return scored.slice(0, limit);
1032
1111
  }
1033
1112
  };
1034
1113
  }
1035
1114
  });
1036
1115
 
1037
- // src/core/navigators/Pipeline.ts
1038
- function logPipelineConfig(generator, filters) {
1039
- const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
1040
- logger.info(
1041
- `[Pipeline] Configuration:
1042
- Generator: ${generator.name}
1043
- Filters:${filterList}`
1044
- );
1045
- }
1046
- function logTagHydration(cards, tagsByCard) {
1047
- const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
1048
- const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length;
1049
- logger.debug(
1050
- `[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
1051
- );
1052
- }
1053
- function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
1054
- const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
1055
- logger.info(
1056
- `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
1057
- );
1058
- }
1059
- function logCardProvenance(cards, maxCards = 3) {
1060
- const cardsToLog = cards.slice(0, maxCards);
1061
- logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
1062
- for (const card of cardsToLog) {
1063
- logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
1064
- for (const entry of card.provenance) {
1065
- const scoreChange = entry.score.toFixed(3);
1066
- const action = entry.action.padEnd(9);
1067
- logger.debug(
1068
- `[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
1069
- );
1070
- }
1071
- }
1072
- }
1073
- var import_common5, Pipeline;
1074
- var init_Pipeline = __esm({
1075
- "src/core/navigators/Pipeline.ts"() {
1116
+ // src/core/navigators/generators/index.ts
1117
+ var generators_exports = {};
1118
+ var init_generators = __esm({
1119
+ "src/core/navigators/generators/index.ts"() {
1076
1120
  "use strict";
1077
- import_common5 = require("@vue-skuilder/common");
1078
- init_navigators();
1079
- init_logger();
1080
- Pipeline = class extends ContentNavigator {
1081
- generator;
1082
- filters;
1083
- /**
1084
- * Create a new pipeline.
1085
- *
1086
- * @param generator - The generator (or CompositeGenerator) that produces candidates
1087
- * @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
1088
- * @param user - User database interface
1089
- * @param course - Course database interface
1090
- */
1091
- constructor(generator, filters, user, course) {
1092
- super();
1093
- this.generator = generator;
1094
- this.filters = filters;
1095
- this.user = user;
1096
- this.course = course;
1097
- course.getCourseConfig().then((cfg) => {
1098
- logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
1099
- }).catch((e) => {
1100
- logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
1101
- });
1102
- logPipelineConfig(generator, filters);
1103
- }
1104
- /**
1105
- * Get weighted cards by running generator and applying filters.
1106
- *
1107
- * 1. Build shared context (user ELO, etc.)
1108
- * 2. Get candidates from generator (passing context)
1109
- * 3. Batch hydrate tags for all candidates
1110
- * 4. Apply each filter sequentially
1111
- * 5. Remove zero-score cards
1112
- * 6. Sort by score descending
1113
- * 7. Return top N
1114
- *
1115
- * @param limit - Maximum number of cards to return
1116
- * @returns Cards sorted by score descending
1117
- */
1118
- async getWeightedCards(limit) {
1119
- const context = await this.buildContext();
1120
- const overFetchMultiplier = 2 + this.filters.length * 0.5;
1121
- const fetchLimit = Math.ceil(limit * overFetchMultiplier);
1122
- logger.debug(
1123
- `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
1124
- );
1125
- let cards = await this.generator.getWeightedCards(fetchLimit, context);
1126
- const generatedCount = cards.length;
1127
- logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
1128
- cards = await this.hydrateTags(cards);
1129
- for (const filter of this.filters) {
1130
- const beforeCount = cards.length;
1131
- cards = await filter.transform(cards, context);
1132
- logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
1133
- }
1134
- cards = cards.filter((c) => c.score > 0);
1135
- cards.sort((a, b) => b.score - a.score);
1136
- const result = cards.slice(0, limit);
1137
- const topScores = result.slice(0, 3).map((c) => c.score);
1138
- logExecutionSummary(
1139
- this.generator.name,
1140
- generatedCount,
1141
- this.filters.length,
1142
- result.length,
1143
- topScores
1144
- );
1145
- logCardProvenance(result, 3);
1146
- return result;
1147
- }
1148
- /**
1149
- * Batch hydrate tags for all cards.
1150
- *
1151
- * Fetches tags for all cards in a single database query and attaches them
1152
- * to the WeightedCard objects. Filters can then use card.tags instead of
1153
- * making individual getAppliedTags() calls.
1154
- *
1155
- * @param cards - Cards to hydrate
1156
- * @returns Cards with tags populated
1157
- */
1158
- async hydrateTags(cards) {
1159
- if (cards.length === 0) {
1160
- return cards;
1161
- }
1162
- const cardIds = cards.map((c) => c.cardId);
1163
- const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
1164
- logTagHydration(cards, tagsByCard);
1165
- return cards.map((card) => ({
1166
- ...card,
1167
- tags: tagsByCard.get(card.cardId) ?? []
1168
- }));
1169
- }
1170
- /**
1171
- * Build shared context for generator and filters.
1172
- *
1173
- * Called once per getWeightedCards() invocation.
1174
- * Contains data that the generator and multiple filters might need.
1175
- *
1176
- * The context satisfies both GeneratorContext and FilterContext interfaces.
1177
- */
1178
- async buildContext() {
1179
- let userElo = 1e3;
1180
- try {
1181
- const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
1182
- const courseElo = (0, import_common5.toCourseElo)(courseReg.elo);
1183
- userElo = courseElo.global.score;
1184
- } catch (e) {
1185
- logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
1186
- }
1187
- return {
1188
- user: this.user,
1189
- course: this.course,
1190
- userElo
1191
- };
1192
- }
1193
- /**
1194
- * Get the course ID for this pipeline.
1195
- */
1196
- getCourseID() {
1197
- return this.course.getCourseID();
1198
- }
1199
- };
1200
1121
  }
1201
1122
  });
1202
1123
 
1203
- // src/core/navigators/generators/CompositeGenerator.ts
1204
- var DEFAULT_AGGREGATION_MODE, FREQUENCY_BOOST_FACTOR, CompositeGenerator;
1205
- var init_CompositeGenerator = __esm({
1206
- "src/core/navigators/generators/CompositeGenerator.ts"() {
1124
+ // src/core/navigators/generators/srs.ts
1125
+ var srs_exports = {};
1126
+ __export(srs_exports, {
1127
+ default: () => SRSNavigator
1128
+ });
1129
+ var import_moment3, SRSNavigator;
1130
+ var init_srs = __esm({
1131
+ "src/core/navigators/generators/srs.ts"() {
1207
1132
  "use strict";
1133
+ import_moment3 = __toESM(require("moment"), 1);
1208
1134
  init_navigators();
1209
1135
  init_logger();
1210
- DEFAULT_AGGREGATION_MODE = "frequencyBoost" /* FREQUENCY_BOOST */;
1211
- FREQUENCY_BOOST_FACTOR = 0.1;
1212
- CompositeGenerator = class _CompositeGenerator extends ContentNavigator {
1136
+ SRSNavigator = class extends ContentNavigator {
1213
1137
  /** Human-readable name for CardGenerator interface */
1214
- name = "Composite Generator";
1215
- generators;
1216
- aggregationMode;
1217
- constructor(generators, aggregationMode = DEFAULT_AGGREGATION_MODE) {
1218
- super();
1219
- this.generators = generators;
1220
- this.aggregationMode = aggregationMode;
1221
- if (generators.length === 0) {
1222
- throw new Error("CompositeGenerator requires at least one generator");
1223
- }
1224
- logger.debug(
1225
- `[CompositeGenerator] Created with ${generators.length} generators, mode: ${aggregationMode}`
1226
- );
1138
+ name;
1139
+ constructor(user, course, strategyData) {
1140
+ super(user, course, strategyData);
1141
+ this.name = strategyData?.name || "SRS";
1227
1142
  }
1228
1143
  /**
1229
- * Creates a CompositeGenerator from strategy data.
1144
+ * Get review cards scored by urgency.
1230
1145
  *
1231
- * This is a convenience factory for use by PipelineAssembler.
1232
- */
1233
- static async fromStrategies(user, course, strategies, aggregationMode = DEFAULT_AGGREGATION_MODE) {
1234
- const generators = await Promise.all(
1235
- strategies.map((s) => ContentNavigator.create(user, course, s))
1236
- );
1237
- return new _CompositeGenerator(generators, aggregationMode);
1238
- }
1239
- /**
1240
- * Get weighted cards from all generators, merge and deduplicate.
1146
+ * Score formula combines:
1147
+ * - Relative overdueness: hoursOverdue / intervalHours
1148
+ * - Interval recency: exponential decay favoring shorter intervals
1241
1149
  *
1242
- * Cards appearing in multiple generators receive a score boost.
1243
- * Provenance tracks which generators produced each card and how scores were aggregated.
1150
+ * Cards not yet due are excluded (not scored as 0).
1244
1151
  *
1245
1152
  * This method supports both the legacy signature (limit only) and the
1246
1153
  * CardGenerator interface signature (limit, context).
1247
1154
  *
1248
1155
  * @param limit - Maximum number of cards to return
1249
- * @param context - GeneratorContext passed to child generators (required when called via Pipeline)
1156
+ * @param _context - Optional GeneratorContext (currently unused, but required for interface)
1250
1157
  */
1251
- async getWeightedCards(limit, context) {
1252
- if (!context) {
1253
- throw new Error(
1254
- "CompositeGenerator.getWeightedCards requires a GeneratorContext. It should be called via Pipeline, not directly."
1255
- );
1256
- }
1257
- const results = await Promise.all(
1258
- this.generators.map((g) => g.getWeightedCards(limit, context))
1259
- );
1260
- const byCardId = /* @__PURE__ */ new Map();
1261
- for (const cards of results) {
1262
- for (const card of cards) {
1263
- const existing = byCardId.get(card.cardId) || [];
1264
- existing.push(card);
1265
- byCardId.set(card.cardId, existing);
1266
- }
1158
+ async getWeightedCards(limit, _context) {
1159
+ if (!this.user || !this.course) {
1160
+ throw new Error("SRSNavigator requires user and course to be set");
1267
1161
  }
1268
- const merged = [];
1269
- for (const [, cards] of byCardId) {
1270
- const aggregatedScore = this.aggregateScores(cards);
1271
- const finalScore = Math.min(1, aggregatedScore);
1272
- const mergedProvenance = cards.flatMap((c) => c.provenance);
1273
- const initialScore = cards[0].score;
1274
- const action = finalScore > initialScore ? "boosted" : finalScore < initialScore ? "penalized" : "passed";
1275
- const reason = this.buildAggregationReason(cards, finalScore);
1276
- merged.push({
1277
- ...cards[0],
1278
- score: finalScore,
1162
+ const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1163
+ const now = import_moment3.default.utc();
1164
+ const dueReviews = reviews.filter((r) => now.isAfter(import_moment3.default.utc(r.reviewTime)));
1165
+ const scored = dueReviews.map((review) => {
1166
+ const { score, reason } = this.computeUrgencyScore(review, now);
1167
+ return {
1168
+ cardId: review.cardId,
1169
+ courseId: review.courseId,
1170
+ score,
1171
+ reviewID: review._id,
1279
1172
  provenance: [
1280
- ...mergedProvenance,
1281
1173
  {
1282
- strategy: "composite",
1283
- strategyName: "Composite Generator",
1284
- strategyId: "COMPOSITE_GENERATOR",
1285
- action,
1286
- score: finalScore,
1174
+ strategy: "srs",
1175
+ strategyName: this.strategyName || this.name,
1176
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-SRS-default",
1177
+ action: "generated",
1178
+ score,
1287
1179
  reason
1288
1180
  }
1289
1181
  ]
1290
- });
1291
- }
1292
- return merged.sort((a, b) => b.score - a.score).slice(0, limit);
1293
- }
1294
- /**
1295
- * Build human-readable reason for score aggregation.
1296
- */
1297
- buildAggregationReason(cards, finalScore) {
1298
- const count = cards.length;
1299
- const scores = cards.map((c) => c.score.toFixed(2)).join(", ");
1300
- if (count === 1) {
1301
- return `Single generator, score ${finalScore.toFixed(2)}`;
1302
- }
1303
- const strategies = cards.map((c) => c.provenance[0]?.strategy || "unknown").join(", ");
1304
- switch (this.aggregationMode) {
1305
- case "max" /* MAX */:
1306
- return `Max of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
1307
- case "average" /* AVERAGE */:
1308
- return `Average of ${count} generators (${strategies}): scores [${scores}] \u2192 ${finalScore.toFixed(2)}`;
1309
- case "frequencyBoost" /* FREQUENCY_BOOST */: {
1310
- const avg = cards.reduce((sum, c) => sum + c.score, 0) / count;
1311
- const boost = 1 + FREQUENCY_BOOST_FACTOR * (count - 1);
1312
- return `Frequency boost from ${count} generators (${strategies}): avg ${avg.toFixed(2)} \xD7 ${boost.toFixed(2)} \u2192 ${finalScore.toFixed(2)}`;
1313
- }
1314
- default:
1315
- return `Aggregated from ${count} generators: ${finalScore.toFixed(2)}`;
1316
- }
1182
+ };
1183
+ });
1184
+ logger.debug(`[srsNav] got ${scored.length} weighted cards`);
1185
+ return scored.sort((a, b) => b.score - a.score).slice(0, limit);
1317
1186
  }
1318
1187
  /**
1319
- * Aggregate scores from multiple generators for the same card.
1188
+ * Compute urgency score for a review card.
1189
+ *
1190
+ * Two factors:
1191
+ * 1. Relative overdueness = hoursOverdue / intervalHours
1192
+ * - 2 days overdue on 3-day interval = 0.67 (urgent)
1193
+ * - 2 days overdue on 180-day interval = 0.01 (not urgent)
1194
+ *
1195
+ * 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
1196
+ * - 24h interval → ~1.0 (very recent learning)
1197
+ * - 30 days (720h) → ~0.56
1198
+ * - 180 days → ~0.30
1199
+ *
1200
+ * Combined: base 0.5 + weighted average of factors * 0.45
1201
+ * Result range: approximately 0.5 to 0.95
1320
1202
  */
1321
- aggregateScores(cards) {
1322
- const scores = cards.map((c) => c.score);
1323
- switch (this.aggregationMode) {
1324
- case "max" /* MAX */:
1325
- return Math.max(...scores);
1326
- case "average" /* AVERAGE */:
1327
- return scores.reduce((sum, s) => sum + s, 0) / scores.length;
1328
- case "frequencyBoost" /* FREQUENCY_BOOST */: {
1329
- const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length;
1330
- const frequencyBoost = 1 + FREQUENCY_BOOST_FACTOR * (cards.length - 1);
1331
- return avg * frequencyBoost;
1332
- }
1333
- default:
1334
- return scores[0];
1335
- }
1203
+ computeUrgencyScore(review, now) {
1204
+ const scheduledAt = import_moment3.default.utc(review.scheduledAt);
1205
+ const due = import_moment3.default.utc(review.reviewTime);
1206
+ const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
1207
+ const hoursOverdue = now.diff(due, "hours");
1208
+ const relativeOverdue = hoursOverdue / intervalHours;
1209
+ const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
1210
+ const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
1211
+ const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
1212
+ const score = Math.min(0.95, 0.5 + urgency * 0.45);
1213
+ const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
1214
+ return { score, reason };
1336
1215
  }
1337
1216
  };
1338
1217
  }
1339
1218
  });
1340
1219
 
1341
- // src/core/navigators/PipelineAssembler.ts
1342
- var PipelineAssembler;
1343
- var init_PipelineAssembler = __esm({
1344
- "src/core/navigators/PipelineAssembler.ts"() {
1220
+ // src/core/navigators/generators/types.ts
1221
+ var types_exports = {};
1222
+ var init_types = __esm({
1223
+ "src/core/navigators/generators/types.ts"() {
1345
1224
  "use strict";
1346
- init_navigators();
1347
- init_Pipeline();
1348
- init_types_legacy();
1349
- init_logger();
1350
- init_CompositeGenerator();
1351
- PipelineAssembler = class {
1225
+ }
1226
+ });
1227
+
1228
+ // import("./generators/**/*") in src/core/navigators/index.ts
1229
+ var globImport_generators;
1230
+ var init_ = __esm({
1231
+ 'import("./generators/**/*") in src/core/navigators/index.ts'() {
1232
+ globImport_generators = __glob({
1233
+ "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
1234
+ "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
1235
+ "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
1236
+ "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
1237
+ "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports))
1238
+ });
1239
+ }
1240
+ });
1241
+
1242
+ // src/core/types/contentNavigationStrategy.ts
1243
+ var DEFAULT_LEARNABLE_WEIGHT;
1244
+ var init_contentNavigationStrategy = __esm({
1245
+ "src/core/types/contentNavigationStrategy.ts"() {
1246
+ "use strict";
1247
+ DEFAULT_LEARNABLE_WEIGHT = {
1248
+ weight: 1,
1249
+ confidence: 0.1,
1250
+ // Low confidence initially = wide exploration
1251
+ sampleSize: 0
1252
+ };
1253
+ }
1254
+ });
1255
+
1256
+ // src/core/navigators/filters/WeightedFilter.ts
1257
+ var WeightedFilter_exports = {};
1258
+ __export(WeightedFilter_exports, {
1259
+ WeightedFilter: () => WeightedFilter
1260
+ });
1261
+ var WeightedFilter;
1262
+ var init_WeightedFilter = __esm({
1263
+ "src/core/navigators/filters/WeightedFilter.ts"() {
1264
+ "use strict";
1265
+ init_contentNavigationStrategy();
1266
+ WeightedFilter = class {
1267
+ name;
1268
+ inner;
1269
+ learnable;
1270
+ staticWeight;
1271
+ strategyId;
1272
+ constructor(inner, learnable = DEFAULT_LEARNABLE_WEIGHT, staticWeight = false, strategyId) {
1273
+ this.inner = inner;
1274
+ this.name = inner.name;
1275
+ this.learnable = learnable;
1276
+ this.staticWeight = staticWeight;
1277
+ this.strategyId = strategyId;
1278
+ }
1352
1279
  /**
1353
- * Assembles a navigation pipeline from strategy documents.
1354
- *
1355
- * 1. Separates into generators and filters by role
1356
- * 2. Validates at least one generator exists (or creates default ELO)
1357
- * 3. Instantiates generators - wraps multiple in CompositeGenerator
1358
- * 4. Instantiates filters
1359
- * 5. Returns Pipeline(generator, filters)
1360
- *
1361
- * @param input - Strategy documents plus user/course interfaces
1362
- * @returns Assembled pipeline and any warnings
1280
+ * Apply the inner filter, then scale its effect by the configured weight.
1363
1281
  */
1364
- async assemble(input) {
1365
- const { strategies, user, course } = input;
1366
- const warnings = [];
1367
- if (strategies.length === 0) {
1368
- return {
1369
- pipeline: null,
1370
- generatorStrategies: [],
1371
- filterStrategies: [],
1372
- warnings
1373
- };
1374
- }
1375
- const generatorStrategies = [];
1376
- const filterStrategies = [];
1377
- for (const s of strategies) {
1378
- if (isGenerator(s.implementingClass)) {
1379
- generatorStrategies.push(s);
1380
- } else if (isFilter(s.implementingClass)) {
1381
- filterStrategies.push(s);
1382
- } else {
1383
- warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
1282
+ async transform(cards, context) {
1283
+ let effectiveWeight = this.learnable.weight;
1284
+ let deviation;
1285
+ if (!this.staticWeight && context.orchestration) {
1286
+ const strategyId = this.strategyId || this.inner.strategyId || this.name;
1287
+ effectiveWeight = context.orchestration.getEffectiveWeight(strategyId, this.learnable);
1288
+ deviation = context.orchestration.getDeviation(strategyId);
1289
+ }
1290
+ if (Math.abs(effectiveWeight - 1) < 1e-3) {
1291
+ return this.inner.transform(cards, context);
1292
+ }
1293
+ const originalScores = /* @__PURE__ */ new Map();
1294
+ for (const card of cards) {
1295
+ originalScores.set(card.cardId, card.score);
1296
+ }
1297
+ const transformedCards = await this.inner.transform(cards, context);
1298
+ return transformedCards.map((card) => {
1299
+ const originalScore = originalScores.get(card.cardId);
1300
+ if (originalScore === void 0 || originalScore === 0 || card.score === 0) {
1301
+ return card;
1384
1302
  }
1385
- }
1386
- if (generatorStrategies.length === 0) {
1387
- if (filterStrategies.length > 0) {
1388
- logger.debug(
1389
- "[PipelineAssembler] No generator found, using default ELO with configured filters"
1390
- );
1391
- generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
1392
- } else {
1393
- warnings.push("No generator strategy found");
1303
+ const rawEffect = card.score / originalScore;
1304
+ if (Math.abs(rawEffect - 1) < 1e-4) {
1305
+ return card;
1306
+ }
1307
+ const weightedEffect = Math.pow(rawEffect, effectiveWeight);
1308
+ const newScore = originalScore * weightedEffect;
1309
+ const lastProvIndex = card.provenance.length - 1;
1310
+ const lastProv = card.provenance[lastProvIndex];
1311
+ if (lastProv) {
1312
+ const updatedProvenance = [...card.provenance];
1313
+ updatedProvenance[lastProvIndex] = {
1314
+ ...lastProv,
1315
+ score: newScore,
1316
+ effectiveWeight,
1317
+ deviation
1318
+ // We can optionally append to the reason, but the structured field is key
1319
+ };
1394
1320
  return {
1395
- pipeline: null,
1396
- generatorStrategies: [],
1397
- filterStrategies: [],
1398
- warnings
1321
+ ...card,
1322
+ score: newScore,
1323
+ provenance: updatedProvenance
1399
1324
  };
1400
1325
  }
1401
- }
1402
- let generator;
1403
- if (generatorStrategies.length === 1) {
1404
- const nav = await ContentNavigator.create(user, course, generatorStrategies[0]);
1405
- generator = nav;
1406
- logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
1407
- } else {
1408
- logger.debug(
1409
- `[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
1410
- );
1411
- generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
1412
- }
1413
- const filters = [];
1414
- const sortedFilterStrategies = [...filterStrategies].sort(
1415
- (a, b) => a.name.localeCompare(b.name)
1416
- );
1417
- for (const filterStrategy of sortedFilterStrategies) {
1418
- try {
1419
- const nav = await ContentNavigator.create(user, course, filterStrategy);
1420
- if ("transform" in nav && typeof nav.transform === "function") {
1421
- filters.push(nav);
1422
- logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
1423
- } else {
1424
- warnings.push(
1425
- `Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
1426
- );
1427
- }
1428
- } catch (e) {
1429
- warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
1430
- }
1431
- }
1432
- const pipeline = new Pipeline(generator, filters, user, course);
1433
- logger.debug(
1434
- `[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
1435
- );
1436
- return {
1437
- pipeline,
1438
- generatorStrategies,
1439
- filterStrategies: sortedFilterStrategies,
1440
- warnings
1441
- };
1442
- }
1443
- /**
1444
- * Creates a default ELO generator strategy.
1445
- * Used when filters are configured but no generator is specified.
1446
- */
1447
- makeDefaultEloStrategy(courseId) {
1448
- return {
1449
- _id: "NAVIGATION_STRATEGY-ELO-default",
1450
- course: courseId,
1451
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1452
- name: "ELO (default)",
1453
- description: "Default ELO-based generator",
1454
- implementingClass: "elo" /* ELO */,
1455
- serializedData: ""
1456
- };
1326
+ return {
1327
+ ...card,
1328
+ score: newScore
1329
+ };
1330
+ });
1457
1331
  }
1458
1332
  };
1459
1333
  }
1460
1334
  });
1461
1335
 
1462
- // src/core/navigators/generators/elo.ts
1463
- var import_common6, ELONavigator;
1464
- var init_elo = __esm({
1465
- "src/core/navigators/generators/elo.ts"() {
1466
- "use strict";
1467
- init_navigators();
1468
- import_common6 = require("@vue-skuilder/common");
1469
- ELONavigator = class extends ContentNavigator {
1470
- /** Human-readable name for CardGenerator interface */
1471
- name;
1472
- constructor(user, course, strategyData) {
1473
- super(user, course, strategyData);
1474
- this.name = strategyData?.name || "ELO";
1475
- }
1476
- /**
1477
- * Get new cards with suitability scores based on ELO distance.
1478
- *
1479
- * Cards closer to user's ELO get higher scores.
1480
- * Score formula: max(0, 1 - distance / 500)
1481
- *
1482
- * NOTE: This generator only handles NEW cards. Reviews are handled by
1483
- * SRSNavigator. Use CompositeGenerator to combine both.
1484
- *
1485
- * This method supports both the legacy signature (limit only) and the
1486
- * CardGenerator interface signature (limit, context).
1487
- *
1488
- * @param limit - Maximum number of cards to return
1489
- * @param context - Optional GeneratorContext (used when called via Pipeline)
1336
+ // src/core/navigators/filters/eloDistance.ts
1337
+ var eloDistance_exports = {};
1338
+ __export(eloDistance_exports, {
1339
+ DEFAULT_HALF_LIFE: () => DEFAULT_HALF_LIFE,
1340
+ DEFAULT_MAX_MULTIPLIER: () => DEFAULT_MAX_MULTIPLIER,
1341
+ DEFAULT_MIN_MULTIPLIER: () => DEFAULT_MIN_MULTIPLIER,
1342
+ createEloDistanceFilter: () => createEloDistanceFilter
1343
+ });
1344
+ function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
1345
+ const normalizedDistance = distance / halfLife;
1346
+ const decay = Math.exp(-(normalizedDistance * normalizedDistance));
1347
+ return minMultiplier + (maxMultiplier - minMultiplier) * decay;
1348
+ }
1349
+ function createEloDistanceFilter(config) {
1350
+ const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
1351
+ const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
1352
+ const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
1353
+ return {
1354
+ name: "ELO Distance Filter",
1355
+ async transform(cards, context) {
1356
+ const { course, userElo } = context;
1357
+ const cardIds = cards.map((c) => c.cardId);
1358
+ const cardElos = await course.getCardEloData(cardIds);
1359
+ return cards.map((card, i) => {
1360
+ const cardElo = cardElos[i]?.global?.score ?? 1e3;
1361
+ const distance = Math.abs(cardElo - userElo);
1362
+ const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
1363
+ const newScore = card.score * multiplier;
1364
+ const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
1365
+ return {
1366
+ ...card,
1367
+ score: newScore,
1368
+ provenance: [
1369
+ ...card.provenance,
1370
+ {
1371
+ strategy: "eloDistance",
1372
+ strategyName: "ELO Distance Filter",
1373
+ strategyId: "ELO_DISTANCE_FILTER",
1374
+ action,
1375
+ score: newScore,
1376
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
1377
+ }
1378
+ ]
1379
+ };
1380
+ });
1381
+ }
1382
+ };
1383
+ }
1384
+ var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
1385
+ var init_eloDistance = __esm({
1386
+ "src/core/navigators/filters/eloDistance.ts"() {
1387
+ "use strict";
1388
+ DEFAULT_HALF_LIFE = 200;
1389
+ DEFAULT_MIN_MULTIPLIER = 0.3;
1390
+ DEFAULT_MAX_MULTIPLIER = 1;
1391
+ }
1392
+ });
1393
+
1394
+ // src/core/navigators/filters/hierarchyDefinition.ts
1395
+ var hierarchyDefinition_exports = {};
1396
+ __export(hierarchyDefinition_exports, {
1397
+ default: () => HierarchyDefinitionNavigator
1398
+ });
1399
+ var import_common6, DEFAULT_MIN_COUNT, HierarchyDefinitionNavigator;
1400
+ var init_hierarchyDefinition = __esm({
1401
+ "src/core/navigators/filters/hierarchyDefinition.ts"() {
1402
+ "use strict";
1403
+ init_navigators();
1404
+ import_common6 = require("@vue-skuilder/common");
1405
+ DEFAULT_MIN_COUNT = 3;
1406
+ HierarchyDefinitionNavigator = class extends ContentNavigator {
1407
+ config;
1408
+ /** Human-readable name for CardFilter interface */
1409
+ name;
1410
+ constructor(user, course, strategyData) {
1411
+ super(user, course, strategyData);
1412
+ this.config = this.parseConfig(strategyData.serializedData);
1413
+ this.name = strategyData.name || "Hierarchy Definition";
1414
+ }
1415
+ parseConfig(serializedData) {
1416
+ try {
1417
+ const parsed = JSON.parse(serializedData);
1418
+ return {
1419
+ prerequisites: parsed.prerequisites || {}
1420
+ };
1421
+ } catch {
1422
+ return {
1423
+ prerequisites: {}
1424
+ };
1425
+ }
1426
+ }
1427
+ /**
1428
+ * Check if a specific prerequisite is satisfied
1490
1429
  */
1491
- async getWeightedCards(limit, context) {
1492
- let userGlobalElo;
1493
- if (context?.userElo !== void 0) {
1494
- userGlobalElo = context.userElo;
1430
+ isPrerequisiteMet(prereq, userTagElo, userGlobalElo) {
1431
+ if (!userTagElo) return false;
1432
+ const minCount = prereq.masteryThreshold?.minCount ?? DEFAULT_MIN_COUNT;
1433
+ if (userTagElo.count < minCount) return false;
1434
+ if (prereq.masteryThreshold?.minElo !== void 0) {
1435
+ return userTagElo.score >= prereq.masteryThreshold.minElo;
1495
1436
  } else {
1496
- const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
1437
+ return userTagElo.score >= userGlobalElo;
1438
+ }
1439
+ }
1440
+ /**
1441
+ * Get the set of tags the user has mastered.
1442
+ * A tag is "mastered" if it appears as a prerequisite somewhere and meets its threshold.
1443
+ */
1444
+ async getMasteredTags(context) {
1445
+ const mastered = /* @__PURE__ */ new Set();
1446
+ try {
1447
+ const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
1497
1448
  const userElo = (0, import_common6.toCourseElo)(courseReg.elo);
1498
- userGlobalElo = userElo.global.score;
1449
+ for (const prereqs of Object.values(this.config.prerequisites)) {
1450
+ for (const prereq of prereqs) {
1451
+ const tagElo = userElo.tags[prereq.tag];
1452
+ if (this.isPrerequisiteMet(prereq, tagElo, userElo.global.score)) {
1453
+ mastered.add(prereq.tag);
1454
+ }
1455
+ }
1456
+ }
1457
+ } catch {
1499
1458
  }
1500
- const activeCards = await this.user.getActiveCards();
1501
- const newCards = (await this.course.getCardsCenteredAtELO(
1502
- { limit, elo: "user" },
1503
- (c) => !activeCards.some((ac) => c.cardID === ac.cardID)
1504
- )).map((c) => ({ ...c, status: "new" }));
1505
- const cardIds = newCards.map((c) => c.cardID);
1506
- const cardEloData = await this.course.getCardEloData(cardIds);
1507
- const scored = newCards.map((c, i) => {
1508
- const cardElo = cardEloData[i]?.global?.score ?? 1e3;
1509
- const distance = Math.abs(cardElo - userGlobalElo);
1510
- const score = Math.max(0, 1 - distance / 500);
1459
+ return mastered;
1460
+ }
1461
+ /**
1462
+ * Get the set of tags that are unlocked (prerequisites met)
1463
+ */
1464
+ getUnlockedTags(masteredTags) {
1465
+ const unlocked = /* @__PURE__ */ new Set();
1466
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
1467
+ const allPrereqsMet = prereqs.every((prereq) => masteredTags.has(prereq.tag));
1468
+ if (allPrereqsMet) {
1469
+ unlocked.add(tagId);
1470
+ }
1471
+ }
1472
+ return unlocked;
1473
+ }
1474
+ /**
1475
+ * Check if a tag has prerequisites defined in config
1476
+ */
1477
+ hasPrerequisites(tagId) {
1478
+ return tagId in this.config.prerequisites;
1479
+ }
1480
+ /**
1481
+ * Check if a card is unlocked and generate reason.
1482
+ */
1483
+ async checkCardUnlock(card, _course, unlockedTags, masteredTags) {
1484
+ try {
1485
+ const cardTags = card.tags ?? [];
1486
+ const lockedTags = cardTags.filter(
1487
+ (tag) => this.hasPrerequisites(tag) && !unlockedTags.has(tag)
1488
+ );
1489
+ if (lockedTags.length === 0) {
1490
+ const tagList = cardTags.length > 0 ? cardTags.join(", ") : "none";
1491
+ return {
1492
+ isUnlocked: true,
1493
+ reason: `Prerequisites met, tags: ${tagList}`
1494
+ };
1495
+ }
1496
+ const missingPrereqs = lockedTags.flatMap((tag) => {
1497
+ const prereqs = this.config.prerequisites[tag] || [];
1498
+ return prereqs.filter((p) => !masteredTags.has(p.tag)).map((p) => p.tag);
1499
+ });
1511
1500
  return {
1512
- cardId: c.cardID,
1513
- courseId: c.courseID,
1514
- score,
1501
+ isUnlocked: false,
1502
+ reason: `Blocked: missing prerequisites ${missingPrereqs.join(", ")} for tags ${lockedTags.join(", ")}`
1503
+ };
1504
+ } catch {
1505
+ return {
1506
+ isUnlocked: true,
1507
+ reason: "Prerequisites check skipped (tag lookup failed)"
1508
+ };
1509
+ }
1510
+ }
1511
+ /**
1512
+ * CardFilter.transform implementation.
1513
+ *
1514
+ * Apply prerequisite gating to cards. Cards with locked tags receive score: 0.
1515
+ */
1516
+ async transform(cards, context) {
1517
+ const masteredTags = await this.getMasteredTags(context);
1518
+ const unlockedTags = this.getUnlockedTags(masteredTags);
1519
+ const gated = [];
1520
+ for (const card of cards) {
1521
+ const { isUnlocked, reason } = await this.checkCardUnlock(
1522
+ card,
1523
+ context.course,
1524
+ unlockedTags,
1525
+ masteredTags
1526
+ );
1527
+ const finalScore = isUnlocked ? card.score : 0;
1528
+ const action = isUnlocked ? "passed" : "penalized";
1529
+ gated.push({
1530
+ ...card,
1531
+ score: finalScore,
1515
1532
  provenance: [
1533
+ ...card.provenance,
1516
1534
  {
1517
- strategy: "elo",
1535
+ strategy: "hierarchyDefinition",
1518
1536
  strategyName: this.strategyName || this.name,
1519
- strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
1520
- action: "generated",
1521
- score,
1522
- reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), new card`
1537
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-hierarchy",
1538
+ action,
1539
+ score: finalScore,
1540
+ reason
1523
1541
  }
1524
1542
  ]
1525
- };
1526
- });
1527
- scored.sort((a, b) => b.score - a.score);
1528
- return scored.slice(0, limit);
1543
+ });
1544
+ }
1545
+ return gated;
1546
+ }
1547
+ /**
1548
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
1549
+ *
1550
+ * Use transform() via Pipeline instead.
1551
+ */
1552
+ async getWeightedCards(_limit) {
1553
+ throw new Error(
1554
+ "HierarchyDefinitionNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
1555
+ );
1529
1556
  }
1530
1557
  };
1531
1558
  }
1532
1559
  });
1533
1560
 
1534
- // src/core/navigators/generators/srs.ts
1535
- var import_moment3, SRSNavigator;
1536
- var init_srs = __esm({
1537
- "src/core/navigators/generators/srs.ts"() {
1561
+ // src/core/navigators/filters/userTagPreference.ts
1562
+ var userTagPreference_exports = {};
1563
+ __export(userTagPreference_exports, {
1564
+ default: () => UserTagPreferenceFilter
1565
+ });
1566
+ var UserTagPreferenceFilter;
1567
+ var init_userTagPreference = __esm({
1568
+ "src/core/navigators/filters/userTagPreference.ts"() {
1538
1569
  "use strict";
1539
- import_moment3 = __toESM(require("moment"), 1);
1540
1570
  init_navigators();
1541
- init_logger();
1542
- SRSNavigator = class extends ContentNavigator {
1543
- /** Human-readable name for CardGenerator interface */
1571
+ UserTagPreferenceFilter = class extends ContentNavigator {
1572
+ _strategyData;
1573
+ /** Human-readable name for CardFilter interface */
1544
1574
  name;
1545
1575
  constructor(user, course, strategyData) {
1546
1576
  super(user, course, strategyData);
1547
- this.name = strategyData?.name || "SRS";
1577
+ this._strategyData = strategyData;
1578
+ this.name = strategyData.name || "User Tag Preferences";
1579
+ }
1580
+ /**
1581
+ * Compute multiplier for a card based on its tags and user preferences.
1582
+ * Returns the maximum multiplier among all matching tags, or 1.0 if no matches.
1583
+ */
1584
+ computeMultiplier(cardTags, boostMap) {
1585
+ const multipliers = cardTags.map((tag) => boostMap[tag]).filter((val) => val !== void 0);
1586
+ if (multipliers.length === 0) {
1587
+ return 1;
1588
+ }
1589
+ return Math.max(...multipliers);
1590
+ }
1591
+ /**
1592
+ * Build human-readable reason for the filter's decision.
1593
+ */
1594
+ buildReason(cardTags, boostMap, multiplier) {
1595
+ const matchingTags = cardTags.filter((tag) => boostMap[tag] === multiplier);
1596
+ if (multiplier === 0) {
1597
+ return `Excluded by user preference: ${matchingTags.join(", ")} (${multiplier}x)`;
1598
+ }
1599
+ if (multiplier < 1) {
1600
+ return `Penalized by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
1601
+ }
1602
+ if (multiplier > 1) {
1603
+ return `Boosted by user preference: ${matchingTags.join(", ")} (${multiplier.toFixed(2)}x)`;
1604
+ }
1605
+ return "No matching user preferences";
1606
+ }
1607
+ /**
1608
+ * CardFilter.transform implementation.
1609
+ *
1610
+ * Apply user tag preferences:
1611
+ * 1. Read preferences from strategy state
1612
+ * 2. If no preferences, pass through unchanged
1613
+ * 3. For each card:
1614
+ * - Look up tag in boost record
1615
+ * - If tag found: apply multiplier (0 = exclude, 1 = neutral, >1 = boost)
1616
+ * - If multiple tags match: use max multiplier
1617
+ * - Append provenance with clear reason
1618
+ */
1619
+ async transform(cards, _context) {
1620
+ const prefs = await this.getStrategyState();
1621
+ if (!prefs || Object.keys(prefs.boost).length === 0) {
1622
+ return cards.map((card) => ({
1623
+ ...card,
1624
+ provenance: [
1625
+ ...card.provenance,
1626
+ {
1627
+ strategy: "userTagPreference",
1628
+ strategyName: this.strategyName || this.name,
1629
+ strategyId: this.strategyId || this._strategyData._id,
1630
+ action: "passed",
1631
+ score: card.score,
1632
+ reason: "No user tag preferences configured"
1633
+ }
1634
+ ]
1635
+ }));
1636
+ }
1637
+ const adjusted = await Promise.all(
1638
+ cards.map(async (card) => {
1639
+ const cardTags = card.tags ?? [];
1640
+ const multiplier = this.computeMultiplier(cardTags, prefs.boost);
1641
+ const finalScore = Math.min(1, card.score * multiplier);
1642
+ let action;
1643
+ if (multiplier === 0 || multiplier < 1) {
1644
+ action = "penalized";
1645
+ } else if (multiplier > 1) {
1646
+ action = "boosted";
1647
+ } else {
1648
+ action = "passed";
1649
+ }
1650
+ return {
1651
+ ...card,
1652
+ score: finalScore,
1653
+ provenance: [
1654
+ ...card.provenance,
1655
+ {
1656
+ strategy: "userTagPreference",
1657
+ strategyName: this.strategyName || this.name,
1658
+ strategyId: this.strategyId || this._strategyData._id,
1659
+ action,
1660
+ score: finalScore,
1661
+ reason: this.buildReason(cardTags, prefs.boost, multiplier)
1662
+ }
1663
+ ]
1664
+ };
1665
+ })
1666
+ );
1667
+ return adjusted;
1668
+ }
1669
+ /**
1670
+ * Legacy getWeightedCards - throws as filters should not be used as generators.
1671
+ */
1672
+ async getWeightedCards(_limit) {
1673
+ throw new Error(
1674
+ "UserTagPreferenceFilter is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
1675
+ );
1676
+ }
1677
+ };
1678
+ }
1679
+ });
1680
+
1681
+ // src/core/navigators/filters/index.ts
1682
+ var filters_exports = {};
1683
+ __export(filters_exports, {
1684
+ UserTagPreferenceFilter: () => UserTagPreferenceFilter,
1685
+ createEloDistanceFilter: () => createEloDistanceFilter
1686
+ });
1687
+ var init_filters = __esm({
1688
+ "src/core/navigators/filters/index.ts"() {
1689
+ "use strict";
1690
+ init_eloDistance();
1691
+ init_userTagPreference();
1692
+ }
1693
+ });
1694
+
1695
+ // src/core/navigators/filters/inferredPreferenceStub.ts
1696
+ var inferredPreferenceStub_exports = {};
1697
+ __export(inferredPreferenceStub_exports, {
1698
+ INFERRED_PREFERENCE_NAVIGATOR_STUB: () => INFERRED_PREFERENCE_NAVIGATOR_STUB
1699
+ });
1700
+ var INFERRED_PREFERENCE_NAVIGATOR_STUB;
1701
+ var init_inferredPreferenceStub = __esm({
1702
+ "src/core/navigators/filters/inferredPreferenceStub.ts"() {
1703
+ "use strict";
1704
+ INFERRED_PREFERENCE_NAVIGATOR_STUB = true;
1705
+ }
1706
+ });
1707
+
1708
+ // src/core/navigators/filters/interferenceMitigator.ts
1709
+ var interferenceMitigator_exports = {};
1710
+ __export(interferenceMitigator_exports, {
1711
+ default: () => InterferenceMitigatorNavigator
1712
+ });
1713
+ var import_common7, DEFAULT_MIN_COUNT2, DEFAULT_MIN_ELAPSED_DAYS, DEFAULT_INTERFERENCE_DECAY, InterferenceMitigatorNavigator;
1714
+ var init_interferenceMitigator = __esm({
1715
+ "src/core/navigators/filters/interferenceMitigator.ts"() {
1716
+ "use strict";
1717
+ init_navigators();
1718
+ import_common7 = require("@vue-skuilder/common");
1719
+ DEFAULT_MIN_COUNT2 = 10;
1720
+ DEFAULT_MIN_ELAPSED_DAYS = 3;
1721
+ DEFAULT_INTERFERENCE_DECAY = 0.8;
1722
+ InterferenceMitigatorNavigator = class extends ContentNavigator {
1723
+ config;
1724
+ /** Human-readable name for CardFilter interface */
1725
+ name;
1726
+ /** Precomputed map: tag -> set of { partner, decay } it interferes with */
1727
+ interferenceMap;
1728
+ constructor(user, course, strategyData) {
1729
+ super(user, course, strategyData);
1730
+ this.config = this.parseConfig(strategyData.serializedData);
1731
+ this.interferenceMap = this.buildInterferenceMap();
1732
+ this.name = strategyData.name || "Interference Mitigator";
1733
+ }
1734
+ parseConfig(serializedData) {
1735
+ try {
1736
+ const parsed = JSON.parse(serializedData);
1737
+ let sets = parsed.interferenceSets || [];
1738
+ if (sets.length > 0 && Array.isArray(sets[0])) {
1739
+ sets = sets.map((tags) => ({ tags }));
1740
+ }
1741
+ return {
1742
+ interferenceSets: sets,
1743
+ maturityThreshold: {
1744
+ minCount: parsed.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2,
1745
+ minElo: parsed.maturityThreshold?.minElo,
1746
+ minElapsedDays: parsed.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS
1747
+ },
1748
+ defaultDecay: parsed.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY
1749
+ };
1750
+ } catch {
1751
+ return {
1752
+ interferenceSets: [],
1753
+ maturityThreshold: {
1754
+ minCount: DEFAULT_MIN_COUNT2,
1755
+ minElapsedDays: DEFAULT_MIN_ELAPSED_DAYS
1756
+ },
1757
+ defaultDecay: DEFAULT_INTERFERENCE_DECAY
1758
+ };
1759
+ }
1760
+ }
1761
+ /**
1762
+ * Build a map from each tag to its interference partners with decay coefficients.
1763
+ * If tags A, B, C are in an interference group with decay 0.8, then:
1764
+ * - A interferes with B (decay 0.8) and C (decay 0.8)
1765
+ * - B interferes with A (decay 0.8) and C (decay 0.8)
1766
+ * - etc.
1767
+ */
1768
+ buildInterferenceMap() {
1769
+ const map = /* @__PURE__ */ new Map();
1770
+ for (const group of this.config.interferenceSets) {
1771
+ const decay = group.decay ?? this.config.defaultDecay ?? DEFAULT_INTERFERENCE_DECAY;
1772
+ for (const tag of group.tags) {
1773
+ if (!map.has(tag)) {
1774
+ map.set(tag, []);
1775
+ }
1776
+ const partners = map.get(tag);
1777
+ for (const other of group.tags) {
1778
+ if (other !== tag) {
1779
+ const existing = partners.find((p) => p.partner === other);
1780
+ if (existing) {
1781
+ existing.decay = Math.max(existing.decay, decay);
1782
+ } else {
1783
+ partners.push({ partner: other, decay });
1784
+ }
1785
+ }
1786
+ }
1787
+ }
1788
+ }
1789
+ return map;
1790
+ }
1791
+ /**
1792
+ * Get the set of tags that are currently immature for this user.
1793
+ * A tag is immature if the user has interacted with it but hasn't
1794
+ * reached the maturity threshold.
1795
+ */
1796
+ async getImmatureTags(context) {
1797
+ const immature = /* @__PURE__ */ new Set();
1798
+ try {
1799
+ const courseReg = await context.user.getCourseRegDoc(context.course.getCourseID());
1800
+ const userElo = (0, import_common7.toCourseElo)(courseReg.elo);
1801
+ const minCount = this.config.maturityThreshold?.minCount ?? DEFAULT_MIN_COUNT2;
1802
+ const minElo = this.config.maturityThreshold?.minElo;
1803
+ const minElapsedDays = this.config.maturityThreshold?.minElapsedDays ?? DEFAULT_MIN_ELAPSED_DAYS;
1804
+ const minCountForElapsed = minElapsedDays * 2;
1805
+ for (const [tagId, tagElo] of Object.entries(userElo.tags)) {
1806
+ if (tagElo.count === 0) continue;
1807
+ const belowCount = tagElo.count < minCount;
1808
+ const belowElo = minElo !== void 0 && tagElo.score < minElo;
1809
+ const belowElapsed = tagElo.count < minCountForElapsed;
1810
+ if (belowCount || belowElo || belowElapsed) {
1811
+ immature.add(tagId);
1812
+ }
1813
+ }
1814
+ } catch {
1815
+ }
1816
+ return immature;
1817
+ }
1818
+ /**
1819
+ * Get all tags that interfere with any immature tag, along with their decay coefficients.
1820
+ * These are the tags we want to avoid introducing.
1821
+ */
1822
+ getTagsToAvoid(immatureTags) {
1823
+ const avoid = /* @__PURE__ */ new Map();
1824
+ for (const immatureTag of immatureTags) {
1825
+ const partners = this.interferenceMap.get(immatureTag);
1826
+ if (partners) {
1827
+ for (const { partner, decay } of partners) {
1828
+ if (!immatureTags.has(partner)) {
1829
+ const existing = avoid.get(partner) ?? 0;
1830
+ avoid.set(partner, Math.max(existing, decay));
1831
+ }
1832
+ }
1833
+ }
1834
+ }
1835
+ return avoid;
1836
+ }
1837
+ /**
1838
+ * Compute interference score reduction for a card.
1839
+ * Returns: { multiplier, interfering tags, reason }
1840
+ */
1841
+ computeInterferenceEffect(cardTags, tagsToAvoid, immatureTags) {
1842
+ if (tagsToAvoid.size === 0) {
1843
+ return {
1844
+ multiplier: 1,
1845
+ interferingTags: [],
1846
+ reason: "No interference detected"
1847
+ };
1848
+ }
1849
+ let multiplier = 1;
1850
+ const interferingTags = [];
1851
+ for (const tag of cardTags) {
1852
+ const decay = tagsToAvoid.get(tag);
1853
+ if (decay !== void 0) {
1854
+ interferingTags.push(tag);
1855
+ multiplier *= 1 - decay;
1856
+ }
1857
+ }
1858
+ if (interferingTags.length === 0) {
1859
+ return {
1860
+ multiplier: 1,
1861
+ interferingTags: [],
1862
+ reason: "No interference detected"
1863
+ };
1864
+ }
1865
+ const causingTags = /* @__PURE__ */ new Set();
1866
+ for (const tag of interferingTags) {
1867
+ for (const immatureTag of immatureTags) {
1868
+ const partners = this.interferenceMap.get(immatureTag);
1869
+ if (partners?.some((p) => p.partner === tag)) {
1870
+ causingTags.add(immatureTag);
1871
+ }
1872
+ }
1873
+ }
1874
+ const reason = `Interferes with immature tags ${Array.from(causingTags).join(", ")} (tags: ${interferingTags.join(", ")}, multiplier: ${multiplier.toFixed(2)})`;
1875
+ return { multiplier, interferingTags, reason };
1876
+ }
1877
+ /**
1878
+ * CardFilter.transform implementation.
1879
+ *
1880
+ * Apply interference-aware scoring. Cards with tags that interfere with
1881
+ * immature learnings get reduced scores.
1882
+ */
1883
+ async transform(cards, context) {
1884
+ const immatureTags = await this.getImmatureTags(context);
1885
+ const tagsToAvoid = this.getTagsToAvoid(immatureTags);
1886
+ const adjusted = [];
1887
+ for (const card of cards) {
1888
+ const cardTags = card.tags ?? [];
1889
+ const { multiplier, reason } = this.computeInterferenceEffect(
1890
+ cardTags,
1891
+ tagsToAvoid,
1892
+ immatureTags
1893
+ );
1894
+ const finalScore = card.score * multiplier;
1895
+ const action = multiplier < 1 ? "penalized" : multiplier > 1 ? "boosted" : "passed";
1896
+ adjusted.push({
1897
+ ...card,
1898
+ score: finalScore,
1899
+ provenance: [
1900
+ ...card.provenance,
1901
+ {
1902
+ strategy: "interferenceMitigator",
1903
+ strategyName: this.strategyName || this.name,
1904
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-interference",
1905
+ action,
1906
+ score: finalScore,
1907
+ reason
1908
+ }
1909
+ ]
1910
+ });
1911
+ }
1912
+ return adjusted;
1913
+ }
1914
+ /**
1915
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
1916
+ *
1917
+ * Use transform() via Pipeline instead.
1918
+ */
1919
+ async getWeightedCards(_limit) {
1920
+ throw new Error(
1921
+ "InterferenceMitigatorNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
1922
+ );
1923
+ }
1924
+ };
1925
+ }
1926
+ });
1927
+
1928
+ // src/core/navigators/filters/relativePriority.ts
1929
+ var relativePriority_exports = {};
1930
+ __export(relativePriority_exports, {
1931
+ default: () => RelativePriorityNavigator
1932
+ });
1933
+ var DEFAULT_PRIORITY, DEFAULT_PRIORITY_INFLUENCE, DEFAULT_COMBINE_MODE, RelativePriorityNavigator;
1934
+ var init_relativePriority = __esm({
1935
+ "src/core/navigators/filters/relativePriority.ts"() {
1936
+ "use strict";
1937
+ init_navigators();
1938
+ DEFAULT_PRIORITY = 0.5;
1939
+ DEFAULT_PRIORITY_INFLUENCE = 0.5;
1940
+ DEFAULT_COMBINE_MODE = "max";
1941
+ RelativePriorityNavigator = class extends ContentNavigator {
1942
+ config;
1943
+ /** Human-readable name for CardFilter interface */
1944
+ name;
1945
+ constructor(user, course, strategyData) {
1946
+ super(user, course, strategyData);
1947
+ this.config = this.parseConfig(strategyData.serializedData);
1948
+ this.name = strategyData.name || "Relative Priority";
1949
+ }
1950
+ parseConfig(serializedData) {
1951
+ try {
1952
+ const parsed = JSON.parse(serializedData);
1953
+ return {
1954
+ tagPriorities: parsed.tagPriorities || {},
1955
+ defaultPriority: parsed.defaultPriority ?? DEFAULT_PRIORITY,
1956
+ combineMode: parsed.combineMode ?? DEFAULT_COMBINE_MODE,
1957
+ priorityInfluence: parsed.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE
1958
+ };
1959
+ } catch {
1960
+ return {
1961
+ tagPriorities: {},
1962
+ defaultPriority: DEFAULT_PRIORITY,
1963
+ combineMode: DEFAULT_COMBINE_MODE,
1964
+ priorityInfluence: DEFAULT_PRIORITY_INFLUENCE
1965
+ };
1966
+ }
1967
+ }
1968
+ /**
1969
+ * Look up the priority for a tag.
1970
+ */
1971
+ getTagPriority(tagId) {
1972
+ return this.config.tagPriorities[tagId] ?? this.config.defaultPriority ?? DEFAULT_PRIORITY;
1973
+ }
1974
+ /**
1975
+ * Compute combined priority for a card based on its tags.
1976
+ */
1977
+ computeCardPriority(cardTags) {
1978
+ if (cardTags.length === 0) {
1979
+ return this.config.defaultPriority ?? DEFAULT_PRIORITY;
1980
+ }
1981
+ const priorities = cardTags.map((tag) => this.getTagPriority(tag));
1982
+ switch (this.config.combineMode) {
1983
+ case "max":
1984
+ return Math.max(...priorities);
1985
+ case "min":
1986
+ return Math.min(...priorities);
1987
+ case "average":
1988
+ return priorities.reduce((sum, p) => sum + p, 0) / priorities.length;
1989
+ default:
1990
+ return Math.max(...priorities);
1991
+ }
1992
+ }
1993
+ /**
1994
+ * Compute boost factor based on priority.
1995
+ *
1996
+ * The formula: 1 + (priority - 0.5) * priorityInfluence
1997
+ *
1998
+ * This creates a multiplier centered around 1.0:
1999
+ * - Priority 1.0 with influence 0.5 → 1.25 (25% boost)
2000
+ * - Priority 0.5 with any influence → 1.00 (neutral)
2001
+ * - Priority 0.0 with influence 0.5 → 0.75 (25% reduction)
2002
+ */
2003
+ computeBoostFactor(priority) {
2004
+ const influence = this.config.priorityInfluence ?? DEFAULT_PRIORITY_INFLUENCE;
2005
+ return 1 + (priority - 0.5) * influence;
2006
+ }
2007
+ /**
2008
+ * Build human-readable reason for priority adjustment.
2009
+ */
2010
+ buildPriorityReason(cardTags, priority, boostFactor, finalScore) {
2011
+ if (cardTags.length === 0) {
2012
+ return `No tags, neutral priority (${priority.toFixed(2)})`;
2013
+ }
2014
+ const tagList = cardTags.slice(0, 3).join(", ");
2015
+ const more = cardTags.length > 3 ? ` (+${cardTags.length - 3} more)` : "";
2016
+ if (boostFactor === 1) {
2017
+ return `Neutral priority (${priority.toFixed(2)}) for tags: ${tagList}${more}`;
2018
+ } else if (boostFactor > 1) {
2019
+ return `High-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 boost ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
2020
+ } else {
2021
+ return `Low-priority tags: ${tagList}${more} (priority ${priority.toFixed(2)} \u2192 reduce ${boostFactor.toFixed(2)}x \u2192 ${finalScore.toFixed(2)})`;
2022
+ }
2023
+ }
2024
+ /**
2025
+ * CardFilter.transform implementation.
2026
+ *
2027
+ * Apply priority-adjusted scoring. Cards with high-priority tags get boosted,
2028
+ * cards with low-priority tags get reduced scores.
2029
+ */
2030
+ async transform(cards, _context) {
2031
+ const adjusted = await Promise.all(
2032
+ cards.map(async (card) => {
2033
+ const cardTags = card.tags ?? [];
2034
+ const priority = this.computeCardPriority(cardTags);
2035
+ const boostFactor = this.computeBoostFactor(priority);
2036
+ const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
2037
+ const action = boostFactor > 1 ? "boosted" : boostFactor < 1 ? "penalized" : "passed";
2038
+ const reason = this.buildPriorityReason(cardTags, priority, boostFactor, finalScore);
2039
+ return {
2040
+ ...card,
2041
+ score: finalScore,
2042
+ provenance: [
2043
+ ...card.provenance,
2044
+ {
2045
+ strategy: "relativePriority",
2046
+ strategyName: this.strategyName || this.name,
2047
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-priority",
2048
+ action,
2049
+ score: finalScore,
2050
+ reason
2051
+ }
2052
+ ]
2053
+ };
2054
+ })
2055
+ );
2056
+ return adjusted;
2057
+ }
2058
+ /**
2059
+ * Legacy getWeightedCards - now throws as filters should not be used as generators.
2060
+ *
2061
+ * Use transform() via Pipeline instead.
2062
+ */
2063
+ async getWeightedCards(_limit) {
2064
+ throw new Error(
2065
+ "RelativePriorityNavigator is a filter and should not be used as a generator. Use Pipeline with a generator and this filter via transform()."
2066
+ );
2067
+ }
2068
+ };
2069
+ }
2070
+ });
2071
+
2072
+ // src/core/navigators/filters/types.ts
2073
+ var types_exports2 = {};
2074
+ var init_types2 = __esm({
2075
+ "src/core/navigators/filters/types.ts"() {
2076
+ "use strict";
2077
+ }
2078
+ });
2079
+
2080
+ // src/core/navigators/filters/userGoalStub.ts
2081
+ var userGoalStub_exports = {};
2082
+ __export(userGoalStub_exports, {
2083
+ USER_GOAL_NAVIGATOR_STUB: () => USER_GOAL_NAVIGATOR_STUB
2084
+ });
2085
+ var USER_GOAL_NAVIGATOR_STUB;
2086
+ var init_userGoalStub = __esm({
2087
+ "src/core/navigators/filters/userGoalStub.ts"() {
2088
+ "use strict";
2089
+ USER_GOAL_NAVIGATOR_STUB = true;
2090
+ }
2091
+ });
2092
+
2093
+ // import("./filters/**/*") in src/core/navigators/index.ts
2094
+ var globImport_filters;
2095
+ var init_2 = __esm({
2096
+ 'import("./filters/**/*") in src/core/navigators/index.ts'() {
2097
+ globImport_filters = __glob({
2098
+ "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
2099
+ "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
2100
+ "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
2101
+ "./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
2102
+ "./filters/inferredPreferenceStub.ts": () => Promise.resolve().then(() => (init_inferredPreferenceStub(), inferredPreferenceStub_exports)),
2103
+ "./filters/interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
2104
+ "./filters/relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
2105
+ "./filters/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
2106
+ "./filters/userGoalStub.ts": () => Promise.resolve().then(() => (init_userGoalStub(), userGoalStub_exports)),
2107
+ "./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports))
2108
+ });
2109
+ }
2110
+ });
2111
+
2112
+ // src/core/orchestration/gradient.ts
2113
+ function aggregateOutcomesForGradient(outcomes, strategyId) {
2114
+ const observations = [];
2115
+ for (const outcome of outcomes) {
2116
+ const deviation = outcome.deviations[strategyId];
2117
+ if (deviation === void 0) {
2118
+ continue;
2119
+ }
2120
+ observations.push({
2121
+ deviation,
2122
+ outcomeValue: outcome.outcomeValue,
2123
+ weight: 1
2124
+ });
2125
+ }
2126
+ logger.debug(
2127
+ `[Orchestration] Aggregated ${observations.length} observations for strategy ${strategyId}`
2128
+ );
2129
+ return observations;
2130
+ }
2131
+ function computeStrategyGradient(observations) {
2132
+ const n = observations.length;
2133
+ if (n < 3) {
2134
+ logger.debug(`[Orchestration] Insufficient observations for gradient (${n} < 3)`);
2135
+ return null;
2136
+ }
2137
+ let sumX = 0;
2138
+ let sumY = 0;
2139
+ let sumW = 0;
2140
+ for (const obs of observations) {
2141
+ const w = obs.weight ?? 1;
2142
+ sumX += obs.deviation * w;
2143
+ sumY += obs.outcomeValue * w;
2144
+ sumW += w;
2145
+ }
2146
+ const meanX = sumX / sumW;
2147
+ const meanY = sumY / sumW;
2148
+ let numerator = 0;
2149
+ let denominator = 0;
2150
+ let ssTotal = 0;
2151
+ for (const obs of observations) {
2152
+ const w = obs.weight ?? 1;
2153
+ const dx = obs.deviation - meanX;
2154
+ const dy = obs.outcomeValue - meanY;
2155
+ numerator += w * dx * dy;
2156
+ denominator += w * dx * dx;
2157
+ ssTotal += w * dy * dy;
2158
+ }
2159
+ if (denominator < 1e-10) {
2160
+ logger.debug(`[Orchestration] No variance in deviations, cannot compute gradient`);
2161
+ return {
2162
+ gradient: 0,
2163
+ intercept: meanY,
2164
+ rSquared: 0,
2165
+ sampleSize: n
2166
+ };
2167
+ }
2168
+ const gradient = numerator / denominator;
2169
+ const intercept = meanY - gradient * meanX;
2170
+ let ssResidual = 0;
2171
+ for (const obs of observations) {
2172
+ const w = obs.weight ?? 1;
2173
+ const predicted = gradient * obs.deviation + intercept;
2174
+ const residual = obs.outcomeValue - predicted;
2175
+ ssResidual += w * residual * residual;
2176
+ }
2177
+ const rSquared = ssTotal > 1e-10 ? 1 - ssResidual / ssTotal : 0;
2178
+ logger.debug(
2179
+ `[Orchestration] Computed gradient: ${gradient.toFixed(4)}, intercept: ${intercept.toFixed(4)}, R\xB2: ${rSquared.toFixed(4)}, n=${n}`
2180
+ );
2181
+ return {
2182
+ gradient,
2183
+ intercept,
2184
+ rSquared: Math.max(0, Math.min(1, rSquared)),
2185
+ // Clamp to [0,1]
2186
+ sampleSize: n
2187
+ };
2188
+ }
2189
+ var init_gradient = __esm({
2190
+ "src/core/orchestration/gradient.ts"() {
2191
+ "use strict";
2192
+ init_logger();
2193
+ }
2194
+ });
2195
+
2196
+ // src/core/orchestration/learning.ts
2197
+ function updateStrategyWeight(current, gradient) {
2198
+ if (gradient.sampleSize < MIN_OBSERVATIONS_FOR_UPDATE) {
2199
+ logger.debug(
2200
+ `[Orchestration] Insufficient samples (${gradient.sampleSize} < ${MIN_OBSERVATIONS_FOR_UPDATE}), keeping current weight`
2201
+ );
2202
+ return {
2203
+ ...current,
2204
+ sampleSize: current.sampleSize + gradient.sampleSize
2205
+ };
2206
+ }
2207
+ const isReliable = gradient.rSquared >= MIN_R_SQUARED_FOR_GRADIENT;
2208
+ const isFlat = Math.abs(gradient.gradient) < FLAT_GRADIENT_THRESHOLD;
2209
+ let newWeight = current.weight;
2210
+ let newConfidence = current.confidence;
2211
+ if (!isReliable || isFlat) {
2212
+ const confidenceGain = 0.05 * (1 - current.confidence);
2213
+ newConfidence = Math.min(1, current.confidence + confidenceGain);
2214
+ logger.debug(
2215
+ `[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)}`
2216
+ );
2217
+ } else {
2218
+ let delta = gradient.gradient * LEARNING_RATE;
2219
+ delta = Math.max(-MAX_WEIGHT_DELTA, Math.min(MAX_WEIGHT_DELTA, delta));
2220
+ newWeight = current.weight + delta;
2221
+ newWeight = Math.max(0.1, Math.min(3, newWeight));
2222
+ const confidenceGain = 0.02 * (1 - current.confidence);
2223
+ newConfidence = Math.min(1, current.confidence + confidenceGain);
2224
+ logger.debug(
2225
+ `[Orchestration] Adjusting weight: ${current.weight.toFixed(3)} \u2192 ${newWeight.toFixed(3)} (gradient=${gradient.gradient.toFixed(4)}, delta=${delta.toFixed(4)})`
2226
+ );
2227
+ }
2228
+ return {
2229
+ weight: newWeight,
2230
+ confidence: newConfidence,
2231
+ sampleSize: current.sampleSize + gradient.sampleSize
2232
+ };
2233
+ }
2234
+ function updateLearningState(courseId, strategyId, currentWeight, gradient, existing) {
2235
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2236
+ const id = `STRATEGY_LEARNING_STATE::${courseId}::${strategyId}`;
2237
+ const historyEntry = {
2238
+ timestamp: now,
2239
+ weight: currentWeight.weight,
2240
+ confidence: currentWeight.confidence,
2241
+ gradient: gradient.gradient
2242
+ };
2243
+ let history = existing?.history ?? [];
2244
+ history = [...history, historyEntry];
2245
+ if (history.length > MAX_HISTORY_LENGTH) {
2246
+ history = history.slice(history.length - MAX_HISTORY_LENGTH);
2247
+ }
2248
+ const state = {
2249
+ _id: id,
2250
+ _rev: existing?._rev,
2251
+ docType: "STRATEGY_LEARNING_STATE" /* STRATEGY_LEARNING_STATE */,
2252
+ courseId,
2253
+ strategyId,
2254
+ currentWeight,
2255
+ regression: {
2256
+ gradient: gradient.gradient,
2257
+ intercept: gradient.intercept,
2258
+ rSquared: gradient.rSquared,
2259
+ sampleSize: gradient.sampleSize,
2260
+ computedAt: now
2261
+ },
2262
+ history,
2263
+ updatedAt: now
2264
+ };
2265
+ return state;
2266
+ }
2267
+ function runPeriodUpdate(input) {
2268
+ const { courseId, strategyId, currentWeight, gradient, existingState } = input;
2269
+ logger.info(
2270
+ `[Orchestration] Running period update for strategy ${strategyId} (${gradient.sampleSize} observations)`
2271
+ );
2272
+ const newWeight = updateStrategyWeight(currentWeight, gradient);
2273
+ const updated = newWeight.weight !== currentWeight.weight;
2274
+ const learningState = updateLearningState(
2275
+ courseId,
2276
+ strategyId,
2277
+ newWeight,
2278
+ gradient,
2279
+ existingState
2280
+ );
2281
+ logger.info(
2282
+ `[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)}`
2283
+ );
2284
+ return {
2285
+ strategyId,
2286
+ previousWeight: currentWeight,
2287
+ newWeight,
2288
+ gradient,
2289
+ learningState,
2290
+ updated
2291
+ };
2292
+ }
2293
+ function getDefaultLearnableWeight() {
2294
+ return { ...DEFAULT_LEARNABLE_WEIGHT };
2295
+ }
2296
+ var MIN_OBSERVATIONS_FOR_UPDATE, LEARNING_RATE, MAX_WEIGHT_DELTA, MIN_R_SQUARED_FOR_GRADIENT, FLAT_GRADIENT_THRESHOLD, MAX_HISTORY_LENGTH;
2297
+ var init_learning = __esm({
2298
+ "src/core/orchestration/learning.ts"() {
2299
+ "use strict";
2300
+ init_contentNavigationStrategy();
2301
+ init_types_legacy();
2302
+ init_logger();
2303
+ MIN_OBSERVATIONS_FOR_UPDATE = 10;
2304
+ LEARNING_RATE = 0.1;
2305
+ MAX_WEIGHT_DELTA = 0.3;
2306
+ MIN_R_SQUARED_FOR_GRADIENT = 0.05;
2307
+ FLAT_GRADIENT_THRESHOLD = 0.02;
2308
+ MAX_HISTORY_LENGTH = 100;
2309
+ }
2310
+ });
2311
+
2312
+ // src/core/orchestration/signal.ts
2313
+ function computeOutcomeSignal(records, config = {}) {
2314
+ if (!records || records.length === 0) {
2315
+ return null;
2316
+ }
2317
+ const target = config.targetAccuracy ?? 0.85;
2318
+ const tolerance = config.tolerance ?? 0.05;
2319
+ let correct = 0;
2320
+ for (const r of records) {
2321
+ if (r.isCorrect) correct++;
2322
+ }
2323
+ const accuracy = correct / records.length;
2324
+ return scoreAccuracyInZone(accuracy, target, tolerance);
2325
+ }
2326
+ function scoreAccuracyInZone(accuracy, target, tolerance) {
2327
+ const dist = Math.abs(accuracy - target);
2328
+ if (dist <= tolerance) {
2329
+ return 1;
2330
+ }
2331
+ const excess = dist - tolerance;
2332
+ const slope = 2.5;
2333
+ return Math.max(0, 1 - excess * slope);
2334
+ }
2335
+ var init_signal = __esm({
2336
+ "src/core/orchestration/signal.ts"() {
2337
+ "use strict";
2338
+ }
2339
+ });
2340
+
2341
+ // src/core/orchestration/recording.ts
2342
+ async function recordUserOutcome(context, periodStart, periodEnd, records, activeStrategyIds, eloStart = 0, eloEnd = 0, config) {
2343
+ const { user, course, userId } = context;
2344
+ const courseId = course.getCourseID();
2345
+ const outcomeValue = computeOutcomeSignal(records, config);
2346
+ if (outcomeValue === null) {
2347
+ logger.debug(
2348
+ `[Orchestration] No outcome signal computed for ${userId} (insufficient data). Skipping record.`
2349
+ );
2350
+ return;
2351
+ }
2352
+ const deviations = {};
2353
+ for (const strategyId of activeStrategyIds) {
2354
+ deviations[strategyId] = context.getDeviation(strategyId);
2355
+ }
2356
+ const id = `USER_OUTCOME::${courseId}::${userId}::${periodEnd}`;
2357
+ const record = {
2358
+ _id: id,
2359
+ docType: "USER_OUTCOME" /* USER_OUTCOME */,
2360
+ courseId,
2361
+ userId,
2362
+ periodStart,
2363
+ periodEnd,
2364
+ outcomeValue,
2365
+ deviations,
2366
+ metadata: {
2367
+ sessionsCount: 1,
2368
+ // Assumes recording is triggered per-session currently
2369
+ cardsSeen: records.length,
2370
+ eloStart,
2371
+ eloEnd,
2372
+ signalType: "accuracy_in_zone"
2373
+ }
2374
+ };
2375
+ try {
2376
+ await user.putUserOutcome(record);
2377
+ logger.debug(
2378
+ `[Orchestration] Recorded outcome ${outcomeValue.toFixed(3)} for ${userId} (doc: ${id})`
2379
+ );
2380
+ } catch (e) {
2381
+ logger.error(`[Orchestration] Failed to record outcome: ${e}`);
2382
+ }
2383
+ }
2384
+ var init_recording = __esm({
2385
+ "src/core/orchestration/recording.ts"() {
2386
+ "use strict";
2387
+ init_signal();
2388
+ init_types_legacy();
2389
+ init_logger();
2390
+ }
2391
+ });
2392
+
2393
+ // src/core/orchestration/index.ts
2394
+ function fnv1a(str) {
2395
+ let hash = 2166136261;
2396
+ for (let i = 0; i < str.length; i++) {
2397
+ hash ^= str.charCodeAt(i);
2398
+ hash = Math.imul(hash, 16777619);
2399
+ }
2400
+ return hash >>> 0;
2401
+ }
2402
+ function computeDeviation(userId, strategyId, salt) {
2403
+ const input = `${userId}:${strategyId}:${salt}`;
2404
+ const hash = fnv1a(input);
2405
+ const normalized = hash / 4294967296;
2406
+ return normalized * 2 - 1;
2407
+ }
2408
+ function computeSpread(confidence) {
2409
+ const clampedConfidence = Math.max(0, Math.min(1, confidence));
2410
+ return MAX_SPREAD - clampedConfidence * (MAX_SPREAD - MIN_SPREAD);
2411
+ }
2412
+ function computeEffectiveWeight(learnable, userId, strategyId, salt) {
2413
+ const deviation = computeDeviation(userId, strategyId, salt);
2414
+ const spread = computeSpread(learnable.confidence);
2415
+ const adjustment = deviation * spread * learnable.weight;
2416
+ const effective = learnable.weight + adjustment;
2417
+ return Math.max(MIN_WEIGHT, Math.min(MAX_WEIGHT, effective));
2418
+ }
2419
+ async function createOrchestrationContext(user, course) {
2420
+ let courseConfig;
2421
+ try {
2422
+ courseConfig = await course.getCourseConfig();
2423
+ } catch (e) {
2424
+ logger.error(`[Orchestration] Failed to load course config: ${e}`);
2425
+ courseConfig = {
2426
+ name: "Unknown",
2427
+ description: "",
2428
+ public: false,
2429
+ deleted: false,
2430
+ creator: "",
2431
+ admins: [],
2432
+ moderators: [],
2433
+ dataShapes: [],
2434
+ questionTypes: [],
2435
+ orchestration: { salt: "default" }
2436
+ };
2437
+ }
2438
+ const userId = user.getUsername();
2439
+ const salt = courseConfig.orchestration?.salt || "default_salt";
2440
+ return {
2441
+ user,
2442
+ course,
2443
+ userId,
2444
+ courseConfig,
2445
+ getEffectiveWeight(strategyId, learnable) {
2446
+ return computeEffectiveWeight(learnable, userId, strategyId, salt);
2447
+ },
2448
+ getDeviation(strategyId) {
2449
+ return computeDeviation(userId, strategyId, salt);
2450
+ }
2451
+ };
2452
+ }
2453
+ var MIN_SPREAD, MAX_SPREAD, MIN_WEIGHT, MAX_WEIGHT;
2454
+ var init_orchestration = __esm({
2455
+ "src/core/orchestration/index.ts"() {
2456
+ "use strict";
2457
+ init_logger();
2458
+ init_gradient();
2459
+ init_learning();
2460
+ init_signal();
2461
+ init_recording();
2462
+ MIN_SPREAD = 0.1;
2463
+ MAX_SPREAD = 0.5;
2464
+ MIN_WEIGHT = 0.1;
2465
+ MAX_WEIGHT = 3;
2466
+ }
2467
+ });
2468
+
2469
+ // src/core/navigators/Pipeline.ts
2470
+ var Pipeline_exports = {};
2471
+ __export(Pipeline_exports, {
2472
+ Pipeline: () => Pipeline
2473
+ });
2474
+ function logPipelineConfig(generator, filters) {
2475
+ const filterList = filters.length > 0 ? "\n - " + filters.map((f) => f.name).join("\n - ") : " none";
2476
+ logger.info(
2477
+ `[Pipeline] Configuration:
2478
+ Generator: ${generator.name}
2479
+ Filters:${filterList}`
2480
+ );
2481
+ }
2482
+ function logTagHydration(cards, tagsByCard) {
2483
+ const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0);
2484
+ const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length;
2485
+ logger.debug(
2486
+ `[Pipeline] Tag hydration: ${cards.length} cards, ${cardsWithTags} have tags (${totalTags} total tags) - single batch query`
2487
+ );
2488
+ }
2489
+ function logExecutionSummary(generatorName, generatedCount, filterCount, finalCount, topScores) {
2490
+ const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(", ") : "none";
2491
+ logger.info(
2492
+ `[Pipeline] Execution: ${generatorName} produced ${generatedCount} \u2192 ${filterCount} filters \u2192 ${finalCount} results (top scores: ${scoreDisplay})`
2493
+ );
2494
+ }
2495
+ function logCardProvenance(cards, maxCards = 3) {
2496
+ const cardsToLog = cards.slice(0, maxCards);
2497
+ logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`);
2498
+ for (const card of cardsToLog) {
2499
+ logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`);
2500
+ for (const entry of card.provenance) {
2501
+ const scoreChange = entry.score.toFixed(3);
2502
+ const action = entry.action.padEnd(9);
2503
+ logger.debug(
2504
+ `[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}`
2505
+ );
2506
+ }
2507
+ }
2508
+ }
2509
+ var import_common8, Pipeline;
2510
+ var init_Pipeline = __esm({
2511
+ "src/core/navigators/Pipeline.ts"() {
2512
+ "use strict";
2513
+ import_common8 = require("@vue-skuilder/common");
2514
+ init_navigators();
2515
+ init_logger();
2516
+ init_orchestration();
2517
+ Pipeline = class extends ContentNavigator {
2518
+ generator;
2519
+ filters;
2520
+ /**
2521
+ * Create a new pipeline.
2522
+ *
2523
+ * @param generator - The generator (or CompositeGenerator) that produces candidates
2524
+ * @param filters - Filters to apply sequentially (order doesn't matter for multipliers)
2525
+ * @param user - User database interface
2526
+ * @param course - Course database interface
2527
+ */
2528
+ constructor(generator, filters, user, course) {
2529
+ super();
2530
+ this.generator = generator;
2531
+ this.filters = filters;
2532
+ this.user = user;
2533
+ this.course = course;
2534
+ course.getCourseConfig().then((cfg) => {
2535
+ logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`);
2536
+ }).catch((e) => {
2537
+ logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`);
2538
+ });
2539
+ logPipelineConfig(generator, filters);
2540
+ }
2541
+ /**
2542
+ * Get weighted cards by running generator and applying filters.
2543
+ *
2544
+ * 1. Build shared context (user ELO, etc.)
2545
+ * 2. Get candidates from generator (passing context)
2546
+ * 3. Batch hydrate tags for all candidates
2547
+ * 4. Apply each filter sequentially
2548
+ * 5. Remove zero-score cards
2549
+ * 6. Sort by score descending
2550
+ * 7. Return top N
2551
+ *
2552
+ * @param limit - Maximum number of cards to return
2553
+ * @returns Cards sorted by score descending
2554
+ */
2555
+ async getWeightedCards(limit) {
2556
+ const context = await this.buildContext();
2557
+ const overFetchMultiplier = 2 + this.filters.length * 0.5;
2558
+ const fetchLimit = Math.ceil(limit * overFetchMultiplier);
2559
+ logger.debug(
2560
+ `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'`
2561
+ );
2562
+ let cards = await this.generator.getWeightedCards(fetchLimit, context);
2563
+ const generatedCount = cards.length;
2564
+ logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`);
2565
+ cards = await this.hydrateTags(cards);
2566
+ for (const filter of this.filters) {
2567
+ const beforeCount = cards.length;
2568
+ cards = await filter.transform(cards, context);
2569
+ logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeCount} \u2192 ${cards.length} cards`);
2570
+ }
2571
+ cards = cards.filter((c) => c.score > 0);
2572
+ cards.sort((a, b) => b.score - a.score);
2573
+ const result = cards.slice(0, limit);
2574
+ const topScores = result.slice(0, 3).map((c) => c.score);
2575
+ logExecutionSummary(
2576
+ this.generator.name,
2577
+ generatedCount,
2578
+ this.filters.length,
2579
+ result.length,
2580
+ topScores
2581
+ );
2582
+ logCardProvenance(result, 3);
2583
+ return result;
2584
+ }
2585
+ /**
2586
+ * Batch hydrate tags for all cards.
2587
+ *
2588
+ * Fetches tags for all cards in a single database query and attaches them
2589
+ * to the WeightedCard objects. Filters can then use card.tags instead of
2590
+ * making individual getAppliedTags() calls.
2591
+ *
2592
+ * @param cards - Cards to hydrate
2593
+ * @returns Cards with tags populated
2594
+ */
2595
+ async hydrateTags(cards) {
2596
+ if (cards.length === 0) {
2597
+ return cards;
2598
+ }
2599
+ const cardIds = cards.map((c) => c.cardId);
2600
+ const tagsByCard = await this.course.getAppliedTagsBatch(cardIds);
2601
+ logTagHydration(cards, tagsByCard);
2602
+ return cards.map((card) => ({
2603
+ ...card,
2604
+ tags: tagsByCard.get(card.cardId) ?? []
2605
+ }));
2606
+ }
2607
+ /**
2608
+ * Build shared context for generator and filters.
2609
+ *
2610
+ * Called once per getWeightedCards() invocation.
2611
+ * Contains data that the generator and multiple filters might need.
2612
+ *
2613
+ * The context satisfies both GeneratorContext and FilterContext interfaces.
2614
+ */
2615
+ async buildContext() {
2616
+ let userElo = 1e3;
2617
+ try {
2618
+ const courseReg = await this.user.getCourseRegDoc(this.course.getCourseID());
2619
+ const courseElo = (0, import_common8.toCourseElo)(courseReg.elo);
2620
+ userElo = courseElo.global.score;
2621
+ } catch (e) {
2622
+ logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`);
2623
+ }
2624
+ const orchestration = await createOrchestrationContext(this.user, this.course);
2625
+ return {
2626
+ user: this.user,
2627
+ course: this.course,
2628
+ userElo,
2629
+ orchestration
2630
+ };
2631
+ }
2632
+ /**
2633
+ * Get the course ID for this pipeline.
2634
+ */
2635
+ getCourseID() {
2636
+ return this.course.getCourseID();
2637
+ }
2638
+ /**
2639
+ * Get orchestration context for outcome recording.
2640
+ */
2641
+ async getOrchestrationContext() {
2642
+ return createOrchestrationContext(this.user, this.course);
2643
+ }
2644
+ /**
2645
+ * Get IDs of all strategies in this pipeline.
2646
+ * Used to record which strategies contributed to an outcome.
2647
+ */
2648
+ getStrategyIds() {
2649
+ const ids = [];
2650
+ const extractId = (obj) => {
2651
+ if (obj.strategyId) return obj.strategyId;
2652
+ return null;
2653
+ };
2654
+ const genId = extractId(this.generator);
2655
+ if (genId) ids.push(genId);
2656
+ if (this.generator.generators && Array.isArray(this.generator.generators)) {
2657
+ this.generator.generators.forEach((g) => {
2658
+ const subId = extractId(g);
2659
+ if (subId) ids.push(subId);
2660
+ });
2661
+ }
2662
+ for (const filter of this.filters) {
2663
+ const fId = extractId(filter);
2664
+ if (fId) ids.push(fId);
2665
+ }
2666
+ return [...new Set(ids)];
2667
+ }
2668
+ };
2669
+ }
2670
+ });
2671
+
2672
+ // src/core/navigators/PipelineAssembler.ts
2673
+ var PipelineAssembler_exports = {};
2674
+ __export(PipelineAssembler_exports, {
2675
+ PipelineAssembler: () => PipelineAssembler
2676
+ });
2677
+ var PipelineAssembler;
2678
+ var init_PipelineAssembler = __esm({
2679
+ "src/core/navigators/PipelineAssembler.ts"() {
2680
+ "use strict";
2681
+ init_navigators();
2682
+ init_WeightedFilter();
2683
+ init_Pipeline();
2684
+ init_types_legacy();
2685
+ init_logger();
2686
+ init_CompositeGenerator();
2687
+ PipelineAssembler = class {
2688
+ /**
2689
+ * Assembles a navigation pipeline from strategy documents.
2690
+ *
2691
+ * 1. Separates into generators and filters by role
2692
+ * 2. Validates at least one generator exists (or creates default ELO)
2693
+ * 3. Instantiates generators - wraps multiple in CompositeGenerator
2694
+ * 4. Instantiates filters
2695
+ * 5. Returns Pipeline(generator, filters)
2696
+ *
2697
+ * @param input - Strategy documents plus user/course interfaces
2698
+ * @returns Assembled pipeline and any warnings
2699
+ */
2700
+ async assemble(input) {
2701
+ const { strategies, user, course } = input;
2702
+ const warnings = [];
2703
+ if (strategies.length === 0) {
2704
+ return {
2705
+ pipeline: null,
2706
+ generatorStrategies: [],
2707
+ filterStrategies: [],
2708
+ warnings
2709
+ };
2710
+ }
2711
+ const generatorStrategies = [];
2712
+ const filterStrategies = [];
2713
+ for (const s of strategies) {
2714
+ if (isGenerator(s.implementingClass)) {
2715
+ generatorStrategies.push(s);
2716
+ } else if (isFilter(s.implementingClass)) {
2717
+ filterStrategies.push(s);
2718
+ } else {
2719
+ warnings.push(`Unknown strategy type '${s.implementingClass}', skipping: ${s.name}`);
2720
+ }
2721
+ }
2722
+ if (generatorStrategies.length === 0) {
2723
+ if (filterStrategies.length > 0) {
2724
+ logger.debug(
2725
+ "[PipelineAssembler] No generator found, using default ELO with configured filters"
2726
+ );
2727
+ generatorStrategies.push(this.makeDefaultEloStrategy(course.getCourseID()));
2728
+ } else {
2729
+ warnings.push("No generator strategy found");
2730
+ return {
2731
+ pipeline: null,
2732
+ generatorStrategies: [],
2733
+ filterStrategies: [],
2734
+ warnings
2735
+ };
2736
+ }
2737
+ }
2738
+ let generator;
2739
+ if (generatorStrategies.length === 1) {
2740
+ const nav = await ContentNavigator.create(user, course, generatorStrategies[0]);
2741
+ generator = nav;
2742
+ logger.debug(`[PipelineAssembler] Using single generator: ${generatorStrategies[0].name}`);
2743
+ } else {
2744
+ logger.debug(
2745
+ `[PipelineAssembler] Using CompositeGenerator for ${generatorStrategies.length} generators: ${generatorStrategies.map((g) => g.name).join(", ")}`
2746
+ );
2747
+ generator = await CompositeGenerator.fromStrategies(user, course, generatorStrategies);
2748
+ }
2749
+ const filters = [];
2750
+ const sortedFilterStrategies = [...filterStrategies].sort(
2751
+ (a, b) => a.name.localeCompare(b.name)
2752
+ );
2753
+ for (const filterStrategy of sortedFilterStrategies) {
2754
+ try {
2755
+ const nav = await ContentNavigator.create(user, course, filterStrategy);
2756
+ if ("transform" in nav && typeof nav.transform === "function") {
2757
+ let filter = nav;
2758
+ if (filterStrategy.learnable) {
2759
+ filter = new WeightedFilter(
2760
+ filter,
2761
+ filterStrategy.learnable,
2762
+ filterStrategy.staticWeight,
2763
+ filterStrategy._id
2764
+ );
2765
+ }
2766
+ filters.push(filter);
2767
+ logger.debug(`[PipelineAssembler] Added filter: ${filterStrategy.name}`);
2768
+ } else {
2769
+ warnings.push(
2770
+ `Filter '${filterStrategy.name}' does not implement CardFilter.transform(), skipping`
2771
+ );
2772
+ }
2773
+ } catch (e) {
2774
+ warnings.push(`Failed to instantiate filter '${filterStrategy.name}': ${e}`);
2775
+ }
2776
+ }
2777
+ const pipeline = new Pipeline(generator, filters, user, course);
2778
+ logger.debug(
2779
+ `[PipelineAssembler] Assembled pipeline with ${generatorStrategies.length} generator(s) and ${filters.length} filter(s)`
2780
+ );
2781
+ return {
2782
+ pipeline,
2783
+ generatorStrategies,
2784
+ filterStrategies: sortedFilterStrategies,
2785
+ warnings
2786
+ };
2787
+ }
2788
+ /**
2789
+ * Creates a default ELO generator strategy.
2790
+ * Used when filters are configured but no generator is specified.
2791
+ */
2792
+ makeDefaultEloStrategy(courseId) {
2793
+ return {
2794
+ _id: "NAVIGATION_STRATEGY-ELO-default",
2795
+ course: courseId,
2796
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2797
+ name: "ELO (default)",
2798
+ description: "Default ELO-based generator",
2799
+ implementingClass: "elo" /* ELO */,
2800
+ serializedData: ""
2801
+ };
2802
+ }
2803
+ };
2804
+ }
2805
+ });
2806
+
2807
+ // src/core/navigators/defaults.ts
2808
+ var defaults_exports = {};
2809
+ __export(defaults_exports, {
2810
+ createDefaultEloStrategy: () => createDefaultEloStrategy,
2811
+ createDefaultPipeline: () => createDefaultPipeline,
2812
+ createDefaultSrsStrategy: () => createDefaultSrsStrategy
2813
+ });
2814
+ function createDefaultEloStrategy(courseId) {
2815
+ return {
2816
+ _id: "NAVIGATION_STRATEGY-ELO-default",
2817
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2818
+ name: "ELO (default)",
2819
+ description: "Default ELO-based navigation strategy for new cards",
2820
+ implementingClass: "elo" /* ELO */,
2821
+ course: courseId,
2822
+ serializedData: ""
2823
+ };
2824
+ }
2825
+ function createDefaultSrsStrategy(courseId) {
2826
+ return {
2827
+ _id: "NAVIGATION_STRATEGY-SRS-default",
2828
+ docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
2829
+ name: "SRS (default)",
2830
+ description: "Default SRS-based navigation strategy for reviews",
2831
+ implementingClass: "srs" /* SRS */,
2832
+ course: courseId,
2833
+ serializedData: ""
2834
+ };
2835
+ }
2836
+ function createDefaultPipeline(user, course) {
2837
+ const courseId = course.getCourseID();
2838
+ const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
2839
+ const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
2840
+ const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
2841
+ const eloDistanceFilter = createEloDistanceFilter();
2842
+ return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
2843
+ }
2844
+ var init_defaults = __esm({
2845
+ "src/core/navigators/defaults.ts"() {
2846
+ "use strict";
2847
+ init_navigators();
2848
+ init_Pipeline();
2849
+ init_CompositeGenerator();
2850
+ init_elo();
2851
+ init_srs();
2852
+ init_eloDistance();
2853
+ init_types_legacy();
2854
+ }
2855
+ });
2856
+
2857
+ // import("./**/*") in src/core/navigators/index.ts
2858
+ var globImport;
2859
+ var init_3 = __esm({
2860
+ 'import("./**/*") in src/core/navigators/index.ts'() {
2861
+ globImport = __glob({
2862
+ "./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
2863
+ "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
2864
+ "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
2865
+ "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
2866
+ "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
2867
+ "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
2868
+ "./filters/index.ts": () => Promise.resolve().then(() => (init_filters(), filters_exports)),
2869
+ "./filters/inferredPreferenceStub.ts": () => Promise.resolve().then(() => (init_inferredPreferenceStub(), inferredPreferenceStub_exports)),
2870
+ "./filters/interferenceMitigator.ts": () => Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
2871
+ "./filters/relativePriority.ts": () => Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
2872
+ "./filters/types.ts": () => Promise.resolve().then(() => (init_types2(), types_exports2)),
2873
+ "./filters/userGoalStub.ts": () => Promise.resolve().then(() => (init_userGoalStub(), userGoalStub_exports)),
2874
+ "./filters/userTagPreference.ts": () => Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports)),
2875
+ "./generators/CompositeGenerator.ts": () => Promise.resolve().then(() => (init_CompositeGenerator(), CompositeGenerator_exports)),
2876
+ "./generators/elo.ts": () => Promise.resolve().then(() => (init_elo(), elo_exports)),
2877
+ "./generators/index.ts": () => Promise.resolve().then(() => (init_generators(), generators_exports)),
2878
+ "./generators/srs.ts": () => Promise.resolve().then(() => (init_srs(), srs_exports)),
2879
+ "./generators/types.ts": () => Promise.resolve().then(() => (init_types(), types_exports)),
2880
+ "./index.ts": () => Promise.resolve().then(() => (init_navigators(), navigators_exports))
2881
+ });
2882
+ }
2883
+ });
2884
+
2885
+ // src/core/navigators/index.ts
2886
+ var navigators_exports = {};
2887
+ __export(navigators_exports, {
2888
+ ContentNavigator: () => ContentNavigator,
2889
+ NavigatorRole: () => NavigatorRole,
2890
+ NavigatorRoles: () => NavigatorRoles,
2891
+ Navigators: () => Navigators,
2892
+ getCardOrigin: () => getCardOrigin,
2893
+ getRegisteredNavigator: () => getRegisteredNavigator,
2894
+ getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
2895
+ hasRegisteredNavigator: () => hasRegisteredNavigator,
2896
+ initializeNavigatorRegistry: () => initializeNavigatorRegistry,
2897
+ isFilter: () => isFilter,
2898
+ isGenerator: () => isGenerator,
2899
+ registerNavigator: () => registerNavigator
2900
+ });
2901
+ function registerNavigator(implementingClass, constructor) {
2902
+ navigatorRegistry.set(implementingClass, constructor);
2903
+ logger.debug(`[NavigatorRegistry] Registered: ${implementingClass}`);
2904
+ }
2905
+ function getRegisteredNavigator(implementingClass) {
2906
+ return navigatorRegistry.get(implementingClass);
2907
+ }
2908
+ function hasRegisteredNavigator(implementingClass) {
2909
+ return navigatorRegistry.has(implementingClass);
2910
+ }
2911
+ function getRegisteredNavigatorNames() {
2912
+ return Array.from(navigatorRegistry.keys());
2913
+ }
2914
+ async function initializeNavigatorRegistry() {
2915
+ logger.debug("[NavigatorRegistry] Initializing built-in navigators...");
2916
+ const [eloModule, srsModule] = await Promise.all([
2917
+ Promise.resolve().then(() => (init_elo(), elo_exports)),
2918
+ Promise.resolve().then(() => (init_srs(), srs_exports))
2919
+ ]);
2920
+ registerNavigator("elo", eloModule.default);
2921
+ registerNavigator("srs", srsModule.default);
2922
+ const [
2923
+ hierarchyModule,
2924
+ interferenceModule,
2925
+ relativePriorityModule,
2926
+ userTagPreferenceModule
2927
+ ] = await Promise.all([
2928
+ Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
2929
+ Promise.resolve().then(() => (init_interferenceMitigator(), interferenceMitigator_exports)),
2930
+ Promise.resolve().then(() => (init_relativePriority(), relativePriority_exports)),
2931
+ Promise.resolve().then(() => (init_userTagPreference(), userTagPreference_exports))
2932
+ ]);
2933
+ registerNavigator("hierarchyDefinition", hierarchyModule.default);
2934
+ registerNavigator("interferenceMitigator", interferenceModule.default);
2935
+ registerNavigator("relativePriority", relativePriorityModule.default);
2936
+ registerNavigator("userTagPreference", userTagPreferenceModule.default);
2937
+ logger.debug(
2938
+ `[NavigatorRegistry] Initialized ${navigatorRegistry.size} navigators: ${getRegisteredNavigatorNames().join(", ")}`
2939
+ );
2940
+ }
2941
+ function getCardOrigin(card) {
2942
+ if (card.provenance.length === 0) {
2943
+ throw new Error("Card has no provenance - cannot determine origin");
2944
+ }
2945
+ const firstEntry = card.provenance[0];
2946
+ const reason = firstEntry.reason.toLowerCase();
2947
+ if (reason.includes("failed")) {
2948
+ return "failed";
2949
+ }
2950
+ if (reason.includes("review")) {
2951
+ return "review";
2952
+ }
2953
+ return "new";
2954
+ }
2955
+ function isGenerator(impl) {
2956
+ return NavigatorRoles[impl] === "generator" /* GENERATOR */;
2957
+ }
2958
+ function isFilter(impl) {
2959
+ return NavigatorRoles[impl] === "filter" /* FILTER */;
2960
+ }
2961
+ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigator;
2962
+ var init_navigators = __esm({
2963
+ "src/core/navigators/index.ts"() {
2964
+ "use strict";
2965
+ init_logger();
2966
+ init_();
2967
+ init_2();
2968
+ init_3();
2969
+ navigatorRegistry = /* @__PURE__ */ new Map();
2970
+ Navigators = /* @__PURE__ */ ((Navigators2) => {
2971
+ Navigators2["ELO"] = "elo";
2972
+ Navigators2["SRS"] = "srs";
2973
+ Navigators2["HIERARCHY"] = "hierarchyDefinition";
2974
+ Navigators2["INTERFERENCE"] = "interferenceMitigator";
2975
+ Navigators2["RELATIVE_PRIORITY"] = "relativePriority";
2976
+ Navigators2["USER_TAG_PREFERENCE"] = "userTagPreference";
2977
+ return Navigators2;
2978
+ })(Navigators || {});
2979
+ NavigatorRole = /* @__PURE__ */ ((NavigatorRole2) => {
2980
+ NavigatorRole2["GENERATOR"] = "generator";
2981
+ NavigatorRole2["FILTER"] = "filter";
2982
+ return NavigatorRole2;
2983
+ })(NavigatorRole || {});
2984
+ NavigatorRoles = {
2985
+ ["elo" /* ELO */]: "generator" /* GENERATOR */,
2986
+ ["srs" /* SRS */]: "generator" /* GENERATOR */,
2987
+ ["hierarchyDefinition" /* HIERARCHY */]: "filter" /* FILTER */,
2988
+ ["interferenceMitigator" /* INTERFERENCE */]: "filter" /* FILTER */,
2989
+ ["relativePriority" /* RELATIVE_PRIORITY */]: "filter" /* FILTER */,
2990
+ ["userTagPreference" /* USER_TAG_PREFERENCE */]: "filter" /* FILTER */
2991
+ };
2992
+ ContentNavigator = class {
2993
+ /** User interface for this navigation session */
2994
+ user;
2995
+ /** Course interface for this navigation session */
2996
+ course;
2997
+ /** Human-readable name for this strategy instance (from ContentNavigationStrategyData.name) */
2998
+ strategyName;
2999
+ /** Unique document ID for this strategy instance (from ContentNavigationStrategyData._id) */
3000
+ strategyId;
3001
+ /** Evolutionary weighting configuration */
3002
+ learnable;
3003
+ /** Whether to bypass deviation (manual/static weighting) */
3004
+ staticWeight;
3005
+ /**
3006
+ * Constructor for standard navigators.
3007
+ * Call this from subclass constructors to initialize common fields.
3008
+ *
3009
+ * Note: CompositeGenerator and Pipeline call super() without args, then set
3010
+ * user/course fields directly if needed.
3011
+ */
3012
+ constructor(user, course, strategyData) {
3013
+ this.user = user;
3014
+ this.course = course;
3015
+ if (strategyData) {
3016
+ this.strategyName = strategyData.name;
3017
+ this.strategyId = strategyData._id;
3018
+ this.learnable = strategyData.learnable;
3019
+ this.staticWeight = strategyData.staticWeight;
3020
+ }
3021
+ }
3022
+ // ============================================================================
3023
+ // STRATEGY STATE HELPERS
3024
+ // ============================================================================
3025
+ //
3026
+ // These methods allow strategies to persist their own state (user preferences,
3027
+ // learned patterns, temporal tracking) in the user database.
3028
+ //
3029
+ // ============================================================================
3030
+ /**
3031
+ * Unique key identifying this strategy for state storage.
3032
+ *
3033
+ * Defaults to the constructor name (e.g., "UserTagPreferenceFilter").
3034
+ * Override in subclasses if multiple instances of the same strategy type
3035
+ * need separate state storage.
3036
+ */
3037
+ get strategyKey() {
3038
+ return this.constructor.name;
3039
+ }
3040
+ /**
3041
+ * Get this strategy's persisted state for the current course.
3042
+ *
3043
+ * @returns The strategy's data payload, or null if no state exists
3044
+ * @throws Error if user or course is not initialized
3045
+ */
3046
+ async getStrategyState() {
3047
+ if (!this.user || !this.course) {
3048
+ throw new Error(
3049
+ `Cannot get strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
3050
+ );
3051
+ }
3052
+ return this.user.getStrategyState(this.course.getCourseID(), this.strategyKey);
3053
+ }
3054
+ /**
3055
+ * Persist this strategy's state for the current course.
3056
+ *
3057
+ * @param data - The strategy's data payload to store
3058
+ * @throws Error if user or course is not initialized
3059
+ */
3060
+ async putStrategyState(data) {
3061
+ if (!this.user || !this.course) {
3062
+ throw new Error(
3063
+ `Cannot put strategy state: navigator not properly initialized. Ensure user and course are provided to constructor.`
3064
+ );
3065
+ }
3066
+ return this.user.putStrategyState(this.course.getCourseID(), this.strategyKey, data);
3067
+ }
3068
+ /**
3069
+ * Factory method to create navigator instances.
3070
+ *
3071
+ * First checks the navigator registry for a pre-registered constructor.
3072
+ * If not found, falls back to dynamic import (for custom navigators).
3073
+ *
3074
+ * For reliable operation in test environments, call initializeNavigatorRegistry()
3075
+ * before using this method.
3076
+ *
3077
+ * @param user - User interface
3078
+ * @param course - Course interface
3079
+ * @param strategyData - Strategy configuration document
3080
+ * @returns the runtime object used to steer a study session.
3081
+ */
3082
+ static async create(user, course, strategyData) {
3083
+ const implementingClass = strategyData.implementingClass;
3084
+ const RegisteredImpl = getRegisteredNavigator(implementingClass);
3085
+ if (RegisteredImpl) {
3086
+ logger.debug(`[ContentNavigator.create] Using registered navigator: ${implementingClass}`);
3087
+ return new RegisteredImpl(user, course, strategyData);
3088
+ }
3089
+ logger.debug(
3090
+ `[ContentNavigator.create] Navigator not in registry, attempting dynamic import: ${implementingClass}`
3091
+ );
3092
+ let NavigatorImpl;
3093
+ const variations = [".ts", ".js", ""];
3094
+ for (const ext of variations) {
3095
+ try {
3096
+ const module2 = await globImport_generators(`./generators/${implementingClass}${ext}`);
3097
+ NavigatorImpl = module2.default;
3098
+ if (NavigatorImpl) break;
3099
+ } catch (e) {
3100
+ logger.debug(`Failed to load generator ${implementingClass}${ext}:`, e);
3101
+ }
3102
+ try {
3103
+ const module2 = await globImport_filters(`./filters/${implementingClass}${ext}`);
3104
+ NavigatorImpl = module2.default;
3105
+ if (NavigatorImpl) break;
3106
+ } catch (e) {
3107
+ logger.debug(`Failed to load filter ${implementingClass}${ext}:`, e);
3108
+ }
3109
+ try {
3110
+ const module2 = await globImport(`./${implementingClass}${ext}`);
3111
+ NavigatorImpl = module2.default;
3112
+ if (NavigatorImpl) break;
3113
+ } catch (e) {
3114
+ logger.debug(`Failed to load legacy ${implementingClass}${ext}:`, e);
3115
+ }
3116
+ if (NavigatorImpl) break;
3117
+ }
3118
+ if (!NavigatorImpl) {
3119
+ throw new Error(`Could not load navigator implementation for: ${implementingClass}`);
3120
+ }
3121
+ return new NavigatorImpl(user, course, strategyData);
1548
3122
  }
1549
3123
  /**
1550
- * Get review cards scored by urgency.
1551
- *
1552
- * Score formula combines:
1553
- * - Relative overdueness: hoursOverdue / intervalHours
1554
- * - Interval recency: exponential decay favoring shorter intervals
3124
+ * Get cards with suitability scores and provenance trails.
1555
3125
  *
1556
- * Cards not yet due are excluded (not scored as 0).
3126
+ * **This is the PRIMARY API for navigation strategies.**
1557
3127
  *
1558
- * This method supports both the legacy signature (limit only) and the
1559
- * CardGenerator interface signature (limit, context).
3128
+ * Returns cards ranked by suitability score (0-1). Higher scores indicate
3129
+ * better candidates for presentation. Each card includes a provenance trail
3130
+ * documenting how strategies contributed to the final score.
1560
3131
  *
1561
- * @param limit - Maximum number of cards to return
1562
- * @param _context - Optional GeneratorContext (currently unused, but required for interface)
1563
- */
1564
- async getWeightedCards(limit, _context) {
1565
- if (!this.user || !this.course) {
1566
- throw new Error("SRSNavigator requires user and course to be set");
1567
- }
1568
- const reviews = await this.user.getPendingReviews(this.course.getCourseID());
1569
- const now = import_moment3.default.utc();
1570
- const dueReviews = reviews.filter((r) => now.isAfter(import_moment3.default.utc(r.reviewTime)));
1571
- const scored = dueReviews.map((review) => {
1572
- const { score, reason } = this.computeUrgencyScore(review, now);
1573
- return {
1574
- cardId: review.cardId,
1575
- courseId: review.courseId,
1576
- score,
1577
- reviewID: review._id,
1578
- provenance: [
1579
- {
1580
- strategy: "srs",
1581
- strategyName: this.strategyName || this.name,
1582
- strategyId: this.strategyId || "NAVIGATION_STRATEGY-SRS-default",
1583
- action: "generated",
1584
- score,
1585
- reason
1586
- }
1587
- ]
1588
- };
1589
- });
1590
- logger.debug(`[srsNav] got ${scored.length} weighted cards`);
1591
- return scored.sort((a, b) => b.score - a.score).slice(0, limit);
1592
- }
1593
- /**
1594
- * Compute urgency score for a review card.
3132
+ * ## Implementation Required
3133
+ * All navigation strategies MUST override this method. The base class does
3134
+ * not provide a default implementation.
1595
3135
  *
1596
- * Two factors:
1597
- * 1. Relative overdueness = hoursOverdue / intervalHours
1598
- * - 2 days overdue on 3-day interval = 0.67 (urgent)
1599
- * - 2 days overdue on 180-day interval = 0.01 (not urgent)
3136
+ * ## For Generators
3137
+ * Override this method to generate candidates and compute scores based on
3138
+ * your strategy's logic (e.g., ELO proximity, review urgency). Create the
3139
+ * initial provenance entry with action='generated'.
1600
3140
  *
1601
- * 2. Interval recency factor = 0.3 + 0.7 * exp(-intervalHours / 720)
1602
- * - 24h interval ~1.0 (very recent learning)
1603
- * - 30 days (720h) → ~0.56
1604
- * - 180 days → ~0.30
3141
+ * ## For Filters
3142
+ * Filters should implement the CardFilter interface instead and be composed
3143
+ * via Pipeline. Filters do not directly implement getWeightedCards().
1605
3144
  *
1606
- * Combined: base 0.5 + weighted average of factors * 0.45
1607
- * Result range: approximately 0.5 to 0.95
3145
+ * @param limit - Maximum cards to return
3146
+ * @returns Cards sorted by score descending, with provenance trails
1608
3147
  */
1609
- computeUrgencyScore(review, now) {
1610
- const scheduledAt = import_moment3.default.utc(review.scheduledAt);
1611
- const due = import_moment3.default.utc(review.reviewTime);
1612
- const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
1613
- const hoursOverdue = now.diff(due, "hours");
1614
- const relativeOverdue = hoursOverdue / intervalHours;
1615
- const recencyFactor = 0.3 + 0.7 * Math.exp(-intervalHours / 720);
1616
- const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
1617
- const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
1618
- const score = Math.min(0.95, 0.5 + urgency * 0.45);
1619
- const reason = `${Math.round(hoursOverdue)}h overdue (interval: ${Math.round(intervalHours)}h, relative: ${relativeOverdue.toFixed(2)}), recency: ${recencyFactor.toFixed(2)}, review`;
1620
- return { score, reason };
3148
+ async getWeightedCards(_limit) {
3149
+ throw new Error(`${this.constructor.name} must implement getWeightedCards(). `);
1621
3150
  }
1622
3151
  };
1623
3152
  }
1624
3153
  });
1625
3154
 
1626
- // src/core/navigators/filters/eloDistance.ts
1627
- function computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier) {
1628
- const normalizedDistance = distance / halfLife;
1629
- const decay = Math.exp(-(normalizedDistance * normalizedDistance));
1630
- return minMultiplier + (maxMultiplier - minMultiplier) * decay;
1631
- }
1632
- function createEloDistanceFilter(config) {
1633
- const halfLife = config?.halfLife ?? DEFAULT_HALF_LIFE;
1634
- const minMultiplier = config?.minMultiplier ?? DEFAULT_MIN_MULTIPLIER;
1635
- const maxMultiplier = config?.maxMultiplier ?? DEFAULT_MAX_MULTIPLIER;
1636
- return {
1637
- name: "ELO Distance Filter",
1638
- async transform(cards, context) {
1639
- const { course, userElo } = context;
1640
- const cardIds = cards.map((c) => c.cardId);
1641
- const cardElos = await course.getCardEloData(cardIds);
1642
- return cards.map((card, i) => {
1643
- const cardElo = cardElos[i]?.global?.score ?? 1e3;
1644
- const distance = Math.abs(cardElo - userElo);
1645
- const multiplier = computeMultiplier(distance, halfLife, minMultiplier, maxMultiplier);
1646
- const newScore = card.score * multiplier;
1647
- const action = multiplier < maxMultiplier - 0.01 ? "penalized" : "passed";
1648
- return {
1649
- ...card,
1650
- score: newScore,
1651
- provenance: [
1652
- ...card.provenance,
1653
- {
1654
- strategy: "eloDistance",
1655
- strategyName: "ELO Distance Filter",
1656
- strategyId: "ELO_DISTANCE_FILTER",
1657
- action,
1658
- score: newScore,
1659
- reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userElo)}) \u2192 ${multiplier.toFixed(2)}x`
1660
- }
1661
- ]
1662
- };
1663
- });
1664
- }
1665
- };
1666
- }
1667
- var DEFAULT_HALF_LIFE, DEFAULT_MIN_MULTIPLIER, DEFAULT_MAX_MULTIPLIER;
1668
- var init_eloDistance = __esm({
1669
- "src/core/navigators/filters/eloDistance.ts"() {
1670
- "use strict";
1671
- DEFAULT_HALF_LIFE = 200;
1672
- DEFAULT_MIN_MULTIPLIER = 0.3;
1673
- DEFAULT_MAX_MULTIPLIER = 1;
1674
- }
1675
- });
1676
-
1677
- // src/core/navigators/defaults.ts
1678
- function createDefaultEloStrategy(courseId) {
1679
- return {
1680
- _id: "NAVIGATION_STRATEGY-ELO-default",
1681
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1682
- name: "ELO (default)",
1683
- description: "Default ELO-based navigation strategy for new cards",
1684
- implementingClass: "elo" /* ELO */,
1685
- course: courseId,
1686
- serializedData: ""
1687
- };
1688
- }
1689
- function createDefaultSrsStrategy(courseId) {
1690
- return {
1691
- _id: "NAVIGATION_STRATEGY-SRS-default",
1692
- docType: "NAVIGATION_STRATEGY" /* NAVIGATION_STRATEGY */,
1693
- name: "SRS (default)",
1694
- description: "Default SRS-based navigation strategy for reviews",
1695
- implementingClass: "srs" /* SRS */,
1696
- course: courseId,
1697
- serializedData: ""
1698
- };
1699
- }
1700
- function createDefaultPipeline(user, course) {
1701
- const courseId = course.getCourseID();
1702
- const eloNavigator = new ELONavigator(user, course, createDefaultEloStrategy(courseId));
1703
- const srsNavigator = new SRSNavigator(user, course, createDefaultSrsStrategy(courseId));
1704
- const compositeGenerator = new CompositeGenerator([eloNavigator, srsNavigator]);
1705
- const eloDistanceFilter = createEloDistanceFilter();
1706
- return new Pipeline(compositeGenerator, [eloDistanceFilter], user, course);
1707
- }
1708
- var init_defaults = __esm({
1709
- "src/core/navigators/defaults.ts"() {
1710
- "use strict";
1711
- init_navigators();
1712
- init_Pipeline();
1713
- init_CompositeGenerator();
1714
- init_elo();
1715
- init_srs();
1716
- init_eloDistance();
1717
- init_types_legacy();
1718
- }
1719
- });
1720
-
1721
3155
  // src/impl/couch/courseDB.ts
1722
3156
  function randIntWeightedTowardZero(n) {
1723
3157
  return Math.floor(Math.random() * Math.random() * Math.random() * n);
@@ -1794,11 +3228,11 @@ ${JSON.stringify(config)}
1794
3228
  function isSuccessRow(row) {
1795
3229
  return "doc" in row && row.doc !== null && row.doc !== void 0;
1796
3230
  }
1797
- var import_common7, CoursesDB, CourseDB;
3231
+ var import_common9, CoursesDB, CourseDB;
1798
3232
  var init_courseDB = __esm({
1799
3233
  "src/impl/couch/courseDB.ts"() {
1800
3234
  "use strict";
1801
- import_common7 = require("@vue-skuilder/common");
3235
+ import_common9 = require("@vue-skuilder/common");
1802
3236
  init_couch();
1803
3237
  init_updateQueue();
1804
3238
  init_types_legacy();
@@ -1920,14 +3354,14 @@ var init_courseDB = __esm({
1920
3354
  docs.rows.forEach((r) => {
1921
3355
  if (isSuccessRow(r)) {
1922
3356
  if (r.doc && r.doc.elo) {
1923
- ret.push((0, import_common7.toCourseElo)(r.doc.elo));
3357
+ ret.push((0, import_common9.toCourseElo)(r.doc.elo));
1924
3358
  } else {
1925
3359
  logger.warn("no elo data for card: " + r.id);
1926
- ret.push((0, import_common7.blankCourseElo)());
3360
+ ret.push((0, import_common9.blankCourseElo)());
1927
3361
  }
1928
3362
  } else {
1929
3363
  logger.warn("no elo data for card: " + JSON.stringify(r));
1930
- ret.push((0, import_common7.blankCourseElo)());
3364
+ ret.push((0, import_common9.blankCourseElo)());
1931
3365
  }
1932
3366
  });
1933
3367
  return ret;
@@ -2122,7 +3556,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
2122
3556
  async getCourseTagStubs() {
2123
3557
  return getCourseTagStubs(this.id);
2124
3558
  }
2125
- async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0, import_common7.blankCourseElo)()) {
3559
+ async addNote(codeCourse, shape, data, author, tags, uploads, elo = (0, import_common9.blankCourseElo)()) {
2126
3560
  try {
2127
3561
  const resp = await addNote55(this.id, codeCourse, shape, data, author, tags, uploads, elo);
2128
3562
  if (resp.ok) {
@@ -2131,19 +3565,19 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
2131
3565
  `[courseDB.addNote] Note added but card creation failed: ${resp.cardCreationError}`
2132
3566
  );
2133
3567
  return {
2134
- status: import_common7.Status.error,
3568
+ status: import_common9.Status.error,
2135
3569
  message: `Note was added but no cards were created: ${resp.cardCreationError}`,
2136
3570
  id: resp.id
2137
3571
  };
2138
3572
  }
2139
3573
  return {
2140
- status: import_common7.Status.ok,
3574
+ status: import_common9.Status.ok,
2141
3575
  message: "",
2142
3576
  id: resp.id
2143
3577
  };
2144
3578
  } else {
2145
3579
  return {
2146
- status: import_common7.Status.error,
3580
+ status: import_common9.Status.error,
2147
3581
  message: "Unexpected error adding note"
2148
3582
  };
2149
3583
  }
@@ -2155,7 +3589,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
2155
3589
  message: ${err.message}`
2156
3590
  );
2157
3591
  return {
2158
- status: import_common7.Status.error,
3592
+ status: import_common9.Status.error,
2159
3593
  message: `Error adding note to course. ${e.reason || err.message}`
2160
3594
  };
2161
3595
  }
@@ -2283,7 +3717,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
2283
3717
  const courseDoc = (await u.getCourseRegistrationsDoc()).courses.find((c) => {
2284
3718
  return c.courseID === this.id;
2285
3719
  });
2286
- targetElo = (0, import_common7.EloToNumber)(courseDoc.elo);
3720
+ targetElo = (0, import_common9.EloToNumber)(courseDoc.elo);
2287
3721
  } catch {
2288
3722
  targetElo = 1e3;
2289
3723
  }
@@ -2802,14 +4236,14 @@ var CouchDBSyncStrategy_exports = {};
2802
4236
  __export(CouchDBSyncStrategy_exports, {
2803
4237
  CouchDBSyncStrategy: () => CouchDBSyncStrategy
2804
4238
  });
2805
- var import_common8, log3, CouchDBSyncStrategy;
4239
+ var import_common10, log3, CouchDBSyncStrategy;
2806
4240
  var init_CouchDBSyncStrategy = __esm({
2807
4241
  "src/impl/couch/CouchDBSyncStrategy.ts"() {
2808
4242
  "use strict";
2809
4243
  init_factory();
2810
4244
  init_types_legacy();
2811
4245
  init_logger();
2812
- import_common8 = require("@vue-skuilder/common");
4246
+ import_common10 = require("@vue-skuilder/common");
2813
4247
  init_common();
2814
4248
  init_pouchdb_setup();
2815
4249
  init_couch();
@@ -2880,32 +4314,32 @@ var init_CouchDBSyncStrategy = __esm({
2880
4314
  }
2881
4315
  }
2882
4316
  return {
2883
- status: import_common8.Status.ok,
4317
+ status: import_common10.Status.ok,
2884
4318
  error: void 0
2885
4319
  };
2886
4320
  } else {
2887
4321
  return {
2888
- status: import_common8.Status.error,
4322
+ status: import_common10.Status.error,
2889
4323
  error: "Failed to log in after account creation"
2890
4324
  };
2891
4325
  }
2892
4326
  } else {
2893
4327
  logger.warn(`Signup not OK: ${JSON.stringify(signupRequest)}`);
2894
4328
  return {
2895
- status: import_common8.Status.error,
4329
+ status: import_common10.Status.error,
2896
4330
  error: "Account creation failed"
2897
4331
  };
2898
4332
  }
2899
4333
  } catch (e) {
2900
4334
  if (e.reason === "Document update conflict.") {
2901
4335
  return {
2902
- status: import_common8.Status.error,
4336
+ status: import_common10.Status.error,
2903
4337
  error: "This username is taken!"
2904
4338
  };
2905
4339
  }
2906
4340
  logger.error(`Error on signup: ${JSON.stringify(e)}`);
2907
4341
  return {
2908
- status: import_common8.Status.error,
4342
+ status: import_common10.Status.error,
2909
4343
  error: e.message || "Unknown error during account creation"
2910
4344
  };
2911
4345
  }
@@ -3286,13 +4720,13 @@ async function dropUserFromClassroom(user, classID) {
3286
4720
  async function getUserClassrooms(user) {
3287
4721
  return getOrCreateClassroomRegistrationsDoc(user);
3288
4722
  }
3289
- var import_common10, import_moment6, log4, BaseUser, userCoursesDoc, userClassroomsDoc;
4723
+ var import_common12, import_moment6, log4, BaseUser, userCoursesDoc, userClassroomsDoc;
3290
4724
  var init_BaseUserDB = __esm({
3291
4725
  "src/impl/common/BaseUserDB.ts"() {
3292
4726
  "use strict";
3293
4727
  init_core();
3294
4728
  init_util();
3295
- import_common10 = require("@vue-skuilder/common");
4729
+ import_common12 = require("@vue-skuilder/common");
3296
4730
  import_moment6 = __toESM(require("moment"), 1);
3297
4731
  init_types_legacy();
3298
4732
  init_logger();
@@ -3342,7 +4776,7 @@ Currently logged-in as ${this._username}.`
3342
4776
  );
3343
4777
  }
3344
4778
  const result = await this.syncStrategy.createAccount(username, password);
3345
- if (result.status === import_common10.Status.ok) {
4779
+ if (result.status === import_common12.Status.ok) {
3346
4780
  log4(`Account created successfully, updating username to ${username}`);
3347
4781
  this._username = username;
3348
4782
  try {
@@ -3384,7 +4818,7 @@ Currently logged-in as ${this._username}.`
3384
4818
  async resetUserData() {
3385
4819
  if (this.syncStrategy.canAuthenticate()) {
3386
4820
  return {
3387
- status: import_common10.Status.error,
4821
+ status: import_common12.Status.error,
3388
4822
  error: "Reset user data is only available for local-only mode. Use logout instead for remote sync."
3389
4823
  };
3390
4824
  }
@@ -3403,11 +4837,11 @@ Currently logged-in as ${this._username}.`
3403
4837
  await localDB.bulkDocs(docsToDelete);
3404
4838
  }
3405
4839
  await this.init();
3406
- return { status: import_common10.Status.ok };
4840
+ return { status: import_common12.Status.ok };
3407
4841
  } catch (error) {
3408
4842
  logger.error("Failed to reset user data:", error);
3409
4843
  return {
3410
- status: import_common10.Status.error,
4844
+ status: import_common12.Status.error,
3411
4845
  error: error instanceof Error ? error.message : "Unknown error during reset"
3412
4846
  };
3413
4847
  }
@@ -4134,6 +5568,19 @@ Currently logged-in as ${this._username}.`
4134
5568
  };
4135
5569
  await this.localDB.put(doc);
4136
5570
  }
5571
+ async putUserOutcome(record) {
5572
+ try {
5573
+ await this.localDB.put(record);
5574
+ } catch (err) {
5575
+ if (err.status === 409) {
5576
+ const existing = await this.localDB.get(record._id);
5577
+ record._rev = existing._rev;
5578
+ await this.localDB.put(record);
5579
+ } else {
5580
+ throw err;
5581
+ }
5582
+ }
5583
+ }
4137
5584
  async deleteStrategyState(courseId, strategyKey) {
4138
5585
  const docId = buildStrategyStateId(courseId, strategyKey);
4139
5586
  try {
@@ -4666,11 +6113,11 @@ var init_StaticDataUnpacker = __esm({
4666
6113
  });
4667
6114
 
4668
6115
  // src/impl/static/courseDB.ts
4669
- var import_common12, StaticCourseDB;
6116
+ var import_common14, StaticCourseDB;
4670
6117
  var init_courseDB2 = __esm({
4671
6118
  "src/impl/static/courseDB.ts"() {
4672
6119
  "use strict";
4673
- import_common12 = require("@vue-skuilder/common");
6120
+ import_common14 = require("@vue-skuilder/common");
4674
6121
  init_types_legacy();
4675
6122
  init_logger();
4676
6123
  init_defaults();
@@ -4927,7 +6374,7 @@ var init_courseDB2 = __esm({
4927
6374
  }
4928
6375
  async addNote(_codeCourse, _shape, _data, _author, _tags, _uploads, _elo) {
4929
6376
  return {
4930
- status: import_common12.Status.error,
6377
+ status: import_common14.Status.error,
4931
6378
  message: "Cannot add notes in static mode"
4932
6379
  };
4933
6380
  }
@@ -5261,6 +6708,7 @@ async function initializeDataLayer(config) {
5261
6708
  logger.warn("Data layer already initialized. Returning existing instance.");
5262
6709
  return dataLayerInstance;
5263
6710
  }
6711
+ await initializeNavigatorRegistry();
5264
6712
  if (config.options.localStoragePrefix) {
5265
6713
  ENV.LOCAL_STORAGE_PREFIX = config.options.localStoragePrefix;
5266
6714
  }
@@ -5315,6 +6763,7 @@ var init_factory = __esm({
5315
6763
  "use strict";
5316
6764
  init_common();
5317
6765
  init_logger();
6766
+ init_navigators();
5318
6767
  NOT_SET = "NOT_SET";
5319
6768
  ENV = {
5320
6769
  COUCHDB_SERVER_PROTOCOL: NOT_SET,
@@ -5326,11 +6775,11 @@ var init_factory = __esm({
5326
6775
  });
5327
6776
 
5328
6777
  // src/study/TagFilteredContentSource.ts
5329
- var import_common16, TagFilteredContentSource;
6778
+ var import_common18, TagFilteredContentSource;
5330
6779
  var init_TagFilteredContentSource = __esm({
5331
6780
  "src/study/TagFilteredContentSource.ts"() {
5332
6781
  "use strict";
5333
- import_common16 = require("@vue-skuilder/common");
6782
+ import_common18 = require("@vue-skuilder/common");
5334
6783
  init_courseDB();
5335
6784
  init_logger();
5336
6785
  TagFilteredContentSource = class {
@@ -5416,7 +6865,7 @@ var init_TagFilteredContentSource = __esm({
5416
6865
  * @returns Cards sorted by score descending (all scores = 1.0)
5417
6866
  */
5418
6867
  async getWeightedCards(limit) {
5419
- if (!(0, import_common16.hasActiveFilter)(this.filter)) {
6868
+ if (!(0, import_common18.hasActiveFilter)(this.filter)) {
5420
6869
  logger.warn("[TagFilteredContentSource] getWeightedCards called with no active filter");
5421
6870
  return [];
5422
6871
  }
@@ -5504,19 +6953,19 @@ async function getStudySource(source, user) {
5504
6953
  if (source.type === "classroom") {
5505
6954
  return await StudentClassroomDB.factory(source.id, user);
5506
6955
  } else {
5507
- if ((0, import_common17.hasActiveFilter)(source.tagFilter)) {
6956
+ if ((0, import_common19.hasActiveFilter)(source.tagFilter)) {
5508
6957
  return new TagFilteredContentSource(source.id, source.tagFilter, user);
5509
6958
  }
5510
6959
  return getDataLayer().getCourseDB(source.id);
5511
6960
  }
5512
6961
  }
5513
- var import_common17;
6962
+ var import_common19;
5514
6963
  var init_contentSource = __esm({
5515
6964
  "src/core/interfaces/contentSource.ts"() {
5516
6965
  "use strict";
5517
6966
  init_factory();
5518
6967
  init_classroomDB2();
5519
- import_common17 = require("@vue-skuilder/common");
6968
+ import_common19 = require("@vue-skuilder/common");
5520
6969
  init_TagFilteredContentSource();
5521
6970
  }
5522
6971
  });
@@ -5572,6 +7021,13 @@ var init_strategyState = __esm({
5572
7021
  }
5573
7022
  });
5574
7023
 
7024
+ // src/core/types/userOutcome.ts
7025
+ var init_userOutcome = __esm({
7026
+ "src/core/types/userOutcome.ts"() {
7027
+ "use strict";
7028
+ }
7029
+ });
7030
+
5575
7031
  // src/core/bulkImport/cardProcessor.ts
5576
7032
  async function importParsedCards(parsedCards, courseDB, config) {
5577
7033
  const results = [];
@@ -5640,7 +7096,7 @@ elo: ${elo}`;
5640
7096
  misc: {}
5641
7097
  } : void 0
5642
7098
  );
5643
- if (result.status === import_common18.Status.ok) {
7099
+ if (result.status === import_common20.Status.ok) {
5644
7100
  return {
5645
7101
  originalText,
5646
7102
  status: "success",
@@ -5684,17 +7140,17 @@ function validateProcessorConfig(config) {
5684
7140
  }
5685
7141
  return { isValid: true };
5686
7142
  }
5687
- var import_common18;
7143
+ var import_common20;
5688
7144
  var init_cardProcessor = __esm({
5689
7145
  "src/core/bulkImport/cardProcessor.ts"() {
5690
7146
  "use strict";
5691
- import_common18 = require("@vue-skuilder/common");
7147
+ import_common20 = require("@vue-skuilder/common");
5692
7148
  init_logger();
5693
7149
  }
5694
7150
  });
5695
7151
 
5696
7152
  // src/core/bulkImport/types.ts
5697
- var init_types = __esm({
7153
+ var init_types3 = __esm({
5698
7154
  "src/core/bulkImport/types.ts"() {
5699
7155
  "use strict";
5700
7156
  }
@@ -5705,7 +7161,7 @@ var init_bulkImport = __esm({
5705
7161
  "src/core/bulkImport/index.ts"() {
5706
7162
  "use strict";
5707
7163
  init_cardProcessor();
5708
- init_types();
7164
+ init_types3();
5709
7165
  }
5710
7166
  });
5711
7167
 
@@ -5717,10 +7173,12 @@ var init_core = __esm({
5717
7173
  init_types_legacy();
5718
7174
  init_user();
5719
7175
  init_strategyState();
7176
+ init_userOutcome();
5720
7177
  init_Loggable();
5721
7178
  init_util();
5722
7179
  init_navigators();
5723
7180
  init_bulkImport();
7181
+ init_orchestration();
5724
7182
  }
5725
7183
  });
5726
7184
 
@@ -5745,8 +7203,15 @@ __export(index_exports, {
5745
7203
  StaticToCouchDBMigrator: () => StaticToCouchDBMigrator,
5746
7204
  TagFilteredContentSource: () => TagFilteredContentSource,
5747
7205
  _resetDataLayer: () => _resetDataLayer,
7206
+ aggregateOutcomesForGradient: () => aggregateOutcomesForGradient,
5748
7207
  areQuestionRecords: () => areQuestionRecords,
5749
7208
  buildStrategyStateId: () => buildStrategyStateId,
7209
+ computeDeviation: () => computeDeviation,
7210
+ computeEffectiveWeight: () => computeEffectiveWeight,
7211
+ computeOutcomeSignal: () => computeOutcomeSignal,
7212
+ computeSpread: () => computeSpread,
7213
+ computeStrategyGradient: () => computeStrategyGradient,
7214
+ createOrchestrationContext: () => createOrchestrationContext,
5750
7215
  docIsDeleted: () => docIsDeleted,
5751
7216
  ensureAppDataDirectory: () => ensureAppDataDirectory,
5752
7217
  getAppDataDirectory: () => getAppDataDirectory,
@@ -5754,10 +7219,15 @@ __export(index_exports, {
5754
7219
  getCardOrigin: () => getCardOrigin,
5755
7220
  getDataLayer: () => getDataLayer,
5756
7221
  getDbPath: () => getDbPath,
7222
+ getDefaultLearnableWeight: () => getDefaultLearnableWeight,
7223
+ getRegisteredNavigator: () => getRegisteredNavigator,
7224
+ getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
5757
7225
  getStudySource: () => getStudySource,
7226
+ hasRegisteredNavigator: () => hasRegisteredNavigator,
5758
7227
  importParsedCards: () => importParsedCards,
5759
7228
  initializeDataDirectory: () => initializeDataDirectory,
5760
7229
  initializeDataLayer: () => initializeDataLayer,
7230
+ initializeNavigatorRegistry: () => initializeNavigatorRegistry,
5761
7231
  isDataShapeRegistered: () => isDataShapeRegistered,
5762
7232
  isDataShapeSchemaAvailable: () => isDataShapeSchemaAvailable,
5763
7233
  isFilter: () => isFilter,
@@ -5769,11 +7239,20 @@ __export(index_exports, {
5769
7239
  newInterval: () => newInterval,
5770
7240
  parseCardHistoryID: () => parseCardHistoryID,
5771
7241
  processCustomQuestionsData: () => processCustomQuestionsData,
7242
+ recordUserOutcome: () => recordUserOutcome,
5772
7243
  registerBlanksCard: () => registerBlanksCard,
5773
7244
  registerCustomQuestionTypes: () => registerCustomQuestionTypes,
5774
7245
  registerDataShape: () => registerDataShape,
7246
+ registerNavigator: () => registerNavigator,
5775
7247
  registerQuestionType: () => registerQuestionType,
5776
7248
  registerSeedData: () => registerSeedData,
7249
+ removeCustomQuestionTypes: () => removeCustomQuestionTypes,
7250
+ removeDataShape: () => removeDataShape,
7251
+ removeQuestionType: () => removeQuestionType,
7252
+ runPeriodUpdate: () => runPeriodUpdate,
7253
+ scoreAccuracyInZone: () => scoreAccuracyInZone,
7254
+ updateLearningState: () => updateLearningState,
7255
+ updateStrategyWeight: () => updateStrategyWeight,
5777
7256
  validateMigration: () => validateMigration,
5778
7257
  validateProcessorConfig: () => validateProcessorConfig,
5779
7258
  validateStaticCourse: () => validateStaticCourse
@@ -5783,10 +7262,10 @@ init_core();
5783
7262
  init_courseLookupDB();
5784
7263
 
5785
7264
  // src/courseConfigRegistration.ts
5786
- var import_common19 = require("@vue-skuilder/common");
7265
+ var import_common21 = require("@vue-skuilder/common");
5787
7266
  init_logger();
5788
7267
  function isDataShapeRegistered(dataShape, courseConfig) {
5789
- const namespacedName = import_common19.NameSpacer.getDataShapeString({
7268
+ const namespacedName = import_common21.NameSpacer.getDataShapeString({
5790
7269
  dataShape: dataShape.name,
5791
7270
  course: dataShape.course
5792
7271
  });
@@ -5794,7 +7273,7 @@ function isDataShapeRegistered(dataShape, courseConfig) {
5794
7273
  return existingDataShape !== void 0;
5795
7274
  }
5796
7275
  function isDataShapeSchemaAvailable(dataShape, courseConfig) {
5797
- const namespacedName = import_common19.NameSpacer.getDataShapeString({
7276
+ const namespacedName = import_common21.NameSpacer.getDataShapeString({
5798
7277
  dataShape: dataShape.name,
5799
7278
  course: dataShape.course
5800
7279
  });
@@ -5802,7 +7281,7 @@ function isDataShapeSchemaAvailable(dataShape, courseConfig) {
5802
7281
  return existingDataShape !== void 0 && existingDataShape.serializedZodSchema !== void 0;
5803
7282
  }
5804
7283
  function isQuestionTypeRegistered(question, courseConfig) {
5805
- const namespacedName = import_common19.NameSpacer.getQuestionString({
7284
+ const namespacedName = import_common21.NameSpacer.getQuestionString({
5806
7285
  course: question.course,
5807
7286
  questionType: question.name
5808
7287
  });
@@ -5834,13 +7313,13 @@ function processCustomQuestionsData(customQuestions) {
5834
7313
  return { dataShapes: processedDataShapes, questions: processedQuestions };
5835
7314
  }
5836
7315
  function registerDataShape(dataShape, courseConfig) {
5837
- const namespacedName = import_common19.NameSpacer.getDataShapeString({
7316
+ const namespacedName = import_common21.NameSpacer.getDataShapeString({
5838
7317
  dataShape: dataShape.name,
5839
7318
  course: dataShape.course
5840
7319
  });
5841
7320
  let serializedZodSchema;
5842
7321
  try {
5843
- serializedZodSchema = (0, import_common19.toZodJSON)(dataShape.dataShape);
7322
+ serializedZodSchema = (0, import_common21.toZodJSON)(dataShape.dataShape);
5844
7323
  } catch (error) {
5845
7324
  logger.warn(`Failed to generate schema for ${namespacedName}:`, error);
5846
7325
  serializedZodSchema = void 0;
@@ -5885,7 +7364,7 @@ function registerQuestionType(question, courseConfig) {
5885
7364
  logger.info(`QuestionType '${question.name}' from '${question.course}' already registered`);
5886
7365
  return false;
5887
7366
  }
5888
- const namespacedQuestionName = import_common19.NameSpacer.getQuestionString({
7367
+ const namespacedQuestionName = import_common21.NameSpacer.getQuestionString({
5889
7368
  course: question.course,
5890
7369
  questionType: question.name
5891
7370
  });
@@ -5897,7 +7376,7 @@ function registerQuestionType(question, courseConfig) {
5897
7376
  }
5898
7377
  });
5899
7378
  const dataShapeList = question.dataShapes.map(
5900
- (dataShape) => import_common19.NameSpacer.getDataShapeString({
7379
+ (dataShape) => import_common21.NameSpacer.getDataShapeString({
5901
7380
  course: question.course,
5902
7381
  dataShape: dataShape.name
5903
7382
  })
@@ -5908,7 +7387,7 @@ function registerQuestionType(question, courseConfig) {
5908
7387
  dataShapeList
5909
7388
  });
5910
7389
  question.dataShapes.forEach((dataShape) => {
5911
- const namespacedDataShapeName = import_common19.NameSpacer.getDataShapeString({
7390
+ const namespacedDataShapeName = import_common21.NameSpacer.getDataShapeString({
5912
7391
  course: question.course,
5913
7392
  dataShape: dataShape.name
5914
7393
  });
@@ -5921,6 +7400,66 @@ function registerQuestionType(question, courseConfig) {
5921
7400
  logger.info(`Registered QuestionType: ${namespacedQuestionName}`);
5922
7401
  return true;
5923
7402
  }
7403
+ function removeDataShape(dataShapeName, courseConfig) {
7404
+ const index = courseConfig.dataShapes.findIndex((ds) => ds.name === dataShapeName);
7405
+ if (index === -1) {
7406
+ logger.info(`DataShape '${dataShapeName}' not found in course config`);
7407
+ return false;
7408
+ }
7409
+ courseConfig.dataShapes.splice(index, 1);
7410
+ courseConfig.questionTypes.forEach((qt) => {
7411
+ const dsIndex = qt.dataShapeList.indexOf(dataShapeName);
7412
+ if (dsIndex !== -1) {
7413
+ qt.dataShapeList.splice(dsIndex, 1);
7414
+ }
7415
+ });
7416
+ logger.info(`Removed DataShape: ${dataShapeName}`);
7417
+ return true;
7418
+ }
7419
+ function removeQuestionType(questionTypeName, courseConfig) {
7420
+ const index = courseConfig.questionTypes.findIndex((qt) => qt.name === questionTypeName);
7421
+ if (index === -1) {
7422
+ logger.info(`QuestionType '${questionTypeName}' not found in course config`);
7423
+ return false;
7424
+ }
7425
+ courseConfig.questionTypes.splice(index, 1);
7426
+ courseConfig.dataShapes.forEach((ds) => {
7427
+ const qtIndex = ds.questionTypes.indexOf(questionTypeName);
7428
+ if (qtIndex !== -1) {
7429
+ ds.questionTypes.splice(qtIndex, 1);
7430
+ }
7431
+ });
7432
+ logger.info(`Removed QuestionType: ${questionTypeName}`);
7433
+ return true;
7434
+ }
7435
+ async function removeCustomQuestionTypes(dataShapeNames, questionTypeNames, courseConfig, courseDB) {
7436
+ try {
7437
+ logger.info("Beginning custom question removal");
7438
+ logger.info(`Removing ${dataShapeNames.length} data shapes and ${questionTypeNames.length} question types`);
7439
+ let removedCount = 0;
7440
+ for (const qtName of questionTypeNames) {
7441
+ if (removeQuestionType(qtName, courseConfig)) {
7442
+ removedCount++;
7443
+ }
7444
+ }
7445
+ for (const dsName of dataShapeNames) {
7446
+ if (removeDataShape(dsName, courseConfig)) {
7447
+ removedCount++;
7448
+ }
7449
+ }
7450
+ logger.info("Updating course configuration...");
7451
+ const updateResult = await courseDB.updateCourseConfig(courseConfig);
7452
+ if (!updateResult.ok) {
7453
+ throw new Error(`Failed to update course config: ${JSON.stringify(updateResult)}`);
7454
+ }
7455
+ logger.info(`Custom question removal complete: ${removedCount} items removed`);
7456
+ return { success: true, removedCount };
7457
+ } catch (error) {
7458
+ const errorMessage = error instanceof Error ? error.message : String(error);
7459
+ logger.error(`Custom question removal failed: ${errorMessage}`);
7460
+ return { success: false, removedCount: 0, errorMessage };
7461
+ }
7462
+ }
5924
7463
  async function registerSeedData(question, courseDB, username) {
5925
7464
  if (question.questionClass.seedData && Array.isArray(question.questionClass.seedData)) {
5926
7465
  logger.info(`Registering seed data for question: ${question.name}`);
@@ -6137,7 +7676,7 @@ var SrsService = class {
6137
7676
  };
6138
7677
 
6139
7678
  // src/study/services/EloService.ts
6140
- var import_common20 = require("@vue-skuilder/common");
7679
+ var import_common22 = require("@vue-skuilder/common");
6141
7680
  init_logger();
6142
7681
  var EloService = class {
6143
7682
  dataLayer;
@@ -6160,10 +7699,10 @@ var EloService = class {
6160
7699
  logger.warn(`k value interpretation not currently implemented`);
6161
7700
  }
6162
7701
  const courseDB = this.dataLayer.getCourseDB(currentCard.card.course_id);
6163
- const userElo = (0, import_common20.toCourseElo)(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
7702
+ const userElo = (0, import_common22.toCourseElo)(userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo);
6164
7703
  const cardElo = (await courseDB.getCardEloData([currentCard.card.card_id]))[0];
6165
7704
  if (cardElo && userElo) {
6166
- const eloUpdate = (0, import_common20.adjustCourseScores)(userElo, cardElo, userScore);
7705
+ const eloUpdate = (0, import_common22.adjustCourseScores)(userElo, cardElo, userScore);
6167
7706
  userCourseRegDoc.courses.find((c) => c.courseID === course_id).elo = eloUpdate.userElo;
6168
7707
  const results = await Promise.allSettled([
6169
7708
  this.user.updateUserElo(course_id, eloUpdate.userElo),
@@ -6365,7 +7904,7 @@ var ResponseProcessor = class {
6365
7904
  };
6366
7905
 
6367
7906
  // src/study/services/CardHydrationService.ts
6368
- var import_common21 = require("@vue-skuilder/common");
7907
+ var import_common23 = require("@vue-skuilder/common");
6369
7908
  init_logger();
6370
7909
  function parseAudioURIs(data) {
6371
7910
  if (typeof data !== "string") return [];
@@ -6500,8 +8039,8 @@ var CardHydrationService = class {
6500
8039
  try {
6501
8040
  const courseDB = this.getCourseDB(item.courseID);
6502
8041
  const cardData = await courseDB.getCourseDoc(item.cardID);
6503
- if (!(0, import_common21.isCourseElo)(cardData.elo)) {
6504
- cardData.elo = (0, import_common21.toCourseElo)(cardData.elo);
8042
+ if (!(0, import_common23.isCourseElo)(cardData.elo)) {
8043
+ cardData.elo = (0, import_common23.toCourseElo)(cardData.elo);
6505
8044
  }
6506
8045
  const view = this.getViewComponent(cardData.id_view);
6507
8046
  const dataDocs = await Promise.all(
@@ -6525,7 +8064,7 @@ var CardHydrationService = class {
6525
8064
  );
6526
8065
  await Promise.allSettled(uniqueAudioUrls.map(prefetchAudio));
6527
8066
  }
6528
- const data = dataDocs.map(import_common21.displayableDataToViewData).reverse();
8067
+ const data = dataDocs.map(import_common23.displayableDataToViewData).reverse();
6529
8068
  this.hydratedCards.set(item.cardID, {
6530
8069
  item,
6531
8070
  view,
@@ -6586,6 +8125,7 @@ var ItemQueue = class {
6586
8125
 
6587
8126
  // src/study/SessionController.ts
6588
8127
  init_couch();
8128
+ init_recording();
6589
8129
 
6590
8130
  // src/util/index.ts
6591
8131
  init_Loggable();
@@ -8433,6 +9973,49 @@ var SessionController = class extends Loggable {
8433
9973
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
8434
9974
  }
8435
9975
  }
9976
+ /**
9977
+ * End the session and record learning outcomes.
9978
+ *
9979
+ * This method aggregates all responses from the session and records a
9980
+ * UserOutcomeRecord if evolutionary orchestration is enabled.
9981
+ */
9982
+ async endSession() {
9983
+ if (!this._sessionRecord || this._sessionRecord.length === 0) {
9984
+ return;
9985
+ }
9986
+ const questionRecords = this._sessionRecord.flatMap((r) => r.records).filter((r) => r.userAnswer !== void 0);
9987
+ if (questionRecords.length === 0) {
9988
+ return;
9989
+ }
9990
+ let orchestrationContext = null;
9991
+ const strategies = [];
9992
+ for (const source of this.sources) {
9993
+ if (source.getOrchestrationContext) {
9994
+ try {
9995
+ orchestrationContext = await source.getOrchestrationContext();
9996
+ if (source.getStrategyIds) {
9997
+ strategies.push(...source.getStrategyIds());
9998
+ }
9999
+ } catch (e) {
10000
+ logger.warn(`[SessionController] Failed to get orchestration context: ${e}`);
10001
+ }
10002
+ if (orchestrationContext) break;
10003
+ }
10004
+ }
10005
+ if (!orchestrationContext) {
10006
+ logger.debug("[SessionController] No orchestration context available, skipping outcome recording");
10007
+ return;
10008
+ }
10009
+ const periodEnd = (/* @__PURE__ */ new Date()).toISOString();
10010
+ const periodStart = new Date(this.startTime).toISOString();
10011
+ await recordUserOutcome(
10012
+ orchestrationContext,
10013
+ periodStart,
10014
+ periodEnd,
10015
+ questionRecords,
10016
+ strategies
10017
+ );
10018
+ }
8436
10019
  };
8437
10020
 
8438
10021
  // src/study/index.ts
@@ -8460,8 +10043,15 @@ init_factory();
8460
10043
  StaticToCouchDBMigrator,
8461
10044
  TagFilteredContentSource,
8462
10045
  _resetDataLayer,
10046
+ aggregateOutcomesForGradient,
8463
10047
  areQuestionRecords,
8464
10048
  buildStrategyStateId,
10049
+ computeDeviation,
10050
+ computeEffectiveWeight,
10051
+ computeOutcomeSignal,
10052
+ computeSpread,
10053
+ computeStrategyGradient,
10054
+ createOrchestrationContext,
8465
10055
  docIsDeleted,
8466
10056
  ensureAppDataDirectory,
8467
10057
  getAppDataDirectory,
@@ -8469,10 +10059,15 @@ init_factory();
8469
10059
  getCardOrigin,
8470
10060
  getDataLayer,
8471
10061
  getDbPath,
10062
+ getDefaultLearnableWeight,
10063
+ getRegisteredNavigator,
10064
+ getRegisteredNavigatorNames,
8472
10065
  getStudySource,
10066
+ hasRegisteredNavigator,
8473
10067
  importParsedCards,
8474
10068
  initializeDataDirectory,
8475
10069
  initializeDataLayer,
10070
+ initializeNavigatorRegistry,
8476
10071
  isDataShapeRegistered,
8477
10072
  isDataShapeSchemaAvailable,
8478
10073
  isFilter,
@@ -8484,11 +10079,20 @@ init_factory();
8484
10079
  newInterval,
8485
10080
  parseCardHistoryID,
8486
10081
  processCustomQuestionsData,
10082
+ recordUserOutcome,
8487
10083
  registerBlanksCard,
8488
10084
  registerCustomQuestionTypes,
8489
10085
  registerDataShape,
10086
+ registerNavigator,
8490
10087
  registerQuestionType,
8491
10088
  registerSeedData,
10089
+ removeCustomQuestionTypes,
10090
+ removeDataShape,
10091
+ removeQuestionType,
10092
+ runPeriodUpdate,
10093
+ scoreAccuracyInZone,
10094
+ updateLearningState,
10095
+ updateStrategyWeight,
8492
10096
  validateMigration,
8493
10097
  validateProcessorConfig,
8494
10098
  validateStaticCourse