@vue-skuilder/db 0.2.5 → 0.2.8

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.
package/dist/index.d.cts CHANGED
@@ -4,7 +4,7 @@ import { D as DataLayerProvider } from './dataLayerProvider-CiA2Rr0v.cjs';
4
4
  import { C as CardHistory, c as CardRecord, d as QuestionRecord } from './types-legacy-4tlwHnXo.cjs';
5
5
  export { e as CardData, f as CourseListData, h as DataShapeData, g as DisplayableData, D as DocType, b as DocTypePrefixes, F as Field, G as GuestUsername, Q as QualifiedCardID, i as QuestionData, S as SkuilderCourseData, a as Tag, T as TagStub, l as log } from './types-legacy-4tlwHnXo.cjs';
6
6
  import { Loggable } from './core/index.cjs';
7
- export { BulkCardProcessorConfig, CardFilter, CardFilterFactory, FilterContext, FilterImpact, GeneratorSummary, GradientObservation, GradientResult, ImportResult, PeriodUpdateInput, PeriodUpdateResult, PipelineRunReport, SignalConfig, StrategyLearningState, StrategyStateDoc, StrategyStateId, aggregateOutcomesForGradient, areQuestionRecords, buildStrategyStateId, computeOutcomeSignal, computeStrategyGradient, docIsDeleted, getCardHistoryID, getDefaultLearnableWeight, importParsedCards, isQuestionRecord, mountPipelineDebugger, mountUserDBDebugger, parseCardHistoryID, pipelineDebugAPI, recordUserOutcome, runPeriodUpdate, scoreAccuracyInZone, updateLearningState, updateStrategyWeight, userDBDebugAPI, validateProcessorConfig } from './core/index.cjs';
7
+ export { BulkCardProcessorConfig, CardFilter, CardFilterFactory, DIVERSITY_FLOOR, DIVERSITY_STRENGTH, DiversityRerankOptions, FilterContext, FilterImpact, GeneratorSummary, GradientObservation, GradientResult, ImportResult, PeriodUpdateInput, PeriodUpdateResult, PipelineForecaster, PipelineRunReport, SignalConfig, StrategyLearningState, StrategyStateDoc, StrategyStateId, aggregateOutcomesForGradient, areQuestionRecords, buildStrategyStateId, computeOutcomeSignal, computeStrategyGradient, diversityRerank, docIsDeleted, getActivePipeline, getCardHistoryID, getDefaultLearnableWeight, importParsedCards, isQuestionRecord, mountPipelineDebugger, mountUserDBDebugger, parseCardHistoryID, pipelineDebugAPI, recordUserOutcome, runPeriodUpdate, scoreAccuracyInZone, updateLearningState, updateStrategyWeight, userDBDebugAPI, validateProcessorConfig } from './core/index.cjs';
8
8
  import { TaggedPerformance, TagFilter, DataShape, CourseConfig } from '@vue-skuilder/common';
9
9
  import { S as StaticCourseManifest } from './types-BFUa1pa3.cjs';
10
10
  export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig } from './types-BFUa1pa3.cjs';
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@ import { D as DataLayerProvider } from './dataLayerProvider-DrBqOUa3.js';
4
4
  import { C as CardHistory, c as CardRecord, d as QuestionRecord } from './types-legacy-4tlwHnXo.js';
5
5
  export { e as CardData, f as CourseListData, h as DataShapeData, g as DisplayableData, D as DocType, b as DocTypePrefixes, F as Field, G as GuestUsername, Q as QualifiedCardID, i as QuestionData, S as SkuilderCourseData, a as Tag, T as TagStub, l as log } from './types-legacy-4tlwHnXo.js';
6
6
  import { Loggable } from './core/index.js';
