Files
Hankan/lib/gate.js
MechaCat02 e72b77d6c2 chore: take in the 16 Sep bundle — lib, curriculum v5, prompt, gate audit
The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.

Copied byte-identical from the bundle:
  lib/        lexicon.js and sync.js are new; gate.js gains enforcement,
              hangul.js letter-level marking, srs.js recall evidence,
              conjugation.js deconjugate(); blocks.js now takes the last
              block, closes gloss at "=", and parses recall, ::result and
              ::confirmed
  data/       curriculum.json v5 — six 다지기 phase reviews; the 371
              roadmap words are unchanged and no band moves
  prompt/     English-only rule, recall, LETTER-LEVEL CHECK, marking
  audit-gate.mjs, run-checks.sh, fixtures/  — the word gate measured
              against 54 real tutor messages

CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.

types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.

TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:49:52 +02:00

205 lines
12 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");
}
/* ══════════════════════════════════════════════════════════════════════
ENFORCING THE GATE
══════════════════════════════════════════════════════════════════════
Everything above tells the tutor what he may use. This half checks
whether he listened, because he does not reliably. Two real failures
from the artifact's transcript:
· 좁아 appeared in a review exercise. The word is in no unit, no deck
and nowhere in the prompt. The student tapped it and got a blank.
· 안 (not) appeared in a Phase 1 exercise, declared in a ::words
block. It is a real word belonging to a later unit, and the old
check only asked "does the app know this word" — never "has he been
taught it". The tutor himself admitted, one message later, that it
was not on the roadmap yet.
THE SCOPE IS THE WHOLE DESIGN, AND IT WAS MEASURED, NOT REASONED.
Checking his prose against the allowed vocabulary was run over all 41 of
his real messages: it would have rejected 17 of them, 41%. Nearly every
hit was a PRONUNCIATION — "국물 is read as → 궁물". 궁물 is not a word and
never will be; it is how a word sounds, and sound is the whole of Phase
1. Others were misreadings quoted on purpose (감사함니다) to contrast
with the right form. Nothing separates those from a stray word by
spelling alone, and a tutor who cannot write 궁물 cannot teach 비음화.
So the gate reads only the side of an exercise the student must decode.
Re-measured with that scope: 1 of 41, and that one is a true positive.
KEEP A CORPUS OF REAL TUTOR MESSAGES AS A FIXTURE and re-run the audit
whenever this file changes (see fixtures/ and audit-gate.mjs). A
vocabulary gate that has not been measured against real output will be
far too aggressive; 41% is not a near miss. */
const HANGUL_RUN = /[가-힣]+/g;
/** Words that belong in a teacher's prose without being taught vocabulary:
* unit and phase names, and the metalanguage of the course itself.
* Rejecting a message for saying 받침 is worse than the problem solved. */
export function buildScaffold(curriculum) {
const set = new Set();
const add = s => (String(s || "").match(HANGUL_RUN) || []).forEach(w => set.add(w));
curriculum.phases.forEach(p => { add(p.ko); add(p.name); p.units.forEach(u => { add(u.ko); add(u.name); }); });
(`한글 한국어 한국말 선생님 학생 반말 존댓말 높임말 말투
자음 모음 받침 겹받침 음절 글자 낱말 단어 문장 어절 띄어쓰기
연음 비음화 격음화 경음화 구개음화 유음화 탈락 된소리 거센소리 예사소리 소리
조사 어미 어간 동사 형용사 명사 대명사 부사 관형사 수사 감탄사 조동사
의성어 의태어 수업 복습 다지기 힌트 제출 정답 오답 문제 연습 예문 보기
주어 목적어 서술어 주제 자리 이름 뜻 읽기 쓰기 듣기 말하기 때 것 거 수
네 아니요 그래 맞아 처럼 같이`).split(/\s+/).forEach(w => w && set.add(w));
return set;
}
/** Only the side of an exercise the student has to decode. The answer side
* is where readings live, and it is deliberately not gated. */
export function taskMaterial(task) {
if (!task) return [];
return (task.rows || []).map(r => {
const c = String(r).split("|");
if (task.type === "translate") return r; // the line to read
if (task.type === "build") return c.slice(1).join(" "); // the chips
if (task.type === "match" || task.type === "choice") return c[0] || "";
return ""; // recall prompts are English
});
}
/**
* @param parsed a parsed tutor message (see blocks.js)
* @param ctx { allowed:Set<string>, scaffold:Set<string>,
* heads:(token)=>string[] every dictionary word this
* surface could be, best first,
* unitOf:(word)=>string the unit that introduces it }
* @returns [{ word, unit, known }] known=false means no gloss exists at all
*/
export function scanTask(parsed, ctx) {
const body = taskMaterial(parsed && parsed.task).join(" ")
.replace(/\[[^\]]*\]/g, "") // 학교 [학꾜] — a sound, not a word
.replace(/\([^)]*\)/g, "");
// Hangul in the MEANING or NOTE of a ::words entry is a reading he is
// quoting ("국물 | soup broth | read 궁물"), not a word he is using.
// The headword itself is NOT exempt: declaring a word does not license it.
const readings = new Set();
((parsed && parsed.words) || []).forEach(w =>
((`${w.note || ""} ${w.gloss || w.m || ""}`).match(HANGUL_RUN) || []).forEach(t => readings.add(t)));
const seen = new Set(), out = [];
(body.match(HANGUL_RUN) || []).forEach(tok => {
if (seen.has(tok)) return;
seen.add(tok);
if (ctx.scaffold.has(tok) || readings.has(tok)) return;
const heads = ctx.heads(tok);
if (!heads.length) { out.push({ word: tok, unit: "", known: false }); return; }
// allowed if ANY route to a dictionary word is allowed: 닭이 is 닭 with
// a particle, 넓어 is 넓다. Stopping at the first route misreported both.
if (heads.some(h => ctx.scaffold.has(h) || ctx.allowed.has(h))) return;
out.push({ word: tok, unit: heads.map(ctx.unitOf).find(Boolean) || "", known: true });
});
return out;
}
/** The second rule, and far easier to be sure about: he must EXPLAIN in
* English. Korean inside a block or in brackets is material; Korean in the
* prose around it is the failure. Fires on exactly the one message in the
* 41-message corpus that deserved it. */
export function proseIsKorean(text) {
const prose = String(text || "")
.replace(/::(task|words|gloss|result|confirmed|progress)[\s\S]*?(?:\n::|$)/g, " ")
.replace(/\[[^\]]*\]/g, " ");
const ko = (prose.match(/[가-힣]/g) || []).length;
const la = (prose.match(/[A-Za-z]/g) || []).length;
return ko >= 40 && ko / ((ko + la) || 1) >= 0.5;
}
/** What to tell the tutor when a message is sent back. The two severities
* need different fixes, so they are reported separately. */
export function rejectionNote(findings) {
const unknown = findings.filter(f => !f.known).map(f => f.word);
const early = findings.filter(f => f.known);
const L = [];
if (unknown.length)
L.push(`${unknown.join(" · ")} — the app has no gloss for ${unknown.length > 1 ? "these" : "this"} at all, ` +
`so he taps the word and gets nothing. Either a typo, or a word that exists nowhere in his course.`);
if (early.length)
L.push(`${early.map(f => f.word + (f.unit ? ` (belongs to unit ${f.unit})` : " (not on his list)")).join(" · ")}` +
`real ${early.length > 1 ? "words" : "word"}, but from a unit he has not reached. Declaring ` +
`${early.length > 1 ? "them" : "it"} in a ::words block does NOT make ${early.length > 1 ? "them" : "it"} ` +
`allowed; the roadmap decides that, not you.`);
return L.join("\n");
}
/** Bounded retry. A student stuck behind a tutor that cannot satisfy the
* checker is worse than a bad word, so after GATE_TRIES the message is
* shown anyway with the words flagged in the UI. */
export const GATE_TRIES = 2;