feat(dict): build pipeline, grammar lexicon, and the shipped band files

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>
This commit is contained in:
MechaCat02
2026-09-08 19:13:15 +02:00
parent bbe6302a9b
commit 7bd8507909
23 changed files with 1396 additions and 0 deletions

69
shared/bands.mjs Normal file
View File

@@ -0,0 +1,69 @@
/* The vocabulary bands — the mechanism that makes the gate scale.
PORT.md's idea: ship only the bands the learner has reached, and let
buildGate()'s vocabQuery hook replace 371 hand-typed words with a query.
One band per phase, widening as he goes.
Two signals decide which band a word lands in:
- the dictionary's own curated learner level (초급 / 중급 / 고급), which
is human-graded for exactly this purpose;
- frequency rank, as the tiebreaker inside a level.
Level leads because a frequency list built from subtitles is a list of
SURFACE forms, and a lemma-keyed dictionary joins onto it badly — see
tools/dict/freq-forms.mjs for how the ranks are recovered at all. When a
source carries no level, bandOf() degrades to frequency alone.
PHASE 1 ADMITS NO FREQUENCY BAND AT ALL. During the writing-system phase
every word must be phonologically legal for the unit reached — a rank
ceiling would hand the learner a 겹받침 during unit 1.4. Phase 1 gets the
curated words and nothing else; see shared/phonology.mjs. */
/** Bands 0-5 are gated vocabulary, one per curriculum phase. */
export const BANDS = [
{ band: 0, phase: 1, maxFreq: 0, levels: [] },
{ band: 1, phase: 2, maxFreq: 1500, levels: ["초급"] },
{ band: 2, phase: 3, maxFreq: 3000, levels: ["초급"] },
{ band: 3, phase: 4, maxFreq: 5000, levels: ["초급", "중급"] },
{ band: 4, phase: 5, maxFreq: 8000, levels: ["초급", "중급"] },
{ band: 5, phase: 6, maxFreq: 15000, levels: ["초급", "중급", "고급"] },
];
/**
* Everything the dictionary knows that no band admits. Shipped so the word
* rail can gloss an unfamiliar word the learner meets in the wild, but never
* returned by vocabQuery — the tutor cannot reach it.
*/
export const REFERENCE_BAND = 6;
/** Sources that are always available, whatever the frequency data says. */
export const ALWAYS_AVAILABLE = new Set(["curated", "grammar", "sentence", "sfx"]);
/** Curriculum phase (1-6) → band. */
export const bandForPhase = (phase) =>
Math.max(0, Math.min(BANDS.length - 1, Math.round(phase) - 1));
/** Unit id ("3.4") → band. */
export const bandForUnit = (unitId) => bandForPhase(Number.parseInt(String(unitId), 10) || 1);
/** The frequency ceiling a learner at this band may draw on. */
export const ceilingForBand = (band) => BANDS[Math.max(0, Math.min(BANDS.length - 1, band))].maxFreq;
/**
* The lowest band that admits this word, or REFERENCE_BAND if none does.
* `level` may be null when the source does not grade its entries.
*/
export function bandOf({ source, freqRank, level }) {
if (ALWAYS_AVAILABLE.has(source)) return 0;
for (const b of BANDS) {
if (b.band === 0) continue; // phase 1 takes curated words only
if (freqRank == null || freqRank > b.maxFreq) continue;
// A graded source must also clear the level gate; an ungraded one is
// admitted on frequency alone.
if (level && b.levels.length && !b.levels.includes(level)) continue;
return b.band;
}
return REFERENCE_BAND;
}

109
shared/phonology.mjs Normal file
View File