7
- export { BulkCardProcessorConfig, CardFilter, CardFilterFactory, FilterContext, FilterImpact, GeneratorSummary, GradientObservation, GradientResult, ImportResult, PeriodUpdateInput, PeriodUpdateResult, PipelineRunReport, SignalConfig, StrategyLearningState, StrategyStateDoc, StrategyStateId, aggregateOutcomesForGradient, areQuestionRecords, buildStrategyStateId, computeOutcomeSignal, computeStrategyGradient, docIsDeleted, getCardHistoryID, getDefaultLearnableWeight, importParsedCards, isQuestionRecord, mountPipelineDebugger, mountUserDBDebugger, parseCardHistoryID, pipelineDebugAPI, recordUserOutcome, runPeriodUpdate, scoreAccuracyInZone, updateLearningState, updateStrategyWeight, userDBDebugAPI, validateProcessorConfig } from './core/index.js';
7
+ export { BulkCardProcessorConfig, CardFilter, CardFilterFactory, DIVERSITY_FLOOR, DIVERSITY_STRENGTH, DiversityRerankOptions, FilterContext, FilterImpact, GeneratorSummary, GradientObservation, GradientResult, ImportResult, PeriodUpdateInput, PeriodUpdateResult, PipelineForecaster, PipelineRunReport, SignalConfig, StrategyLearningState, StrategyStateDoc, StrategyStateId, aggregateOutcomesForGradient, areQuestionRecords, buildStrategyStateId, computeOutcomeSignal, computeStrategyGradient, diversityRerank, docIsDeleted, getActivePipeline, getCardHistoryID, getDefaultLearnableWeight, importParsedCards, isQuestionRecord, mountPipelineDebugger, mountUserDBDebugger, parseCardHistoryID, pipelineDebugAPI, recordUserOutcome, runPeriodUpdate, scoreAccuracyInZone, updateLearningState, updateStrategyWeight, userDBDebugAPI, validateProcessorConfig } from './core/index.js';
8
8
  import { TaggedPerformance, TagFilter, DataShape, CourseConfig } from '@vue-skuilder/common';
9
9
  import { S as StaticCourseManifest } from './types-CHgpWQAY.js';
10
10
  export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig } from './types-CHgpWQAY.js';
package/dist/index.js CHANGED
@@ -872,12 +872,102 @@ var init_courseLookupDB = __esm({
872
872
  }
873
873
  });
874
874
 
