270 lines
10 KiB
Go
270 lines
10 KiB
Go
// 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 // "<src>-<tgt>", e.g. "zh-ru" (== config.Book.LangPair())
|
|
|
|
// <src> source morphology (configs/langpacks/<src>/) — 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
|
|
|
|
// <src>-<tgt> pair transliteration (configs/langpacks/<pair>/) — 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/<src>/), 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/<pair>/).
|
|
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/<src>/ and the pair-transliteration files under root/<src>-<tgt>/.
|
|
// 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 `category<TAB>pinyin<TAB>cyrillic` 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' }
|