Walk-through web/lib/bkt.ts line by line
Deep dive into web/lib/bkt.ts.
Goal: open the file and grok every decision inside ~10 minutes.
Imports and constants
Section titled “Imports and constants”import { BktParams, DEFAULT_BKT, MicroSkill, MicroSkillId, StudentState, Task,} from "./microskills";DEFAULT_BKT bundles literature defaults (P(L_0)=0.2, P(T)=0.1, P(S)=0.1, P(G)=0.2). Functions accept optional params so tests/widgets tweak behavior without signature churn.
pSolve — prediction formula
Section titled “pSolve — prediction formula”export function pSolve(pL: number, params: BktParams = DEFAULT_BKT): number { return pL * (1 - params.pSlip) + (1 - pL) * params.pGuess;}Single line — entire P(solve) computation (chapter 8). Numerically tame (no division/logs), called heavily inside scoring loops — keep it tight.
bktUpdate — Bayes core
Section titled “bktUpdate — Bayes core”export function bktUpdate( pL: number, observedCorrect: boolean, params: BktParams = DEFAULT_BKT): number { const { pSlip, pGuess, pTransit } = params; const posterior = observedCorrect ? (pL * (1 - pSlip)) / (pL * (1 - pSlip) + (1 - pL) * pGuess) : (pL * pSlip) / (pL * pSlip + (1 - pL) * (1 - pGuess)); return posterior + (1 - posterior) * pTransit;}Heart of BKT. Ternary keeps branches dense; equivalent if/else would balloon.
Numerical safety: denominators stay positive for , — no 0/0.
ensureMastery — lazy initialization
Section titled “ensureMastery — lazy initialization”export function ensureMastery( state: StudentState, skillId: MicroSkillId, params: BktParams = DEFAULT_BKT): number { const cur = state.mastery[skillId]; if (cur === undefined) { state.mastery[skillId] = params.pInit; return params.pInit; } return cur;}First encounter with a skill seeds pInit. Mutates state on purpose — heatmaps/UI need concrete numbers, not lingering undefined.
Alternative immutable cur ?? pInit pushes bookkeeping outward — mutate-on-first-touch stays simpler.
applyAttempt — post-attempt update
Section titled “applyAttempt — post-attempt update”export function applyAttempt( state: StudentState, task: Task, correct: boolean, perSkill?: Record<MicroSkillId, boolean>, params: BktParams = DEFAULT_BKT): StudentState { for (const skillId of task.microskills) { const observed = perSkill?.[skillId] ?? correct; const prior = ensureMastery(state, skillId, params); state.mastery[skillId] = bktUpdate(prior, observed, params); } state.history.push({ task_id: task.id, correct, per_skill: perSkill, ts: new Date().toISOString(), }); return state;}perSkill answers:
Student missed the problem — which micro-skills failed?
Step-level captures (tablet workflow / scanner) enable positives on expand_brackets while penalizing arith.signs — far sharper than uniform +/- updates.
Fallback without perSkill: uniform signal — OK for MVP sans scanner.
history.push powers navigation, audit logs, anti-repeat heuristics.
scoreTaskForStudent — selector kernel
Section titled “scoreTaskForStudent — selector kernel”export function scoreTaskForStudent( state: StudentState, task: Task, opts: SelectorOptions = {}, params: BktParams = DEFAULT_BKT): ScoredTask { const target = opts.target ?? 0.7; const rareBonus = opts.rareSkillBonus ?? 0.15;
// Per-skill P(solve), then take the geometric mean as the joint — // a task fails if *any* required skill fails, so geo-mean penalises // missing one skill more than arithmetic mean. const perSkillPL: Record<MicroSkillId, number> = {}; let logSum = 0; for (const skillId of task.microskills) { const pL = state.mastery[skillId] ?? params.pInit; perSkillPL[skillId] = pL; logSum += Math.log(Math.max(1e-6, pSolve(pL, params))); } const pSolveJoint = Math.exp(logSum / task.microskills.length);
// Closeness to target — Gaussian-ish, peaks at target=0.7 const closeness = Math.exp(-Math.pow(pSolveJoint - target, 2) / 0.03);
// Rarity bonus: how many of this task's skills are below 0.4? const undertrained = task.microskills.filter( (s) => (state.mastery[s] ?? params.pInit) < 0.4 ).length; const rarity = undertrained / task.microskills.length;
const score = closeness + rareBonus * rarity;
return { task, pSolve: pSolveJoint, perSkillPL, score };}Dense block — unpack by section.
target = 0.7
Section titled “target = 0.7”ZPD bullseye (chapter 8). Override via opts when experimenting.
Math.max(1e-6, pSolve(pL, params))
Section titled “Math.max(1e-6, pSolve(pL, params))”Guards Math.log(0). Real runs rarely hit zero thanks to pGuess > 0, but clamp is cheap insurance.
Geometric mean via logs
Section titled “Geometric mean via logs”logSum = Σ log(P_i)GM = exp(logSum / n)Benefits:
- avoids multiplicative underflow for large
n; - corresponds to log-average metrics familiar from IRT extensions.
Gaussian closeness
Section titled “Gaussian closeness”closeness = exp(-(p - 0.7)^2 / 0.03)Width 0.03 discussion — chapter 8.
Alternatives:
1 - |p - 0.7|— linear, weak contrast near target;- Lorentzian — softer tails.
Gaussian stays smooth, symmetric, conventional in adaptive-testing literature.
Rarity bonus — exploration
Section titled “Rarity bonus — exploration”rarity = (#skills with P(L) < 0.4) / (#skills required)score = closeness + rareBonus * rarityWithout it selectors linger in comfort zones.
rareBonus = 0.15 balances exploration vs frustration (selector chapter).
Return payload
Section titled “Return payload”return { task, pSolve: pSolveJoint, perSkillPL, score };perSkillPL feeds explainability copy.
recommend — final ordering
Section titled “recommend — final ordering”export function recommend( state: StudentState, pool: Task[], topN = 5, opts?: SelectorOptions, params?: BktParams): ScoredTask[] { const recentIds = new Set( state.history.slice(-5).map((h) => h.task_id) ); const scored = pool .filter((t) => !recentIds.has(t.id)) .map((t) => scoreTaskForStudent(state, t, opts, params)); scored.sort((a, b) => b.score - a.score); return scored.slice(0, topN);}Drop last five task IDs — anti-repeat:
- repeating identical items bores students;
- five-step window balances novelty vs coverage.
Alternative exponential decay:
const recencyPenalty = recent.findIndex(h => h.task_id === t.id);score *= recencyPenalty < 0 ? 1 : Math.exp(-recencyPenalty / 3);Harder to explain; MVP doesn’t need it.
Bottom line
Section titled “Bottom line”~150 lines. Zero heavyweight deps (only shared types). Explicit formulas — no opaque sklearn stacks.
That’s intentional — every decision is verbally defensible for teachers and external reviewers.