@vue-skuilder/db 0.1.32-b → 0.1.32-c

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 (50) hide show
  1. package/dist/core/index.d.cts +16 -12
  2. package/dist/core/index.d.ts +16 -12
  3. package/dist/core/index.js +2262 -223
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/core/index.mjs +2239 -196
  6. package/dist/core/index.mjs.map +1 -1
  7. package/dist/{contentSource-Bdwkvqa8.d.ts → dataLayerProvider-BAn-LRh5.d.ts} +626 -83
  8. package/dist/{contentSource-DF1nUbPQ.d.cts → dataLayerProvider-BJqBlMIl.d.cts} +626 -83
  9. package/dist/impl/couch/index.d.cts +17 -4
  10. package/dist/impl/couch/index.d.ts +17 -4
  11. package/dist/impl/couch/index.js +2306 -220
  12. package/dist/impl/couch/index.js.map +1 -1
  13. package/dist/impl/couch/index.mjs +2294 -204
  14. package/dist/impl/couch/index.mjs.map +1 -1
  15. package/dist/impl/static/index.d.cts +4 -5
  16. package/dist/impl/static/index.d.ts +4 -5
  17. package/dist/impl/static/index.js +2266 -227
  18. package/dist/impl/static/index.js.map +1 -1
  19. package/dist/impl/static/index.mjs +2251 -208
  20. package/dist/impl/static/index.mjs.map +1 -1
  21. package/dist/{index-BWvO-_rJ.d.ts → index-X6wHrURm.d.ts} +1 -1
  22. package/dist/{index-Ba7hYbHj.d.cts → index-m8MMGxxR.d.cts} +1 -1
  23. package/dist/index.d.cts +9 -444
  24. package/dist/index.d.ts +9 -444
  25. package/dist/index.js +9637 -8931
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +9539 -8833
  28. package/dist/index.mjs.map +1 -1
  29. package/dist/{types-CJrLM1Ew.d.ts → types-DZ5dUqbL.d.ts} +1 -1
  30. package/dist/{types-W8n-B6HG.d.cts → types-ZL8tOPQZ.d.cts} +1 -1
  31. package/dist/{types-legacy-JXDxinpU.d.cts → types-legacy-C7r0T4OV.d.cts} +1 -1
  32. package/dist/{types-legacy-JXDxinpU.d.ts → types-legacy-C7r0T4OV.d.ts} +1 -1
  33. package/dist/util/packer/index.d.cts +3 -3
  34. package/dist/util/packer/index.d.ts +3 -3
  35. package/docs/navigators-architecture.md +2 -2
  36. package/package.json +2 -2
  37. package/src/core/interfaces/contentSource.ts +2 -1
  38. package/src/core/navigators/Pipeline.ts +47 -29
  39. package/src/core/navigators/PipelineDebugger.ts +49 -1
  40. package/src/core/navigators/filters/hierarchyDefinition.ts +88 -5
  41. package/src/core/navigators/generators/prescribed.ts +618 -43
  42. package/src/core/navigators/index.ts +2 -1
  43. package/src/impl/couch/CourseSyncService.ts +72 -4
  44. package/src/impl/couch/courseDB.ts +3 -2
  45. package/src/impl/static/courseDB.ts +3 -2
  46. package/src/study/SessionController.ts +79 -9
  47. package/src/study/services/EloService.ts +22 -3
  48. package/src/study/services/ResponseProcessor.ts +7 -3
  49. package/dist/dataLayerProvider-BKmVoyJR.d.ts +0 -67
  50. package/dist/dataLayerProvider-BQdfJuBN.d.cts +0 -67
@@ -1,5 +1,5 @@
1
1
  import { CourseConfig } from '@vue-skuilder/common';
2
- import { D as DocType } from './types-legacy-JXDxinpU.js';
2
+ import { D as DocType } from './types-legacy-C7r0T4OV.js';
3
3
 
