@vue-skuilder/db 0.2.5 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/index.d.cts +75 -1
- package/dist/core/index.d.ts +75 -1
- package/dist/core/index.js +281 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +277 -4
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.js +273 -4
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +273 -4
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.js +273 -4
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +273 -4
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +307 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +303 -7
- package/dist/index.mjs.map +1 -1
- package/docs/navigators-architecture.md +42 -2
- package/package.json +3 -3
- package/src/core/navigators/Pipeline.ts +103 -2
- package/src/core/navigators/PipelineDebugger.ts +11 -1
- package/src/core/navigators/diversityRerank.ts +185 -0
- package/src/core/navigators/generators/prescribed.ts +173 -1
- package/src/core/navigators/index.ts +12 -0
- package/src/study/ItemQueue.test.ts +71 -0
- package/src/study/ItemQueue.ts +19 -1
- package/src/study/SessionController.ts +20 -5
|
@@ -117,8 +117,12 @@ class Pipeline {
|
|
|
117
117
|
cards = await filter.transform(cards, context);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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[]>,
|