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"` // ContentLabels are the book's declared content properties — DATA, not engine knowledge (D39.25): // the engine only compares this set against a model's accepts_labels (models.yaml) and against the // run config's content_policy registry. No label VALUE is known to Go, so a second label // (archaic-register …) is three data edits and zero code. Owner-declared in v1 (there is no // automatic screen — the L3 entry policy stays human, D22.7), lower-cased/sorted/deduped on load so // a cosmetic reorder is not a different book. Deliberately NOT part of BriefHash: the labels' // entire wire effect is the RESOLVED per-stage model, which is folded per-stage and per-wave // (D39.26 point 1) — folding the set book-globally would re-pay a paid draft wave when only the // editor is re-routed, against the per-stage granularity ratified in D20.2-Q1. ContentLabels []string `yaml:"content_labels"` // LegacyContentFlag is the RETIRED `adult:` key, declared for exactly two reasons (the // Stage.LegacyPrompt discipline): to REJECT `adult: true` with the migration named, and to keep // the brief canon's byte layout frozen (see BriefHash). `adult: false` / an absent key stay // TOLERATED on purpose — 13 book-shaped files outside git carry the line explicitly, and failing // their load would buy nothing (the value false says nothing the empty label set does not). LegacyContentFlag 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"` // RegisterBlocklist is the DC6 out-of-register target lexis for THIS book (D39.79 Q4): fairy-tale / // chancery Russian words wrong for the book's genre («терем»…). It lives with the BOOK, not the pair // pack, because it is a genre/book property — a different zh→ru book of another genre ships its own (or // none). Lower-cased on load; verdict-affecting → folded into BriefHash (omitempty keeps a book without // one byte-identical). Empty = the DC6 register checker runs inert («inert without data»). RegisterBlocklist []string `yaml:"register_blocklist"` // 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"` // RebillConsentUSD overrides the ratified default threshold above which a run must be given // explicit consent (`--accept-rebill[=usd]`) before it re-pays for work already billed — // `min($0.50, 5% × ProjectedBookUSD)`, D20.2-Q2. 0 (absent) = the default formula. WIRING, not // brief: it is deliberately absent from BriefHash and from the snapshot, so setting it never // re-pays anything by itself. RebillConsentUSD float64 `yaml:"rebill_consent_usd"` } // 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) } // The retired `adult:` key: `true` is a MIGRATION error naming the replacement, never a silently // honoured routing request (it used to be the whole 18+ mechanism, so a stale `true` would read as // "this book is routed" while nothing routes it). `false`/absent is tolerated (see the field). if b.LegacyContentFlag { bad("`adult: true` is retired — declare the book's content properties as data instead: `content_labels: [