// 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" "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 memnorm.go drift-proofing). const packAlgoVersion = "langpack-v1" // 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 // - pair transliteration (configs/langpacks//) — the Palladius (Палладий) table. PalladiusInitials map[string]string PalladiusFinals map[string]string PalladiusYW map[string]string PalladiusSpecialI map[string]string version string } // 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. 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", } // pairFiles are the pair-transliteration files (under configs/langpacks//). var pairFiles = []string{"palladius.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} 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) } } 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 } // 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("palladius/initials", len(p.PalladiusInitials)) req("palladius/finals", len(p.PalladiusFinals)) req("palladius/yw", len(p.PalladiusYW)) req("palladius/special_i", len(p.PalladiusSpecialI)) 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) default: return fmt.Errorf("unknown source file") } return nil } func (p *Pack) assignPair(name string, b []byte) error { switch name { case "palladius.txt": ini, fin, yw, si, err := parsePalladius(b) if err != nil { return err } p.PalladiusInitials, p.PalladiusFinals, p.PalladiusYW, p.PalladiusSpecialI = ini, fin, yw, si default: return fmt.Errorf("unknown pair file") } return 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 } // parsePalladius reads the `categorypinyincyrillic` table into the four maps. It scans the raw // lines directly (not contentLines) so an error names the PHYSICAL file line — the point of the diagnostic // is to send a human editing the table to the right line. func parsePalladius(b []byte) (ini, fin, yw, si map[string]string, err error) { ini, fin, yw, si = map[string]string{}, map[string]string{}, map[string]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) != 3 { return nil, nil, nil, nil, fmt.Errorf("line %d: want 3 tab-separated fields, got %d (%q)", i+1, len(f), t) } switch f[0] { case "initials": ini[f[1]] = f[2] case "finals": fin[f[1]] = f[2] case "yw": yw[f[1]] = f[2] case "special_i": si[f[1]] = f[2] default: return nil, nil, nil, nil, fmt.Errorf("line %d: unknown category %q", i+1, f[0]) } } return ini, fin, yw, si, 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' }