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>
This commit is contained in:
MechaCat02
2026-09-16 19:49:52 +02:00
parent 75dd699f3e
commit e72b77d6c2
34 changed files with 2201 additions and 89 deletions

View File

@@ -3,57 +3,107 @@
as real interface and sends structured answers back. */
const RE = {
task: /::task\s+(translate|match|build|choice)\s*\n([\s\S]*?)(?:\n::|$)/,
task: /::task\s+(translate|recall|match|build|choice)\s*\n([\s\S]*?)(?:\n::|$)/,
words: /::words\s*\n([\s\S]*?)(?:\n::|$)/,
gloss: /::gloss\s*\n([\s\S]*?)(?:\n::|$)/,
progress: /::progress\s+(\d{1,3})\s*(?:\|\s*([^\n]*))?/,
result: /::result\s*\n([\s\S]*?)(?:\n::|$)/,
confirmed:/::confirmed\s*\n([\s\S]*?)(?:\n::|$)/,
};
const rows = s => s.split("\n").map(l => l.trim()).filter(l => l && !/^::/.test(l));
const cols = l => l.split("|").map(x => x.trim());
export function parse(text) {
const w = text.match(RE.words), t = text.match(RE.task);
const g = text.match(RE.gloss), p = text.match(RE.progress);
/* 선생님 sometimes writes an exercise, notices mid-message that it broke the
gate, and rewrites it below. Matching the FIRST block served the draft it
had just retracted — a real bug seen in production, where the student was
handed and answered an exercise the tutor had already withdrawn.
So: collect every block; the LAST task, words and progress win, and gloss
blocks accumulate (a message may legitimately gloss several sentences). */
const all = (text, re) => {
const r = new RegExp(re.source, "g");
const out = [];
let m;
while ((m = r.exec(text)) !== null) {
out.push(m);
if (m.index === r.lastIndex) r.lastIndex++;
}
return out;
};
const words = w ? rows(w[1]).map(l => { const c = cols(l);
return { ko: c[0], gloss: c[1] || "", note: c[2] || "" }; }) : null;
export function parse(text) {
const ws = all(text, RE.words), ts = all(text, RE.task);
const gs = all(text, RE.gloss), ps = all(text, RE.progress);
const t = ts.length ? ts[ts.length - 1] : null;
const p = ps.length ? ps[ps.length - 1] : null;
/* keep every word listed anywhere, first gloss of a term wins */
let words = null;
if (ws.length) {
const seen = new Set(), acc = [];
ws.forEach(w => rows(w[1]).forEach(l => {
const c = cols(l);
if (!c[0] || seen.has(c[0])) return;
seen.add(c[0]);
acc.push({ ko: c[0], gloss: c[1] || "", note: c[2] || "" });
}));
if (acc.length) words = acc;
}
let task = null;
if (t) {
const r = rows(t[2]);
if (t[1] === "translate") task = { type: "translate", items: r.map(q => ({ q })) };
/* recall: English prompt, the student WRITES the 한글. The one task type
that proves memory rather than recognition — and the only one whose
answers need letter-level marking, see hangul.js letterCheck(). */
if (t[1] === "recall") task = { type: "recall", items: r.map(l => { const c = cols(l);
return { q: c[0], hint: c[1] || "" }; }).filter(x => x.q) };
if (t[1] === "match") task = { type: "match", pairs: r.map(l => { const c = cols(l);
return { ko: c[0], gloss: c[1] }; }).filter(x => x.ko && x.gloss) };
if (t[1] === "build") task = { type: "build", items: r.map(l => { const c = cols(l);
return { en: c[0], chips: c.slice(1).filter(Boolean) }; }).filter(x => x.en && x.chips.length) };
if (t[1] === "choice") task = { type: "choice", items: r.map(l => { const c = cols(l);
return { q: c[0], options: c.slice(1).filter(Boolean) }; }).filter(x => x.q && x.options.length > 1) };
if (task) { task.retracted = ts.length - 1; /* >0 means a draft was withdrawn */
task.rows = r; } /* raw rows, for the gate to read */
}
let gloss = null;
if (g) {
const blocks = []; let cur = null;
g[1].split("\n").forEach(line => {
const l = line.trim();
if (!l || /^::/.test(l)) return;
if (l.startsWith("=")) { if (cur) cur.en = l.slice(1).trim(); return; }
const c = cols(l);
if (!cur) { cur = { parts: [], en: "" }; blocks.push(cur); }
cur.parts.push({ ko: c[0], role: (c[1] || "N").toUpperCase()[0], gloss: c[2] || "", highlight: c[3] || "" });
if (gs.length) {
const blocks = [];
gs.forEach(g => {
let cur = null;
g[1].split("\n").forEach(line => {
const l = line.trim();
if (!l || /^::/.test(l)) return;
if (l.startsWith("=")) { if (cur) { cur.en = l.slice(1).trim(); cur = null; } return; }
const c = cols(l);
if (!cur) { cur = { parts: [], en: "" }; blocks.push(cur); }
cur.parts.push({ ko: c[0], role: (c[1] || "N").toUpperCase()[0], gloss: c[2] || "", highlight: c[3] || "" });
});
});
const keep = blocks.filter(b => b.parts.length);
if (keep.length) gloss = keep;
}
/* marking the tutor sends back: per-item outcomes, and words he certifies */
const rs = all(text, RE.result), cs = all(text, RE.confirmed);
const results = rs.length ? rows(rs[rs.length - 1][1]).map(l => {
const c = cols(l);
return { item: c[0], ok: /^ok$/i.test(c[1] || ""), mistakenFor: c[2] || "" };
}).filter(x => x.item) : null;
const confirmed = cs.length ? rows(cs[cs.length - 1][1]).map(l => cols(l)[0]).filter(Boolean) : null;
/* strip every block, not only the matched one */
let body = text;
if (p) body = body.replace(p[0], "");
if (g) body = body.replace(g[0], "");
if (w) body = body.slice(0, body.indexOf("::words") >= 0 ? body.indexOf("::words") : body.length);
if (t) body = body.replace(t[0], "");
[RE.progress, RE.gloss, RE.task, RE.words, RE.result, RE.confirmed].forEach(re => {
body = body.replace(new RegExp(re.source, "g"), "");
});
body = body.replace(/^[ \t]*::[ \t]*$/gm, "");
return {
body: body.replace(/\n{3,}/g, "\n\n").trim(),
words, task, gloss,
words, task, gloss, results, confirmed,
progress: p ? { score: Math.max(0, Math.min(100, +p[1])), note: (p[2] || "").trim() } : null,
};
}
@@ -65,10 +115,20 @@ export const ROLES = {
Q: "인용 quotation", M: "수식 modifier", N: "",
};
/** Turn a completed task back into the message the student sends. */
export function answerText(task, state, lookups = []) {
/**
* Turn a completed task back into the message the student sends.
*
* `letterBlock` is the output of hangul.js letterCheck() for a recall task —
* pass it, always. The tutor cannot see inside a Hangul syllable and will
* invent a diagnosis if you leave him to it.
*/
export function answerText(task, state, lookups = [], letterBlock = "") {
let body;
if (task.type === "translate")
if (task.type === "recall")
body = "My written answers:\n" + task.items.map((it, i) =>
`${it.q}${(state[i] || "").trim() || "(not sure)"}`).join("\n")
+ (letterBlock ? "\n\n" + letterBlock : "");
else if (task.type === "translate")
body = "My answers:\n" + task.items.map((it, i) =>
`${it.q}${(state[i] || "").trim() || "(not sure)"}`).join("\n");
else if (task.type === "match")

View File

@@ -97,3 +97,66 @@ export function surfaceForms(dict, gloss) {
if (q) out.push({ form: q, gloss: `${g} (past)`, note: `반말 past, from ${dict}` });
return out;
}
/* ── Reading an inflected form back to its dictionary entry ────────────
The artifact's lexicon held 가다 plus a handful of pre-generated forms,
and nothing else. Measured over 660 realistic inflections of the 74
curriculum verbs and adjectives — 가고, 가면, 가네, 앉으면, 갑니다 —
ALL 660 failed to resolve, so the student tapped a word he had been
taught and was told it was not in the word list. With the stripper
below, all 660 resolve.
The guard matters as much as the list: a candidate is only accepted if
the stem + 다 is a word the lexicon actually holds AS A VERB OR
ADJECTIVE. Without that, 가지 (eggplant) becomes "a form of 가다". */
export const ENDINGS = [
"았어요","었어요","였어요","으세요","자마자","으니까",
"았어","었어","였어","았다","었다","으면","으니","는데",
"아서","어서","아도","어도","아요","어요","여요","네요",
"세요","지요","거든","더라","는다","았","었","였",
"고","지","면","네","자","니","게","는","며","는지","은지","아","어","여","다"
];
const LEAD_N = 4, LEAD_B = 17; // ㄴ and ㅂ as batchim indices
/** 갑 → 가, but only when the final really is the jamo given. */
function dropFinal(ch, jamo) {
const d = decompose(ch);
if (!d || d[2] !== jamo) return null;
return compose(d[0], d[1], 0);
}
/** Every dictionary form this surface could plausibly be. */
export function deconjugateCandidates(token) {
const out = [], push = stem => { if (stem && !out.includes(stem + "다")) out.push(stem + "다"); };
const bases = [token];
if (/요$/.test(token) && token.length > 1) bases.push(token.slice(0, -1));
for (const t of bases) {
if (/니다$/.test(t) && t.length > 2) { // 앉습니다 → 앉 · 갑니다 → 가
const head = t.slice(0, -2), last = head[head.length - 1];
if (last === "습") push(head.slice(0, -1));
const s = dropFinal(last, LEAD_B);
if (s) push(head.slice(0, -1) + s);
}
if (/다$/.test(t) && t.length > 1) { // 간다 → 가
const s = dropFinal(t[t.length - 2], LEAD_N);
if (s) push(t.slice(0, -2) + s);
}
for (const e of ENDINGS)
if (t.length > e.length && t.slice(-e.length) === e) push(t.slice(0, -e.length));
}
return out;
}
/**
* @param token the surface form seen in text
* @param isVerb (dictionaryForm) => boolean — true only for words the
* lexicon holds as a verb or adjective. REQUIRED; without
* it this guesses nouns into verbs.
* @returns the dictionary form, or null.
*/
export function deconjugate(token, isVerb) {
for (const d of deconjugateCandidates(token)) if (isVerb(d)) return d;
return null;
}

View File

@@ -70,3 +70,135 @@ export function renderGate(g) {
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;

View File

@@ -123,3 +123,78 @@ export const KEYBOARD = {
],
shift: {"ㅂ":"ㅃ","ㅈ":"ㅉ","ㄷ":"ㄸ","ㄱ":"ㄲ","ㅅ":"ㅆ","ㅐ":"ㅒ","ㅔ":"ㅖ"},
};
/* ── Letter-level marking ──────────────────────────────────────────────
A Hangul syllable is a single character, so a language model cannot see
the letters inside it. Asked which letter a student got wrong it will
reconstruct a plausible answer, and plausible is not the same as right:
asked about 빫다 for 짧다 the artifact's tutor blamed the ㄼ batchim —
which was identical in both — when the slip was the initial ㅉ→ㅃ.
So the client computes the comparison and the prompt forbids the tutor
from inferring one. Port this before anything else in the marking path;
it is the difference between a tutor that teaches spelling and one that
invents explanations. */
/** Two-consonant batchim clusters, unpacked for explanation. */
export const CLUSTER = {
"ㄳ":"ㄱ+ㅅ", "ㄵ":"ㄴ+ㅈ", "ㄶ":"ㄴ+ㅎ", "ㄺ":"ㄹ+ㄱ", "ㄻ":"ㄹ+ㅁ", "ㄼ":"ㄹ+ㅂ",
"ㄽ":"ㄹ+ㅅ", "ㄾ":"ㄹ+ㅌ", "ㄿ":"ㄹ+ㅍ", "ㅀ":"ㄹ+ㅎ", "ㅄ":"ㅂ+ㅅ"
};
export const SLOT = ["first consonant", "vowel", "batchim"];
const finalOf = d => { const f = JONG[d[2]] === " " ? "" : JONG[d[2]]; return f; };
const slotValue = (d, slot) =>
slot === 0 ? CHO[d[0]]
: slot === 1 ? JUNG[d[1]]
: (finalOf(d) ? finalOf(d) + (CLUSTER[finalOf(d)] ? ` (${CLUSTER[finalOf(d)]})` : "") : "none");
/** "짧다" → "짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ" */
export function spellOut(word) {
return String(word || "").split("").map(ch => {
const d = decompose(ch);
if (!d) return ch;
const f = finalOf(d);
return `${ch}=${CHO[d[0]]}+${JUNG[d[1]]}${f ? "+" + f + (CLUSTER[f] ? `(${CLUSTER[f]})` : "") : ""}`;
}).join(" · ");
}
/**
* The exact jamo difference between the word wanted and the word written.
* Always names what was CORRECT as well — the tutor's failure mode is
* calling a right letter wrong, so it must be told which ones to leave alone.
*/
export function letterDiff(expected, written) {
const exp = String(expected || ""), got = String(written || "");
if (!exp || !got) return "";
if (exp === got) return "identical";
const out = [];
if (exp.length !== got.length)
out.push(`length differs: ${exp.length} syllables expected, ${got.length} written`);
const n = Math.min(exp.length, got.length);
for (let i = 0; i < n; i++) {
if (exp[i] === got[i]) continue;
const a = decompose(exp[i]), b = decompose(got[i]);
if (!a || !b) { out.push(`syllable ${i + 1}: wrote ${got[i]} for ${exp[i]}`); continue; }
const wrong = [], right = [];
for (let s = 0; s < 3; s++) {
if (a[s] === b[s]) right.push(`${SLOT[s]} ${slotValue(a, s)}`);
else wrong.push(`${SLOT[s]}: wrote ${slotValue(b, s)}, should be ${slotValue(a, s)}`);
}
out.push(`syllable ${i + 1} (${got[i]} for ${exp[i]}) — WRONG: ${wrong.join("; ")}` +
(right.length ? ` — CORRECT, do not call these mistakes: ${right.join(", ")}` : ""));
}
return out.join(" | ");
}
/** The block a marking message should carry. Empty when nothing differs. */
export function letterCheck(rows) {
const an = rows.filter(r => r.expected && r.written && r.expected !== r.written)
.map(r => `${r.prompt}\n wanted ${r.expected} [${spellOut(r.expected)}]\n` +
` wrote ${r.written} [${spellOut(r.written)}]\n ${letterDiff(r.expected, r.written)}`);
if (!an.length) return "";
return "════ LETTER-LEVEL CHECK — computed by the app ════\n" +
"This is the actual jamo comparison. Use it exactly. Do NOT work out for yourself " +
"which letter was wrong, and never call a letter wrong that is listed as correct.\n" +
an.join("\n");
}

108
lib/lexicon.js Normal file
View File

@@ -0,0 +1,108 @@
/* ══════════════════════════════════════════════════════════════════════
THE LEXICON — every surface form the app can gloss
══════════════════════════════════════════════════════════════════════
Two jobs, and they must share one resolver or they drift apart:
· the student taps a word and gets its meaning
· the gate asks whether the tutor was allowed to use it
Both need the same question answered: which dictionary word is this
surface form? Get it wrong in the first and the student is told a word he
was taught is "not in the word list". Get it wrong in the second and 닭이
is reported as an unknown word when it is just 닭 with a particle. Both
happened.
An entry carries `base` when it is a surface form of something else, and
resolution returns EVERY route, best first — the caller decides which it
needs. A single "best" answer was the bug. */
import { deconjugateCandidates } from "./conjugation.js";
export const PARTICLES = ["이랑","에서","에게","한테","으로","부터","까지","보다","처럼","같이",
"까","은","는","이","가","을","를","도","만","에","와","과","랑","의","로"];
export class Lexicon {
constructor() { this.map = new Map(); this.verbs = new Set(); }
/** @param base the dictionary word this is a form of, if any */
add(ko, gloss, note = "", src = "deck", base = "") {
if (!ko || this.map.has(ko)) return;
this.map.set(ko, { ko, gloss, note, src, base });
}
addVerb(dictionaryForm) { if (/다$/.test(dictionaryForm)) this.verbs.add(dictionaryForm); }
get(ko) { return this.map.get(ko) || null; }
isVerb(ko) { return this.verbs.has(ko); }
/** Every dictionary word this surface form could be, best first. */
heads(token) {
const out = [], push = x => { if (x && !out.includes(x)) out.push(x); };
const e = this.get(token);
if (e) { push(token); push(e.base); }
for (const p of PARTICLES) {
if (token.length > p.length && token.slice(-p.length) === p) {
const b = token.slice(0, -p.length), eb = this.get(b);
if (eb) { push(b); push(eb.base); }
}
}
for (const d of deconjugateCandidates(token))
if (this.verbs.has(d) && this.get(d)) push(d);
return out;
}
/** What to show when the student taps a word. */
lookup(token) {
const direct = this.get(token);
if (direct) return direct;
for (const p of PARTICLES) {
if (token.length > p.length && token.slice(-p.length) === p) {
const e = this.get(token.slice(0, -p.length));
if (e) return { ...e, ko: token, note: (e.note ? e.note + " · " : "") + `with 조사 ${p}` };
}
}
for (const d of deconjugateCandidates(token)) {
if (!this.verbs.has(d)) continue;
const e = this.get(d);
if (e) return { ...e, ko: token, note: (e.note ? e.note + " · " : "") + `a form of ${d}` };
}
return null;
}
}
/** Build it from the shipped data. Expand verbs and adjectives into their
* surface forms at load: it is cheap and it is what makes 앉아 resolve. */
/**
* ORDER MATTERS, and it changes what the gate permits.
*
* A word the roadmap schedules for a specific unit must be entered FIRST,
* with no `base`. 마셔 belongs to unit 2.3. If the deck is expanded first,
* 마셔 is created as a surface form of 마시다 and inherits that stem's
* permission — so a unit-2.3 word silently becomes legal in Phase 1 because
* its dictionary form happens to be a card the student has met. That is a
* real difference: it is four messages' worth of violations in the fixture
* corpus, and it is invisible unless you run audit-gate.mjs.
*
* The roadmap decides when a form may appear. Enter it first; let the deck
* expansion fill in only what the roadmap has not already claimed.
*/
export function buildLexicon({ roadmapWords = [], deck = [], glossExtra = [], sentences = [], sfx = [] } = {},
{ haeche, past }) {
const lex = new Lexicon();
roadmapWords.forEach(w => { lex.add(w, w, "", "roadmap"); if (/다$/.test(w)) lex.addVerb(w); });
const addWord = (ko, gloss, pos) => {
lex.add(ko, gloss, "", "deck");
if (pos !== "verb" && pos !== "adj" || !/다$/.test(ko)) return;
lex.addVerb(ko);
const p = haeche(ko);
if (!p) return;
const g = String(gloss).replace(/^to be /, "").replace(/^to /, "");
lex.add(p, g, `반말, from ${ko}`, "form", ko);
lex.add(p + "요", g, `polite, from ${ko}`, "form", ko);
const q = past(p);
if (q) lex.add(q, g + " (past)", `반말 past, from ${ko}`, "form", ko);
};
deck.forEach(w => addWord(w.ko || w[0], w.en || w[2], w.pos || w[3]));
glossExtra.forEach(g => lex.add(g[0], g[1], g[2] || "", "gloss"));
sentences.forEach(s => (s.parts || []).forEach(p => lex.add(p[0], p[1], "seen in a sentence", "sentence")));
sfx.forEach(f => lex.add(f[0], f[1], "의성어 · 의태어", "sfx"));
return lex;
}

View File

@@ -44,3 +44,45 @@ export function preview(card, g, today) {
/** Local day number, DST-safe. */
export const dayNumber = (d = new Date()) =>
Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 864e5);
/* ── Recall evidence, kept separate from the schedule ──────────────────
The SRS above decides WHEN to show a card. This decides whether the
student actually knows the word, and the two must not be conflated.
The artifact's tutor kept certifying words on a single correct answer.
Getting a word right once proves nothing: it may be a guess, or it may
still be in the student's head from the line above. So a word counts as
learned only after three corrects, in three separate rounds, spread over
at least five rounds — which forces at least one genuine recall rather
than an echo. A lookup resets the streak: answering correctly after
looking the word up is not recall.
Enforce this in the CLIENT. The prompt asks the tutor to hold the line
and he does not; the artifact only stopped premature certification once
the app refused to store it. */
export const LEARNED_OK = 3, LEARNED_STREAK = 2, LEARNED_SPAN = 5;
export const newEvidence = () => ({
ok: 0, wrong: 0, lookups: 0, streak: 0,
firstRound: 0, lastRound: 0, lastSeen: 0, rounds: 0,
});
/** @param outcome "ok" | "wrong" @param round monotonic round counter */
export function noteOutcome(ev, outcome, round, lookedUp) {
const e = { ...ev };
if (e.firstRound === 0) e.firstRound = round;
if (e.lastSeen !== round) { e.rounds++; e.lastSeen = round; }
e.lastRound = round;
if (lookedUp) { e.lookups++; e.streak = 0; return e; } // not recall
if (outcome === "ok") { e.ok++; e.streak++; }
else { e.wrong++; e.streak = 0; }
return e;
}
export const isLearned = ev =>
ev.ok >= LEARNED_OK && ev.streak >= LEARNED_STREAK &&
ev.rounds >= LEARNED_OK && (ev.lastRound - ev.firstRound) >= LEARNED_SPAN;
/** Reject a tutor's ::confirmed for a word the evidence does not support. */
export const acceptConfirmation = ev => isLearned(ev);

115
lib/sync.js Normal file
View File

@@ -0,0 +1,115 @@
/* ══════════════════════════════════════════════════════════════════════
SYNCING BETWEEN DEVICES — three gates, each paid for in lost user data
══════════════════════════════════════════════════════════════════════
The artifact shipped without these twice and destroyed real work twice.
Last-write-wins is adequate for one user whose worst conflict is a
duplicated SRS grade — but ONLY with all three of the following.
1. HYDRATION. A client may not push a document until the server has told
it what it already holds. This is the one that caused the loss: a
laptop with a week-old local copy ran a boot-time migration, which
re-stamped that stale copy with the current time and pushed it. A
boot-time write always looks like the newest edit in the world. A
week of phone work — chat and roadmap both — was gone. Note that rule
2 would NOT have saved it: the data was real, just old.
2. A COUNTER, NOT A CLOCK. Phone and laptop clocks disagree by minutes.
Compare a monotonic per-document counter; fall back to the timestamp
only when one side has none (an older client).
3. NO SILENT SHRINKING. A copy holding strictly less than the local one —
fewer chat turns, fewer finished units, fewer cards — is never adopted
by accident: keep the local copy and push it back. Deliberate
deletions set a flag and are obeyed. This is what made the loss
recoverable: the phone still had the real chat, refused the truncated
server copy, and restored it.
And: MIGRATIONS RUN AFTER THE FIRST PULL, NEVER AT BOOT, and never
rewrite history. The trigger for the whole incident was a one-shot repair
that cleared the chat. It was correct for the device it was written for
and a loaded gun for every other one. */
export const DOCS = ["srs", "log", "meta", "chat"];
/** How much a copy holds. Shrinking is always deliberate, never a race. */
export function weigh(name, d) {
if (!d) return 0;
if (name === "chat") return (d.turns || []).length;
if (name === "meta") return d.road && d.road.done ? Object.keys(d.road.done).length : 0;
if (name === "srs") return d.cards ? Object.keys(d.cards).length : 0;
if (name === "log") return d.days ? Object.keys(d.days).length : 0;
return 0;
}
/** Is the copy that just arrived later than ours? */
export function isLater(remote, localVersion, localStamp) {
if (typeof remote.v === "number" && localVersion > 0) {
if (remote.v !== localVersion) return remote.v > localVersion;
return typeof remote.u === "number" && remote.u > localStamp;
}
return typeof remote.u === "number" && remote.u > localStamp;
}
/**
* Decide what to do with an incoming document.
* @returns "ignore" | "adopt" | "reassert"
* reassert = ours holds more and nothing said the shrink was
* deliberate, so keep ours, bump past theirs, and push it back.
*/
export function reconcile(name, remote, local) {
if (!remote || !remote.d) return "ignore";
if (!isLater(remote, local.version || 0, local.stamp || 0)) return "ignore";
if (weigh(name, local.data) > weigh(name, remote.d) && !remote.x) return "reassert";
return "adopt";
}
/**
* The write side. Hold every edit until the document has been hydrated;
* an edit made before then keeps its old stamp so a newer server copy
* still wins.
*/
export function makeWriter({ push, now = () => Date.now() }) {
const state = {}; // name -> {version,stamp,dirty,hydrated,intent}
const S = n => (state[n] = state[n] || { version: 0, stamp: 0, dirty: false, hydrated: false, intent: false });
return {
state,
/** a normal user edit */
touch(name, deliberateShrink = false) {
const s = S(name);
s.dirty = true;
if (deliberateShrink) s.intent = true;
if (s.hydrated) { s.stamp = now(); s.version += 1; } // otherwise: hold
return s;
},
/** call when the first snapshot for this document arrives (or is known absent) */
hydrate(name) {
const s = S(name);
if (s.hydrated) return s;
s.hydrated = true;
if (s.dirty) { s.stamp = now(); s.version += 1; } // the held edit is real after all
return s;
},
adopted(name, remote) {
const s = S(name);
s.stamp = typeof remote.u === "number" ? remote.u : now();
s.version = Math.max(s.version, typeof remote.v === "number" ? remote.v : 0);
s.dirty = false; s.intent = false;
return s;
},
reasserted(name, remote) {
const s = S(name);
s.version = Math.max(s.version, typeof remote.v === "number" ? remote.v : 0) + 1;
s.stamp = now(); s.dirty = true;
return s;
},
flush(name, data) {
const s = S(name);
if (!s.dirty || !s.hydrated) return null;
s.dirty = false;
const body = { u: s.stamp, v: s.version, d: data };
if (s.intent) { body.x = 1; s.intent = false; }
return push(name, body);
},
};
}