// Package lang holds the language-specific DATA the translation engine reads, isolated OUT of // internal/pipeline/ as versioned files under configs/langpacks/ (owner directive D39.15: language data // must not live as Go constants inside the engine; horizon = hundreds of languages, data-as-files, add a // pair = drop a directory, no recompile, no pipeline/ edits). // // This package carries NO behaviour. The miner / checker ALGORITHMS stay in internal/pipeline (parity- // locked, owner: "from the engine, DATA leaves; the ALGORITHM stays") and READ these tables. The one-way // dependency (pipeline → lang, never the reverse) is compile-enforced: nothing here imports pipeline, so // the data/algorithm boundary is real, not a convention. // // A Pack is resolved by (source, target) language and content-hashed at load, mirroring the two in-repo // precedents: the pair-keyed prompt seam (config.Stage.Prompts + PromptPathFor + PromptSHA256, resolved // by Book.LangPair(), fail-loud on a missing pair) and the memnorm trad→simp table (a data file whose // bytes ARE its version via a content hash). Editing a pack file changes its Version() → the pipeline // folds that into the snapshot → a loud --resnapshot, drift-proof by mechanism, not discipline. package lang import ( "crypto/sha256" "encoding/hex" "fmt" "os" "path/filepath" "sort" "strconv" "strings" ) // packAlgoVersion tags the PARSE/layout of a pack (the file manifest + how each file is read). Bump it on // a schema change (a new file, a format change). The DATA content is versioned separately by hashing the // files into Version(), so editing a table also invalidates — you cannot forget to bump a version when // you change the data, because the data's bytes ARE the version (the text/norm.go drift-proofing). // v2 (pair-14): new pair/source files (title-formant, sentence-terminator, palladius-phonotactics, dc-checkers) // + new formats (the pattern/category rows, the generic Palladius parser) — a schema change, so the tag bumps. const packAlgoVersion = "langpack-v2" // Pack is a loaded, versioned language-data pack for one source→target pair. Fields are the DATA the // pipeline algorithms read; the zero value is unusable (load via Load). Maps are membership sets / lookup // tables (order-free); slices preserve their authored order. type Pack struct { Pair string // "-", e.g. "zh-ru" (== config.Book.LangPair()) // source morphology (configs/langpacks//) — the miner's typed-candidate channels. SurnamesSingle map[rune]bool // 百家姓 single-char surnames SurnamesCompound map[string]bool // two-char compound surnames TitleSuffix []string // title suffixes → title (ordered) OrdinalTitle []string // ordinal titles 一代/第一… (ordered) RankWord []string // rank/measure words 等/转… (ordered) TopoSuffix map[rune]bool // topographic suffix chars → place GradePrefix map[rune]bool // grade/stem prefix chars 甲乙丙… Numeral map[rune]bool // CJK numeral chars AliasParticle map[rune]bool // trailing-particle set marking a boundary fragment // TitleFormant: the rank/measure formant chars a miner classifies as a TITLE (patterns.formant_type // 等/转/阶 → title). Pair-14: moved out of the pipeline formant switch, sitting beside TopoSuffix (its // place-side neighbour). A rune SET. TitleFormant map[rune]bool // SentenceTerminator: the source sentence-ending marks the alias miner splits on (。!?, alias. // cooccur_same_sentence). Pair-14: moved out of the pipeline literal. A rune SET; the miner adds "\n" // (a structural newline, not a language mark) itself. SentenceTerminator map[rune]bool // Palladius is the - transliteration table (configs/langpacks//palladius.txt + // palladius-phonotactics.txt) — the pinyin→Cyrillic maps and the syllable-generator's phonotactic // constraints, as ONE typed value (owner addendum 24.07: a struct, not four+three parallel Pack fields). Palladius Palladius // DCCheckers is the OPTIONAL pair data for the WS5 defect-class checkers (configs/langpacks// // dc-checkers.txt, checkers_zh_ru.go). nil when the pair ships no file — the checker ALGORITHMS then read // empty tables and fire 0 (a pair that does not opt in is never flagged). DATA only (pair-14 §6: the // LOOKUP TABLES move out of the pipeline; the regex DETECTION patterns stay as the checker algorithm). DCCheckers *DCCheckerData // Heading is the OPTIONAL chapter-heading rule (configs/langpacks//heading.txt). nil when the pair // carries no heading.txt — the chapter-title feature is then inert (the chunker keeps the source header // as-is), so a pair that does not opt in is never re-billed for it. It is DATA only: the detect/strip/ // render ALGORITHM lives in the chunker (internal/pipeline), which reads this table — the same // data/algorithm boundary the miner tables keep. Heading *HeadingRule version string } // Palladius is the pair transliteration table: the pinyin→Cyrillic maps and the syllable-generator's // phonotactic constraint sets (owner addendum 24.07 — one typed struct instead of four+three parallel Pack // fields). Populated from palladius.txt (Initials/Finals/YW/SpecialI) + palladius-phonotactics.txt // (Retroflex/VFinal/VFinalInitial) by the generic category parser; which categories are REQUIRED is enforced // by validate(), not the parser (an unknown category is collected, never a parse error). type Palladius struct { Initials, Finals, YW, SpecialI map[string]string // pinyin→Cyrillic Retroflex, VFinal, VFinalInitial map[string]bool // phonotactic constraint sets (pinyin tokens) } // newPalladius returns a Palladius with all tables allocated (so merge/union is nil-safe). func newPalladius() Palladius { return Palladius{ Initials: map[string]string{}, Finals: map[string]string{}, YW: map[string]string{}, SpecialI: map[string]string{}, Retroflex: map[string]bool{}, VFinal: map[string]bool{}, VFinalInitial: map[string]bool{}, } } // DCCheckerData is the per-pair lookup data the WS5 defect-class checkers read (checkers_zh_ru.go). The // checker DETECTION regexes stay in the pipeline as the pair-scoped algorithm (§12.2); only the LOOKUP // TABLES live here as data. Parsed from dc-checkers.txt. type DCCheckerData struct { Numeral map[rune]int // DC1: small CJK count → value (一→1 … 十→10, incl. 两→2) RuHours map[string]int // DC1: Russian hours-count word → value (один→1 … шесть→6) RegisterNeg []string // DC6: register negative-list lexemes (терем + case forms), authored order // Patterns are the DETECTION patterns as DATA (pair-14 data-out): key → verbatim regex or literal probe. // The `*_re` keys are compiled by the consumer; the rest are literal strings.Contains probes. A pair that // ships no pattern for a key runs that sub-checker inert. Patterns map[string]string } // HeadingRule is the per-pair data for the chapter-title policy: instead of letting the model render a // chapter heading (which drifted to «Раздел 2» / «Первая глава» / an orphaned « :» across models), the // chunker detects a source header (Marker + a numeral + a Unit rune), strips it from the model input, and // the read-models render Template deterministically instead. Data only; parsed from heading.txt. type HeadingRule struct { Marker string // the prefix rune(s) that open a numbered heading, e.g. "第" Units map[rune]bool // the section-unit runes accepted right after the numeral (章 节 節 回) Template string // the target rendering; the literal "{n}" is replaced by the parsed Arabic number } // Version is the content hash of the pack (packAlgoVersion + a sha256 of the authored file bytes). A pack // edit changes it, so the pipeline can fold it into the snapshot (a loud --resnapshot on any data edit). func (p *Pack) Version() string { return p.version } // srcFiles are the source-morphology files (under configs/langpacks//), read in this fixed order. // SCOPE (honest): this manifest is the zh-family NAME-MINER's morphology schema (百家姓 surnames, 甲乙丙 grade // prefixes, 等/转/阶 title-formants, CJK numerals, …), and validate() forbids an empty table. So "add a pair = // drop a directory, no recompile" holds for a source in THIS family (a second CJK source drops its files); a // different source family that wants mining needs new Pack fields + parse + miner channels, not just a dir. var srcFiles = []string{ "surnames-single.txt", "surnames-compound.txt", "title-suffix.txt", "ordinal-title.txt", "rank-word.txt", "topo-suffix.txt", "grade-prefix.txt", "numeral.txt", "alias-particle.txt", "title-formant.txt", "sentence-terminator.txt", } // pairFiles are the pair-transliteration files (under configs/langpacks//). var pairFiles = []string{"palladius.txt", "palladius-phonotactics.txt"} // Load resolves and reads the pack for (sourceLang, targetLang) from root (e.g. "configs/langpacks"): the // source-morphology files under root// and the pair-transliteration files under root/-/. // EVERY declared file must be present and well-formed — a missing/corrupt file fails LOUD (the caller runs // this at load, before any billing, mirroring the prompt-pack os.Stat loop). The content hash covers all // files in a fixed order, so it is deterministic and drift-proof. func Load(root, sourceLang, targetLang string) (*Pack, error) { pair := sourceLang + "-" + targetLang p := &Pack{Pair: pair, Palladius: newPalladius()} h := sha256.New() h.Write([]byte(packAlgoVersion)) read := func(dir, name string) ([]byte, error) { path := filepath.Join(root, dir, name) b, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("langpack %q: %w (add the file or fix the book's source_lang/target_lang; D39.15: language data is required, never silently empty)", pair, err) } // Fold the RELATIVE path + bytes so a rename or a moved byte both shift the hash. h.Write([]byte("\x00" + dir + "/" + name + "\x00")) h.Write(b) return b, nil } for _, name := range srcFiles { b, err := read(sourceLang, name) if err != nil { return nil, err } if err := p.assignSrc(name, b); err != nil { return nil, fmt.Errorf("langpack %q %s: %w", pair, name, err) } } for _, name := range pairFiles { b, err := read(pair, name) if err != nil { return nil, err } if err := p.assignPair(name, b); err != nil { return nil, fmt.Errorf("langpack %q %s: %w", pair, name, err) } } // Optional per-pair heading rule (pack-13 title policy). ABSENT → nil, the title feature is inert and // the pack's Version() is byte-stable (a pair that does not opt in is never re-billed); PRESENT → its // bytes fold into the content hash (a loud --resnapshot on any edit) and it is parsed; CORRUPT → fail // loud, like the required-file path. Distinct from read(): a missing heading.txt is NOT an error. if hb, ok, herr := readOptional(root, pair, "heading.txt"); herr != nil { return nil, fmt.Errorf("langpack %q heading.txt: %w", pair, herr) } else if ok { h.Write([]byte("\x00" + pair + "/heading.txt\x00")) h.Write(hb) hr, perr := parseHeading(hb) if perr != nil { return nil, fmt.Errorf("langpack %q heading.txt: %w", pair, perr) } p.Heading = hr } // Optional per-pair DC-checker tables (pair-14 §6). ABSENT → nil, the checkers read empty tables and fire // 0 (a pair that does not opt in is never re-billed / flagged); PRESENT → bytes fold into the hash and it // is parsed; CORRUPT → fail loud. Same optional contract as heading.txt. if db, ok, derr := readOptional(root, pair, "dc-checkers.txt"); derr != nil { return nil, fmt.Errorf("langpack %q dc-checkers.txt: %w", pair, derr) } else if ok { h.Write([]byte("\x00" + pair + "/dc-checkers.txt\x00")) h.Write(db) dc, perr := parseDCCheckers(db) if perr != nil { return nil, fmt.Errorf("langpack %q dc-checkers.txt: %w", pair, perr) } p.DCCheckers = dc } if err := p.validate(); err != nil { return nil, fmt.Errorf("langpack %q: %w", pair, err) } p.version = packAlgoVersion + "-" + hex.EncodeToString(h.Sum(nil))[:12] return p, nil } // LoadWithOverlay loads the shared pair pack (Load) and then UNIONS a book-scoped OVERLAY on top: a book's // PRIVATE canon (a clan surname 古月, a sect term) that belongs to ONE book, not the shared pair langpack // (pair-14 §1 — a book term in the shared pair layer is a leak). overlayRoot has the same layout as root // (/… + /…); ONLY the files a book chooses to ship are present, each read OPTIONALLY and UNIONED // (additive — sets gain members, ordered slices append; an overlay never removes a base entry). The overlay // bytes fold into Version(), so a book-canon edit is a loud --resnapshot for THAT book while the shared pack // stays byte-stable. overlayRoot == "" ⇒ identical to Load (same *Pack, same Version). func LoadWithOverlay(root, src, tgt, overlayRoot string) (*Pack, error) { p, err := Load(root, src, tgt) if err != nil { return nil, err } if overlayRoot == "" { return p, nil } pair := src + "-" + tgt // FAIL LOUD one level UP first (pack-15): an overlay root that does not exist, or that holds anything // besides the two expected subdirectories, is an operator mistake — and the fold below would swallow it // in silence. langpack_extend is an EXPLICIT opt-in in book.yaml: if it is set, the caller asserts that a // private canon exists, so a missing/typo'd path must stop the load, never degrade recall quietly. if fi, serr := os.Stat(overlayRoot); serr != nil || !fi.IsDir() { return nil, fmt.Errorf("langpack %q overlay root %s is not a readable directory (langpack_extend is an explicit opt-in — a missing or mistyped path would silently drop the book's private canon): %v", pair, overlayRoot, serr) } rootEnts, rerr := os.ReadDir(overlayRoot) if rerr != nil { return nil, fmt.Errorf("langpack %q overlay root %s: %w", pair, overlayRoot, rerr) } for _, e := range rootEnts { if e.IsDir() && (e.Name() == src || e.Name() == pair) { continue } return nil, fmt.Errorf("langpack %q overlay root %s: unexpected entry %q — an overlay holds exactly the %q/ and %q/ subdirectories (a data file dropped at the root, or a mistyped pair directory, would be silently ignored)", pair, overlayRoot, e.Name(), src, pair) } // FAIL LOUD on a misnamed overlay file (review finding, pair-14 scale lens): the fold loop below reads // ONLY the manifest names, so a typo (surname-compound.txt) or a non-mergeable file (heading.txt) shipped // in an overlay would be SILENTLY ignored — the book's private canon never reaches the miner, recall // degrades with no load-time signal. Enumerate the overlay dirs and refuse any unexpected file, keeping // the "never silently empty / drift-proof" guarantee the shared loader makes. mergeable := map[string]bool{} for _, n := range srcFiles { mergeable[src+"/"+n] = true } for _, n := range pairFiles { mergeable[pair+"/"+n] = true } for _, dir := range []string{src, pair} { names, derr := overlayDirFiles(filepath.Join(overlayRoot, dir)) if derr != nil { return nil, fmt.Errorf("langpack %q overlay %s: %w", pair, dir, derr) } for _, n := range names { if !mergeable[dir+"/"+n] { return nil, fmt.Errorf("langpack %q overlay: unexpected file %s/%s — an overlay merges only the source/pair manifest files (a typo, or a non-mergeable file like heading.txt/dc-checkers.txt, would be silently ignored)", pair, dir, n) } } } // Seed a fresh hash with the base version (which already uniquely encodes every base byte), then fold the // overlay files in a fixed order — deterministic and drift-proof (edit the overlay → Version() moves). h := sha256.New() h.Write([]byte(p.version)) merged := false fold := func(dir, name string, pairFile bool) error { b, ok, rerr := readOptional(overlayRoot, dir, name) if rerr != nil { return fmt.Errorf("langpack %q overlay %s/%s: %w", pair, dir, name, rerr) } if !ok { return nil } h.Write([]byte("\x00" + dir + "/" + name + "\x00")) h.Write(b) merged = true if pairFile { return p.mergePair(name, b) } return p.mergeSrc(name, b) } for _, name := range srcFiles { if err := fold(src, name, false); err != nil { return nil, err } } for _, name := range pairFiles { if err := fold(pair, name, true); err != nil { return nil, err } } if merged { p.version = packAlgoVersion + "-x" + hex.EncodeToString(h.Sum(nil))[:12] } return p, nil } // mergeSrc unions an overlay source file into the loaded pack (additive; see LoadWithOverlay). Rune/string // SETS gain members; ordered slices append (a book's extra title/rank tokens follow the shared ones). func (p *Pack) mergeSrc(name string, b []byte) error { switch name { case "surnames-single.txt": unionRuneSet(p.SurnamesSingle, runeSet(b)) case "surnames-compound.txt": unionStringSet(p.SurnamesCompound, stringSet(b)) case "title-suffix.txt": p.TitleSuffix = append(p.TitleSuffix, lines(b)...) case "ordinal-title.txt": p.OrdinalTitle = append(p.OrdinalTitle, lines(b)...) case "rank-word.txt": p.RankWord = append(p.RankWord, lines(b)...) case "topo-suffix.txt": unionRuneSet(p.TopoSuffix, runeSet(b)) case "grade-prefix.txt": unionRuneSet(p.GradePrefix, runeSet(b)) case "numeral.txt": unionRuneSet(p.Numeral, runeSet(b)) case "alias-particle.txt": unionRuneSet(p.AliasParticle, runeSet(b)) case "title-formant.txt": unionRuneSet(p.TitleFormant, runeSet(b)) case "sentence-terminator.txt": unionRuneSet(p.SentenceTerminator, runeSet(b)) default: return fmt.Errorf("unknown source file") } return nil } // mergePair unions an overlay pair file into the loaded pack (additive). Both pair files carry Palladius // categories, parsed generically and unioned into p.Palladius (same path as assignPair). func (p *Pack) mergePair(name string, b []byte) error { return p.assignPair(name, b) } func unionRuneSet(dst, src map[rune]bool) { for k := range src { dst[k] = true } } func unionStringSet(dst, src map[string]bool) { for k := range src { dst[k] = true } } // unionStringMap merges src into dst ADDITIVELY and refuses a COLLISION: a key already present in dst // (the shared pack) that an overlay also carries would have SILENTLY overridden the base value. Override // semantics are deliberately NOT ratified (D39.23) — an overlay exists to ADD a book's private canon, and // a book that needs to change a shared pair mapping is a signal about the shared pack, not something to // absorb quietly. Revisit if a book ever genuinely needs it; until then the collision is loud and names // the key and both values. Sets/slices need no such rule: adding a member twice is idempotent. func unionStringMap(category string, dst, src map[string]string) error { for k, v := range src { if old, ok := dst[k]; ok { return fmt.Errorf("category %q key %q is already defined as %q and the overlay redefines it as %q — an overlay may only ADD (override is not supported: change the shared pair pack instead)", category, k, old, v) } dst[k] = v } return nil } // validate makes the "never silently empty" contract real: a present-but-empty or comment-only data file // (a fat-fingered edit once packs are hand-authored at R1) parses to an empty table with no error and would // silently disable a miner channel — recall degradation with no load-time signal. Every required table must // be non-empty. The content hash catches an EDIT (a --resnapshot signal), but "edited to empty" is a corrupt // pack, not an intended change, so it is refused at load, before any consumer, like the missing-file path. func (p *Pack) validate() error { var empty []string req := func(name string, n int) { if n == 0 { empty = append(empty, name) } } req("surnames-single", len(p.SurnamesSingle)) req("surnames-compound", len(p.SurnamesCompound)) req("title-suffix", len(p.TitleSuffix)) req("ordinal-title", len(p.OrdinalTitle)) req("rank-word", len(p.RankWord)) req("topo-suffix", len(p.TopoSuffix)) req("grade-prefix", len(p.GradePrefix)) req("numeral", len(p.Numeral)) req("alias-particle", len(p.AliasParticle)) req("title-formant", len(p.TitleFormant)) req("sentence-terminator", len(p.SentenceTerminator)) // The Palladius REQUIRED-category list lives HERE (the consumer), not in the generic parser (addendum). req("palladius/initials", len(p.Palladius.Initials)) req("palladius/finals", len(p.Palladius.Finals)) req("palladius/yw", len(p.Palladius.YW)) req("palladius/special_i", len(p.Palladius.SpecialI)) req("palladius-phonotactics/retroflex", len(p.Palladius.Retroflex)) req("palladius-phonotactics/vfinal", len(p.Palladius.VFinal)) req("palladius-phonotactics/vfinal_initial", len(p.Palladius.VFinalInitial)) if len(empty) > 0 { return fmt.Errorf("empty required table(s) %s — a present-but-empty/comment-only data file is a corrupt pack, not a valid one", strings.Join(empty, ", ")) } return nil } func (p *Pack) assignSrc(name string, b []byte) error { switch name { case "surnames-single.txt": p.SurnamesSingle = runeSet(b) case "surnames-compound.txt": p.SurnamesCompound = stringSet(b) case "title-suffix.txt": p.TitleSuffix = lines(b) case "ordinal-title.txt": p.OrdinalTitle = lines(b) case "rank-word.txt": p.RankWord = lines(b) case "topo-suffix.txt": p.TopoSuffix = runeSet(b) case "grade-prefix.txt": p.GradePrefix = runeSet(b) case "numeral.txt": p.Numeral = runeSet(b) case "alias-particle.txt": p.AliasParticle = runeSet(b) case "title-formant.txt": p.TitleFormant = runeSet(b) case "sentence-terminator.txt": p.SentenceTerminator = runeSet(b) default: return fmt.Errorf("unknown source file") } return nil } // assignPair reads a pair file into p.Palladius. Both pair files (palladius.txt, palladius-phonotactics.txt) // carry `category…` rows, parsed by ONE generic category parser (owner addendum 24.07); the known // Palladius categories are UNIONED into p.Palladius and an unknown category is simply ignored, NEVER a parse // error — which categories are REQUIRED is enforced downstream by validate(), not here. func (p *Pack) assignPair(name string, b []byte) error { cats, err := parseCategoryRows(b) if err != nil { return err } if merr := p.Palladius.merge(cats); merr != nil { return merr } return nil } // overlayDirFiles lists the regular-file names directly in dir (an overlay's or subdir). A // MISSING dir is fine (returns nil — a book may overlay only sources or only the pair). Any other read // error is loud. Nested dirs are ignored (only top-level manifest files are mergeable). func overlayDirFiles(dir string) ([]string, error) { ents, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } var out []string for _, e := range ents { if !e.IsDir() { out = append(out, e.Name()) } } return out, nil } // readOptional reads an OPTIONAL pack file. A missing file returns (nil, false, nil) — the feature it // backs is simply inert — while any OTHER read error (permission, a directory) is a loud failure; a // present file returns (bytes, true, nil). Used for the pack-13 heading rule, which a pair opts into. func readOptional(root, dir, name string) ([]byte, bool, error) { b, err := os.ReadFile(filepath.Join(root, dir, name)) if err != nil { if os.IsNotExist(err) { return nil, false, nil } return nil, false, err } return b, true, nil } // parseHeading reads heading.txt into a HeadingRule. Format: `keyvalue` per non-comment line, keys // marker | units | template (all three required). `units` is a rune SET (each rune a member); `template` // must contain the literal "{n}" placeholder (else it could never render a number). Fail-loud on a missing // key / unknown key / empty value / a template without {n} — a malformed rule is a corrupt pack, not an // intended silent no-op (mirrors the required-file "never silently empty" contract). func parseHeading(b []byte) (*HeadingRule, error) { hr := &HeadingRule{Units: map[rune]bool{}} seen := map[string]bool{} for i, raw := range strings.Split(string(b), "\n") { t := strings.TrimRight(raw, "\r") if strings.TrimSpace(t) == "" || strings.HasPrefix(strings.TrimSpace(t), "#") { continue } f := strings.SplitN(t, "\t", 2) if len(f) != 2 || strings.TrimSpace(f[1]) == "" { return nil, fmt.Errorf("line %d: want `keyvalue` with a non-empty value (%q)", i+1, t) } key, val := strings.TrimSpace(f[0]), strings.TrimSpace(f[1]) seen[key] = true switch key { case "marker": hr.Marker = val case "units": for _, r := range val { if !isSpace(r) { hr.Units[r] = true } } case "template": hr.Template = val default: return nil, fmt.Errorf("line %d: unknown key %q (want marker|units|template)", i+1, key) } } if !seen["marker"] || !seen["units"] || !seen["template"] || len(hr.Units) == 0 { return nil, fmt.Errorf("heading rule needs non-empty marker, units and template") } if !strings.Contains(hr.Template, "{n}") { return nil, fmt.Errorf("template %q must contain the {n} number placeholder", hr.Template) } return hr, nil } // runeSet reads a rune SET: every non-whitespace rune of every non-comment line is a member (order-free). func runeSet(b []byte) map[rune]bool { m := map[rune]bool{} for _, ln := range contentLines(b) { for _, r := range ln { if !isSpace(r) { m[r] = true } } } return m } // stringSet reads a set of whole tokens, one per non-comment line (trimmed). func stringSet(b []byte) map[string]bool { m := map[string]bool{} for _, ln := range contentLines(b) { if t := strings.TrimSpace(ln); t != "" { m[t] = true } } return m } // lines reads an ORDERED slice of whole tokens, one per non-comment line (trimmed), in file order. func lines(b []byte) []string { var out []string for _, ln := range contentLines(b) { if t := strings.TrimSpace(ln); t != "" { out = append(out, t) } } return out } // parseCategoryRows is the GENERIC Palladius category parser (owner addendum 24.07): it reads a pair file's // `categorykey[value]` rows into category → key → value, WITHOUT a per-category switch and WITHOUT // treating an unknown category as an error (which categories are required is the CONSUMER's call — validate()). // A 3-field row (initials/finals/yw/special_i) stores key→cyrillic; a 2-field row (retroflex/vfinal/…) stores // key→"" (a set member). Scans raw lines so an error names the PHYSICAL file line; the only parse errors are a // bad field count / an empty key. func parseCategoryRows(b []byte) (map[string]map[string]string, error) { cats := map[string]map[string]string{} for i, raw := range strings.Split(string(b), "\n") { t := strings.TrimSpace(strings.TrimRight(raw, "\r")) if t == "" || strings.HasPrefix(t, "#") { continue } f := strings.Split(t, "\t") if len(f) < 2 || len(f) > 3 || strings.TrimSpace(f[0]) == "" || strings.TrimSpace(f[1]) == "" { return nil, fmt.Errorf("line %d: want `categorykey[value]`, got %q", i+1, t) } if cats[f[0]] == nil { cats[f[0]] = map[string]string{} } val := "" // a 2-field row is a set member (value "") if len(f) == 3 { val = f[2] } cats[f[0]][f[1]] = val } return cats, nil } // palladiusCategories are the categories this CONSUMER reads. The generic parser stays category-agnostic // (an unknown category is not a parse error — owner addendum 24.07); the CONSUMER is where a category that // nothing consumes is caught, because only the consumer knows what "consumed" means. var palladiusCategories = map[string]bool{ "initials": true, "finals": true, "yw": true, "special_i": true, "retroflex": true, "vfinal": true, "vfinal_initial": true, } // merge unions parsed categories into the Palladius table. KNOWN categories populate the typed fields (a // map category takes key→cyrillic, a set category takes its keys). A category NOTHING consumes is an // ERROR here (pack-15): a typo (`retroflexx`, `initals`) parses fine and used to vanish — the file looked // authored, the table stayed empty for that class, and the miner quietly lost a channel. A map-category // key COLLISION between the base pack and an overlay is loud for the same reason (see unionStringMap). func (pal *Palladius) merge(cats map[string]map[string]string) error { var unknown []string for c := range cats { if !palladiusCategories[c] { unknown = append(unknown, c) } } if len(unknown) > 0 { sort.Strings(unknown) known := make([]string, 0, len(palladiusCategories)) for c := range palladiusCategories { known = append(known, c) } sort.Strings(known) return fmt.Errorf("category %s is not consumed by anything (a typo would parse fine and silently leave its table empty); known categories: %s", strings.Join(unknown, ", "), strings.Join(known, ", ")) } for _, m := range []struct { name string dst map[string]string }{ {"initials", pal.Initials}, {"finals", pal.Finals}, {"yw", pal.YW}, {"special_i", pal.SpecialI}, } { if err := unionStringMap(m.name, m.dst, cats[m.name]); err != nil { return err } } unionKeysAsSet(pal.Retroflex, cats["retroflex"]) unionKeysAsSet(pal.VFinal, cats["vfinal"]) unionKeysAsSet(pal.VFinalInitial, cats["vfinal_initial"]) return nil } // unionKeysAsSet adds the KEYS of a parsed category (a 2-field set) to dst. func unionKeysAsSet(dst map[string]bool, src map[string]string) { for k := range src { dst[k] = true } } // parseDCCheckers reads the DC-checker pair tables from category-keyed lines (checkers_zh_ru.go data): // // cjk_numeralrunevalue | ru_hourwordvalue | register_neglexeme // // Scans raw lines so an error names the PHYSICAL file line. Fail-loud on a bad field count / non-integer // value / unknown category (a malformed table is a corrupt pack). RegisterNeg keeps its authored order. func parseDCCheckers(b []byte) (*DCCheckerData, error) { d := &DCCheckerData{Numeral: map[rune]int{}, RuHours: map[string]int{}, Patterns: map[string]string{}} for i, raw := range strings.Split(string(b), "\n") { // A `pattern` line's VALUE is verbatim (a regex may carry trailing metachars), so only \r is stripped // from the whole line, not the value; other categories tolerate the trimmed form. line := strings.TrimRight(raw, "\r") t := strings.TrimSpace(line) if t == "" || strings.HasPrefix(t, "#") { continue } if strings.HasPrefix(line, "pattern\t") { f := strings.SplitN(line, "\t", 3) // value (f[2]) VERBATIM if len(f) != 3 || strings.TrimSpace(f[1]) == "" || f[2] == "" { return nil, fmt.Errorf("line %d: pattern wants `patternkeyvalue`, got %q", i+1, line) } d.Patterns[f[1]] = f[2] continue } f := strings.Split(t, "\t") switch f[0] { case "cjk_numeral": if len(f) != 3 { return nil, fmt.Errorf("line %d: cjk_numeral wants `cjk_numeralrunevalue`, got %q", i+1, t) } r := []rune(f[1]) if len(r) != 1 { return nil, fmt.Errorf("line %d: cjk_numeral key %q must be a single rune", i+1, f[1]) } v, err := strconv.Atoi(strings.TrimSpace(f[2])) if err != nil { return nil, fmt.Errorf("line %d: cjk_numeral value %q: %w", i+1, f[2], err) } d.Numeral[r[0]] = v case "ru_hour": if len(f) != 3 { return nil, fmt.Errorf("line %d: ru_hour wants `ru_hourwordvalue`, got %q", i+1, t) } v, err := strconv.Atoi(strings.TrimSpace(f[2])) if err != nil { return nil, fmt.Errorf("line %d: ru_hour value %q: %w", i+1, f[2], err) } d.RuHours[f[1]] = v case "register_neg": if len(f) != 2 || strings.TrimSpace(f[1]) == "" { return nil, fmt.Errorf("line %d: register_neg wants `register_neglexeme`, got %q", i+1, t) } d.RegisterNeg = append(d.RegisterNeg, f[1]) default: return nil, fmt.Errorf("line %d: unknown category %q (want cjk_numeral|ru_hour|register_neg)", i+1, f[0]) } } if len(d.Numeral) == 0 || len(d.RuHours) == 0 || len(d.RegisterNeg) == 0 { return nil, fmt.Errorf("dc-checkers needs non-empty cjk_numeral, ru_hour and register_neg sections") } return d, nil } // contentLines splits into lines, dropping '#'-comment and blank lines. func contentLines(b []byte) []string { var out []string for _, ln := range strings.Split(string(b), "\n") { s := strings.TrimRight(ln, "\r") if strings.HasPrefix(strings.TrimSpace(s), "#") || strings.TrimSpace(s) == "" { continue } out = append(out, s) } return out } func isSpace(r rune) bool { return r == ' ' || r == '\t' || r == '\n' || r == '\r' }