@vue-skuilder/db 0.1.32-a → 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 (51) 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 +2279 -227
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/core/index.mjs +2256 -200
  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 +18 -3
  10. package/dist/impl/couch/index.d.ts +18 -3
  11. package/dist/impl/couch/index.js +2323 -224
  12. package/dist/impl/couch/index.js.map +1 -1
  13. package/dist/impl/couch/index.mjs +2311 -208
  14. package/dist/impl/couch/index.mjs.map +1 -1
  15. package/dist/impl/static/index.d.cts +5 -4
  16. package/dist/impl/static/index.d.ts +5 -4
  17. package/dist/impl/static/index.js +2283 -231
  18. package/dist/impl/static/index.js.map +1 -1
  19. package/dist/impl/static/index.mjs +2268 -212
  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 -381
  24. package/dist/index.d.ts +9 -381
  25. package/dist/index.js +9626 -8815
  26. package/dist/index.js.map +1 -1
  27. package/dist/index.mjs +9559 -8748
  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 +51 -25
  39. package/src/core/navigators/PipelineDebugger.ts +49 -1
  40. package/src/core/navigators/filters/hierarchyDefinition.ts +92 -5
  41. package/src/core/navigators/filters/relativePriority.ts +7 -1
  42. package/src/core/navigators/generators/prescribed.ts +618 -43
  43. package/src/core/navigators/index.ts +2 -1
  44. package/src/impl/couch/CourseSyncService.ts +72 -4
  45. package/src/impl/couch/courseDB.ts +11 -0
  46. package/src/impl/static/courseDB.ts +13 -0
  47. package/src/study/SessionController.ts +276 -24
  48. package/src/study/services/EloService.ts +22 -3
  49. package/src/study/services/ResponseProcessor.ts +7 -3
  50. package/dist/dataLayerProvider-BKmVoyJR.d.ts +0 -67
  51. 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-a",
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-a",
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,24 +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
- }
25
+ // ReplanHints is the canonical type — re-export for consumers that import from Pipeline
26
+ import { ReplanHints } from '@db/study/SessionController';
27
+ export { ReplanHints };
43
28
 
44
29
  /**
45
30
  * Convert a glob pattern (with `*` wildcards) to a RegExp.
@@ -289,8 +274,8 @@ export class Pipeline extends ContentNavigator {
289
274
  *
290
275
  * Overrides ContentNavigator.setEphemeralHints() no-op.
291
276
  */
