173 lines
9.5 KiB
Go
173 lines
9.5 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"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 (`<config dir>/pairs/<pair>.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 "<src>-<tgt>" 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 `<root>/<pair>/<role>.md`.
|
|
// Relative paths resolve against the pair file. Empty → "../../prompts" (the repo layout:
|
|
// backend/configs/pairs/<pair>.yaml → backend/prompts/<pair>/<role>.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"`
|
|
// Brief are the BRIEF POLICIES this pair knows, keyed by the book.yaml field they constrain
|
|
// (`transcription`, `honorifics`). A pair states which NAMES are meaningful for it and which one a
|
|
// book gets when it names none; the engine knows no name at all.
|
|
//
|
|
// ⛔ IT EXISTS BECAUSE A BRIEF VALUE IS PAIR-BOUND AND NOTHING SAID SO. `transcription: palladius` is
|
|
// the system for CHINESE, and it sits in the operator's one book template, which renders it into every
|
|
// book of every pair — so an English or Japanese book was handed «transcribe by the Chinese system» on
|
|
// the wire, silently, for the whole book. Not a refusal, not a flag: quietly wrong names in the very
|
|
// place the product exists to get right. A pair that declares its vocabulary turns that into a loud
|
|
// refusal before the first cent; a pair that declares nothing behaves exactly as it did.
|
|
Brief map[string]BriefPolicy `yaml:"brief"`
|
|
// PromptVersions are THIS PAIR's labels for its prompt files, keyed by the file's base name as the
|
|
// convention spells it (`translator`, `translator-banknote`, `editor`), each replacing the run
|
|
// config's `prompt_version` for this pair alone. Absent → the run config's label, as before.
|
|
//
|
|
// ⛔ IT EXISTS BECAUSE THE LABEL IS MONEY, not bookkeeping. `prompt_version` folds into the snapshot,
|
|
// and one string in a pair-agnostic run config labels the prompts of EVERY pair that config serves. So
|
|
// editing one pair's prompt left two bad choices: bump the shared label and re-snapshot — that is,
|
|
// re-buy — the books of every other pair, whose bytes never moved; or leave the label and let one name
|
|
// stand over two different texts, which is the defect the label ledger exists to catch. A pair that
|
|
// edits its own prompts states its own label here and moves nothing else.
|
|
PromptVersions map[string]string `yaml:"prompt_versions"`
|
|
}
|
|
|
|
// 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]
|
|
}
|
|
|
|
// BriefPolicy is one brief field's vocabulary for a pair: the names it knows and the one a book that
|
|
// names none receives.
|
|
//
|
|
// ⚠ THE DEFAULT REACHES THE WIRE, which is why it is applied into the BOOK's own field rather than
|
|
// resolved at render time. The pair layer folds into no snapshot, so a default substituted late would
|
|
// change every request hash with nothing saying it had — the silent re-purchase. Applied into the book,
|
|
// it enters brief_hash, and an edit to this line is a loud --resnapshot like any other brief change.
|
|
type BriefPolicy struct {
|
|
Default string `yaml:"default"`
|
|
Allowed []string `yaml:"allowed"`
|
|
}
|
|
|
|
// BriefPolicyFields are the book.yaml brief fields a pair may constrain. The FIELD names are engine
|
|
// schema — they are the keys of book.yaml — while every VALUE stays data the engine never learns.
|
|
var BriefPolicyFields = []string{"transcription", "honorifics"}
|
|
|
|
// 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 `<configDir>/pairs/<pair>.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))
|
|
}
|
|
for field, pol := range pc.Brief {
|
|
if !containsString(BriefPolicyFields, field) {
|
|
return nil, fmt.Errorf("config: pair config %s: brief.%s is not a brief field the engine has (want one of %s) — a policy nothing reads would look declared and do nothing", path, field, strings.Join(BriefPolicyFields, ", "))
|
|
}
|
|
if len(pol.Allowed) == 0 {
|
|
return nil, fmt.Errorf("config: pair config %s: brief.%s declares no allowed value — an empty vocabulary would refuse every book of this pair", path, field)
|
|
}
|
|
for _, v := range pol.Allowed {
|
|
if strings.TrimSpace(v) == "" {
|
|
return nil, fmt.Errorf("config: pair config %s: brief.%s lists an empty name", path, field)
|
|
}
|
|
}
|
|
// The default has to be one of the names, or a book that states nothing would be handed a value
|
|
// this very pair calls unknown — and the refusal would name the engine's own data.
|
|
if pol.Default != "" && !containsString(pol.Allowed, pol.Default) {
|
|
return nil, fmt.Errorf("config: pair config %s: brief.%s default %q is not among its allowed names %v", path, field, pol.Default, pol.Allowed)
|
|
}
|
|
}
|
|
// A label key is the prompt file's BASE NAME, not a path and not a stage name: it is what the
|
|
// convention resolves, so a key with a separator in it would name a file this pair cannot have. An
|
|
// empty label would be worse than no key at all — it would replace a stated label with nothing, and
|
|
// the stage check that demands one has already passed by then.
|
|
for name, label := range pc.PromptVersions {
|
|
switch {
|
|
case strings.TrimSpace(name) != name || name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, ".."):
|
|
return nil, fmt.Errorf("config: pair config %s: prompt_versions key %q must be a prompt file's base name (`translator`, `translator-banknote`, `editor`) — no paths, no spaces", path, name)
|
|
case strings.TrimSpace(label) == "":
|
|
return nil, fmt.Errorf("config: pair config %s: prompt_versions[%q] is empty — a label is what makes two runs comparable, so an empty one is not a smaller statement but a missing one", path, name)
|
|
}
|
|
}
|
|
return &pc, nil
|
|
}
|
|
|
|
// containsString reports whether v is in list. A local helper rather than a package one: the pair layer is
|
|
// the only reader, and a "utils" home for three lines is what the style notes forbid.
|
|
func containsString(list []string, v string) bool {
|
|
for _, s := range list {
|
|
if s == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|