875
+ // src/core/navigators/diversityRerank.ts
876
+ var diversityRerank_exports = {};
877
+ __export(diversityRerank_exports, {
878
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
879
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
880
+ diversityRerank: () => diversityRerank
881
+ });
882
+ function diversityRerank(cards, opts = {}) {
883
+ const strength = opts.strength ?? DIVERSITY_STRENGTH;
884
+ const floor = opts.floor ?? DIVERSITY_FLOOR;
885
+ const n = cards.length;
886
+ if (n <= 1) return cards;
887
+ const df = /* @__PURE__ */ new Map();
888
+ for (const card of cards) {
889
+ for (const tag of card.tags ?? []) {
890
+ df.set(tag, (df.get(tag) ?? 0) + 1);
891
+ }
892
+ }
893
+ const idf = /* @__PURE__ */ new Map();
894
+ for (const [tag, freq] of df) {
895
+ idf.set(tag, Math.log(n / freq));
896
+ }
897
+ const remaining = [...cards];
898
+ const emittedCount = /* @__PURE__ */ new Map();
899
+ const out = [];
900
+ const repetitionLoad = (card) => {
901
+ let load = 0;
902
+ for (const tag of card.tags ?? []) {
903
+ const seen = emittedCount.get(tag);
904
+ if (seen) load += (idf.get(tag) ?? 0) * seen;
905
+ }
906
+ return load;
907
+ };
908
+ while (remaining.length > 0) {
909
+ let bestIdx = 0;
910
+ let bestValue = -Infinity;
911
+ let bestPenalty = 1;
912
+ let bestLoad = 0;
913
+ for (let i = 0; i < remaining.length; i++) {
914
+ const card = remaining[i];
915
+ const load = repetitionLoad(card);
916
+ const penalty = load > 0 ? Math.max(floor, 1 / (1 + strength * load)) : 1;
917
+ const value = card.score * penalty;
918
+ if (value > bestValue) {
919
+ bestValue = value;
920
+ bestIdx = i;
921
+ bestPenalty = penalty;
922
+ bestLoad = load;
923
+ }
924
+ }
925
+ const [picked] = remaining.splice(bestIdx, 1);
926
+ if (Number.isFinite(picked.score) && bestPenalty < 1) {
927
+ const newScore = picked.score * bestPenalty;
928
+ out.push({
929
+ ...picked,
930
+ score: newScore,
931
+ provenance: [
932
+ ...picked.provenance,
933
+ {
934
+ strategy: STRATEGY,
935
+ strategyId: STRATEGY_ID,
936
+ strategyName: STRATEGY_NAME,
937
+ action: "penalized",
938
+ score: newScore,
939
+ reason: `repeated tags (load ${bestLoad.toFixed(2)}) \u2192 \xD7${bestPenalty.toFixed(2)}`
940
+ }
941
+ ]
942
+ });
943
+ } else {
944
+ out.push(picked);
945
+ }
946
+ for (const tag of picked.tags ?? []) {
947
+ emittedCount.set(tag, (emittedCount.get(tag) ?? 0) + 1);
948
+ }
949
+ }
950
+ return out;
951
+ }
952
+ var DIVERSITY_STRENGTH, DIVERSITY_FLOOR, STRATEGY, STRATEGY_ID, STRATEGY_NAME;
953
+ var init_diversityRerank = __esm({
954
+ "src/core/navigators/diversityRerank.ts"() {
955
+ "use strict";
956
+ DIVERSITY_STRENGTH = 0.6;
957
+ DIVERSITY_FLOOR = 0.3;
958
+ STRATEGY = "diversityRerank";
959
+ STRATEGY_ID = "DIVERSITY_RERANK";
960
+ STRATEGY_NAME = "Diversity Re-rank";
961
+ }
962
+ });
963
+
875
964
  // src/core/navigators/PipelineDebugger.ts
876
965
  var PipelineDebugger_exports = {};
