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:
106
lib/blocks.js
106
lib/blocks.js
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
132
lib/gate.js
132
lib/gate.js
@@ -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;
|
||||
|
||||
@@ -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
108
lib/lexicon.js
Normal 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;
|
||||
}
|
||||
42
lib/srs.js
42
lib/srs.js
@@ -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
115
lib/sync.js
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user