352 lines
17 KiB
Go
352 lines
17 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"sync"
|
||
|
||
"textmachine/backend/internal/checks"
|
||
"textmachine/backend/internal/chunk"
|
||
"textmachine/backend/internal/config"
|
||
"textmachine/backend/internal/lang"
|
||
"textmachine/backend/internal/ledger"
|
||
"textmachine/backend/internal/llm"
|
||
"textmachine/backend/internal/membank"
|
||
"textmachine/backend/internal/store"
|
||
)
|
||
|
||
// runner.go: runner setup — Runner and its wiring (config stack, store in the right
|
||
// ownership mode, client/template caches). The book machinery itself is split across
|
||
// related files (package №4): snapshot — snapshot.go, seed/ruby — seeding.go, the book
|
||
// loop — bookrun.go, the chunk disposition loop — chunkrun.go, stage execution
|
||
// with money (reserve → call → settle+checkpoint) — stagerun.go, single-hop
|
||
// escalation with re-gating — escalation.go, $0-resume of a resolved one — resume.go.
|
||
|
||
// roleTranslator is the stage role whose output is a direct source→target
|
||
// translation — the only role the excision coverage-gate is meaningful on.
|
||
const roleTranslator = "translator"
|
||
|
||
// roleEditor is the BILINGUAL editor stage (D30.1 supersede of D1): editor.md feeds it BOTH the
|
||
// source and the draft, plus the approved glossary's CONFIRMED dst forms as target-consistency
|
||
// constraints. (The stale "monolingual, never sees the source" framing is D1, fully superseded.)
|
||
const roleEditor = "editor"
|
||
|
||
// Runner executes a book's pipeline.
|
||
type Runner struct {
|
||
Book *config.Book
|
||
Models *config.Models
|
||
Pipeline *config.Pipeline
|
||
Store *store.Store
|
||
Pricer *ledger.Pricer
|
||
Log *slog.Logger
|
||
// Resnapshot re-pins existing jobs to the CURRENT snapshot when configs/
|
||
// prompts changed since the job started (tmctl --resnapshot). Without the flag
|
||
// a divergence is a loud error: a context change invalidates the checkpoints and
|
||
// re-pays for the calls, so it is done only deliberately (Р6).
|
||
Resnapshot bool
|
||
|
||
clients map[string]llm.LLMClient
|
||
templates map[string]*PromptTemplate
|
||
// repairTemplates holds one prompt per ENABLED repair class (pack-16), keyed by class. Empty when the
|
||
// repair gate is off, which is the default; its SHAs are folded into the snapshot of the wave that owns
|
||
// the shipping stage, because a repair prompt is not a stage and the per-stage fold cannot see it.
|
||
repairTemplates map[checks.RepairClass]*PromptTemplate
|
||
// rateGuards is the per-model wave-concurrency guard set (WS1 §1б), built once in the precompute pass and
|
||
// read-only in the waves — a transport axis, never snapshot-folded. nil until buildRateGuards.
|
||
rateGuards map[string]*rateGuard
|
||
|
||
// memory is the book's glossary FROZEN for this job (materialized once in
|
||
// TranslateBook, after seeding, before snapshotID). Its Version() is the F1
|
||
// content-hash folded into the snapshot; its Select drives per-chunk injection.
|
||
// nil until materialized (report path / no glossary) → memoryVersion() falls back
|
||
// to the empty-materialization hash, a stable constant.
|
||
memory *membank.Bank
|
||
|
||
// baseMemory is the DRAFT-wave glossary bank: the BASE rows only (Source∈{seed,ruby,auto}, EXCLUDING
|
||
// Source:mined). The draft wave's injection MUST be selected over this — not the enriched `memory` —
|
||
// so it is BYTE-IDENTICAL across a bank-mining enrichment, matching the draft-wave snapshot which folds
|
||
// baseMemoryVersion (mined-excluded). Otherwise signing a mined term would change the draft wire (the
|
||
// injection is a message folded into request_hash) and silently re-bill the draft wave on the owner
|
||
// re-run — even though the base snapshot is unchanged (the review-confirmed "ONE re-payment" hole).
|
||
// When the book has NO mined rows (every $0 test / the golden), it is the SAME object as `memory`
|
||
// (identical content) — no double materialization, the injection is unchanged. The editor keeps the
|
||
// enriched `memory`. nil ⇔ memory is nil (materialized together in seedGlossary).
|
||
baseMemory *membank.Bank
|
||
|
||
// checkers is the compiled WS5/pack-13 observability checker spec (pair-14 data-out): the pair's
|
||
// DETECTION patterns + tables (from pack) + the target-general lists (from embedded target data),
|
||
// compiled once in loadLangPack. nil-safe: a no-pack / no-target-data book runs the checkers inert.
|
||
checkers *checks.Checkers
|
||
|
||
// pack is the book's language-data pack (internal/lang), loaded once in openRunner from
|
||
// book.LangpackRoot (D39.15/16). The the bank-mining stop bank-miner reads its tables; pack.Version() is folded into
|
||
// the snapshot (a pack edit is a loud --resnapshot). nil when the book declares no langpack_root, or
|
||
// its pair has no catalog dir — then the miner is inert (the bank-mining stop auto-continues) and the fold is omitted.
|
||
pack *lang.Pack
|
||
|
||
// escMu serializes the single-hop escalation budget admission across the PARALLEL draft workers
|
||
// (R1). escalationBudgetRemains is a non-atomic read-then-act over EscalationSpentUSD, so N concurrent
|
||
// draft chunks could each read spent<budget and all be admitted → the premium soft-cap overshoots by
|
||
// up to N-1 hops. Holding escMu across the budget check AND the fresh hop's settle makes the cap exact
|
||
// (a later chunk reads the updated spend). Escalations are the rare content-failure exception, so the
|
||
// contention is negligible; the $0 checkpoint-replay of an already-paid hop stays lock-free.
|
||
escMu sync.Mutex
|
||
|
||
// lastMinedCount is the size of the most recent the bank-mining stop mined delta (set by runBankMiningStop) — carried into the
|
||
// WaveSignatureStop the driver returns so the CLI can report "N terms await signature". Single-writer
|
||
// (runBankMiningStop runs between the parallel waves, not inside one), so no synchronization is needed.
|
||
lastMinedCount int
|
||
}
|
||
|
||
// NewRunner loads all three configs rooted at book.yaml and opens the project
|
||
// store. Caller owns Close.
|
||
func NewRunner(bookPath string, logger *slog.Logger) (*Runner, error) {
|
||
return openRunner(bookPath, logger, true)
|
||
}
|
||
|
||
// NewReadOnlyRunner builds a runner for the $0 read-only commands (`tmctl report`/`status`): it
|
||
// SKIPS the API-key preflight so an auditor WITHOUT provider keys can read the store (D20.4). Those
|
||
// commands make zero LLM calls (they only project the persisted rows / re-chunk the source), so a
|
||
// missing key is irrelevant — the config-mechanics and adult-channel lints still run. translate and
|
||
// redrive (which DO call providers) keep NewRunner.
|
||
//
|
||
// The store opens READ-ONLY and WITHOUT the exclusive flock (store.OpenReadOnly), so
|
||
// status/report work DURING a live translate — previously the operator was locked out of them for the
|
||
// entire run (the pain of the package №4 smoke-run). The first touch of a project (no DB file
|
||
// yet) falls back to a full Open: create+migrate an empty database — the prior
|
||
// behavior "status before the first run shows 0/N pending", and a live writer
|
||
// to collide with does not exist at that moment.
|
||
func NewReadOnlyRunner(bookPath string, logger *slog.Logger) (*Runner, error) {
|
||
return openRunner(bookPath, logger, false)
|
||
}
|
||
|
||
// openRunner loads the config stack and opens the store. forWrite selects the call
|
||
// path: translate/redrive (provider keys required, store owned —
|
||
// flock+migrations+recovery) versus status/report (no keys, store read-only).
|
||
func openRunner(bookPath string, logger *slog.Logger, forWrite bool) (*Runner, error) {
|
||
book, err := config.LoadBook(bookPath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
models, err := config.LoadModels(book.ModelsFile)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
pipe, err := config.LoadPipeline(book.Pipeline, models, book.LangPair())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Fail-fast BEFORE opening the store (taking the flock and side-effects): whether
|
||
// the config mechanics are executable and the keys of the used models are set.
|
||
if err := pipe.CheckRunnable(); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := pipe.CheckAdultChannel(book.Adult); err != nil {
|
||
return nil, err
|
||
}
|
||
// Key preflight only for the call paths that actually reach a provider — read-only report/status
|
||
// are $0 (D20.4). CheckRunnable/CheckAdultChannel above stay unconditional (they cost nothing and
|
||
// catch a broken config even on a read).
|
||
if forWrite {
|
||
if err := models.CheckKeys(pipe); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
pricer, err := models.Prices()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var st *store.Store
|
||
if forWrite {
|
||
// Write path (translate/redrive): exclusive owner — flock, migrations,
|
||
// reservation recovery.
|
||
st, err = store.Open(book.ProjectDB)
|
||
} else {
|
||
// Read path (status/report): no flock — works during a live run; on
|
||
// the first touch of a project (no DB yet) — a full Open (create+migrate).
|
||
st, err = store.OpenReadOnly(book.ProjectDB)
|
||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
||
st, err = store.Open(book.ProjectDB)
|
||
}
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
r := &Runner{
|
||
Book: book,
|
||
Models: models,
|
||
Pipeline: pipe,
|
||
Store: st,
|
||
Pricer: pricer,
|
||
Log: logger,
|
||
clients: map[string]llm.LLMClient{},
|
||
templates: map[string]*PromptTemplate{},
|
||
}
|
||
if err := r.loadTemplates(); err != nil {
|
||
st.Close()
|
||
return nil, err
|
||
}
|
||
if err := r.loadLangPack(); err != nil {
|
||
st.Close()
|
||
return nil, err
|
||
}
|
||
// The repair prompts are resolved by the same convention discipline as the stage prompts: a missing file
|
||
// for an enabled class is a LOUD stop naming the path, never a silent skip of the loop.
|
||
if err := r.loadRepairTemplates(); err != nil {
|
||
st.Close()
|
||
return nil, err
|
||
}
|
||
return r, nil
|
||
}
|
||
|
||
// loadLangPack resolves the book's language-data pack (D39.15/16), loaded on BOTH the write path and the
|
||
// read-only path so status/export/report reproduce the identical snapshot (pack.Version() is folded). The
|
||
// contract mirrors the owner directive R1: a pair WITH a catalog directory (configs/langpacks/<src>-<tgt>/)
|
||
// loads FAIL-LOUD (a missing/corrupt file stops the run, never a silently-empty miner); a pair WITHOUT a
|
||
// catalog — or a book with no langpack_root — runs with a nil pack (the miner is inert, the bank-mining stop auto-continues,
|
||
// and the snapshot fold is omitted). Presence of the pair directory is the "catalog exists" signal.
|
||
func (r *Runner) loadLangPack() error {
|
||
// The observability checkers compile from the pair pack (DETECTION patterns + tables, may be nil) AND the
|
||
// embedded target data (target-general lists, keyed by target lang). Built here so it exists even for a
|
||
// no-langpack book (a nil pack → inert pair checkers; the target lists still load). r.pack is set below.
|
||
defer func() {
|
||
r.checkers = checks.CompileCheckers(checks.DCCheckerData(r.pack), lang.TargetChecksFor(r.Book.TargetLang))
|
||
}()
|
||
if r.Book.LangpackRoot == "" {
|
||
return nil // no langpack declared → nil pack, miner inert
|
||
}
|
||
pairDir := filepath.Join(r.Book.LangpackRoot, r.Book.LangPair())
|
||
if fi, err := os.Stat(pairDir); err != nil || !fi.IsDir() {
|
||
// No catalog for this pair → nil-and-run (a ja book against a zh-only root just runs without mining).
|
||
return nil
|
||
}
|
||
// A book-scoped overlay (LangpackExtend) unions the book's PRIVATE canon onto the shared pair pack
|
||
// (pair-14 §1: 古月 is a 蛊真人 clan, not a shared 百家姓 surname). "" ⇒ plain Load. Folds into Version().
|
||
pack, err := lang.LoadWithOverlay(r.Book.LangpackRoot, r.Book.SourceLang, r.Book.TargetLang, r.Book.LangpackExtend)
|
||
if err != nil {
|
||
return fmt.Errorf("pipeline: load langpack for %s: %w", r.Book.LangPair(), err)
|
||
}
|
||
r.pack = pack
|
||
return nil
|
||
}
|
||
|
||
// packVersion is the snapshot-folded language-pack version: pack.Version() when a pack is loaded, "" when
|
||
// not (omitted from the snapshot so a no-pack book is byte-stable and never re-billed for a feature it does
|
||
// not use). A pack DATA edit changes Version() → the snapshot moves → a loud --resnapshot (R1, drift-proof).
|
||
func (r *Runner) packVersion() string {
|
||
if r.pack != nil {
|
||
return r.pack.Version()
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (r *Runner) Close() error { return r.Store.Close() }
|
||
|
||
func (r *Runner) loadTemplates() error {
|
||
for _, st := range r.Pipeline.Stages {
|
||
// The path was resolved at load — by the pair/role convention, or by the stage's deliberate
|
||
// prompt_override — and a pair with no prompt pack already failed there, naming the file it
|
||
// looked for (D39.23). Here it is only read.
|
||
tpl, err := LoadPromptTemplate(st.PromptPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// Resolve the effective system once per stage: fold the few-shot block into
|
||
// System unless the stage switched it off (D38.4). FewShot is retained so the
|
||
// snapshot can fold the on/off state only for stages that actually have a block.
|
||
tpl.System = tpl.SystemFor(fewShotEnabled(st))
|
||
r.templates[st.Name] = tpl
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// headingRule is the book's deterministic chapter-heading rule (pack-13 title policy), or nil when the
|
||
// book declares no langpack, its pair has no catalog, or the pair ships no heading.txt. The chunker uses
|
||
// it to detect+strip a source header and render «Глава N»; nil ⇒ the feature is inert (byte-identical
|
||
// chunks). Its bytes ride pack.Version() (folded into the snapshot via LangpackVersion), so a rule edit is
|
||
// a loud --resnapshot and a book without a rule is never re-billed for a feature it does not use.
|
||
func (r *Runner) headingRule() *lang.HeadingRule {
|
||
if r.pack != nil {
|
||
return r.pack.Heading
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// estOutTokens is the DISPLAY-ONLY fertility estimate of a text's ru output tokens (est_out = fertility
|
||
// over the source char-classes, research/20 — NOT the char/4 EstimateTokens whose CJK undercount is the
|
||
// «CJK-mine»). Used ONLY to annotate a settle-estimate telemetry row (pack-13 point-9): it feeds NOTHING
|
||
// on the money path (not the reservation, not CostUSD), so it can never shift a verdict or a wire byte.
|
||
func (r *Runner) estOutTokens(text string) int {
|
||
cjk, other := chunk.TokenClassCounts(text)
|
||
return int(r.segBudget().EstOut(cjk, other))
|
||
}
|
||
|
||
// segBudget resolves the pipeline's config.Segmentation into the pipeline-package chunk.SegBudget the
|
||
// chunker consumes (WS2). Kept in the runner (not chunker.go) so the segmenter stays config-free.
|
||
func (r *Runner) segBudget() chunk.SegBudget {
|
||
seg := r.Pipeline.Segmentation
|
||
return chunk.SegBudget{
|
||
DraftBudgetOut: float64(seg.DraftBudgetOut),
|
||
EditCeilingOut: float64(seg.EditCeilingOut),
|
||
FertCJK: seg.Fertility.CJK,
|
||
FertOther: seg.Fertility.Other,
|
||
}
|
||
}
|
||
|
||
// reachableModels enumerates every model a run can call: the union of the stage models and their
|
||
// single-hop escalate_to fallbacks (stagerun.go calls r.client ONLY with model∈{st.Model, EscalateTo}).
|
||
// This set is complete and static for a book, which is what lets the precompute pass pre-build every client (a third
|
||
// model axis — channel B / annotator — must extend this to stay race-free). Deterministic order.
|
||
func (r *Runner) reachableModels() []string {
|
||
seen := map[string]bool{}
|
||
var out []string
|
||
add := func(m string) {
|
||
if m != "" && !seen[m] {
|
||
seen[m] = true
|
||
out = append(out, m)
|
||
}
|
||
}
|
||
for _, st := range r.Pipeline.Stages {
|
||
add(st.Model)
|
||
add(st.EscalateTo)
|
||
}
|
||
// The repair sub-step is the THIRD model axis this function's contract warns about: without it the
|
||
// pre-built client map has no entry for the repair model and r.client returns a loud error mid-wave
|
||
// (and CheckKeys would not require its API key at load).
|
||
if r.Pipeline.Gates.Repair.Enabled {
|
||
add(r.Pipeline.Gates.Repair.Model)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// buildClients EAGER-constructs every reachable LLM client BEFORE any wave goroutine starts (the precompute pass).
|
||
// After this the clients map is READ-ONLY in the waves, so r.client is lock-free and a miss is a
|
||
// loud error, not a lazy build under a data race — closing the runner.go lazy-init race (D12) and
|
||
// tripwiring a future un-enumerated model axis. Idempotent; BuildClient needs only the provider
|
||
// config (not the API key), so eager-build is safe even on a key-less resume.
|
||
func (r *Runner) buildClients() error {
|
||
for _, m := range r.reachableModels() {
|
||
if _, ok := r.clients[m]; ok {
|
||
continue
|
||
}
|
||
c, err := BuildClient(r.Models, m, r.Log)
|
||
if err != nil {
|
||
return fmt.Errorf("pipeline: eager-build client for model %q (the precompute pass): %w", m, err)
|
||
}
|
||
r.clients[m] = c
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// client returns the PRE-BUILT client for a model. Read-only (no lazy build, no lock): buildClients
|
||
// constructed every reachable client in the precompute pass, so the map is only read in the waves; a miss means an
|
||
// un-enumerated model reached the wire and is a loud error, never a silent lazy build under a race.
|
||
func (r *Runner) client(model string) (llm.LLMClient, error) {
|
||
if c, ok := r.clients[model]; ok {
|
||
return c, nil
|
||
}
|
||
return nil, fmt.Errorf("pipeline: no pre-built client for model %q — the precompute pass buildClients enumerates stage models ∪ escalate_to; a model outside that set reached the wire, add it to the eager set to keep the waves race-free", model)
|
||
}
|