877
966
  __export(PipelineDebugger_exports, {
878
967
  buildRunReport: () => buildRunReport,
879
968
  captureRun: () => captureRun,
880
969
  clearRunHistory: () => clearRunHistory,
970
+ getActivePipeline: () => getActivePipeline,
881
971
  mountPipelineDebugger: () => mountPipelineDebugger,
882
972
  pipelineDebugAPI: () => pipelineDebugAPI,
883
973
  registerPipelineForDebug: () => registerPipelineForDebug
@@ -885,6 +975,9 @@ __export(PipelineDebugger_exports, {
885
975
  function registerPipelineForDebug(pipeline) {
886
976
  _activePipeline = pipeline;
887
977
  }
978
+ function getActivePipeline() {
979
+ return _activePipeline;
980
+ }
888
981
  function clearRunHistory() {
889
982
  runHistory.length = 0;
890
983
  }
@@ -2067,7 +2160,7 @@ function shuffleInPlace(arr) {
2067
2160
  function pickTopByScore(cards, limit) {
2068
2161
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
2069
2162
  }
2070
- var DEFAULT_FRESHNESS_WINDOW, DEFAULT_MAX_DIRECT_PER_RUN, DEFAULT_MAX_SUPPORT_PER_RUN, DEFAULT_HIERARCHY_DEPTH, DEFAULT_MIN_COUNT, BASE_TARGET_SCORE, BASE_SUPPORT_SCORE, DISCOVERED_SUPPORT_SCORE, MAX_TARGET_MULTIPLIER, MAX_SUPPORT_MULTIPLIER, PRESCRIBED_DEBUG_VERSION, PrescribedCardsGenerator;
2163
+ var DEFAULT_FRESHNESS_WINDOW, DEFAULT_MAX_DIRECT_PER_RUN, DEFAULT_MAX_SUPPORT_PER_RUN, DEFAULT_HIERARCHY_DEPTH, DEFAULT_MIN_COUNT, DEFAULT_PRACTICE_MIN_COUNT, DEFAULT_MAX_PRACTICE_PER_RUN, BASE_TARGET_SCORE, BASE_SUPPORT_SCORE, DISCOVERED_SUPPORT_SCORE, BASE_PRACTICE_SCORE, MAX_TARGET_MULTIPLIER, MAX_SUPPORT_MULTIPLIER, PRESCRIBED_DEBUG_VERSION, PrescribedCardsGenerator;
2071
2164
  var init_prescribed = __esm({
2072
2165
  "src/core/navigators/generators/prescribed.ts"() {
2073
2166
  "use strict";
@@ -2078,9 +2171,12 @@ var init_prescribed = __esm({
2078
2171
  DEFAULT_MAX_SUPPORT_PER_RUN = 3;
2079
2172
  DEFAULT_HIERARCHY_DEPTH = 2;
2080
2173
  DEFAULT_MIN_COUNT = 3;
2174
+ DEFAULT_PRACTICE_MIN_COUNT = 3;
2175
+ DEFAULT_MAX_PRACTICE_PER_RUN = 4;
2081
2176
  BASE_TARGET_SCORE = 1;
2082
2177
  BASE_SUPPORT_SCORE = 0.8;
2083
2178
  DISCOVERED_SUPPORT_SCORE = 12;
2179
+ BASE_PRACTICE_SCORE = 1;
2084
2180
  MAX_TARGET_MULTIPLIER = 8;
2085
2181
  MAX_SUPPORT_MULTIPLIER = 4;
2086
2182
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -2188,7 +2284,18 @@ var init_prescribed = __esm({
2188
2284
  courseId,
2189
2285
  emittedIds
2190
2286
  );
2191
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
2287
+ const practiceCards = this.buildPracticeCards({
2288
+ group,
2289
+ courseId,
2290
+ emittedIds,
2291
+ cardsByTag,
2292
+ hierarchyConfigs,
2293
+ userTagElo,
2294
+ userGlobalElo,
2295
+ activeIds,
2296
+ seenIds
2297
+ });
2298
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
2192
2299
  }
2193
2300
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
2194
2301
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
@@ -2216,6 +2323,7 @@ var init_prescribed = __esm({
2216
2323
  const surfacedByGroup = /* @__PURE__ */ new Map();
2217
2324
  for (const card of finalCards) {
2218
2325
  const prov = card.provenance[0];
2326
+ if (prov?.reason.includes("mode=practice")) continue;
2219
2327
  const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
2220
2328
  const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
2221
2329
  if (!groupId) continue;
@@ -2285,7 +2393,12 @@ var init_prescribed = __esm({
2285
2393
  enabled: raw.hierarchyWalk?.enabled !== false,
2286
2394
  maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
2287
2395
  },
2288
- retireOnEncounter: raw.retireOnEncounter !== false
2396
+ retireOnEncounter: raw.retireOnEncounter !== false,
2397
+ practiceTagPatterns: dedupe(
2398
+ Array.isArray(raw.practiceTagPatterns) ? raw.practiceTagPatterns.filter((v) => typeof v === "string") : []
2399
+ ),
2400
+ practiceMinCount: typeof raw.practiceMinCount === "number" ? raw.practiceMinCount : DEFAULT_PRACTICE_MIN_COUNT,
2401
+ maxPracticeCardsPerRun: typeof raw.maxPracticeCardsPerRun === "number" ? raw.maxPracticeCardsPerRun : DEFAULT_MAX_PRACTICE_PER_RUN
2289
2402
  })).filter((g) => g.targetCardIds.length > 0);
2290
2403
  return { groups };
2291
2404
  } catch {
@@ -2508,6 +2621,92 @@ var init_prescribed = __esm({
2508
2621
  }
2509
2622
  return cards;
2510
2623
  }
2624
+ /**
2625
+ * Emit drill cards for *unlocked-but-under-practiced* skills.
2626
+ *
2627
+ * For each course tag matching the group's `practiceTagPatterns` that is both
2628
+ * unlocked (all hierarchy prerequisites met — i.e. the learner has been
2629
+ * introduced to it) and under-practiced (per-tag attempt count below
2630
+ * `practiceMinCount`), this resolves cards carrying that tag and emits them
2631
+ * into the candidate pool. It exists because global-ELO retrieval
2632
+ * systematically fails to fetch the (low-ELO) drill cards for a
2633
+ * freshly-introduced skill — putting them in the pool here lets the pipeline's
2634
+ * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2635
+ * this method's job; it only guarantees presence.
2636
+ *
2637
+ * Fully data-driven: the unlock relation comes from the hierarchy config and
2638
+ * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
2639
+ */
2640
+ buildPracticeCards(args) {
2641
+ const {
2642
+ group,
2643
+ courseId,
2644
+ emittedIds,
2645
+ cardsByTag,
2646
+ hierarchyConfigs,
2647
+ userTagElo,
2648
+ userGlobalElo,
2649
+ activeIds,
2650
+ seenIds
2651
+ } = args;
2652
+ const patterns = group.practiceTagPatterns ?? [];
2653
+ if (patterns.length === 0) return [];
2654
+ const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2655
+ const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2656
+ const practiceTags = [...cardsByTag.keys()].filter(
2657
+ (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2658
+ );
2659
+ if (practiceTags.length === 0) return [];
2660
+ const practiceCardIds = this.findDiscoveredSupportCards({
2661
+ supportTags: practiceTags,
2662
+ cardsByTag,
2663
+ activeIds,
2664
+ seenIds,
2665
+ excludedIds: emittedIds,
2666
+ limit: maxPractice
2667
+ });
2668
+ if (practiceCardIds.length === 0) return [];
2669
+ logger.info(
2670
+ `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2671
+ );
2672
+ const cards = [];
2673
+ for (const cardId of practiceCardIds) {
2674
+ emittedIds.add(cardId);
2675
+ cards.push({
2676
+ cardId,
2677
+ courseId,
2678
+ score: BASE_PRACTICE_SCORE,
2679
+ provenance: [
2680
+ {
2681
+ strategy: "prescribed",
2682
+ strategyName: this.strategyName || this.name,
2683
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2684
+ action: "generated",
2685
+ score: BASE_PRACTICE_SCORE,
2686
+ reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2687
+ }
2688
+ ]
2689
+ });
2690
+ }
2691
+ return cards;
2692
+ }
2693
+ /**
2694
+ * True for a skill that was *gated and is now reached*: it has at least one
2695
+ * declared hierarchy prerequisite set, and every set is fully satisfied by the
2696
+ * learner's per-tag ELO. This deliberately EXCLUDES tags with no prerequisites
2697
+ * — an ungated tag was never "introduced" in the curricular sense, so it isn't
2698
+ * a post-intro drill target (e.g. whole-word spelling tags that share the
2699
+ * `gpc:exercise:*` prefix but have no intro gate). Those are left to normal
2700
+ * ELO retrieval. This is the precise population the retrieval gap strands:
2701
+ * just-unlocked, low-ELO skills.
2702
+ */
2703
+ isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) {
2704
+ const prereqSets = hierarchyConfigs.map((hierarchy) => hierarchy.prerequisites[tag]).filter((prereqs) => Array.isArray(prereqs) && prereqs.length > 0);
2705
+ if (prereqSets.length === 0) return false;
2706
+ return prereqSets.every(
2707
+ (prereqs) => prereqs.every((pr) => this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo))
2708
+ );
2709
+ }
2511
2710
  findSupportCardsByTags(group, tagsByCard, supportTags) {
2512
2711
  if (supportTags.length === 0) {
2513
2712
  return [];
@@ -4301,7 +4500,7 @@ function logResultCards(cards) {
4301
4500
  for (let i = 0; i < cards.length; i++) {
4302
4501
  const c = cards[i];
4303
4502
  const tags = c.tags?.slice(0, 3).join(", ") || "";
4304
- const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
4503
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint" || p.strategy === "diversityRerank").map((p) => {
4305
4504
  const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
4306
4505
  return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
4307
4506
  }).join(" | ");
@@ -4333,6 +4532,7 @@ var init_Pipeline = __esm({
4333
4532
  init_logger();
4334
4533
  init_orchestration();
4335
4534
  init_PipelineDebugger();
4535
+ init_diversityRerank();
4336
4536
  VERBOSE_RESULTS = true;
4337
4537
  Pipeline = class extends ContentNavigator {
4338
4538
  generator;
@@ -4506,6 +4706,7 @@ var init_Pipeline = __esm({
4506
4706
  this._ephemeralHints = null;
4507
4707
  cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
4508
4708
  }
4709
+ cards = diversityRerank(cards);
4509
4710
  cards.sort((a, b) => b.score - a.score);
4510
4711
  const tFilter = performance.now();
4511
4712
  const result = cards.slice(0, limit);
@@ -4809,6 +5010,68 @@ var init_Pipeline = __esm({
4809
5010
  // ---------------------------------------------------------------------------
4810
5011
  // Card-space diagnostic
4811
5012
  // ---------------------------------------------------------------------------
5013
+ /**
5014
+ * Commit-free forecast: score the user's full card space through the filter
5015
+ * chain and return the cards that are currently *reachable* (score >=
5016
+ * threshold), optionally nudged by caller-supplied hints and/or restricted
5017
+ * to cards the user hasn't seen yet.
5018
+ *
5019
+ * This is a GENERIC primitive — it returns scored, tag-hydrated cards and
5020
+ * stops there. It has no knowledge of any particular tag convention; callers
5021
+ * decide what the surviving cards mean (e.g. filter to their own "intro"
5022
+ * tag family). Nothing is written and no session is started.
5023
+ *
5024
+ * The optional `hints` are the "out-of-band kick": they run through the same
5025
+ * {@link applyHints} path a live replan uses, so the two semantics carry over —
5026
+ * - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
5027
+ * stays out), and
5028
+ * - `requireTags`/`requireCards` inject from the full pre-filter pool,
5029
+ * *bypassing* gating (use when you want a card regardless of reachability).
5030
+ * Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
5031
+ * user has already seen — pass `unseenOnly: false` if that matters.
5032
+ *
5033
+ * Cost note: like {@link diagnoseCardSpace}, this scans every card through the
5034
+ * filters, so it's heavier than a normal replan. Intended for one-shot
5035
+ * out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
5036
+ *
5037
+ * @param opts.hints Optional ephemeral hints to apply after the filter chain.
5038
+ * @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
5039
+ * @param opts.threshold Min score to count as reachable (default 0.10).
5040
+ * @param opts.limit Optional cap on results (already sorted desc).
5041
+ */
5042
+ async forecast(opts) {
5043
+ const threshold = opts?.threshold ?? 0.1;
5044
+ const unseenOnly = opts?.unseenOnly ?? true;
5045
+ const courseId = this.course.getCourseID();
5046
+ const allCardIds = await this.course.getAllCardIds();
5047
+ let cards = allCardIds.map((cardId) => ({
5048
+ cardId,
5049
+ courseId,
5050
+ score: 1,
5051
+ provenance: []
5052
+ }));
5053
+ cards = await this.hydrateTags(cards);
5054
+ const fullPool = cards.slice();
5055
+ const context = await this.buildContext();
5056
+ for (const filter of this.filters) {
5057
+ cards = await filter.transform(cards, context);
5058
+ }
5059
+ if (opts?.hints) {
5060
+ cards = this.applyHints(cards, opts.hints, fullPool);
5061
+ }
5062
+ cards = cards.filter((c) => c.score >= threshold);
5063
+ if (unseenOnly) {
5064
+ let encountered;
5065
+ try {
5066
+ encountered = new Set(await this.user.getSeenCards(courseId));
5067
+ } catch {
5068
+ encountered = /* @__PURE__ */ new Set();
5069
+ }
5070
+ cards = cards.filter((c) => !encountered.has(c.cardId));
5071
+ }
5072
+ cards.sort((a, b) => b.score - a.score);
5073
+ return opts?.limit ? cards.slice(0, opts.limit) : cards;
5074
+ }
4812
5075
  /**
4813
5076
  * Scan every card in the course through the filter chain and report
4814
5077
  * how many are "well indicated" (score >= threshold) for the current user.
@@ -5074,6 +5337,7 @@ var init_3 = __esm({
5074
5337
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
5075
5338
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
5076
5339
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
5340
+ "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
5077
5341
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
5078
5342
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
5079
5343
  "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
@@ -5099,9 +5363,13 @@ var init_3 = __esm({
5099
5363
  var navigators_exports = {};
5100
5364
  __export(navigators_exports, {
5101
5365
  ContentNavigator: () => ContentNavigator,
5366
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
5367
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
5102
5368
  NavigatorRole: () => NavigatorRole,
5103
5369
  NavigatorRoles: () => NavigatorRoles,
5104
5370
  Navigators: () => Navigators,
5371
+ diversityRerank: () => diversityRerank,
5372
+ getActivePipeline: () => getActivePipeline,
5105
5373
  getCardOrigin: () => getCardOrigin,
5106
5374
  getRegisteredNavigator: () => getRegisteredNavigator,
5107
5375
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
@@ -5185,6 +5453,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
5185
5453
  var init_navigators = __esm({
5186
5454
  "src/core/navigators/index.ts"() {
5187
5455
  "use strict";
5456
+ init_diversityRerank();
5188
5457
  init_PipelineDebugger();
5189
5458
  init_logger();
5190
5459
  init_();
@@ -10187,6 +10456,8 @@ __export(index_exports, {
10187
10456
  ContentNavigator: () => ContentNavigator,
10188
10457
  CouchDBToStaticPacker: () => CouchDBToStaticPacker,
10189
10458
  CourseLookup: () => CourseLookup,
10459
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
10460
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
10190
10461
  DocType: () => DocType,
10191
10462
  DocTypePrefixes: () => DocTypePrefixes,
10192
10463
  ENV: () => ENV,
@@ -10212,9 +10483,11 @@ __export(index_exports, {
10212
10483
  computeSpread: () => computeSpread,
10213
10484
  computeStrategyGradient: () => computeStrategyGradient,
10214
10485
  createOrchestrationContext: () => createOrchestrationContext,
10486
+ diversityRerank: () => diversityRerank,
10215
10487
  docIsDeleted: () => docIsDeleted,
10216
10488
  endSessionTracking: () => endSessionTracking,
10217
10489
  ensureAppDataDirectory: () => ensureAppDataDirectory,
10490
+ getActivePipeline: () => getActivePipeline,
10218
10491
  getAppDataDirectory: () => getAppDataDirectory,
10219
10492
  getCardHistoryID: () => getCardHistoryID,
10220
10493
  getCardOrigin: () => getCardOrigin,
@@ -11323,8 +11596,17 @@ var ItemQueue = class {
11323
11596
  * Merge new items into the front of the queue, skipping duplicates.
11324
11597
  * Used by additive replans to inject high-quality candidates without
11325
11598
  * discarding the existing queue contents.
11599
+ *
11600
+ * `forceFrontIds` carries the mandatory (`+INF`) cards in this batch — a
11601
+ * durable `requireCard`/`requireTag` re-asserted by every replan. An ordinary
11602
+ * duplicate is left in place (skip), but a mandatory one that's *already*
11603
+ * queued is pulled out of its current slot so it rejoins at the front in batch
11604
+ * order. Without this, an additive merge unshifts fresh non-required cards
11605
+ * ahead of an already-present required card, steadily burying it until it never
11606
+ * gets drawn — defeating the "must appear" guarantee. Returns the count of
11607
+ * genuinely new cards added (re-fronted duplicates are not counted).
11326
11608
  */
11327
- mergeToFront(items, cardIdExtractor) {
11609
+ mergeToFront(items, cardIdExtractor, forceFrontIds) {
11328
11610
  let added = 0;
11329
11611
  const toInsert = [];
11330
11612
  for (const item of items) {
@@ -11333,6 +11615,11 @@ var ItemQueue = class {
11333
11615
  this.seenCardIds.push(cardId);
11334
11616
  toInsert.push(item);
11335
11617
  added++;
11618
+ } else if (forceFrontIds?.has(cardId)) {
11619
+ const idx = this.q.findIndex((qi) => cardIdExtractor(qi) === cardId);
11620
+ if (idx >= 0) {
11621
+ toInsert.push(...this.q.splice(idx, 1));
11622
+ }
11336
11623
  }
11337
11624
  }
11338
11625
  this.q.unshift(...toInsert);
@@ -14450,7 +14737,16 @@ var SessionController = class _SessionController extends Loggable {
14450
14737
  mixedWeighted
14451
14738
  );
14452
14739
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review").slice(0, this._initialReviewCap);
14453
- const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new" && !this._servedCardIds.has(w.cardId)).slice(0, newLimit);
14740
+ const newCandidates = mixedWeighted.filter(
14741
+ (w) => getCardOrigin(w) === "new" && !this._servedCardIds.has(w.cardId)
14742
+ );
14743
+ const mandatoryWeighted = newCandidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
14744
+ const optionalWeighted = newCandidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
14745
+ const newWeighted = [
14746
+ ...mandatoryWeighted,
14747
+ ...optionalWeighted.slice(0, Math.max(0, newLimit - mandatoryWeighted.length))
14748
+ ];
14749
+ const mandatoryIds = new Set(mandatoryWeighted.map((w) => w.cardId));
14454
14750
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
14455
14751
  let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
14456
14752
  if (!replan) {
@@ -14485,7 +14781,7 @@ var SessionController = class _SessionController extends Loggable {
14485
14781
  `;
14486
14782
  }
14487
14783
  if (additive) {
14488
- const added = this.newQ.mergeToFront(newItems, (item) => item.cardID);
14784
+ const added = this.newQ.mergeToFront(newItems, (item) => item.cardID, mandatoryIds);
14489
14785
  report += `Additive merge: ${added} new cards added to front of newQ
14490
14786
  `;
14491
14787
  } else if (replan) {
@@ -14817,6 +15113,8 @@ init_factory();
14817
15113
  ContentNavigator,
14818
15114
  CouchDBToStaticPacker,
14819
15115
  CourseLookup,
15116
+ DIVERSITY_FLOOR,
15117
+ DIVERSITY_STRENGTH,
14820
15118
  DocType,
14821
15119
  DocTypePrefixes,
14822
15120
  ENV,
@@ -14842,9 +15140,11 @@ init_factory();
14842
15140
  computeSpread,
14843
15141
  computeStrategyGradient,
14844
15142
  createOrchestrationContext,
15143
+ diversityRerank,
14845
15144
  docIsDeleted,
14846
15145
  endSessionTracking,
14847
15146
  ensureAppDataDirectory,
15147
+ getActivePipeline,
14848
15148
  getAppDataDirectory,
14849
15149
  getCardHistoryID,
14850
15150
  getCardOrigin,