package config import ( "bytes" "fmt" "os" "path/filepath" "gopkg.in/yaml.v3" ) // pair.go: the LANGUAGE-PAIR configuration layer (D39.23, pack-15). // // A run config answers "how is THIS run wired" — stages, models, temperatures, budgets, gate // switches. A pair config answers "what is true of zh→ru", and stays true across every run of every // zh→ru book: the segmentation calibration (draft/edit budgets in OUTPUT tokens and the fertility // coefficients that convert source characters into them) and the excision corridor for that pair. // Before this split those numbers were copy-pasted into every shipping run config, so adding an arm // meant re-pasting a pair calibration and a drift between two copies was invisible. // // It lives next to the run configs (`/pairs/.yaml`) and is OPTIONAL: a project with // no pair file falls back to the ratified generic numbers in LoadPipeline, exactly as before. The // PROMPTS of a pair are resolved by convention rather than listed here — see promptsRoot. // PairConfig is the per-language-pair layer of the configuration. type PairConfig struct { // Pair is the "-" key, asserted against the file name so a mis-filed calibration // (zh-ru numbers saved as ja-ru.yaml) fails loud instead of silently mis-chunking a book. Pair string `yaml:"pair"` // PromptsRoot is the directory holding the per-pair prompt packs, as `//.md`. // Relative paths resolve against the pair file. Empty → "../../prompts" (the repo layout: // backend/configs/pairs/.yaml → backend/prompts//.md). PromptsRoot string `yaml:"prompts_root"` // Segmentation is the pair's chunking calibration. A field left at zero keeps the generic // fallback, so a pair file may carry only the corridor. Segmentation Segmentation `yaml:"segmentation"` // Coverage carries the pair's excision corridor (the lower bound is the one the gate checks). Coverage PairCoverage `yaml:"coverage"` } // PairCoverage is the pair's slice of the coverage gate: the len_ratio corridor [low, high] for this // language pair. The gate itself (on/off, sentence coverage, minimum chunk) stays run-scoped — whether // to gate is a decision about a RUN, while what "too short for zh→ru" means is a fact about the pair. type PairCoverage struct { LenRatioBounds []float64 `yaml:"len_ratio_bounds"` // [low, high] } // pairsDirName is the directory, relative to the run config, that holds the pair layer. const pairsDirName = "pairs" // defaultPromptsRoot is where a pair's prompt pack lives when the pair file does not say otherwise — // relative to the pair file itself (backend/configs/pairs → backend/prompts). const defaultPromptsRoot = "../../prompts" // LoadPair reads `/pairs/.yaml`. A MISSING file is not an error (nil, nil): the pair // layer is optional and its absence means "use the generic fallbacks" — the same behaviour a project // had before the layer existed. A file that exists but is malformed, or whose `pair` disagrees with // its name, fails LOUD: a calibration that silently does not apply is the failure mode this layer is // supposed to remove. func LoadPair(configDir, pair string) (*PairConfig, error) { path := filepath.Join(configDir, pairsDirName, pair+".yaml") raw, err := os.ReadFile(path) if os.IsNotExist(err) { return nil, nil } if err != nil { return nil, fmt.Errorf("config: read pair config %s: %w", path, err) } var pc PairConfig // STRICT decode, same class as the pipeline/book loaders: a mistyped key in a pair file means the pair // calibration silently does not apply, which is precisely the failure this layer was created to remove. dec := yaml.NewDecoder(bytes.NewReader(raw)) dec.KnownFields(true) if err := dec.Decode(&pc); err != nil { return nil, fmt.Errorf("config: parse pair config %s: %w", path, err) } if pc.Pair != "" && pc.Pair != pair { return nil, fmt.Errorf("config: pair config %s declares pair %q — the file name is the pair key, so the calibration would apply to %q; rename the file or fix the field", path, pc.Pair, pair) } pc.Pair = pair root := pc.PromptsRoot if root == "" { root = defaultPromptsRoot } if !filepath.IsAbs(root) { root = filepath.Join(filepath.Dir(path), root) } pc.PromptsRoot = root if b := pc.Coverage.LenRatioBounds; len(b) != 0 && len(b) != 2 { return nil, fmt.Errorf("config: pair config %s: coverage.len_ratio_bounds must be [low, high] (2 values), got %d", path, len(b)) } return &pc, nil }