Closes REVIEW.md §2. 167 of 371 roadmap words had no lexicon entry, so the word rail silently showed nothing. Now: Roadmap words: 371/371 resolve Spiral targets: 35/35 resolve Deck words: 386/386 resolve and npm run dict:assert makes it a blocking build failure, not a silent empty rail. No runtime morphological analyser ships. lib/conjugation.js surfaceForms() runs at BUILD time over every verb and adjective, so looking up a conjugated form is an index hit on the surface table. The frequency join had to be inverted. A subtitle frequency list holds surface forms; a dictionary holds lemmas whose -다 citation form barely occurs in running text, so joining on headword gives verbs a frequency of roughly zero. Expanding each lemma into the forms it plausibly takes and summing recovers 하다 from 118 to 89,041. Forms claimed by more than one lemma are dropped rather than split, so homographs don't inherit each other's mass. Those expansions score frequency only — the surface table itself stays strictly surfaceForms() output plus the headword. Bands are one per curriculum phase. Phase 1 admits no frequency band at all: during the writing-system phase every word must be phonologically legal for the unit reached, and a rank ceiling would hand the learner a 겹받침 during unit 1.4. shared/phonology.mjs lifts validate.mjs's own feature ladder to enforce that; it agrees with the validator on all 371 words. Sources are chosen automatically — KRDICT when vendored, otherwise the kaikki.org extract. KRDICT's download is a JS form behind anti-bot protection, so it cannot be fetched by CI; the derived band files are committed instead, which the app needs offline regardless. Attribution and the share-alike terms are in NOTICE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
4.1 KiB
JavaScript
125 lines
4.1 KiB
JavaScript
/* Every roadmap word must resolve. Build-time, blocking.
|
|
|
|
REVIEW.md §2: 167 of the 371 words the curriculum tells the tutor it may
|
|
use had no lexicon entry, so the word rail silently showed nothing. The
|
|
dictionary import is what fixes that; this is what stops it regressing.
|
|
|
|
Runs against the committed band files, so CI needs no network and no
|
|
vendored dictionary. Exits non-zero on any miss.
|
|
|
|
Run: npm run dict:assert */
|
|
|
|
import { readFile } from "node:fs/promises";
|
|
import { gunzipSync } from "node:zlib";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { flatten } from "../../lib/gate.js";
|
|
|
|
const ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
|
const DICT = `${ROOT}app/public/dict/`;
|
|
|
|
const readJson = async (p) => JSON.parse(await readFile(p, "utf8"));
|
|
|
|
async function loadLexicon() {
|
|
let manifest;
|
|
try {
|
|
manifest = await readJson(`${DICT}manifest.json`);
|
|
} catch {
|
|
console.error("No dictionary found. Run `npm run dict:build` first.");
|
|
process.exit(2);
|
|
}
|
|
|
|
const lemmas = new Map(); // headword -> [pos]
|
|
const surfaces = new Map(); // form -> analysis
|
|
|
|
for (const band of manifest.bands) {
|
|
const raw = gunzipSync(await readFile(DICT + band.file));
|
|
const data = JSON.parse(raw.toString("utf8"));
|
|
// Columns are positional to keep the files small; see build.mjs.
|
|
const L = data.columns.lemma;
|
|
const S = data.columns.surface;
|
|
const hw = L.indexOf("headword");
|
|
const pos = L.indexOf("pos");
|
|
const form = S.indexOf("form");
|
|
const analysis = S.indexOf("analysis");
|
|
|
|
for (const row of data.lemmas) {
|
|
const w = row[hw];
|
|
if (!lemmas.has(w)) lemmas.set(w, []);
|
|
lemmas.get(w).push(row[pos]);
|
|
}
|
|
for (const row of data.surfaces) {
|
|
if (!surfaces.has(row[form])) surfaces.set(row[form], row[analysis]);
|
|
}
|
|
}
|
|
|
|
return { manifest, lemmas, surfaces };
|
|
}
|
|
|
|
const resolves = (lex, word) => lex.lemmas.has(word) || lex.surfaces.has(word);
|
|
|
|
async function main() {
|
|
const lex = await loadLexicon();
|
|
const curriculum = await readJson(`${ROOT}data/curriculum.json`);
|
|
const deck = await readJson(`${ROOT}data/deck.json`);
|
|
const units = flatten(curriculum);
|
|
|
|
const failures = [];
|
|
const check = (kind, where, word) => {
|
|
if (!resolves(lex, word)) failures.push({ kind, where, word });
|
|
};
|
|
|
|
/* 1. the roadmap itself — the assertion REVIEW.md asked for */
|
|
let roadmapWords = 0;
|
|
for (const u of units) {
|
|
for (const w of u.words ?? []) {
|
|
roadmapWords++;
|
|
check("roadmap", u.id, w);
|
|
}
|
|
}
|
|
|
|
/* 2. the spiral targets, which the gate renders as their own instruction */
|
|
let revisits = 0;
|
|
for (const u of units) {
|
|
for (const r of u.revisits ?? []) {
|
|
revisits++;
|
|
check("revisit", u.id, r.word);
|
|
}
|
|
}
|
|
|
|
/* 3. the curated deck, which the vocabulary tab renders directly */
|
|
const deckWords = Object.values(deck.topics).flat();
|
|
for (const [w] of deckWords) check("deck", "deck.json", w);
|
|
|
|
/* ── report ── */
|
|
const src = lex.manifest.builtWith.dictionary;
|
|
console.log(`Dictionary: ${src} — ${lex.manifest.totals.lemmas} lemmas, ` +
|
|
`${lex.manifest.totals.surfaces} surface forms`);
|
|
console.log(`Resolvable: ${lex.lemmas.size} headwords, ${lex.surfaces.size} forms\n`);
|
|
|
|
const byKind = (k) => failures.filter((f) => f.kind === k);
|
|
const report = (label, total, kind) => {
|
|
const bad = byKind(kind);
|
|
console.log(`${label}: ${total - bad.length}/${total} resolve` + (bad.length ? " ✗" : " ✓"));
|
|
const grouped = {};
|
|
for (const f of bad) (grouped[f.where] ??= []).push(f.word);
|
|
for (const [where, words] of Object.entries(grouped)) {
|
|
console.log(` ${where}: ${words.join(" · ")}`);
|
|
}
|
|
};
|
|
|
|
report("Roadmap words", roadmapWords, "roadmap");
|
|
report("Spiral targets", revisits, "revisit");
|
|
report("Deck words", deckWords.length, "deck");
|
|
|
|
if (failures.length) {
|
|
console.log(`\nFAIL — ${failures.length} words cannot be glossed.`);
|
|
console.log("Add them to tools/dict/grammar-lexicon.json, or check the dictionary source.");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("\nPASS — every roadmap word, spiral target and deck word resolves.");
|
|
}
|
|
|
|
await main();
|