@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.
@@ -117,8 +117,12 @@ class Pipeline {
117
117
  cards = await filter.transform(cards, context);
118
118
  }
119
119
 
120
- return cards.filter(c => c.score > 0)
121
- .sort((a, b) => b.score - a.score)
120
+ cards = cards.filter(c => c.score > 0);
121
+
122
+ // Stage 3: diversity re-rank (post-filter, pre-sort)
123
+ cards = diversityRerank(cards);
124
+
125
+ return cards.sort((a, b) => b.score - a.score)
122
126
  .slice(0, limit);
123
127
  }
124
128
  }
@@ -128,8 +132,43 @@ class Pipeline {
128
132
  - **Context building** — Fetches shared data (user ELO, orchestration context) once for all strategies
129
133
  - **Data hydration** — Pre-fetches commonly needed data (tags) in batch queries
130
134
  - **Filter orchestration** — Applies filters in sequence, accumulating provenance
135
+ - **Diversity re-rank** — Demotes repeated answers/concepts so no one tag monopolises the head of the queue (see below)
131
136
  - **Result selection** — Removes zero-scores, sorts, and returns top N
132
137
 
138
+ ### Diversity Re-rank (Stage 3)
139
+
140
+ A first-class stage that runs **after** the filter chain on the final scores. The
141
+ three-stage model is: generators *produce* → filters *weigh* → re-rank
142
+ *diversifies*. It exists to break "ruts" where many top-scoring candidates share
143
+ the same answer (e.g. a run of missing-letter cards that all resolve to `i`), so
144
+ the learner can't go on autopilot pressing one key.
145
+
146
+ **Why a separate stage, not a filter:** filters are documented as
147
+ order-independent multipliers (see [Score Semantics](#score-semantics)). The
148
+ re-rank is *rank-dependent* — a card's penalty depends on what outscored it — so
149
+ it deliberately sits outside the commutative filter chain, running last.
150
+
151
+ **Tag-agnostic by construction.** Tags are the only structured similarity signal
152
+ the framework has, so the re-rank operates on tags — but privileges none. It
153
+ weights each shared tag by its rarity in the candidate pool (inverse document
154
+ frequency): ubiquitous scaffolding tags (`ui:*`, incidental `gpc:expose:*`)
155
+ contribute ~0, while the distinctive tag a cluster shares dominates. No namespace
156
+ is hardcoded, so any course benefits for free *provided its sameness axis is
157
+ tagged* ("tag-agnostic" = no tag is special, not "needs no tags").
158
+
159
+ **Algorithm** (greedy maximal-marginal-relevance): walk candidates in score
160
+ order; a candidate's repetition load is `Σ idf[tag]·(#already-emitted cards with
161
+ tag)`; emit `argmax(score / (1 + strength·load))` each step, flooring the penalty
162
+ so a strong-but-repeated card is never driven under downstream "well-indicated"
163
+ thresholds. Penalties are expressed as **scores** (not a positional shuffle) so
164
+ the ordering survives both the Pipeline's final sort and the `SourceMixer`'s
165
+ score-descending re-sort downstream.
166
+
167
+ Per-source: the stage lives inside each Pipeline run, so in multi-source sessions
168
+ it diversifies each source's contribution before the mixer interleaves sources.
169
+ Two global knobs (`strength`, `floor`) have course-general defaults; promote to
170
+ strategy `serializedData` if you want them learnable under orchestration.
171
+
133
172
  ## Pipeline Assembly
134
173
 
135
174
  `PipelineAssembler` builds pipelines from strategy documents:
@@ -590,6 +629,7 @@ return { ...card, score: card.score * multiplier };
590
629
  | `core/navigators/generators/types.ts` | `CardGenerator`, `GeneratorContext` |
591
630
  | `core/navigators/filters/types.ts` | `CardFilter`, `FilterContext` |
592
631
  | `core/navigators/Pipeline.ts` | Pipeline orchestration |
632
+ | `core/navigators/diversityRerank.ts` | Diversity re-rank stage (IDF-weighted MMR, pipeline stage 3) |
593
633
  | `core/navigators/PipelineAssembler.ts` | Builds Pipeline from strategy docs |
594
634
  | `core/navigators/CompositeGenerator.ts` | Merges multiple generators |
595
635
  | `core/navigators/generators/elo.ts` | ELO generator |
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.5",
7
+ "version": "0.2.8",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.2.5",
51
+ "@vue-skuilder/common": "0.2.8",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -62,5 +62,5 @@
62
62
  "vite": "^8.0.0",
63
63
  "vitest": "^4.1.0"
64
64
  },
65
- "stableVersion": "0.2.5"
65
+ "stableVersion": "0.2.8"
66
66
  }
@@ -9,6 +9,7 @@ import type { GeneratorResult } from './generators/types';
9
9
  import { logger } from '../../util/logger';
10
10
  import { createOrchestrationContext, OrchestrationContext } from '../orchestration';
11
11
  import { captureRun, buildRunReport, registerPipelineForDebug, type GeneratorSummary, type FilterImpact } from './PipelineDebugger';
12
+ import { diversityRerank } from './diversityRerank';
12
13
 
13
14
  // ============================================================================
14
15
  // REPLAN HINTS
@@ -180,7 +181,7 @@ function logResultCards(cards: WeightedCard[]): void {
180
181
  const c = cards[i];
181
182
  const tags = c.tags?.slice(0, 3).join(', ') || '';
182
183
  const filters = c.provenance
183
- .filter((p) => p.strategy === 'hierarchyDefinition' || p.strategy === 'priorityDefinition' || p.strategy === 'interferenceFilter' || p.strategy === 'letterGating' || p.strategy === 'ephemeralHint')
184
+ .filter((p) => p.strategy === 'hierarchyDefinition' || p.strategy === 'priorityDefinition' || p.strategy === 'interferenceFilter' || p.strategy === 'letterGating' || p.strategy === 'ephemeralHint' || p.strategy === 'diversityRerank')
184
185
  .map((p) => {
185
186
  const arrow = p.action === 'boosted' ? '↑' : p.action === 'penalized' ? '↓' : '=';
186
187
  return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
@@ -254,7 +255,22 @@ function logCardProvenance(cards: WeightedCard[], maxCards: number = 3): void {
254
255
  * const cards = await pipeline.getWeightedCards(20);
255
256
  * ```
256
257
  */
257
- export class Pipeline extends ContentNavigator {
258
+
259
+ /**
260
+ * Narrow capability surface for out-of-band, commit-free reads against a live
261
+ * pipeline (see {@link getActivePipeline}). Kept minimal on purpose — consumers
262
+ * get the forecast capability, not the whole `Pipeline` class.
263
+ */
264
+ export interface PipelineForecaster {
265
+ forecast(opts?: {
266
+ hints?: ReplanHints;
267
+ unseenOnly?: boolean;
268
+ threshold?: number;
269
+ limit?: number;
270
+ }): Promise<WeightedCard[]>;
271
+ }
272
+
273
+ export class Pipeline extends ContentNavigator implements PipelineForecaster {
258
274
  private generator: CardGenerator;
259
275
  private filters: CardFilter[];
260
276
 
@@ -495,6 +511,14 @@ export class Pipeline extends ContentNavigator {
495
511
  cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
496
512
  }
497
513
 
514
+ // Stage 3: diversity re-rank (post-filter, post-hints, pre-sort). Demotes
515
+ // cards whose distinctive tags already appeared among higher-ranked
516
+ // candidates so a single answer/concept can't monopolise the head of the
517
+ // queue. Tag-agnostic (rarity-weighted overlap — no namespace privileged)
518
+ // and expressed as score penalties so the ordering survives the final sort
519
+ // here AND the SourceMixer's score-descending re-sort downstream.
520
+ cards = diversityRerank(cards);
521
+
498
522
  // Sort by score descending
499
523
  cards.sort((a, b) => b.score - a.score);
500
524
 
@@ -881,6 +905,83 @@ export class Pipeline extends ContentNavigator {
881
905
  // Card-space diagnostic
882
906
  // ---------------------------------------------------------------------------
883
907
 
908
+ /**
909
+ * Commit-free forecast: score the user's full card space through the filter
910
+ * chain and return the cards that are currently *reachable* (score >=
911
+ * threshold), optionally nudged by caller-supplied hints and/or restricted
912
+ * to cards the user hasn't seen yet.
913
+ *
914
+ * This is a GENERIC primitive — it returns scored, tag-hydrated cards and
915
+ * stops there. It has no knowledge of any particular tag convention; callers
916
+ * decide what the surviving cards mean (e.g. filter to their own "intro"
917
+ * tag family). Nothing is written and no session is started.
918
+ *
919
+ * The optional `hints` are the "out-of-band kick": they run through the same
920
+ * {@link applyHints} path a live replan uses, so the two semantics carry over —
921
+ * - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
922
+ * stays out), and
923
+ * - `requireTags`/`requireCards` inject from the full pre-filter pool,
924
+ * *bypassing* gating (use when you want a card regardless of reachability).
925
+ * Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
926
+ * user has already seen — pass `unseenOnly: false` if that matters.
927
+ *
928
+ * Cost note: like {@link diagnoseCardSpace}, this scans every card through the
929
+ * filters, so it's heavier than a normal replan. Intended for one-shot
930
+ * out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
931
+ *
932
+ * @param opts.hints Optional ephemeral hints to apply after the filter chain.
933
+ * @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
934
+ * @param opts.threshold Min score to count as reachable (default 0.10).
935
+ * @param opts.limit Optional cap on results (already sorted desc).
936
+ */
937
+ async forecast(opts?: {
938
+ hints?: ReplanHints;
939
+ unseenOnly?: boolean;
940
+ threshold?: number;
941
+ limit?: number;
942
+ }): Promise<WeightedCard[]> {
943
+ const threshold = opts?.threshold ?? 0.10;
944
+ const unseenOnly = opts?.unseenOnly ?? true;
945
+
946
+ const courseId = this.course!.getCourseID();
947
+ const allCardIds = await this.course!.getAllCardIds();
948
+
949
+ let cards: WeightedCard[] = allCardIds.map((cardId) => ({
950
+ cardId,
951
+ courseId,
952
+ score: 1.0,
953
+ provenance: [],
954
+ }));
955
+
956
+ cards = await this.hydrateTags(cards);
957
+ // Snapshot the full pool before filtering, for require-injection in applyHints.
958
+ const fullPool = cards.slice();
959
+
960
+ const context = await this.buildContext();
961
+ for (const filter of this.filters) {
962
+ cards = await filter.transform(cards, context);
963
+ }
964
+
965
+ if (opts?.hints) {
966
+ cards = this.applyHints(cards, opts.hints, fullPool);
967
+ }
968
+
969
+ cards = cards.filter((c) => c.score >= threshold);
970
+
971
+ if (unseenOnly) {
972
+ let encountered: Set<string>;
973
+ try {
974
+ encountered = new Set(await this.user!.getSeenCards(courseId));
975
+ } catch {
976
+ encountered = new Set();
977
+ }
978
+ cards = cards.filter((c) => !encountered.has(c.cardId));
979
+ }
980
+
981
+ cards.sort((a, b) => b.score - a.score);
982
+ return opts?.limit ? cards.slice(0, opts.limit) : cards;
983
+ }
984
+
884
985
  /**
885
986
  * Scan every card in the course through the filter chain and report
886
987
  * how many are "well indicated" (score >= threshold) for the current user.
@@ -8,7 +8,7 @@ import {
8
8
  isFilter,
9
9
  } from './index';
10
10
  import { logger } from '../../util/logger';
11
- import type { Pipeline, CardSpaceDiagnosis } from './Pipeline';
11
+ import type { Pipeline, CardSpaceDiagnosis, PipelineForecaster } from './Pipeline';
12
12
  import type { ReplanHints } from './generators/types';
13
13
 
14
14
  /**
@@ -25,6 +25,16 @@ export function registerPipelineForDebug(pipeline: Pipeline): void {
25
25
  _activePipeline = pipeline;
26
26
  }
27
27
 
28
+ /**
29
+ * The most recently constructed pipeline for the current session, or null if
30
+ * none has been built yet. This is the supported, non-debug accessor for
31
+ * out-of-band reads against the live pipeline (e.g. a commit-free
32
+ * `forecast()`), replacing reach-ins through `window.skuilder.pipeline`.
33
+ */
34
+ export function getActivePipeline(): PipelineForecaster | null {
35
+ return _activePipeline;
36
+ }
37
+
28
38
  // ============================================================================
29
39
  // PIPELINE DEBUGGER
30
40
  // ============================================================================
@@ -0,0 +1,185 @@
1
+ import type { WeightedCard } from './index';
2
+
3
+ // ============================================================================
4
+ // DIVERSITY RE-RANK (pipeline stage 3)
5
+ // ============================================================================
6
+ //
7
+ // Generators produce candidates → filters weigh them → THIS stage diversifies
8
+ // the ranking. It demotes cards whose distinctive tags already appeared among
9
+ // higher-ranked candidates, so a single answer/concept can't monopolise the
10
+ // head of the queue (the "press `i` repeatedly" rut).
11
+ //
12
+ // ## Tag-agnostic by construction
13
+ //
14
+ // The framework has no first-class notion of an "answer" — tags are the only
15
+ // structured similarity signal it carries. This stage privileges NO tag: it
16
+ // hardcodes no namespace and works on whatever tags a course happens to apply.
17
+ // "Tag-agnostic" means "no tag is special," not "ignores tags": a course that
18
+ // never tags its sameness axis is invisible to the re-rank (acceptable — tags
19
+ // are the framework's content contract).
20
+ //
21
+ // ## Why rarity weighting (IDF)
22
+ //
23
+ // Naive "penalize by count of shared tags" is noisy: cards share lots of
24
+ // scaffolding tags (`ui:*`, incidental `gpc:expose:*`) that say nothing about
25
+ // sameness. We weight each shared tag by its rarity in the candidate pool
26
+ // (inverse document frequency). Ubiquitous tags contribute ~0; the distinctive
27
+ // tag a cluster shares — exactly what makes a rut a rut — dominates. This is
28
+ // self-tuning and needs no per-course configuration: a math course clustering
29
+ // on "answer = 7" or a music course on "interval = M3" gets the same benefit
30
+ // for free, provided that axis is tagged.
31
+ //
32
+ // ## Algorithm — greedy maximal-marginal-relevance
33
+ //
34
+ // 1. df[tag] = #cards carrying tag; idf[tag] = ln(N / df[tag])
35
+ // (tag in every card → idf 0; rarer → larger).
36
+ // 2. Walk candidates in score order, emitting one at a time. Track how many
37
+ // already-emitted cards carry each tag (`emittedCount`).
38
+ // 3. A candidate's repetition load = Σ_{t ∈ card.tags} idf[t]·emittedCount[t].
39
+ // penalty = max(floor, 1 / (1 + strength·load)).
40
+ // 4. Each step emit argmax(score·penalty); freeze that penalised score.
41
+ //
42
+ // Because each step picks the current maximum and selecting a card only lowers
43
+ // (never raises) the remaining cards' values, the frozen scores are
44
+ // monotonically non-increasing in pick order — so a subsequent sort-by-score
45
+ // (the Pipeline's, and the SourceMixer's) reproduces this exact order. This is
46
+ // why the stage expresses itself as SCORE penalties rather than a bare array
47
+ // reorder: a positional shuffle would be silently undone by the mixer's
48
+ // score-descending re-sort.
49
+ //
50
+ // ============================================================================
51
+
52
+ export interface DiversityRerankOptions {
53
+ /**
54
+ * How hard repetition is penalised. Larger → steeper demotion of repeated
55
+ * distinctive tags. Penalty = 1 / (1 + strength·load).
56
+ */
57
+ strength?: number;
58
+ /**
59
+ * Minimum penalty multiplier. A card is never demoted below `floor × score`,
60
+ * however much it repeats. Keeps a strong-but-repeated card from being driven
61
+ * under downstream "well-indicated" thresholds (which would mislabel it as
62
+ * filler and could trigger spurious quality-replans). Tunes "perturb ordering"
63
+ * vs "annihilate candidates."
64
+ */
65
+ floor?: number;
66
+ }
67
+
68
+ /** Default repetition strength. See DiversityRerankOptions.strength. */
69
+ export const DIVERSITY_STRENGTH = 0.6;
70
+
71
+ /** Default penalty floor. See DiversityRerankOptions.floor. */
72
+ export const DIVERSITY_FLOOR = 0.3;
73
+
74
+ const STRATEGY = 'diversityRerank';
75
+ const STRATEGY_ID = 'DIVERSITY_RERANK';
76
+ const STRATEGY_NAME = 'Diversity Re-rank';
77
+
78
+ /**
79
+ * Re-rank a scored candidate pool for answer/concept variety.
80
+ *
81
+ * Pure: returns a new array (diversified order, adjusted scores, appended
82
+ * provenance) and does not mutate the input cards. Cards entering are assumed
83
+ * to have score > 0 (the Pipeline strips zero-score cards before this stage).
84
+ * Non-finite scores (mandatory `requireCards`, score = +Infinity) are emitted
85
+ * untouched and still count toward repetition for later cards.
86
+ *
87
+ * @param cards - Post-filter, post-hint candidates.
88
+ * @param opts - Optional strength/floor overrides (defaults are sane and
89
+ * course-general; promote to strategy config if you ever want
90
+ * this learnable under the orchestration layer).
91
+ * @returns Cards in diversified order with penalised scores.
92
+ */
93
+ export function diversityRerank(
94
+ cards: WeightedCard[],
95
+ opts: DiversityRerankOptions = {}
96
+ ): WeightedCard[] {
97
+ const strength = opts.strength ?? DIVERSITY_STRENGTH;
98
+ const floor = opts.floor ?? DIVERSITY_FLOOR;
99
+
100
+ const n = cards.length;
101
+ if (n <= 1) return cards;
102
+
103
+ // 1. Document frequency → IDF. A tag in every card carries no discriminative
104
+ // signal (idf 0); a rare tag dominates the repetition load.
105
+ const df = new Map<string, number>();
106
+ for (const card of cards) {
107
+ for (const tag of card.tags ?? []) {
108
+ df.set(tag, (df.get(tag) ?? 0) + 1);
109
+ }
110
+ }
111
+ const idf = new Map<string, number>();
112
+ for (const [tag, freq] of df) {
113
+ idf.set(tag, Math.log(n / freq));
114
+ }
115
+
116
+ // 2-4. Greedy MMR. `remaining` holds candidates not yet emitted; `emitted`
117
+ // counts selected cards per tag.
118
+ const remaining = [...cards];
119
+ const emittedCount = new Map<string, number>();
120
+ const out: WeightedCard[] = [];
121
+
122
+ const repetitionLoad = (card: WeightedCard): number => {
123
+ let load = 0;
124
+ for (const tag of card.tags ?? []) {
125
+ const seen = emittedCount.get(tag);
126
+ if (seen) load += (idf.get(tag) ?? 0) * seen;
127
+ }
128
+ return load;
129
+ };
130
+
131
+ while (remaining.length > 0) {
132
+ let bestIdx = 0;
133
+ let bestValue = -Infinity;
134
+ let bestPenalty = 1;
135
+ let bestLoad = 0;
136
+
137
+ for (let i = 0; i < remaining.length; i++) {
138
+ const card = remaining[i];
139
+ const load = repetitionLoad(card);
140
+ const penalty = load > 0 ? Math.max(floor, 1 / (1 + strength * load)) : 1;
141
+ // Non-finite (mandatory) scores stay non-finite: Infinity × penalty =
142
+ // Infinity, so they argmax first and ride through unchanged.
143
+ const value = card.score * penalty;
144
+ // Strict ">" keeps the scan stable: ties resolve to the earlier (already
145
+ // higher-ranked-by-incoming-score) card.
146
+ if (value > bestValue) {
147
+ bestValue = value;
148
+ bestIdx = i;
149
+ bestPenalty = penalty;
150
+ bestLoad = load;
151
+ }
152
+ }
153
+
154
+ const [picked] = remaining.splice(bestIdx, 1);
155
+
156
+ if (Number.isFinite(picked.score) && bestPenalty < 1) {
157
+ const newScore = picked.score * bestPenalty;
158
+ out.push({
159
+ ...picked,
160
+ score: newScore,
161
+ provenance: [
162
+ ...picked.provenance,
163
+ {
164
+ strategy: STRATEGY,
165
+ strategyId: STRATEGY_ID,
166
+ strategyName: STRATEGY_NAME,
167
+ action: 'penalized',
168
+ score: newScore,
169
+ reason: `repeated tags (load ${bestLoad.toFixed(2)}) → ×${bestPenalty.toFixed(2)}`,
170
+ },
171
+ ],
172
+ });
173
+ } else {
174
+ // No penalty (fresh card, or mandatory/non-finite): emit untouched but
175
+ // still let it count toward later cards' repetition load below.
176
+ out.push(picked);
177
+ }
178
+
179
+ for (const tag of picked.tags ?? []) {
180
+ emittedCount.set(tag, (emittedCount.get(tag) ?? 0) + 1);
181
+ }
182
+ }
183
+
184
+ return out;
185
+ }
@@ -45,6 +45,21 @@ interface PrescribedGroupConfig {
45
45
  maxSupportCardsPerRun?: number;
46
46
  hierarchyWalk?: HierarchyWalkConfig;
47
47
  retireOnEncounter?: boolean;
48
+ /**
49
+ * Tag patterns identifying *practice* skills to drill once unlocked. For each
50
+ * course tag matching one of these patterns that is (a) unlocked — all its
51
+ * hierarchy prerequisites met, i.e. the learner has been introduced to it —
52
+ * but (b) still under-practiced (per-tag attempt count below
53
+ * `practiceMinCount`), the generator emits cards carrying that tag into the
54
+ * candidate pool. This closes the post-intro drilling gap independent of
55
+ * global-ELO retrieval (easy drill cards that the ELO window never reaches).
56
+ * Ordering/emphasis is left to the pipeline's scoring + decaying boost.
57
+ */
58
+ practiceTagPatterns?: string[];
59
+ /** Attempt-count threshold below which a practice skill is "under-practiced". */
60
+ practiceMinCount?: number;
61
+ /** Cap on practice cards emitted per run (across all under-practiced skills). */
62
+ maxPracticeCardsPerRun?: number;
48
63
  }
49
64
 
50
65
  interface PrescribedConfig {
@@ -106,9 +121,15 @@ const DEFAULT_MAX_DIRECT_PER_RUN = 3;
106
121
  const DEFAULT_MAX_SUPPORT_PER_RUN = 3;
107
122
  const DEFAULT_HIERARCHY_DEPTH = 2;
108
123
  const DEFAULT_MIN_COUNT = 3;
124
+ const DEFAULT_PRACTICE_MIN_COUNT = 3;
125
+ const DEFAULT_MAX_PRACTICE_PER_RUN = 4;
109
126
  const BASE_TARGET_SCORE = 1.0;
110
127
  const BASE_SUPPORT_SCORE = 0.8;
111
128
  const DISCOVERED_SUPPORT_SCORE = 12.0;
129
+ // Practice drill cards enter the pool at parity with a well-matched target so
130
+ // they survive into the candidate set; per-skill *emphasis* (recency, decay) is
131
+ // the durable boost's job, not this base score's.
132
+ const BASE_PRACTICE_SCORE = 1.0;
112
133
  const MAX_TARGET_MULTIPLIER = 8.0;
113
134
  const MAX_SUPPORT_MULTIPLIER = 4.0;
114
135
 
@@ -315,8 +336,19 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
315
336
  courseId,
316
337
  emittedIds
317
338
  );
339
+ const practiceCards = this.buildPracticeCards({
340
+ group,
341
+ courseId,
342
+ emittedIds,
343
+ cardsByTag,
344
+ hierarchyConfigs,
345
+ userTagElo,
346
+ userGlobalElo,
347
+ activeIds,
348
+ seenIds,
349
+ });
318
350
 
319
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
351
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
320
352
  }
321
353
 
322
354
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
@@ -357,6 +389,10 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
357
389
  const surfacedByGroup = new Map<string, { targetIds: string[]; supportIds: string[] }>();
358
390
  for (const card of finalCards) {
359
391
  const prov = card.provenance[0];
392
+ // Practice cards are not target/support surfacing — they must not reset a
393
+ // group's freshness/pressure state (which tracks whether *intro targets*
394
+ // are getting through). Skip them here.
395
+ if (prov?.reason.includes('mode=practice')) continue;
360
396
  const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
361
397
  const mode = prov?.reason.includes('mode=support') ? 'supportIds' : 'targetIds';
362
398
  if (!groupId) continue;
@@ -449,6 +485,17 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
449
485
  : DEFAULT_HIERARCHY_DEPTH,
450
486
  },
451
487
  retireOnEncounter: raw.retireOnEncounter !== false,
488
+ practiceTagPatterns: dedupe(
489
+ Array.isArray(raw.practiceTagPatterns)
490
+ ? raw.practiceTagPatterns.filter((v: unknown): v is string => typeof v === 'string')
491
+ : []
492
+ ),
493
+ practiceMinCount:
494
+ typeof raw.practiceMinCount === 'number' ? raw.practiceMinCount : DEFAULT_PRACTICE_MIN_COUNT,
495
+ maxPracticeCardsPerRun:
496
+ typeof raw.maxPracticeCardsPerRun === 'number'
497
+ ? raw.maxPracticeCardsPerRun
498
+ : DEFAULT_MAX_PRACTICE_PER_RUN,
452
499
  }))
453
500
  .filter((g) => g.targetCardIds.length > 0);
454
501
 
@@ -765,6 +812,131 @@ export default class PrescribedCardsGenerator extends ContentNavigator implement
765
812
  return cards;
766
813
  }
