@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.
@@ -304,6 +304,73 @@ interface CardFilter {
304
304
  */
305
305
  type CardFilterFactory<TConfig = unknown> = (config: TConfig) => CardFilter;
306
306
 
307
+ interface DiversityRerankOptions {
308
+ /**
309
+ * How hard repetition is penalised. Larger → steeper demotion of repeated
310
+ * distinctive tags. Penalty = 1 / (1 + strength·load).
311
+ */
312
+ strength?: number;
313
+ /**
314
+ * Minimum penalty multiplier. A card is never demoted below `floor × score`,
315
+ * however much it repeats. Keeps a strong-but-repeated card from being driven
316
+ * under downstream "well-indicated" thresholds (which would mislabel it as
317
+ * filler and could trigger spurious quality-replans). Tunes "perturb ordering"
318
+ * vs "annihilate candidates."
319
+ */
320
+ floor?: number;
321
+ }
322
+ /** Default repetition strength. See DiversityRerankOptions.strength. */
323
+ declare const DIVERSITY_STRENGTH = 0.6;
324
+ /** Default penalty floor. See DiversityRerankOptions.floor. */
325
+ declare const DIVERSITY_FLOOR = 0.3;
326
+ /**
327
+ * Re-rank a scored candidate pool for answer/concept variety.
328
+ *
329
+ * Pure: returns a new array (diversified order, adjusted scores, appended
330
+ * provenance) and does not mutate the input cards. Cards entering are assumed
331
+ * to have score > 0 (the Pipeline strips zero-score cards before this stage).
332
+ * Non-finite scores (mandatory `requireCards`, score = +Infinity) are emitted
333
+ * untouched and still count toward repetition for later cards.
334
+ *
335
+ * @param cards - Post-filter, post-hint candidates.
336
+ * @param opts - Optional strength/floor overrides (defaults are sane and
337
+ * course-general; promote to strategy config if you ever want
338
+ * this learnable under the orchestration layer).
339
+ * @returns Cards in diversified order with penalised scores.
340
+ */
341
+ declare function diversityRerank(cards: WeightedCard[], opts?: DiversityRerankOptions): WeightedCard[];
342
+
343
+ /**
344
+ * A navigation pipeline that runs a generator and applies filters sequentially.
345
+ *
346
+ * Implements StudyContentSource for backward compatibility with SessionController.
347
+ *
348
+ * ## Usage
349
+ *
350
+ * ```typescript
351
+ * const pipeline = new Pipeline(
352
+ * compositeGenerator, // or single generator
353
+ * [eloDistanceFilter, interferenceFilter],
354
+ * user,
355
+ * course
356
+ * );
357
+ *
358
+ * const cards = await pipeline.getWeightedCards(20);
359
+ * ```
360
+ */
361
+ /**
362
+ * Narrow capability surface for out-of-band, commit-free reads against a live
363
+ * pipeline (see {@link getActivePipeline}). Kept minimal on purpose — consumers
364
+ * get the forecast capability, not the whole `Pipeline` class.
365
+ */
366
+ interface PipelineForecaster {
367
+ forecast(opts?: {
368
+ hints?: ReplanHints;
369
+ unseenOnly?: boolean;
370
+ threshold?: number;
371
+ limit?: number;
372
+ }): Promise<WeightedCard[]>;
373
+ }
307
374
  /**
308
375
  * Diagnosis of the full card space for the current user.
309
376
  */
@@ -325,6 +392,13 @@ interface CardSpaceDiagnosis {
325
392
  elapsedMs: number;
326
393
  }
327
394
 
395
+ /**
396
+ * The most recently constructed pipeline for the current session, or null if
397
+ * none has been built yet. This is the supported, non-debug accessor for
398
+ * out-of-band reads against the live pipeline (e.g. a commit-free
399
+ * `forecast()`), replacing reach-ins through `window.skuilder.pipeline`.
400
+ */
401
+ declare function getActivePipeline(): PipelineForecaster | null;
328
402
  /**
329
403
  * Summary of a single generator's contribution.
330
404
  */
@@ -706,4 +780,4 @@ declare const userDBDebugAPI: {
706
780
  */
707
781
  declare function mountUserDBDebugger(): void;
708
782
 
709
- export { type BulkCardProcessorConfig, type CardFilter, type CardFilterFactory, CardHistory, CardRecord, CourseDBInterface, DocType, DocTypePrefixes, type FilterContext, type FilterImpact, type GeneratorSummary, type GradientObservation, type GradientResult, type ImportResult, LearnableWeight, Loggable, OrchestrationContext, type PeriodUpdateInput, type PeriodUpdateResult, type PipelineRunReport, QuestionRecord, ReplanHints, type SignalConfig, StrategyContribution, type StrategyLearningState, type StrategyStateDoc, type StrategyStateId, UserDBInterface, UserOutcomeRecord, WeightedCard, aggregateOutcomesForGradient, areQuestionRecords, buildStrategyStateId, computeOutcomeSignal, computeStrategyGradient, docIsDeleted, getCardHistoryID, getDefaultLearnableWeight, importParsedCards, isQuestionRecord, mountPipelineDebugger, mountUserDBDebugger, parseCardHistoryID, pipelineDebugAPI, recordUserOutcome, runPeriodUpdate, scoreAccuracyInZone, updateLearningState, updateStrategyWeight, userDBDebugAPI, validateProcessorConfig };
783
+ export { type BulkCardProcessorConfig, type CardFilter, type CardFilterFactory, CardHistory, CardRecord, CourseDBInterface, DIVERSITY_FLOOR, DIVERSITY_STRENGTH, type DiversityRerankOptions, DocType, DocTypePrefixes, type FilterContext, type FilterImpact, type GeneratorSummary, type GradientObservation, type GradientResult, type ImportResult, LearnableWeight, Loggable, OrchestrationContext, type PeriodUpdateInput, type PeriodUpdateResult, type PipelineForecaster, type PipelineRunReport, QuestionRecord, ReplanHints, type SignalConfig, StrategyContribution, type StrategyLearningState, type StrategyStateDoc, type StrategyStateId, UserDBInterface, UserOutcomeRecord, WeightedCard, aggregateOutcomesForGradient, areQuestionRecords, buildStrategyStateId, computeOutcomeSignal, computeStrategyGradient, diversityRerank, docIsDeleted, getActivePipeline, getCardHistoryID, getDefaultLearnableWeight, importParsedCards, isQuestionRecord, mountPipelineDebugger, mountUserDBDebugger, parseCardHistoryID, pipelineDebugAPI, recordUserOutcome, runPeriodUpdate, scoreAccuracyInZone, updateLearningState, updateStrategyWeight, userDBDebugAPI, validateProcessorConfig };
@@ -304,6 +304,73 @@ interface CardFilter {
304
304
  */
305
305
  type CardFilterFactory<TConfig = unknown> = (config: TConfig) => CardFilter;
306
306
 
307
+ interface DiversityRerankOptions {
308
+ /**
309
+ * How hard repetition is penalised. Larger → steeper demotion of repeated
310
+ * distinctive tags. Penalty = 1 / (1 + strength·load).
311
+ */
312
+ strength?: number;
313
+ /**
314
+ * Minimum penalty multiplier. A card is never demoted below `floor × score`,
315
+ * however much it repeats. Keeps a strong-but-repeated card from being driven
316
+ * under downstream "well-indicated" thresholds (which would mislabel it as
317
+ * filler and could trigger spurious quality-replans). Tunes "perturb ordering"
318
+ * vs "annihilate candidates."
319
+ */
320
+ floor?: number;
321
+ }
322
+ /** Default repetition strength. See DiversityRerankOptions.strength. */
323
+ declare const DIVERSITY_STRENGTH = 0.6;
324
+ /** Default penalty floor. See DiversityRerankOptions.floor. */
325
+ declare const DIVERSITY_FLOOR = 0.3;
326
+ /**
327
+ * Re-rank a scored candidate pool for answer/concept variety.
328
+ *
329
+ * Pure: returns a new array (diversified order, adjusted scores, appended
330
+ * provenance) and does not mutate the input cards. Cards entering are assumed
331
+ * to have score > 0 (the Pipeline strips zero-score cards before this stage).
332
+ * Non-finite scores (mandatory `requireCards`, score = +Infinity) are emitted
333
+ * untouched and still count toward repetition for later cards.
334
+ *
335
+ * @param cards - Post-filter, post-hint candidates.
336
+ * @param opts - Optional strength/floor overrides (defaults are sane and
337
+ * course-general; promote to strategy config if you ever want
338
+ * this learnable under the orchestration layer).
339
+ * @returns Cards in diversified order with penalised scores.
340
+ */
341
+ declare function diversityRerank(cards: WeightedCard[], opts?: DiversityRerankOptions): WeightedCard[];
342
+
343
+ /**
344
+ * A navigation pipeline that runs a generator and applies filters sequentially.
345
+ *
346
+ * Implements StudyContentSource for backward compatibility with SessionController.
347
+ *
348
+ * ## Usage
349
+ *
350
+ * ```typescript
351
+ * const pipeline = new Pipeline(
352
+ * compositeGenerator, // or single generator
353
+ * [eloDistanceFilter, interferenceFilter],
354
+ * user,
355
+ * course
356
+ * );
357
+ *
358
+ * const cards = await pipeline.getWeightedCards(20);
359
+ * ```
360
+ */
361
+ /**
362
+ * Narrow capability surface for out-of-band, commit-free reads against a live
363
+ * pipeline (see {@link getActivePipeline}). Kept minimal on purpose — consumers
364
+ * get the forecast capability, not the whole `Pipeline` class.
365
+ */
366
+ interface PipelineForecaster {
367
+ forecast(opts?: {
368
+ hints?: ReplanHints;
369
+ unseenOnly?: boolean;
370
+ threshold?: number;
371
+ limit?: number;
372
+ }): Promise<WeightedCard[]>;
373
+ }
307
374
  /**
308
375
  * Diagnosis of the full card space for the current user.
309
376
  */
@@ -325,6 +392,13 @@ interface CardSpaceDiagnosis {
325
392
  elapsedMs: number;
326
393
  }
327
394
 
395
+ /**
396
+ * The most recently constructed pipeline for the current session, or null if
397
+ * none has been built yet. This is the supported, non-debug accessor for
398
+ * out-of-band reads against the live pipeline (e.g. a commit-free
399
+ * `forecast()`), replacing reach-ins through `window.skuilder.pipeline`.
400
+ */
401
+ declare function getActivePipeline(): PipelineForecaster | null;
328
402
  /**
329
403
  * Summary of a single generator's contribution.
330
404
  */
@@ -706,4 +780,4 @@ declare const userDBDebugAPI: {
706
780
  */
707
781
  declare function mountUserDBDebugger(): void;
708
782
 
709
- export { type BulkCardProcessorConfig, type CardFilter, type CardFilterFactory, CardHistory, CardRecord, CourseDBInterface, DocType, DocTypePrefixes, type FilterContext, type FilterImpact, type GeneratorSummary, type GradientObservation, type GradientResult, type ImportResult, LearnableWeight, Loggable, OrchestrationContext, type PeriodUpdateInput, type PeriodUpdateResult, type PipelineRunReport, QuestionRecord, ReplanHints, type SignalConfig, StrategyContribution, type StrategyLearningState, type StrategyStateDoc, type StrategyStateId, UserDBInterface, UserOutcomeRecord, WeightedCard, aggregateOutcomesForGradient, areQuestionRecords, buildStrategyStateId, computeOutcomeSignal, computeStrategyGradient, docIsDeleted, getCardHistoryID, getDefaultLearnableWeight, importParsedCards, isQuestionRecord, mountPipelineDebugger, mountUserDBDebugger, parseCardHistoryID, pipelineDebugAPI, recordUserOutcome, runPeriodUpdate, scoreAccuracyInZone, updateLearningState, updateStrategyWeight, userDBDebugAPI, validateProcessorConfig };
783
+ export { type BulkCardProcessorConfig, type CardFilter, type CardFilterFactory, CardHistory, CardRecord, CourseDBInterface, DIVERSITY_FLOOR, DIVERSITY_STRENGTH, type DiversityRerankOptions, DocType, DocTypePrefixes, type FilterContext, type FilterImpact, type GeneratorSummary, type GradientObservation, type GradientResult, type ImportResult, LearnableWeight, Loggable, OrchestrationContext, type PeriodUpdateInput, type PeriodUpdateResult, type PipelineForecaster, type PipelineRunReport, QuestionRecord, ReplanHints, type SignalConfig, StrategyContribution, type StrategyLearningState, type StrategyStateDoc, type StrategyStateId, UserDBInterface, UserOutcomeRecord, WeightedCard, aggregateOutcomesForGradient, areQuestionRecords, buildStrategyStateId, computeOutcomeSignal, computeStrategyGradient, diversityRerank, docIsDeleted, getActivePipeline, getCardHistoryID, getDefaultLearnableWeight, importParsedCards, isQuestionRecord, mountPipelineDebugger, mountUserDBDebugger, parseCardHistoryID, pipelineDebugAPI, recordUserOutcome, runPeriodUpdate, scoreAccuracyInZone, updateLearningState, updateStrategyWeight, userDBDebugAPI, validateProcessorConfig };
@@ -719,12 +719,102 @@ var init_courseLookupDB = __esm({
719
719
  }
720
720
  });
721
721
 
722
+ // src/core/navigators/diversityRerank.ts
723
+ var diversityRerank_exports = {};
724
+ __export(diversityRerank_exports, {
725
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
726
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
727
+ diversityRerank: () => diversityRerank
728
+ });
729
+ function diversityRerank(cards, opts = {}) {
730
+ const strength = opts.strength ?? DIVERSITY_STRENGTH;
731
+ const floor = opts.floor ?? DIVERSITY_FLOOR;
732
+ const n = cards.length;
733
+ if (n <= 1) return cards;
734
+ const df = /* @__PURE__ */ new Map();
735
+ for (const card of cards) {
736
+ for (const tag of card.tags ?? []) {
737
+ df.set(tag, (df.get(tag) ?? 0) + 1);
738
+ }
739
+ }
740
+ const idf = /* @__PURE__ */ new Map();
741
+ for (const [tag, freq] of df) {
742
+ idf.set(tag, Math.log(n / freq));
743
+ }
744
+ const remaining = [...cards];
745
+ const emittedCount = /* @__PURE__ */ new Map();
746
+ const out = [];
747
+ const repetitionLoad = (card) => {
748
+ let load = 0;
749
+ for (const tag of card.tags ?? []) {
750
+ const seen = emittedCount.get(tag);
751
+ if (seen) load += (idf.get(tag) ?? 0) * seen;
752
+ }
753
+ return load;
754
+ };
755
+ while (remaining.length > 0) {
756
+ let bestIdx = 0;
757
+ let bestValue = -Infinity;
758
+ let bestPenalty = 1;
759
+ let bestLoad = 0;
760
+ for (let i = 0; i < remaining.length; i++) {
761
+ const card = remaining[i];
762
+ const load = repetitionLoad(card);
763
+ const penalty = load > 0 ? Math.max(floor, 1 / (1 + strength * load)) : 1;
764
+ const value = card.score * penalty;
765
+ if (value > bestValue) {
766
+ bestValue = value;
767
+ bestIdx = i;
768
+ bestPenalty = penalty;
769
+ bestLoad = load;
770
+ }
771
+ }
772
+ const [picked] = remaining.splice(bestIdx, 1);
773
+ if (Number.isFinite(picked.score) && bestPenalty < 1) {
774
+ const newScore = picked.score * bestPenalty;
775
+ out.push({
776
+ ...picked,
777
+ score: newScore,
778
+ provenance: [
779
+ ...picked.provenance,
780
+ {
781
+ strategy: STRATEGY,
782
+ strategyId: STRATEGY_ID,
783
+ strategyName: STRATEGY_NAME,
784
+ action: "penalized",
785
+ score: newScore,
786
+ reason: `repeated tags (load ${bestLoad.toFixed(2)}) \u2192 \xD7${bestPenalty.toFixed(2)}`
787
+ }
788
+ ]
789
+ });
790
+ } else {
791
+ out.push(picked);
792
+ }
793
+ for (const tag of picked.tags ?? []) {
794
+ emittedCount.set(tag, (emittedCount.get(tag) ?? 0) + 1);
795
+ }
796
+ }
797
+ return out;
798
+ }
799
+ var DIVERSITY_STRENGTH, DIVERSITY_FLOOR, STRATEGY, STRATEGY_ID, STRATEGY_NAME;
800
+ var init_diversityRerank = __esm({
801
+ "src/core/navigators/diversityRerank.ts"() {
802
+ "use strict";
803
+ DIVERSITY_STRENGTH = 0.6;
804
+ DIVERSITY_FLOOR = 0.3;
805
+ STRATEGY = "diversityRerank";
806
+ STRATEGY_ID = "DIVERSITY_RERANK";
807
+ STRATEGY_NAME = "Diversity Re-rank";
808
+ }
809
+ });
810
+
722
811
  // src/core/navigators/PipelineDebugger.ts
723
812
  var PipelineDebugger_exports = {};
724
813
  __export(PipelineDebugger_exports, {
725
814
  buildRunReport: () => buildRunReport,
726
815
  captureRun: () => captureRun,
727
816
  clearRunHistory: () => clearRunHistory,
817
+ getActivePipeline: () => getActivePipeline,
728
818
  mountPipelineDebugger: () => mountPipelineDebugger,
729
819
  pipelineDebugAPI: () => pipelineDebugAPI,
730
820
  registerPipelineForDebug: () => registerPipelineForDebug
@@ -732,6 +822,9 @@ __export(PipelineDebugger_exports, {
732
822
  function registerPipelineForDebug(pipeline) {
733
823
  _activePipeline = pipeline;
734
824
  }
825
+ function getActivePipeline() {
826
+ return _activePipeline;
827
+ }
735
828
  function clearRunHistory() {
736
829
  runHistory.length = 0;
737
830
  }
@@ -1914,7 +2007,7 @@ function shuffleInPlace(arr) {
1914
2007
  function pickTopByScore(cards, limit) {
1915
2008
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1916
2009
  }
1917
- 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;
2010
+ 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;
1918
2011
  var init_prescribed = __esm({
1919
2012
  "src/core/navigators/generators/prescribed.ts"() {
1920
2013
  "use strict";
@@ -1925,9 +2018,12 @@ var init_prescribed = __esm({
1925
2018
  DEFAULT_MAX_SUPPORT_PER_RUN = 3;
1926
2019
  DEFAULT_HIERARCHY_DEPTH = 2;
1927
2020
  DEFAULT_MIN_COUNT = 3;
2021
+ DEFAULT_PRACTICE_MIN_COUNT = 3;
2022
+ DEFAULT_MAX_PRACTICE_PER_RUN = 4;
1928
2023
  BASE_TARGET_SCORE = 1;
1929
2024
  BASE_SUPPORT_SCORE = 0.8;
1930
2025
  DISCOVERED_SUPPORT_SCORE = 12;
2026
+ BASE_PRACTICE_SCORE = 1;
1931
2027
  MAX_TARGET_MULTIPLIER = 8;
1932
2028
  MAX_SUPPORT_MULTIPLIER = 4;
1933
2029
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -2035,7 +2131,18 @@ var init_prescribed = __esm({
2035
2131
  courseId,
2036
2132
  emittedIds
2037
2133
  );
2038
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
2134
+ const practiceCards = this.buildPracticeCards({
2135
+ group,
2136
+ courseId,
2137
+ emittedIds,
2138
+ cardsByTag,
2139
+ hierarchyConfigs,
2140
+ userTagElo,
2141
+ userGlobalElo,
2142
+ activeIds,
2143
+ seenIds
2144
+ });
2145
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
2039
2146
  }
2040
2147
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
2041
2148
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
@@ -2063,6 +2170,7 @@ var init_prescribed = __esm({
2063
2170
  const surfacedByGroup = /* @__PURE__ */ new Map();
2064
2171
  for (const card of finalCards) {
2065
2172
  const prov = card.provenance[0];
2173
+ if (prov?.reason.includes("mode=practice")) continue;
2066
2174
  const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
2067
2175
  const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
2068
2176
  if (!groupId) continue;
@@ -2132,7 +2240,12 @@ var init_prescribed = __esm({
2132
2240
  enabled: raw.hierarchyWalk?.enabled !== false,
2133
2241
  maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
2134
2242
  },
2135
- retireOnEncounter: raw.retireOnEncounter !== false
2243
+ retireOnEncounter: raw.retireOnEncounter !== false,
2244
+ practiceTagPatterns: dedupe(
2245
+ Array.isArray(raw.practiceTagPatterns) ? raw.practiceTagPatterns.filter((v) => typeof v === "string") : []
2246
+ ),
2247
+ practiceMinCount: typeof raw.practiceMinCount === "number" ? raw.practiceMinCount : DEFAULT_PRACTICE_MIN_COUNT,
2248
+ maxPracticeCardsPerRun: typeof raw.maxPracticeCardsPerRun === "number" ? raw.maxPracticeCardsPerRun : DEFAULT_MAX_PRACTICE_PER_RUN
2136
2249
  })).filter((g) => g.targetCardIds.length > 0);
2137
2250
  return { groups };
2138
2251
  } catch {
@@ -2355,6 +2468,92 @@ var init_prescribed = __esm({
2355
2468
  }
2356
2469
  return cards;
2357
2470
  }
2471
+ /**
2472
+ * Emit drill cards for *unlocked-but-under-practiced* skills.
2473
+ *
2474
+ * For each course tag matching the group's `practiceTagPatterns` that is both
2475
+ * unlocked (all hierarchy prerequisites met — i.e. the learner has been
2476
+ * introduced to it) and under-practiced (per-tag attempt count below
2477
+ * `practiceMinCount`), this resolves cards carrying that tag and emits them
2478
+ * into the candidate pool. It exists because global-ELO retrieval
2479
+ * systematically fails to fetch the (low-ELO) drill cards for a
2480
+ * freshly-introduced skill — putting them in the pool here lets the pipeline's
2481
+ * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2482
+ * this method's job; it only guarantees presence.
2483
+ *
2484
+ * Fully data-driven: the unlock relation comes from the hierarchy config and
2485
+ * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
2486
+ */
2487
+ buildPracticeCards(args) {
2488
+ const {
2489
+ group,
2490
+ courseId,
2491
+ emittedIds,
2492
+ cardsByTag,
2493
+ hierarchyConfigs,
2494
+ userTagElo,
2495
+ userGlobalElo,
2496
+ activeIds,
2497
+ seenIds
2498
+ } = args;
2499
+ const patterns = group.practiceTagPatterns ?? [];
2500
+ if (patterns.length === 0) return [];
2501
+ const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2502
+ const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2503
+ const practiceTags = [...cardsByTag.keys()].filter(
2504
+ (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2505
+ );
2506
+ if (practiceTags.length === 0) return [];
2507
+ const practiceCardIds = this.findDiscoveredSupportCards({
2508
+ supportTags: practiceTags,
2509
+ cardsByTag,
2510
+ activeIds,
2511
+ seenIds,
2512
+ excludedIds: emittedIds,
2513
+ limit: maxPractice
2514
+ });
2515
+ if (practiceCardIds.length === 0) return [];
2516
+ logger.info(
2517
+ `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2518
+ );
2519
+ const cards = [];
2520
+ for (const cardId of practiceCardIds) {
2521
+ emittedIds.add(cardId);
2522
+ cards.push({
2523
+ cardId,
2524
+ courseId,
2525
+ score: BASE_PRACTICE_SCORE,
2526
+ provenance: [
2527
+ {
2528
+ strategy: "prescribed",
2529
+ strategyName: this.strategyName || this.name,
2530
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2531
+ action: "generated",
2532
+ score: BASE_PRACTICE_SCORE,
2533
+ reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2534
+ }
2535
+ ]
2536
+ });
2537
+ }
2538
+ return cards;
2539
+ }
2540
+ /**
2541
+ * True for a skill that was *gated and is now reached*: it has at least one
2542
+ * declared hierarchy prerequisite set, and every set is fully satisfied by the
2543
+ * learner's per-tag ELO. This deliberately EXCLUDES tags with no prerequisites
2544
+ * — an ungated tag was never "introduced" in the curricular sense, so it isn't
2545
+ * a post-intro drill target (e.g. whole-word spelling tags that share the
2546
+ * `gpc:exercise:*` prefix but have no intro gate). Those are left to normal
2547
+ * ELO retrieval. This is the precise population the retrieval gap strands:
2548
+ * just-unlocked, low-ELO skills.
2549
+ */
2550
+ isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) {
2551
+ const prereqSets = hierarchyConfigs.map((hierarchy) => hierarchy.prerequisites[tag]).filter((prereqs) => Array.isArray(prereqs) && prereqs.length > 0);
2552
+ if (prereqSets.length === 0) return false;
2553
+ return prereqSets.every(
2554
+ (prereqs) => prereqs.every((pr) => this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo))
2555
+ );
2556
+ }
2358
2557
  findSupportCardsByTags(group, tagsByCard, supportTags) {
2359
2558
  if (supportTags.length === 0) {
2360
2559
  return [];
@@ -4148,7 +4347,7 @@ function logResultCards(cards) {
4148
4347
  for (let i = 0; i < cards.length; i++) {
4149
4348
  const c = cards[i];
4150
4349
  const tags = c.tags?.slice(0, 3).join(", ") || "";
4151
- const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
4350
+ 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) => {
4152
4351
  const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
4153
4352
  return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
4154
4353
  }).join(" | ");
@@ -4180,6 +4379,7 @@ var init_Pipeline = __esm({
4180
4379
  init_logger();
4181
4380
  init_orchestration();
4182
4381
  init_PipelineDebugger();
4382
+ init_diversityRerank();
4183
4383
  VERBOSE_RESULTS = true;
4184
4384
  Pipeline = class extends ContentNavigator {
4185
4385
  generator;
@@ -4353,6 +4553,7 @@ var init_Pipeline = __esm({
4353
4553
  this._ephemeralHints = null;
4354
4554
  cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
4355
4555
  }
4556
+ cards = diversityRerank(cards);
4356
4557
  cards.sort((a, b) => b.score - a.score);
4357
4558
  const tFilter = performance.now();
4358
4559
  const result = cards.slice(0, limit);
@@ -4656,6 +4857,68 @@ var init_Pipeline = __esm({
4656
4857
  // ---------------------------------------------------------------------------
4657
4858
  // Card-space diagnostic
4658
4859
  // ---------------------------------------------------------------------------
4860
+ /**
4861
+ * Commit-free forecast: score the user's full card space through the filter
4862
+ * chain and return the cards that are currently *reachable* (score >=
4863
+ * threshold), optionally nudged by caller-supplied hints and/or restricted
4864
+ * to cards the user hasn't seen yet.
4865
+ *
4866
+ * This is a GENERIC primitive — it returns scored, tag-hydrated cards and
4867
+ * stops there. It has no knowledge of any particular tag convention; callers
4868
+ * decide what the surviving cards mean (e.g. filter to their own "intro"
4869
+ * tag family). Nothing is written and no session is started.
4870
+ *
4871
+ * The optional `hints` are the "out-of-band kick": they run through the same
4872
+ * {@link applyHints} path a live replan uses, so the two semantics carry over —
4873
+ * - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
4874
+ * stays out), and
4875
+ * - `requireTags`/`requireCards` inject from the full pre-filter pool,
4876
+ * *bypassing* gating (use when you want a card regardless of reachability).
4877
+ * Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
4878
+ * user has already seen — pass `unseenOnly: false` if that matters.
4879
+ *
4880
+ * Cost note: like {@link diagnoseCardSpace}, this scans every card through the
4881
+ * filters, so it's heavier than a normal replan. Intended for one-shot
4882
+ * out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
4883
+ *
4884
+ * @param opts.hints Optional ephemeral hints to apply after the filter chain.
4885
+ * @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
4886
+ * @param opts.threshold Min score to count as reachable (default 0.10).
4887
+ * @param opts.limit Optional cap on results (already sorted desc).
4888
+ */
4889
+ async forecast(opts) {
4890
+ const threshold = opts?.threshold ?? 0.1;
4891
+ const unseenOnly = opts?.unseenOnly ?? true;
4892
+ const courseId = this.course.getCourseID();
4893
+ const allCardIds = await this.course.getAllCardIds();
4894
+ let cards = allCardIds.map((cardId) => ({
4895
+ cardId,
4896
+ courseId,
4897
+ score: 1,
4898
+ provenance: []
4899
+ }));
4900
+ cards = await this.hydrateTags(cards);
4901
+ const fullPool = cards.slice();
4902
+ const context = await this.buildContext();
4903
+ for (const filter of this.filters) {
4904
+ cards = await filter.transform(cards, context);
4905
+ }
4906
+ if (opts?.hints) {
4907
+ cards = this.applyHints(cards, opts.hints, fullPool);
4908
+ }
4909
+ cards = cards.filter((c) => c.score >= threshold);
4910
+ if (unseenOnly) {
4911
+ let encountered;
4912
+ try {
4913
+ encountered = new Set(await this.user.getSeenCards(courseId));
4914
+ } catch {
4915
+ encountered = /* @__PURE__ */ new Set();
4916
+ }
4917
+ cards = cards.filter((c) => !encountered.has(c.cardId));
4918
+ }
4919
+ cards.sort((a, b) => b.score - a.score);
4920
+ return opts?.limit ? cards.slice(0, opts.limit) : cards;
4921
+ }
4659
4922
  /**
4660
4923
  * Scan every card in the course through the filter chain and report
4661
4924
  * how many are "well indicated" (score >= threshold) for the current user.
@@ -4921,6 +5184,7 @@ var init_3 = __esm({
4921
5184
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
4922
5185
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
4923
5186
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
5187
+ "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
4924
5188
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
4925
5189
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
4926
5190
  "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
@@ -4946,9 +5210,13 @@ var init_3 = __esm({
4946
5210
  var navigators_exports = {};
4947
5211
  __export(navigators_exports, {
4948
5212
  ContentNavigator: () => ContentNavigator,
5213
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
5214
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
4949
5215
  NavigatorRole: () => NavigatorRole,
4950
5216
  NavigatorRoles: () => NavigatorRoles,
4951
5217
  Navigators: () => Navigators,
5218
+ diversityRerank: () => diversityRerank,
5219
+ getActivePipeline: () => getActivePipeline,
4952
5220
  getCardOrigin: () => getCardOrigin,
4953
5221
  getRegisteredNavigator: () => getRegisteredNavigator,
4954
5222
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
@@ -5032,6 +5300,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
5032
5300
  var init_navigators = __esm({
5033
5301
  "src/core/navigators/index.ts"() {
5034
5302
  "use strict";
5303
+ init_diversityRerank();
5035
5304
  init_PipelineDebugger();
5036
5305
  init_logger();
5037
5306
  init_();
@@ -8039,6 +8308,8 @@ Examples:
8039
8308
  var core_exports = {};
8040
8309
  __export(core_exports, {
8041
8310
  ContentNavigator: () => ContentNavigator,
8311
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
8312
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
8042
8313
  DocType: () => DocType,
8043
8314
  DocTypePrefixes: () => DocTypePrefixes,
8044
8315
  GuestUsername: () => GuestUsername,
@@ -8055,7 +8326,9 @@ __export(core_exports, {
8055
8326
  computeSpread: () => computeSpread,
8056
8327
  computeStrategyGradient: () => computeStrategyGradient,
8057
8328
  createOrchestrationContext: () => createOrchestrationContext,
8329
+ diversityRerank: () => diversityRerank,
8058
8330
  docIsDeleted: () => docIsDeleted,
8331
+ getActivePipeline: () => getActivePipeline,
8059
8332
  getCardHistoryID: () => getCardHistoryID,
8060
8333
  getCardOrigin: () => getCardOrigin,
8061
8334
  getDefaultLearnableWeight: () => getDefaultLearnableWeight,
@@ -8104,6 +8377,8 @@ init_core();
8104
8377
  // Annotate the CommonJS export names for ESM import in node:
8105
8378
  0 && (module.exports = {
8106
8379
  ContentNavigator,
8380
+ DIVERSITY_FLOOR,
8381
+ DIVERSITY_STRENGTH,
8107
8382
  DocType,
8108
8383
  DocTypePrefixes,
8109
8384
  GuestUsername,
@@ -8120,7 +8395,9 @@ init_core();
8120
8395
  computeSpread,
8121
8396
  computeStrategyGradient,
8122
8397
  createOrchestrationContext,
8398
+ diversityRerank,
8123
8399
  docIsDeleted,
8400
+ getActivePipeline,
8124
8401
  getCardHistoryID,
8125
8402
  getCardOrigin,
8126
8403
  getDefaultLearnableWeight,