4
4
  interface StaticCourseManifest {
5
5
  version: string;
@@ -1,5 +1,5 @@
1
1
  import { CourseConfig } from '@vue-skuilder/common';
2
- import { D as DocType } from './types-legacy-JXDxinpU.cjs';
2
+ import { D as DocType } from './types-legacy-C7r0T4OV.cjs';
3
3
 
4
4
  interface StaticCourseManifest {
5
5
  version: string;
@@ -157,4 +157,4 @@ interface QuestionRecord extends CardRecord, Evaluation {
157
157
  priorAttemps: number;
158
158
  }
159
159
 
160
- export { type CardHistory as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, DocTypePrefixes as b, type CardRecord as c, type CardData as d, type CourseListData as e, type DisplayableData as f, type DataShapeData as g, type QuestionData as h, type QuestionRecord as i, log as l };
160
+ export { type CardData as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, type CourseListData as b, type DisplayableData as c, type DataShapeData as d, type QuestionData as e, DocTypePrefixes as f, type CardHistory as g, type CardRecord as h, type QuestionRecord as i, log as l };
@@ -157,4 +157,4 @@ interface QuestionRecord extends CardRecord, Evaluation {
157
157
  priorAttemps: number;
158
158
  }
159
159
 
160
- export { type CardHistory as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, DocTypePrefixes as b, type CardRecord as c, type CardData as d, type CourseListData as e, type DisplayableData as f, type DataShapeData as g, type QuestionData as h, type QuestionRecord as i, log as l };
160
+ export { type CardData as C, DocType as D, type Field as F, GuestUsername as G, type QualifiedCardID as Q, type SkuilderCourseData as S, type TagStub as T, type Tag as a, type CourseListData as b, type DisplayableData as c, type DataShapeData as d, type QuestionData as e, DocTypePrefixes as f, type CardHistory as g, type CardRecord as h, type QuestionRecord as i, log as l };
@@ -1,5 +1,5 @@
1
- export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-W8n-B6HG.cjs';
2
- export { C as CouchDBToStaticPacker } from '../../index-Ba7hYbHj.cjs';
1
+ export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-ZL8tOPQZ.cjs';
2
+ export { C as CouchDBToStaticPacker } from '../../index-m8MMGxxR.cjs';
3
3
  import '@vue-skuilder/common';
4
- import '../../types-legacy-JXDxinpU.cjs';
4
+ import '../../types-legacy-C7r0T4OV.cjs';
5
5
  import 'moment';
@@ -1,5 +1,5 @@
1
- export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-CJrLM1Ew.js';
2
- export { C as CouchDBToStaticPacker } from '../../index-BWvO-_rJ.js';
1
+ export { A as AttachmentData, C as ChunkMetadata, D as DesignDocument, I as IndexMetadata, a as PackedCourseData, P as PackerConfig, S as StaticCourseManifest } from '../../types-DZ5dUqbL.js';
2
+ export { C as CouchDBToStaticPacker } from '../../index-X6wHrURm.js';
3
3
  import '@vue-skuilder/common';
4
- import '../../types-legacy-JXDxinpU.js';
4
+ import '../../types-legacy-C7r0T4OV.js';
5
5
  import 'moment';
@@ -48,7 +48,7 @@ interface CardGenerator {
48
48
  **Implementations:**
49
49
  - `ELONavigator` — New cards scored by ELO proximity to user skill (scores 0.0-1.0)
50
50
  - `SRSNavigator` — Review cards scored by overdueness, interval recency, and **backlog pressure** (scores 0.5-1.0)
51
- - `HardcodedOrderNavigator` — Fixed sequence defined by course author
51
+ - `HardcodedOrderNavigator` — Fixed sequence defined by course author // NB this no longer exists but /home/colin/pn/vue-skuilder/master/packages/db/src/core/navigators/generators/prescribed.ts does - please update the doc in place
52
52
  - `CompositeGenerator` — Merges multiple generators with frequency boost
53
53
 
54
54
  #### SRS Backlog Pressure
@@ -620,4 +620,4 @@ return { ...card, score: card.score * multiplier };
620
620
  - `todo-review-adaptation.md` — Planned per-user review urgency adaptation
621
621
  - `future-orchestration-vision.md` — Long-term adaptive strategy vision (beyond current implementation)
622
622
  - `devlog/1004` — Implementation details for tag hydration optimization
623
- - `devlog/1032-orchestrator` — Evolutionary orchestration implementation details
623
+ - `devlog/1032-orchestrator` — Evolutionary orchestration implementation details
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.1.32-b",
7
+ "version": "0.1.32-c",
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.1.32-b",
51
+ "@vue-skuilder/common": "0.1.32-c",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -5,6 +5,7 @@ import { WeightedCard } from '../navigators';
5
5
  import { TagFilter, hasActiveFilter } from '@vue-skuilder/common';
6
6
  import { TagFilteredContentSource } from '../../study/TagFilteredContentSource';
7
7
  import { OrchestrationContext } from '../orchestration';
8
+ import type { ReplanHints } from '@db/study/SessionController';
8
9
 
9
10
  export type StudySessionFailedItem = StudySessionFailedNewItem | StudySessionFailedReviewItem;
10
11
 
@@ -84,7 +85,7 @@ export interface StudyContentSource {
84
85
  * Set ephemeral hints for the next pipeline run.
85
86
  * No-op for sources that don't support hints.
86
87
  */
87
- setEphemeralHints?(hints: Record<string, unknown>): void;
88
+ setEphemeralHints?(hints: ReplanHints): void;
88
89
  }
89
90
  // #endregion docs_StudyContentSource
90
91
 
@@ -22,31 +22,9 @@ import { captureRun, buildRunReport, registerPipelineForDebug, type GeneratorSum
22
22
  // 'gpc:exercise:t-*' — all t-variant exercises
23
23
  //
24
24
 
25
- /**
26
- * Ephemeral pipeline hints for a single run.
27
- * All fields are optional. Tag/card patterns support `*` wildcards.
28
- */
29
- export interface ReplanHints {
30
- /** Multiply scores for cards matching these tag patterns. */
31
- boostTags?: Record<string, number>;
32
- /** Multiply scores for these specific card IDs (glob patterns). */
33
- boostCards?: Record<string, number>;
34
- /** Cards matching these tag patterns MUST appear in results. */
35
- requireTags?: string[];
36
- /** These specific card IDs MUST appear in results. */
37
- requireCards?: string[];
38
- /** Remove cards matching these tag patterns from results. */
39
- excludeTags?: string[];
40
- /** Remove these specific card IDs from results. */
41
- excludeCards?: string[];
42
- /**
43
- * Debugging label threaded from the replan requester.
44
- * Attached to provenance entries so card scoring history
45
- * can be traced back to the originating event.
46
- * Prefixed with `_` to signal it's metadata, not a scoring hint.
47
- */
48
- _label?: string;
49
- }
25
+ // ReplanHints is the canonical type — re-export for consumers that import from Pipeline
26
+ import { ReplanHints } from '@db/study/SessionController';
27
+ export { ReplanHints };
50
28
 
51
29
  /**
52
30
  * Convert a glob pattern (with `*` wildcards) to a RegExp.
@@ -296,8 +274,8 @@ export class Pipeline extends ContentNavigator {
296
274
  *
297
275
  * Overrides ContentNavigator.setEphemeralHints() no-op.
298
276
  */
299
- override setEphemeralHints(hints: Record<string, unknown>): void {
300
- this._ephemeralHints = hints as ReplanHints;
277
+ override setEphemeralHints(hints: ReplanHints): void {
278
+ this._ephemeralHints = hints;
301
279
  logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
302
280
  }
303
281
 
@@ -439,6 +417,9 @@ export class Pipeline extends ContentNavigator {
439
417
  // Capture run for debug API
440
418
  try {
441
419
  const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => undefined);
420
+ // Use the full post-filter sorted array (not just top N) so that
421
+ // showCard() can inspect provenance for cards that didn't make the cut.
422
+ // `cards` is the post-filter, post-hints, sorted array.
442
423
  const report = buildRunReport(
443
424
  this.course?.getCourseID() || 'unknown',
444
425
  courseName,
@@ -446,8 +427,9 @@ export class Pipeline extends ContentNavigator {
446
427
  generatorSummaries,
447
428
  generatedCount,
448
429
  filterImpacts,
449
- allCardsBeforeFiltering,
450
- result
430
+ cards,
431
+ result,
432
+ context.userElo
451
433
  );
452
434
  captureRun(report);
453
435
  } catch (e) {
@@ -707,6 +689,42 @@ export class Pipeline extends ContentNavigator {
707
689
  return [...new Set(ids)];
708
690
  }
709
691
 
692
+ // ---------------------------------------------------------------------------
693
+ // Tag ELO diagnostic
694
+ // ---------------------------------------------------------------------------
695
+
696
+ /**
697
+ * Get the user's per-tag ELO data for specified tags (or all tags).
698
+ * Useful for diagnosing why hierarchy gates are open/closed.
699
+ */
700
+ async getTagEloStatus(
701
+ tagFilter?: string | string[]
702
+ ): Promise<Record<string, { score: number; count: number }>> {
703
+ const courseReg = await this.user!.getCourseRegDoc(this.course!.getCourseID());
704
+ const courseElo = toCourseElo(courseReg.elo);
705
+
706
+ const result: Record<string, { score: number; count: number }> = {};
707
+
708
+ if (!tagFilter) {
709
+ // Return all tags
710
+ for (const [tag, data] of Object.entries(courseElo.tags)) {
711
+ result[tag] = { score: data.score, count: data.count };
712
+ }
713
+ } else {
714
+ const patterns = Array.isArray(tagFilter) ? tagFilter : [tagFilter];
715
+ for (const pattern of patterns) {
716
+ const regex = globToRegex(pattern);
717
+ for (const [tag, data] of Object.entries(courseElo.tags)) {
718
+ if (regex.test(tag)) {
719
+ result[tag] = { score: data.score, count: data.count };
720
+ }
721
+ }
722
+ }
723
+ }
724
+
725
+ return result;
726
+ }
727
+
710
728
  // ---------------------------------------------------------------------------
711
729
  // Card-space diagnostic
712
730
  // ---------------------------------------------------------------------------
@@ -71,6 +71,9 @@ export interface PipelineRunReport {
71
71
  courseId: string;
72
72
  courseName?: string;
73
73
 
74
+ /** User's global ELO at the time of this pipeline run */
75
+ userElo?: number;
76
+
74
77
  // Generator phase
75
78
  generatorName: string;
76
79
  generators?: GeneratorSummary[];
@@ -90,6 +93,8 @@ export interface PipelineRunReport {
90
93
  courseId: string;
91
94
  origin: 'new' | 'review' | 'unknown';
92
95
  finalScore: number;
96
+ /** Card's ELO (parsed from ELO generator provenance, if available) */
97
+ cardElo?: number;
93
98
  provenance: StrategyContribution[];
94
99
  tags?: string[];
95
100
  selected: boolean;
@@ -133,6 +138,17 @@ export function captureRun(report: Omit<PipelineRunReport, 'runId' | 'timestamp'
133
138
  /**
134
139
  * Build a capture-ready report from pipeline execution data.
135
140
  */
141
+ /**
142
+ * Parse card ELO from the ELO generator's provenance reason string.
143
+ * Format: "ELO distance XX (card: YYYY, user: ZZZZ), ..."
144
+ */
145
+ function parseCardElo(provenance: StrategyContribution[]): number | undefined {
146
+ const eloEntry = provenance.find((p) => p.strategy === 'elo');
147
+ if (!eloEntry?.reason) return undefined;
148
+ const match = eloEntry.reason.match(/card:\s*(\d+)/);
149
+ return match ? parseInt(match[1], 10) : undefined;
150
+ }
151
+
136
152
  export function buildRunReport(
137
153
  courseId: string,
138
154
  courseName: string | undefined,
@@ -141,7 +157,8 @@ export function buildRunReport(
141
157
  generatedCount: number,
142
158
  filters: FilterImpact[],
143
159
  allCards: WeightedCard[],
144
- selectedCards: WeightedCard[]
160
+ selectedCards: WeightedCard[],
161
+ userElo?: number
145
162
  ): Omit<PipelineRunReport, 'runId' | 'timestamp'> {
146
163
  const selectedIds = new Set(selectedCards.map((c) => c.cardId));
147
164
 
@@ -150,6 +167,7 @@ export function buildRunReport(
150
167
  courseId: card.courseId,
151
168
  origin: getOrigin(card),
152
169
  finalScore: card.score,
170
+ cardElo: parseCardElo(card.provenance),
153
171
  provenance: card.provenance,
154
172
  tags: card.tags,
155
173
  selected: selectedIds.has(card.cardId),
@@ -161,6 +179,7 @@ export function buildRunReport(
161
179
  return {
162
180
  courseId,
163
181
  courseName,
182
+ userElo,
164
183
  generatorName,
165
184
  generators,
166
185
  generatedCount,
@@ -203,6 +222,7 @@ function printRunSummary(run: PipelineRunReport): void {
203
222
  console.group(`🔍 Pipeline Run: ${run.courseId} (${run.courseName || 'unnamed'})`);
204
223
  logger.info(`Run ID: ${run.runId}`);
205
224
  logger.info(`Time: ${run.timestamp.toISOString()}`);
225
+ logger.info(`User ELO: ${run.userElo ?? 'unknown'}`);
206
226
  logger.info(`Generator: ${run.generatorName} → ${run.generatedCount} candidates`);
207
227
 
208
228
  if (run.generators && run.generators.length > 0) {
@@ -293,8 +313,12 @@ export const pipelineDebugAPI = {
293
313
  console.group(`🎴 Card: ${cardId}`);
294
314
  logger.info(`Course: ${card.courseId}`);
295
315
  logger.info(`Origin: ${card.origin}`);
316
+ logger.info(`Card ELO: ${card.cardElo ?? 'unknown'} | User ELO: ${run.userElo ?? 'unknown'}`);
296
317
  logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
297
318
  logger.info(`Selected: ${card.selected ? 'Yes ✅' : 'No ❌'}`);
319
+ if (card.tags && card.tags.length > 0) {
320
+ logger.info(`Tags (${card.tags.length}): ${card.tags.join(', ')}`);
321
+ }
298
322
  logger.info('Provenance:');
299
323
  logger.info(formatProvenance(card.provenance));
300
324
  // eslint-disable-next-line no-console
@@ -492,6 +516,29 @@ export const pipelineDebugAPI = {
492
516
  return _activePipeline.diagnoseCardSpace({ threshold });
493
517
  },
494
518
 
519
+ /**
520
+ * Show user's per-tag ELO data. Useful for diagnosing hierarchy gate status.
521
+ *
522
+ * @param tagFilter - Optional glob pattern(s) to filter tags.
523
+ * Examples: 'gpc:expose:*', 'gpc:intro:t-T', ['gpc:expose:t-*', 'gpc:intro:t-*']
524
+ */
525
+ async showTagElo(tagFilter?: string | string[]): Promise<void> {
526
+ if (!_activePipeline) {
527
+ logger.info('[Pipeline Debug] No active pipeline. Run a session first.');
528
+ return;
529
+ }
530
+ const status = await _activePipeline.getTagEloStatus(tagFilter);
531
+ const entries = Object.entries(status).sort(([a], [b]) => a.localeCompare(b));
532
+ if (entries.length === 0) {
533
+ logger.info(`[Pipeline Debug] No tag ELO data found${tagFilter ? ` for pattern: ${tagFilter}` : ''}.`);
534
+ return;
535
+ }
536
+ // eslint-disable-next-line no-console
537
+ console.table(
538
+ Object.fromEntries(entries.map(([tag, data]) => [tag, { score: Math.round(data.score), count: data.count }]))
539
+ );
540
+ },
541
+
495
542
  /**
496
543
  * Show help.
497
544
  */
@@ -503,6 +550,7 @@ Commands:
503
550
  .showLastRun() Show summary of most recent pipeline run
504
551
  .showRun(id|index) Show summary of a specific run (by index or ID suffix)
505
552
  .showCard(cardId) Show provenance trail for a specific card
553
+ .showTagElo(pattern) Show user's tag ELO data (async). E.g. 'gpc:expose:*'
506
554
  .explainReviews() Analyze why reviews were/weren't selected
507
555
  .diagnoseCardSpace() Scan full card space through filters (async)
508
556
  .showRegistry() Show navigator registry (classes + roles)
@@ -30,6 +30,18 @@ interface TagPrerequisite {
30
30
  * tagged `gpc:expose:t-T` while `gpc:intro:t-T` is still locked.
31
31
  */
32
32
  preReqBoost?: number;
33
+ /**
34
+ * Score multiplier applied to cards carrying the *gated* tag once the
35
+ * gate opens (all prereqs met). Ensures newly-unlocked content surfaces
36
+ * promptly before natural ELO/SRS scoring takes over.
37
+ *
38
+ * Falls away naturally as generators stop surfacing the card after
39
+ * the user interacts with it.
40
+ *
41
+ * Example: `targetBoost: 4` on intro-t-T's prerequisite gives a 4×
42
+ * score increase to intro-t-T cards once their expose gate is met.
43
+ */
44
+ targetBoost?: number;
33
45
  }
34
46
 
35
47
  /**
@@ -240,21 +252,68 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
240
252
  return boosts;
241
253
  }
242
254
 
255
+ /**
256
+ * Build a map of gated tag → max configured targetBoost for all *open* gates.
257
+ *
258
+ * When a gate opens (prereqs met), cards carrying the gated tag get boosted —
259
+ * ensuring newly-unlocked content surfaces promptly. The boost is a static
260
+ * multiplier; natural ELO/SRS deprioritization after interaction handles decay.
261
+ */
262
+ private getTargetBoosts(unlockedTags: Set<string>): Map<string, number> {
263
+ const boosts = new Map<string, number>();
264
+
265
+ const configKeys = Object.keys(this.config.prerequisites);
266
+ const unlockedArr = [...unlockedTags];
267
+ logger.info(
268
+ `[HierarchyDefinition:targetBoost:trace] ${this.name} | configKeys=${configKeys.length}, unlocked=${unlockedArr.length} (${unlockedArr.slice(0, 5).join(', ')}${unlockedArr.length > 5 ? '...' : ''})`
269
+ );
270
+
271
+ for (const [tagId, prereqs] of Object.entries(this.config.prerequisites)) {
272
+ // Only boost targets of open gates
273
+ if (!unlockedTags.has(tagId)) continue;
274
+
275
+ // TRACE: dump prereq details for unlocked tags
276
+ logger.info(
277
+ `[HierarchyDefinition:targetBoost:trace] UNLOCKED ${tagId}: ${prereqs.length} prereqs, raw=${JSON.stringify(prereqs.map(p => ({ tag: p.tag, tb: p.targetBoost })))}`
278
+ );
279
+
280
+ for (const prereq of prereqs) {
281
+ if (!prereq.targetBoost || prereq.targetBoost <= 1.0) continue;
282
+
283
+ const existing = boosts.get(tagId) ?? 1.0;
284
+ boosts.set(tagId, Math.max(existing, prereq.targetBoost));
285
+ }
286
+ }
287
+
288
+ if (boosts.size > 0) {
289
+ logger.info(
290
+ `[HierarchyDefinition] targetBoosts active: ${[...boosts.entries()].map(([t, b]) => `${t}=×${b}`).join(', ')}`
291
+ );
292
+ } else {
293
+ logger.info(
294
+ `[HierarchyDefinition:targetBoost:trace] no targetBoosts found despite ${unlockedArr.length} unlocked tags`
295
+ );
296
+ }
297
+
298
+ return boosts;
299
+ }
300
+
243
301
  /**
244
302
  * CardFilter.transform implementation.
245
303
  *
246
- * Two effects:
247
- * 1. Cards with locked tags receive score * 0.05 (gating penalty)
248
- * 2. Cards carrying prereq tags of closed gates receive a configured
249
- * boost (preReqBoost), steering toward content that unlocks gates
304
+ * Three effects:
305
+ * 1. Cards with locked tags receive score * 0.02 (gating penalty)
306
+ * 2. Cards carrying prereq tags of closed gates receive preReqBoost
307
+ * 3. Cards carrying gated tags of open gates receive targetBoost
250
308
  */
251
309
  async transform(cards: WeightedCard[], context: FilterContext): Promise<WeightedCard[]> {
252
310
  // Get mastery state
253
311
  const masteredTags = await this.getMasteredTags(context);
254
312
  const unlockedTags = this.getUnlockedTags(masteredTags);
255
313
  const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
314
+ const targetBoosts = this.getTargetBoosts(unlockedTags);
256
315
 
257
- // Apply prerequisite gating + prereq boosting
316
+ // Apply prerequisite gating + prereq/target boosting
258
317
  const gated: WeightedCard[] = [];
259
318
 
260
319
  for (const card of cards) {
@@ -293,6 +352,30 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
293
352
  }
294
353
  }
295
354
 
355
+ // Apply target boost to unlocked cards whose gated tag just opened
356
+ if (isUnlocked && targetBoosts.size > 0) {
357
+ const cardTags = card.tags ?? [];
358
+ let maxTargetBoost = 1.0;
359
+ const boostedTargets: string[] = [];
360
+
361
+ for (const tag of cardTags) {
362
+ const boost = targetBoosts.get(tag);
363
+ if (boost && boost > maxTargetBoost) {
364
+ maxTargetBoost = boost;
365
+ boostedTargets.push(tag);
366
+ }
367
+ }
368
+
369
+ if (maxTargetBoost > 1.0) {
370
+ finalScore *= maxTargetBoost;
371
+ action = 'boosted';
372
+ finalReason = `${finalReason} | targetBoost ×${maxTargetBoost.toFixed(2)} for ${boostedTargets.join(', ')}`;
373
+ logger.info(
374
+ `[HierarchyDefinition] targetBoost ×${maxTargetBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedTargets.join(', ')}] (score: ${card.score.toFixed(3)} → ${finalScore.toFixed(3)})`
375
+ );
376
+ }
377
+ }
378
+
296
379
  gated.push({
297
380
  ...card,
298
381
  score: finalScore,