/* 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;