@@ -0,0 +1,109 @@
/* The phonological ladder of Phase 1, as a reusable filter.
validate.mjs check 1 verifies that no curriculum word uses a sound
phenomenon its unit has not reached yet — plain vs tense vs aspirated
consonants, basic vs compound vowels, single vs double batchim, and
whether a word creates a liaison or nasalisation context before those
units exist. That check is the reason Phase 1 is correct by construction,
and it reports 0 violations today.
The same rule has to apply to vocabulary that comes from the DICTIONARY,
not just the hand-listed curriculum: a frequency band would happily hand
the learner 괜찮다 during unit 1.4, when he can read one final consonant
and no compound vowels. So the ladder is lifted here and used to filter
vocabQuery's results while the learner is still in Phase 1.
validate.mjs itself is untouched — it ships verbatim and keeps its own
copy. This is a second reader of the same rule, not a refactor of it. */
const TENSE = "ㄲㄸㅃㅆㅉ";
const ASPIRATED = "ㅋㅌㅍㅊ";
const COMPOUND_VOWEL = "ㅐㅔㅒㅖㅘㅙㅚㅝㅞㅟㅢ";
const DOUBLE_FINAL = "ㄳㄵㄶㄺㄻㄼㄽㄾㄿㅀㅄ";
const STOPS = "ㄱㄷㅂㅅㅈㅊㅌㅍㅋ";
const NASALS = "ㄴㅁ";
const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ";
const JUNG = "ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ";
const JONG = " ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ";
/** [initial, medial, final] as jamo, or null if not a syllable block. */
function jamo(ch) {
const c = ch.codePointAt(0) - 0xac00;
if (c < 0 || c > 11171) return null;
const f = JONG[c % 28];
return [CHO[Math.floor(c / 588)], JUNG[Math.floor((c % 588) / 28)], f === " " ? "" : f];
}
/** The phenomena, in the order Phase 1 teaches them. */
export const ORDER = [
"basic",
"compV",
"tense",
"batchim",
"liaison",
"nasal",
"double",
"allsound",
];
/** Which Phase 1 unit introduces which phenomenon. */
export const FEATURE_UNIT = {
1.1: "basic",
1.2: "compV",
1.3: "tense",
1.4: "batchim",
1.5: "liaison",
1.6: "nasal",
1.7: "double",
1.8: "allsound",
};
/**
* How far up the ladder a learner standing at `unitIndex` has climbed.
* `indexOfUnit` maps a unit id to its position in the flattened list.
* Returns -1 before the first sound unit, ORDER.length-1 once past them.
*/
export function featureLevel(unitIndex, indexOfUnit) {
let level = -1;
for (const [id, feature] of Object.entries(FEATURE_UNIT)) {
const at = indexOfUnit(id);
if (at >= 0 && at <= unitIndex) level = Math.max(level, ORDER.indexOf(feature));
}
return level;
}
const hasFeature = (level, feature) => level >= ORDER.indexOf(feature);
/**
* Why this word is unreadable at this rung of the ladder. Empty means it is
* fine. Mirrors validate.mjs check 1 exactly.
*/
export function phonologyViolations(word, level) {
const out = [];
const blocks = [...String(word)].map(jamo).filter(Boolean);
for (const [c, v, f] of blocks) {
if (!hasFeature(level, "tense") && (TENSE.includes(c) || ASPIRATED.includes(c)))
out.push(`tense/aspirated ${c}`);
if (!hasFeature(level, "compV") && COMPOUND_VOWEL.includes(v)) out.push(`compound vowel ${v}`);
if (f && !hasFeature(level, "batchim")) out.push(`batchim ${f}`);
if (f && DOUBLE_FINAL.includes(f) && !hasFeature(level, "double")) out.push(`double batchim ${f}`);
}
for (let i = 0; i < blocks.length - 1; i++) {
const a = blocks[i];
const b = blocks[i + 1];
if (a[2] && b[0] === "ㅇ" && !hasFeature(level, "liaison")) out.push("liaison context");
if (a[2] && STOPS.includes(a[2]) && NASALS.includes(b[0]) && !hasFeature(level, "nasal"))
out.push("nasalisation context");
}
return out;
}
/** True when every sound in the word has already been taught. */
export const isReadableAt = (word, level) => phonologyViolations(word, level).length === 0;
/** Past the sound phase the ladder is fully climbed and the filter is a no-op. */
export const LADDER_COMPLETE = ORDER.length - 1;