package config import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "gopkg.in/yaml.v3" ) // book.go: translation brief — the book project config (Phase 0 of the plan: language // pair, genre, audience, 18+, Venuti slider, honorifics, transcription, // footnotes). brief_hash is part of the TM key and of the snapshot: changing the brief mid- // book is an explicit command that shows the cost of re-translation (Р6). // Book is the parsed book.yaml. type Book struct { BookID string `yaml:"book_id"` Title string `yaml:"title"` SourceLang string `yaml:"source_lang"` // zh | ja | en TargetLang string `yaml:"target_lang"` // ru Genre string `yaml:"genre"` Audience string `yaml:"audience"` // Adult: the interim 18+ rule of Phase 1 — true forcibly excludes // Anthropic from the book's routing (escalation without Opus/Sonnet). Adult bool `yaml:"adult"` // Venuti is the foreignization↔domestication slider, 0.0 (domestication) // … 1.0 (foreignization). Venuti float64 `yaml:"venuti"` Honorifics string `yaml:"honorifics"` // keep | adapt Transcription string `yaml:"transcription"` // e.g. polivanov | palladius Footnotes string `yaml:"footnotes"` // none | minimal | rich // YoPolicy is the brief's ё-policy for the cheap yofikator gate: auto (flag only inconsistency // — the same word both with ё and е) | all-yo | all-e. "" defaults to auto. A brief field: it // changes the style-flag verdict, so it is folded into BriefHash (a change re-pins the snapshot). YoPolicy string `yaml:"yo_policy"` // StyleAllowlist exempts surfaces from the translit-interjection blocklist (a per-project // allowlist, 04-unhappy §6): a character actually named «Ара» is not an untranslated filler. // Lower-cased on load. Verdict-affecting → also folded into BriefHash. StyleAllowlist []string `yaml:"style_allowlist"` // Wiring: paths are resolved relative to the book.yaml location. Pipeline string `yaml:"pipeline"` ModelsFile string `yaml:"models"` SourceFile string `yaml:"source_file"` // Phase 0: one file = one chunk // Encoding of the txt source: auto|utf8|gb18030 ("" defaults to auto). Real zh .txt are often // GB18030 (the 蛊真人 acceptance book is), which the auto-detect handles; declare it explicitly // to skip detection. Ignored for epub (its documents carry their own charset). See ingest.go. Encoding string `yaml:"encoding"` ProjectDB string `yaml:"project_db"` // default: .db next to book.yaml // GlossarySeed is the optional path to the manual glossary seed YAML (memory v2, // step 4). Its curated approved/draft terms + the classified ruby readings are the // deterministic inputs the glossary is REPLACED from each run; editing it changes the // materialized approved set → a loud --resnapshot (F1). Empty = ruby-seed only (or an // empty glossary → the injection is inert, a safe no-op). GlossarySeed string `yaml:"glossary_seed"` // LangpackRoot is the optional root of the language-data packs (configs/langpacks/, D39.15/16): the // miner reads configs/langpacks// (source morphology) + configs/langpacks/-/ (Palladius) // from here. Resolved relative to book.yaml. Empty ⇒ no pack (the miner is inert / W1.5 auto-continues); // set ⇒ the runner loads the pack in W0, failing LOUD when the pair's catalog dir exists but a file is // missing/corrupt, and folds pack.Version() into the snapshot so a pack edit is a loud --resnapshot (R1). LangpackRoot string `yaml:"langpack_root"` // LangpackExtend is the optional root of a BOOK-SCOPED langpack overlay (pair-14 §1): a book's PRIVATE // canon — a clan surname (古月 in 蛊真人 is a book clan, not a 百家姓 family), a sect term — that must reach // the miner FOR THIS BOOK without polluting the shared pair langpack. Same layout as langpack_root; only // the files a book ships are present, each UNIONED onto the shared pack (additive) with its bytes folded // into pack.Version() (a book-canon edit is a loud --resnapshot for this book only). Resolved relative to // book.yaml. Empty ⇒ no overlay (the shared pack is used as-is). LangpackExtend string `yaml:"langpack_extend"` // MinedDelta is the optional path to the owner-curated mined-term delta YAML (the W1.5 sign artifact, // R1). Its terms are loaded as Source:"mined" (NOT Source:"seed"), so they fold into the ENRICHED bank // version but NOT the base — adding them moves only snapshot_W2 (the pay-once invariant). Same seedTerm // schema as glossary_seed; resolved relative to book.yaml. Empty = no mined terms. MinedDelta string `yaml:"mined_delta"` // MinedRejects is the optional path to the owner's mined-term REJECT list YAML (R1-FL-B). It lists the // src surfaces the owner reviewed and DECLINED, so the bank-mining stop stops re-proposing them every // run (else a declined term re-fires the stop forever — a livelock). Unlike MinedDelta it is a PROPOSAL // filter only: rejects NEVER enter the bank content, so they are deliberately NOT folded into the // snapshot. Resolved relative to book.yaml. Empty = no rejects. MinedRejects string `yaml:"mined_rejects"` Ceilings BookCap `yaml:"ceilings"` } // BookCap are the ledger admission limits (Р7: the $ ceiling per book/day). type BookCap struct { BookUSD float64 `yaml:"book_usd"` DayUSD float64 `yaml:"day_usd"` } // LoadBook reads and validates book.yaml, resolving relative paths against // its directory. func LoadBook(path string) (*Book, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("config: read %s: %w", path, err) } var b Book // STRICT decode (KnownFields), sanctioned for pack-16 after a stand audit found ZERO unknown keys in any // book-shaped yaml in the repo or on the stand: yaml.v3 silently DROPS an unknown field, so a typo reads // as "not set" and the run looks normal — `langpack_extend` misspelled means the book's private canon is // quietly absent, `glossary_seed` misspelled means the seed never loads. The pipeline loader has been // strict since pack-15; these are the remaining halves of the same silent-substitution class. dec := yaml.NewDecoder(bytes.NewReader(raw)) dec.KnownFields(true) if err := dec.Decode(&b); err != nil { return nil, fmt.Errorf("config: parse %s: %w", path, err) } dir := filepath.Dir(path) resolve := func(p string) string { if p == "" || filepath.IsAbs(p) { return p } return filepath.Join(dir, p) } b.Pipeline = resolve(b.Pipeline) b.ModelsFile = resolve(b.ModelsFile) b.SourceFile = resolve(b.SourceFile) b.GlossarySeed = resolve(b.GlossarySeed) b.LangpackRoot = resolve(b.LangpackRoot) b.LangpackExtend = resolve(b.LangpackExtend) b.MinedDelta = resolve(b.MinedDelta) b.MinedRejects = resolve(b.MinedRejects) if b.ProjectDB == "" { b.ProjectDB = filepath.Join(dir, b.BookID+".db") } else { b.ProjectDB = resolve(b.ProjectDB) } var problems []string bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) } if b.BookID == "" { bad("book_id is required") } if b.SourceLang == "" || b.TargetLang == "" { bad("source_lang and target_lang are required") } if b.Venuti < 0 || b.Venuti > 1 { bad("venuti must be in [0,1], got %v", b.Venuti) } if b.Pipeline == "" { bad("pipeline config path is required") } if b.ModelsFile == "" { bad("models config path is required") } if b.SourceFile == "" { bad("source_file is required") } else if _, err := os.Stat(b.SourceFile); err != nil { bad("source_file %s is not readable: %v", b.SourceFile, err) } if b.GlossarySeed != "" { if _, err := os.Stat(b.GlossarySeed); err != nil { bad("glossary_seed %s is not readable: %v", b.GlossarySeed, err) } } if b.MinedDelta != "" { if _, err := os.Stat(b.MinedDelta); err != nil { bad("mined_delta %s is not readable: %v", b.MinedDelta, err) } } if b.MinedRejects != "" { if _, err := os.Stat(b.MinedRejects); err != nil { bad("mined_rejects %s is not readable: %v", b.MinedRejects, err) } } if b.Encoding == "" { b.Encoding = "auto" } switch strings.ToLower(b.Encoding) { case "auto", "utf8", "utf-8", "gb18030", "gbk", "gb2312": default: bad("encoding %q is not supported (use auto|utf8|gb18030)", b.Encoding) } if b.YoPolicy == "" { b.YoPolicy = "auto" } switch b.YoPolicy { case "auto", "all-yo", "all-e": default: bad("yo_policy %q is not supported (use auto|all-yo|all-e)", b.YoPolicy) } for i, s := range b.StyleAllowlist { b.StyleAllowlist[i] = strings.ToLower(strings.TrimSpace(s)) } // Canonicalize (sort) the allowlist so a cosmetic reorder in book.yaml does not change BriefHash // → the snapshot → force an unnecessary re-pin (self-review: it is a set, consumed order- // independently in cheapGateConfig). sort.Strings(b.StyleAllowlist) if b.Ceilings.BookUSD <= 0 && b.Ceilings.DayUSD <= 0 { bad("ceilings: at least one of book_usd/day_usd must be set (a ledger with no ceiling is forbidden, Р7)") } if len(problems) > 0 { return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - ")) } return &b, nil } // BriefHash is the canonical hash of the SEMANTIC brief fields — the ones // whose change legitimately invalidates the TM (Р6: brief_hash is part of the // TM key). Wiring fields (paths, ceilings, db) deliberately excluded: moving // the project into another directory must not re-translate the book. JSON with // a fixed field order → a deterministic byte render. func (b *Book) BriefHash() string { // Title is part of the brief: it is a semantic field (used by prompts via // {{title}} and lands in the render → request-hash), not a wiring identifier. // Without it, editing the title would silently invalidate checkpoints, bypassing // the snapshot gate (review finding). canon := struct { BookID string `json:"book_id"` Title string `json:"title"` SourceLang string `json:"source_lang"` TargetLang string `json:"target_lang"` Genre string `json:"genre"` Audience string `json:"audience"` Adult bool `json:"adult"` Venuti float64 `json:"venuti"` Honorifics string `json:"honorifics"` Transcription string `json:"transcription"` Footnotes string `json:"footnotes"` YoPolicy string `json:"yo_policy"` StyleAllowlist []string `json:"style_allowlist"` }{b.BookID, b.Title, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes, b.YoPolicy, b.StyleAllowlist} data, err := json.Marshal(canon) if err != nil { // A struct of scalars cannot fail to marshal; keep the signature clean. panic(fmt.Sprintf("config: brief hash marshal: %v", err)) } sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } // LangPair returns "ja-ru"-style pair key (the coverage-gate threshold keys). func (b *Book) LangPair() string { return b.SourceLang + "-" + b.TargetLang }