292
- override setEphemeralHints(hints: Record<string, unknown>): void {
293
- this._ephemeralHints = hints as ReplanHints;
277
+ override setEphemeralHints(hints: ReplanHints): void {
278
+ this._ephemeralHints = hints;
294
279
  logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`);
295
280
  }
296
281
 
@@ -432,6 +417,9 @@ export class Pipeline extends ContentNavigator {
432
417
  // Capture run for debug API
433
418
  try {
434
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.
435
423
  const report = buildRunReport(
436
424
  this.course?.getCourseID() || 'unknown',
437
425
  courseName,
@@ -439,8 +427,9 @@ export class Pipeline extends ContentNavigator {
439
427
  generatorSummaries,
440
428
  generatedCount,
441
429
  filterImpacts,
442
- allCardsBeforeFiltering,
443
- result
430
+ cards,
431
+ result,
432
+ context.userElo
444
433
  );
445
434
  captureRun(report);
446
435
  } catch (e) {
@@ -544,7 +533,7 @@ export class Pipeline extends ContentNavigator {
544
533
  card.provenance.push({
545
534
  strategy: 'ephemeralHint',
546
535
  strategyId: 'ephemeral-hint',
547
- strategyName: 'Replan Hint',
536
+ strategyName: hints._label ? `Replan Hint (${hints._label})` : 'Replan Hint',
548
537
  action: 'boosted',
549
538
  score: card.score,
550
539
  reason: `boostTag ${pattern} ×${factor}`,
@@ -561,7 +550,7 @@ export class Pipeline extends ContentNavigator {
561
550
  card.provenance.push({
562
551
  strategy: 'ephemeralHint',
563
552
  strategyId: 'ephemeral-hint',
564
- strategyName: 'Replan Hint',
553
+ strategyName: hints._label ? `Replan Hint (${hints._label})` : 'Replan Hint',
565
554
  action: 'boosted',
566
555
  score: card.score,
567
556
  reason: `boostCard ${pattern} ×${factor}`,
@@ -573,6 +562,7 @@ export class Pipeline extends ContentNavigator {
573
562
 
574
563
  // 3. Require — inject from the full pool if not already present
575
564
  const cardIds = new Set(cards.map((c) => c.cardId));
565
+ const hintLabel = hints._label ? `Replan Hint (${hints._label})` : 'Replan Hint';
576
566
  const inject = (card: WeightedCard, reason: string) => {
577
567
  if (!cardIds.has(card.cardId)) {
578
568
  // Give required cards a floor score so they sort above zero-score filler
@@ -585,7 +575,7 @@ export class Pipeline extends ContentNavigator {
585
575
  {
586
576
  strategy: 'ephemeralHint',
587
577
  strategyId: 'ephemeral-hint',
588
- strategyName: 'Replan Hint',
578
+ strategyName: hintLabel,
589
579
  action: 'boosted',
590
580
  score: floorScore,
591
581
  reason,
@@ -699,6 +689,42 @@ export class Pipeline extends ContentNavigator {
699
689
  return [...new Set(ids)];
700
690
  }
701
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
+
702
728
  // ---------------------------------------------------------------------------
703
729
  // Card-space diagnostic
704
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)
@@ -5,6 +5,7 @@ import type { WeightedCard } from '../index';
5
5
  import type { ContentNavigationStrategyData } from '../../types/contentNavigationStrategy';
6
6
  import type { CardFilter, FilterContext } from './types';
7
7
  import { toCourseElo } from '@vue-skuilder/common';
8
+ import { logger } from '../../../util/logger';
8
9
 
9
10
  /**
10
11
  * A single prerequisite requirement for a tag.
@@ -29,6 +30,18 @@ interface TagPrerequisite {
29
30
  * tagged `gpc:expose:t-T` while `gpc:intro:t-T` is still locked.
30
31
  */
31
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;
32
45
  }
33
46
 
34
47
  /**
@@ -239,21 +252,68 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
239
252
  return boosts;
240
253
  }
241
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
+
242
301
  /**
243
302
  * CardFilter.transform implementation.
244
303
  *
245
- * Two effects:
246
- * 1. Cards with locked tags receive score * 0.05 (gating penalty)
247
- * 2. Cards carrying prereq tags of closed gates receive a configured
248
- * 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
249
308
  */
250
309
  async transform(cards: WeightedCard[], context: FilterContext): Promise<WeightedCard[]> {
251
310
  // Get mastery state
252
311
  const masteredTags = await this.getMasteredTags(context);
253
312
  const unlockedTags = this.getUnlockedTags(masteredTags);
254
313
  const preReqBoosts = this.getPreReqBoosts(unlockedTags, masteredTags);
314
+ const targetBoosts = this.getTargetBoosts(unlockedTags);
255
315
 
256
- // Apply prerequisite gating + prereq boosting
316
+ // Apply prerequisite gating + prereq/target boosting
257
317
  const gated: WeightedCard[] = [];
258
318
 
259
319
  for (const card of cards) {
@@ -286,6 +346,33 @@ export default class HierarchyDefinitionNavigator extends ContentNavigator imple
286
346
  finalScore *= maxBoost;
287
347
  action = 'boosted';
288
348
  finalReason = `${reason} | preReqBoost ×${maxBoost.toFixed(2)} for ${boostedPrereqs.join(', ')}`;
349
+ logger.info(
350
+ `[HierarchyDefinition] preReqBoost ×${maxBoost.toFixed(2)} applied to card ${card.cardId} via tags [${boostedPrereqs.join(', ')}] (score: ${card.score.toFixed(3)} → ${finalScore.toFixed(3)})`
351
+ );
352
+ }
353
+ }
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
+ );
289
376
  }
290
377
  }
291
378
 
@@ -198,7 +198,13 @@ export default class RelativePriorityNavigator extends ContentNavigator implemen
198
198
  const cardTags = card.tags ?? [];
199
199
  const priority = this.computeCardPriority(cardTags);
200
200
  const boostFactor = this.computeBoostFactor(priority);
201
- const finalScore = Math.max(0, Math.min(1, card.score * boostFactor));
201
+ // No upper clamp — scores may exceed 1.0 intentionally.
202
+ // Scores are only used for relative ordering within a pipeline run,
203
+ // so absolute magnitude doesn't matter. Clamping to 1.0 here collapsed
204
+ // differentiation when GPC preReqBoosts and priority boosts compounded
205
+ // (e.g. 0.96 × 2.5 × 1.24 → 2.98, previously crushed back to 1.0).
206
+ // Floor of 0 is kept: negative scores have no meaning.
207
+ const finalScore = Math.max(0, card.score * boostFactor);
202
208
 
203
209
  // Determine action based on boost factor
204
210
  const action = boostFactor > 1.0 ? 'boosted' : boostFactor < 1.0 ? 'penalized' : 'passed';