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>
396 lines
14 KiB
JavaScript
396 lines
14 KiB
JavaScript
/* The dictionary build.
|
||
|
||
dictionary source → normalize
|
||
+ frequency list → ranks, via build-time surface expansion
|
||
+ data/ → curated glosses that win over the dictionary
|
||
+ grammar lexicon → the function words a dictionary cannot supply
|
||
+ surfaceForms() → the surface index that replaces a runtime analyser
|
||
------------------------------------------------------------------
|
||
→ app/public/dict/band-N.json.gz + manifest.json + seed.sqlite3
|
||
|
||
Run: npm run dict:build
|
||
|
||
Source choice is automatic. If a KRDICT file has been vendored it wins,
|
||
because it carries curated learner glosses and a graded difficulty level;
|
||
otherwise the kaikki extract is used. Either way the manifest records
|
||
which one produced the files.
|
||
|
||
The output is committed. KRDICT cannot be re-fetched by CI, and the app
|
||
has to ship the data offline regardless, so the band files are artefacts
|
||
of the repo rather than of the build machine. */
|
||
|
||
import { mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
||
import { gzipSync } from "node:zlib";
|
||
import { createHash } from "node:crypto";
|
||
import { fileURLToPath } from "node:url";
|
||
import { DatabaseSync } from "node:sqlite";
|
||
|
||
import { surfaceForms } from "../../lib/conjugation.js";
|
||
import { flatten } from "../../lib/gate.js";
|
||
import { bandOf, bandForUnit, REFERENCE_BAND, BANDS } from "../../shared/bands.mjs";
|
||
|
||
import { readKaikki, KAIKKI_ATTRIBUTION } from "./sources/kaikki.mjs";
|
||
import { readKrdict, findKrdict, KRDICT_ATTRIBUTION } from "./sources/krdict.mjs";
|
||
import { readFrequency, FREQUENCY_ATTRIBUTION } from "./sources/frequency.mjs";
|
||
import { rankByFrequency } from "./freq-forms.mjs";
|
||
import { ensureVendor, fetchSource } from "./fetch.mjs";
|
||
|
||
const ROOT = fileURLToPath(new URL("../../", import.meta.url));
|
||
const VENDOR = `${ROOT}vendor/`;
|
||
const OUT = `${ROOT}app/public/dict/`;
|
||
|
||
const readJson = async (p) => JSON.parse(await readFile(p, "utf8"));
|
||
const log = (...a) => console.log(...a);
|
||
|
||
/** The dictionary-form suffix every Korean predicate ends in. */
|
||
const DICT_FORM = "\uB2E4";
|
||
|
||
/* ── merge ────────────────────────────────────────────────────────────
|
||
One entry per (headword, pos). Later sources overwrite earlier ones,
|
||
so the precedence order below is the precedence order of the glosses. */
|
||
|
||
class Lexicon {
|
||
constructor() {
|
||
this.byKey = new Map();
|
||
this.byHeadword = new Map();
|
||
}
|
||
/** Every entry for a headword, whatever its part of speech. */
|
||
forHeadword(headword) {
|
||
return [...(this.byHeadword.get(headword) ?? [])].map((k) => this.byKey.get(k));
|
||
}
|
||
key(headword, pos) {
|
||
return `${headword} |