Skip to content

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.

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.

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.

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 P(L)[0,1]P(L)\in[0,1], P(S),P(G)(0,1)P(S),P(G)\in(0,1) — no 0/0.

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.

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.

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.

ZPD bullseye (chapter 8). Override via opts when experimenting.

Guards Math.log(0). Real runs rarely hit zero thanks to pGuess > 0, but clamp is cheap insurance.

logSum = Σ log(P_i)
GM = exp(logSum / n)

Benefits:

  • avoids multiplicative underflow for large n;
  • corresponds to log-average metrics familiar from IRT extensions.
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 = (#skills with P(L) < 0.4) / (#skills required)
score = closeness + rareBonus * rarity

Without it selectors linger in comfort zones.

rareBonus = 0.15 balances exploration vs frustration (selector chapter).

return { task, pSolve: pSolveJoint, perSkillPL, score };

perSkillPL feeds explainability copy.

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.

~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.