767
814
 
815
+ /**
816
+ * Emit drill cards for *unlocked-but-under-practiced* skills.
817
+ *
818
+ * For each course tag matching the group's `practiceTagPatterns` that is both
819
+ * unlocked (all hierarchy prerequisites met — i.e. the learner has been
820
+ * introduced to it) and under-practiced (per-tag attempt count below
821
+ * `practiceMinCount`), this resolves cards carrying that tag and emits them
822
+ * into the candidate pool. It exists because global-ELO retrieval
823
+ * systematically fails to fetch the (low-ELO) drill cards for a
824
+ * freshly-introduced skill — putting them in the pool here lets the pipeline's
825
+ * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
826
+ * this method's job; it only guarantees presence.
827
+ *
828
+ * Fully data-driven: the unlock relation comes from the hierarchy config and
829
+ * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
830
+ */
831
+ private buildPracticeCards(args: {
832
+ group: PrescribedGroupConfig;
833
+ courseId: string;
834
+ emittedIds: Set<string>;
835
+ cardsByTag: Map<string, string[]>;
836
+ hierarchyConfigs: HierarchyConfig[];
837
+ userTagElo: Record<string, { score: number; count: number }>;
838
+ userGlobalElo: number;
839
+ activeIds: Set<string>;
840
+ seenIds: Set<string>;
841
+ }): WeightedCard[] {
842
+ const {
843
+ group,
844
+ courseId,
845
+ emittedIds,
846
+ cardsByTag,
847
+ hierarchyConfigs,
848
+ userTagElo,
849
+ userGlobalElo,
850
+ activeIds,
851
+ seenIds,
852
+ } = args;
853
+
854
+ const patterns = group.practiceTagPatterns ?? [];
855
+ if (patterns.length === 0) return [];
856
+
857
+ const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
858
+ const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
859
+
860
+ const practiceTags = [...cardsByTag.keys()].filter(
861
+ (tag) =>
862
+ patterns.some((p) => matchesTagPattern(tag, p)) &&
863
+ this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) &&
864
+ (userTagElo[tag]?.count ?? 0) < practiceMinCount
865
+ );
866
+
867
+ if (practiceTags.length === 0) return [];
868
+
869
+ // Reuse the diversity-aware tag→cards collector (stem-dedup + shuffle).
870
+ const practiceCardIds = this.findDiscoveredSupportCards({
871
+ supportTags: practiceTags,
872
+ cardsByTag,
873
+ activeIds,
874
+ seenIds,
875
+ excludedIds: emittedIds,
876
+ limit: maxPractice,
877
+ });
878
+
879
+ if (practiceCardIds.length === 0) return [];
880
+
881
+ logger.info(
882
+ `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced ` +
883
+ `skill(s), emitting ${practiceCardIds.length} drill card(s)`
884
+ );
885
+
886
+ const cards: WeightedCard[] = [];
887
+ for (const cardId of practiceCardIds) {
888
+ emittedIds.add(cardId);
889
+ cards.push({
890
+ cardId,
891
+ courseId,
892
+ score: BASE_PRACTICE_SCORE,
893
+ provenance: [
894
+ {
895
+ strategy: 'prescribed',
896
+ strategyName: this.strategyName || this.name,
897
+ strategyId: this.strategyId || 'NAVIGATION_STRATEGY-prescribed',
898
+ action: 'generated' as const,
899
+ score: BASE_PRACTICE_SCORE,
900
+ reason:
901
+ `mode=practice;group=${group.id};` +
902
+ `underPracticedSkills=${practiceTags.length};` +
903
+ `practiceTags=${practiceTags.slice(0, 8).join('|')}${practiceTags.length > 8 ? '|…' : ''};` +
904
+ `testversion=${PRESCRIBED_DEBUG_VERSION}`,
905
+ },
906
+ ],
907
+ });
908
+ }
909
+
910
+ return cards;
911
+ }
912
+
913
+ /**
914
+ * True for a skill that was *gated and is now reached*: it has at least one
915
+ * declared hierarchy prerequisite set, and every set is fully satisfied by the
916
+ * learner's per-tag ELO. This deliberately EXCLUDES tags with no prerequisites
917
+ * — an ungated tag was never "introduced" in the curricular sense, so it isn't
918
+ * a post-intro drill target (e.g. whole-word spelling tags that share the
919
+ * `gpc:exercise:*` prefix but have no intro gate). Those are left to normal
920
+ * ELO retrieval. This is the precise population the retrieval gap strands:
921
+ * just-unlocked, low-ELO skills.
922
+ */
923
+ private isUnlockedGatedSkill(
924
+ tag: string,
925
+ hierarchyConfigs: HierarchyConfig[],
926
+ userTagElo: Record<string, { score: number; count: number }>,
927
+ userGlobalElo: number
928
+ ): boolean {
929
+ const prereqSets = hierarchyConfigs
930
+ .map((hierarchy) => hierarchy.prerequisites[tag])
931
+ .filter((prereqs): prereqs is TagPrerequisite[] => Array.isArray(prereqs) && prereqs.length > 0);
932
+
933
+ if (prereqSets.length === 0) return false;
934
+
935
+ return prereqSets.every((prereqs) =>
936
+ prereqs.every((pr) => this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo))
937
+ );
938
+ }
939
+
768
940
  private findSupportCardsByTags(
769
941
  group: PrescribedGroupConfig,
770
942
  tagsByCard: Map<string, string[]>,