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>
124 lines
4.4 KiB
TypeScript
124 lines
4.4 KiB
TypeScript
/* Golden tests pinning lib/lexicon.js — the one resolver shared by word
|
|
lookup and the gate. It ships unchanged. */
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { buildLexicon, Lexicon, PARTICLES } from "@lib/lexicon.js";
|
|
import { haeche, past } from "@lib/conjugation.js";
|
|
import { flatten } from "@lib/gate.js";
|
|
import type { Curriculum } from "@lib/gate.js";
|
|
|
|
const conj = { haeche, past };
|
|
|
|
describe("ORDER MATTERS — roadmap words are entered first", () => {
|
|
/* 마셔 belongs to unit 2.3. Expanded from the deck first, it becomes a form
|
|
of 마시다 and inherits that stem's permission, so a unit-2.3 word turns
|
|
legal in Phase 1. In the fixture corpus that is four messages of
|
|
violations that stop being reported. */
|
|
it("keeps a scheduled form its own head", () => {
|
|
const lex = buildLexicon(
|
|
{ roadmapWords: ["마셔"], deck: [{ ko: "마시다", en: "to drink", pos: "verb" }] },
|
|
conj,
|
|
);
|
|
expect(lex.heads("마셔")).toEqual(["마셔"]);
|
|
expect(lex.get("마셔")).toMatchObject({ src: "roadmap", base: "" });
|
|
});
|
|
|
|
it("links it to the stem when the deck got there first", () => {
|
|
const lex = buildLexicon({ deck: [{ ko: "마시다", en: "to drink", pos: "verb" }] }, conj);
|
|
expect(lex.heads("마셔")).toEqual(["마셔", "마시다"]);
|
|
});
|
|
});
|
|
|
|
describe("heads — every route, best first", () => {
|
|
const lex = buildLexicon(
|
|
{
|
|
roadmapWords: ["닭"],
|
|
deck: [
|
|
{ ko: "마시다", en: "to drink", pos: "verb" },
|
|
{ ko: "친구", en: "friend", pos: "noun" },
|
|
],
|
|
glossExtra: [["이야", "it is"]],
|
|
sfx: [["쿵", "thud"]],
|
|
},
|
|
conj,
|
|
);
|
|
|
|
it("strips a particle", () => {
|
|
expect(lex.heads("닭이")).toEqual(["닭"]);
|
|
expect(lex.heads("친구가")).toEqual(["친구"]);
|
|
});
|
|
|
|
it("follows a generated form back to its stem", () => {
|
|
expect(lex.heads("마셨어")).toEqual(["마셨어", "마시다"]);
|
|
});
|
|
|
|
it("deconjugates a form nobody generated, but only onto a known verb", () => {
|
|
expect(lex.heads("마시면")).toEqual(["마시다"]);
|
|
});
|
|
|
|
it("returns nothing for a word it cannot place", () => {
|
|
expect(lex.heads("없는말")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("lookup — what the student sees on a tap", () => {
|
|
const lex = buildLexicon(
|
|
{ deck: [{ ko: "마시다", en: "to drink", pos: "verb" }, { ko: "친구", en: "friend", pos: "noun" }] },
|
|
conj,
|
|
);
|
|
|
|
it("notes the particle", () => {
|
|
expect(lex.lookup("친구가")).toMatchObject({ ko: "친구가", gloss: "friend", note: "with 조사 가" });
|
|
});
|
|
|
|
it("notes the dictionary form", () => {
|
|
expect(lex.lookup("마시면")).toMatchObject({ gloss: "to drink", note: "a form of 마시다" });
|
|
});
|
|
|
|
it("is null for an unknown word", () => {
|
|
expect(lex.lookup("없는말")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Lexicon", () => {
|
|
it("never replaces an existing entry — the first writer wins", () => {
|
|
const lex = new Lexicon();
|
|
lex.add("밥", "rice");
|
|
lex.add("밥", "meal");
|
|
expect(lex.get("밥")!.gloss).toBe("rice");
|
|
});
|
|
|
|
it("strips the longer particle forms", () => {
|
|
expect(PARTICLES).toEqual(expect.arrayContaining(["에서", "한테", "까지", "처럼"]));
|
|
});
|
|
});
|
|
|
|
describe("the shipped data", () => {
|
|
const read = (f: string) => JSON.parse(readFileSync(new URL(`../../data/${f}`, import.meta.url), "utf8"));
|
|
const curriculum = read("curriculum.json") as Curriculum;
|
|
const deck = read("deck.json") as { topics: Record<string, [string, string, string, string][]> };
|
|
const glossExtra = read("gloss-extra.json") as { entries: { ko: string; en: string; note?: string }[] };
|
|
const sentences = read("sentences.json") as { sentences: { parts: [string, string][] }[] };
|
|
const sfx = read("sfx.json") as { items: { ko: string; en: string }[] };
|
|
|
|
const units = flatten(curriculum);
|
|
const lex = buildLexicon(
|
|
{
|
|
roadmapWords: units.flatMap((u) => u.words ?? []),
|
|
deck: Object.values(deck.topics)
|
|
.flat()
|
|
.map(([ko, , en, pos]) => ({ ko, en, pos })),
|
|
glossExtra: glossExtra.entries.map((g) => [g.ko, g.en, g.note] as const),
|
|
sentences: sentences.sentences,
|
|
sfx: sfx.items.map((i) => [i.ko, i.en] as const),
|
|
},
|
|
conj,
|
|
);
|
|
|
|
it("resolves every roadmap word", () => {
|
|
const missing = units.flatMap((u) => u.words ?? []).filter((w) => !lex.lookup(w));
|
|
expect(missing).toEqual([]);
|
|
});
|
|
});
|