Files
Hankan/lib/gate.js
MechaCat02 b80deb2f6f chore: scaffold, CI, and the export bundle verbatim
The export bundle is the input to this port, not a sketch: the curriculum,
the tutor prompt and the five logic modules are finished and tested. They
land here byte-identical and stay that way.

  diff -r export/data data && diff -r export/lib lib
  diff -r export/prompt prompt && diff export/validate.mjs validate.mjs

data/, lib/, prompt/ and validate.mjs sit at the repo root so validate.mjs
runs verbatim with no path edits. All four are excluded from lint and
formatting — they are not ours to restyle. Types for lib/ live alongside in
types/ rather than as sibling .d.ts files, so the verbatim check stays a
plain directory diff.

CI runs the curriculum gate first, before anything else can pass:

  node validate.mjs   PASS — 0 blocking, 0 advisory

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 19:11:40 +02:00

73 lines
4.0 KiB
JavaScript

/* The gate — what the tutor is allowed to know, say and use, right now.
Generated from the curriculum plus progress. This is the mechanism that
stops material being taught out of order; port it before improving it. */
export function flatten(curriculum) {
const units = [];
curriculum.phases.forEach(p => p.units.forEach(u => units.push({ ...u, phase: p.phase, phaseKo: p.ko, phaseName: p.name })));
return units;
}
/**
* @param curriculum curriculum.json
* @param progress { current: "1.4", done: {"1.1":true,...}, confidence: {"1.4":62} }
* @param opts { vocabQuery } — with a dictionary, replaces the hand-listed words
*/
export function buildGate(curriculum, progress, opts = {}) {
const units = flatten(curriculum);
const at = id => units.findIndex(u => u.id === id);
const i = Math.max(0, at(progress.current));
const unit = units[i];
const done = units.filter(u => progress.done[u.id]);
const taught = done.flatMap(u => u.teaches);
// everything a later unit teaches is, by construction, forbidden now
const future = units.filter((u, k) => k > i || (!progress.done[u.id] && k !== i));
const near = [...new Set(future.flatMap(u => u.teaches))].slice(0, 18);
const tail = future.length ? future[future.length - 1] : null;
const vocabulary = opts.vocabQuery
? opts.vocabQuery(unit, done) // e.g. freq_rank BETWEEN …
: [...new Set(done.flatMap(u => u.words))];
// spiral targets: words first met in an earlier unit that this unit should
// deliberately bring back. Only those whose home unit is actually finished.
const doneIds = new Set(done.map(u => u.id));
const revisits = (unit.revisits || []).filter(r => doneIds.has(r.from)).map(r => r.word);
return {
unit, phase: { n: unit.phase, ko: unit.phaseKo, name: unit.phaseName },
taught, forbidden: { near, tailUnit: tail, count: future.length },
vocabulary, newWords: unit.words, revisits,
confidence: progress.confidence?.[unit.id] ?? null,
next: units[i + 1] || null,
finished: done.map(u => u.id),
};
}
/** Render the gate into the system prompt section. Keep the headings — the
model keys off them, and the pre-flight check refers to them by name. */
export function renderGate(g) {
const L = [];
L.push(`He is on PHASE ${g.phase.n} · ${g.phase.ko} (${g.phase.name}), UNIT ${g.unit.id} · ${g.unit.ko} (${g.unit.name})${g.unit.vocabUnit ? " — a VOCABULARY unit" : ""}.`);
L.push(`THIS UNIT'S GOAL: ${g.unit.goal}`, "");
L.push("════ WHAT HE KNOWS — the complete list ════");
L.push(g.taught.length ? g.taught.map(t => "• " + t).join("\n") : "• nothing yet — this is the very first unit", "");
L.push("════ THIS UNIT ADDS ════", g.unit.teaches.map(t => "• " + t).join("\n"));
if (g.unit.avoid?.length)
L.push("\nAND EXPLICITLY EXCLUDES, even if it seems natural:\n" + g.unit.avoid.map(t => "✗ " + t).join("\n"));
L.push("", "════ NOT TAUGHT YET — MUST NOT APPEAR ════");
L.push("Every one of these belongs to a later unit. Using any of them, even in passing, even to be helpful, breaks the sequence:");
L.push(g.forbidden.near.length ? g.forbidden.near.map(t => "✗ " + t).join("\n") : "— nothing; this is the last unit");
if (g.forbidden.tailUnit)
L.push(`…and everything else on the roadmap through ${g.forbidden.tailUnit.id} ${g.forbidden.tailUnit.ko}. If a thing is not on the KNOWS list above, it is not taught. That is the whole test — you do not need to recognise it on this list to exclude it.`);
L.push("", "════ VOCABULARY YOU MAY USE ════", g.vocabulary.join(" · ") || "(none yet)");
L.push("NEW WORDS THIS UNIT MAY INTRODUCE — and no others:",
g.newWords.length ? g.newWords.join(" · ")
: "(none — this unit adds no new vocabulary on purpose. It is a contrast/synthesis unit: work it entirely with words he already has.)");
if (g.revisits.length)
L.push("BRING BACK ON PURPOSE — he met these earlier and they are due for reuse here:", g.revisits.join(" · "));
return L.join("\n");
}