textmachine/platform/internal/runner/bankapply_live_test.go

389 lines
16 KiB
Go

package runner
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"gopkg.in/yaml.v3"
"textmachine/platform/internal/ingest"
)
// `--dry-run` is the whole of what separates a preview from a write, so its presence is pinned at
// the argv — the one place a dropped flag is a one-token diff.
//
// Mutation caught: passing preview through without the flag (every call becomes a write), or
// passing the flag unconditionally (every write becomes a no-op).
func TestAPreviewIsTheDryRunFlagAndNothingElse(t *testing.T) {
if !slices.Contains(BankApplyArgs("/w", "/d.json", true), "--dry-run") {
t.Error("a preview call carries no --dry-run: it would WRITE")
}
if slices.Contains(BankApplyArgs("/w", "/d.json", false), "--dry-run") {
t.Error("an apply call carries --dry-run: it would never write")
}
}
// The canon's central promise about the preview, proven against the REAL engine: «"preview": true
// answers the same receipt without changing anything» (§applyBankCorrections). A regress of one
// constant turns the safe look into an irreversible write, and no unit test can make this claim —
// what writes is the engine, so the engine is what must be seen not writing.
//
// Gated like every live-engine test: a bare clone stays green and says why.
func TestALivePreviewWritesNothingAndALiveApplyWrites(t *testing.T) {
bin := os.Getenv("TM_PLATFORM_TEST_ENGINE_BIN")
tpl := os.Getenv("TM_PLATFORM_TEST_BOOK_TEMPLATE")
if bin == "" || tpl == "" {
t.Skip("TM_PLATFORM_TEST_ENGINE_BIN and TM_PLATFORM_TEST_BOOK_TEMPLATE not set: " +
"the preview's no-write promise is not checked against a real engine")
}
dir := t.TempDir()
writeProbeBook(t, tpl, dir, "bk_PREVIEWPROBE")
doc, err := ingest.EncodeDecisions("bk_PREVIEWPROBE", []ingest.BankDecision{
{Action: "approve", Src: "测试", Sense: "", Dst: "проба", Note: "preview pin"},
})
if err != nil {
t.Fatal(err)
}
decisions := filepath.Join(t.TempDir(), "decisions.json")
if err := os.WriteFile(decisions, doc, 0o600); err != nil {
t.Fatal(err)
}
before := dirListing(t, dir)
rn := New(nil)
out, err := rn.BankApply(t.Context(), bin, dir, decisions, true)
if err != nil || !out.Exited || out.ExitCode != 0 || !out.Decoded {
t.Fatalf("the live preview did not answer: %+v (%v; stderr %q)", out, err, out.Stderr)
}
if out.Report.Mode != "projection" || !out.Report.Changed {
t.Fatalf("the preview's report: mode %q changed %v, want a projection that WOULD change",
out.Report.Mode, out.Report.Changed)
}
// Nothing appeared and nothing moved — except the lock file, which the verb's own contract
// names: a projection computed without the arbiter is a projection of nothing.
after := dirListing(t, dir)
for name, sum := range after {
if filepath.Ext(name) == ".lock" {
continue
}
if prev, ok := before[name]; !ok {
t.Errorf("the preview CREATED %s", name)
} else if prev != sum {
t.Errorf("the preview CHANGED %s", name)
}
}
// The same document applied for real is the counter-proof: the engine both can and does write
// here, so the silence above was the preview's doing and not a fixture that cannot write.
out, err = rn.BankApply(t.Context(), bin, dir, decisions, false)
if err != nil || out.ExitCode != 0 || !out.Decoded || out.Report.Mode != "apply" || !out.Report.Changed {
t.Fatalf("the live apply did not land: %+v (%v; stderr %q)", out, err, out.Stderr)
}
applied := dirListing(t, dir)
grew := false
for name := range applied {
if _, ok := after[name]; !ok && filepath.Ext(name) != ".lock" {
grew = true
}
}
if !grew {
t.Error("the apply wrote no decision file: this fixture proves nothing about the preview")
}
}
// str reads one string key of a decoded book configuration, and says which key is missing rather
// than panicking on the type assertion — a template without a `pipeline:` is an operator's mistake a
// test should name.
func str(t *testing.T, cfg map[string]any, key string) string {
t.Helper()
v, ok := cfg[key].(string)
if !ok || v == "" {
t.Fatalf("the deployment template carries no %q: this probe cannot render a book from it", key)
}
return v
}
// zeroCostPipeline writes the template's pipeline with every stage pointed at the deployment's LOCAL
// model, and returns the path. The escalation budget is zeroed and the per-stage hops are dropped
// with it: an escalation target is a reachable model too, and one paid name is all it takes for the
// engine to demand a key.
//
// The local model is FOUND, not named here: models.yaml declares providers with a `kind`, and the
// one whose kind is `local` is the pair a stand serves itself (the in-test stub listens where that
// provider points). A deployment that declares no local provider cannot run this probe for free, and
// the skip says exactly that instead of failing on somebody's missing key.
func zeroCostPipeline(t *testing.T, pipelinePath, modelsPath string) string {
t.Helper()
var models struct {
Providers map[string]struct {
Kind string `yaml:"kind"`
} `yaml:"providers"`
Models map[string]struct {
Provider string `yaml:"provider"`
} `yaml:"models"`
}
raw, err := os.ReadFile(modelsPath)
if err != nil {
t.Fatalf("the template's models file cannot be read (%s): %v", modelsPath, err)
}
if err := yaml.Unmarshal(raw, &models); err != nil {
t.Fatal(err)
}
free := ""
for name, m := range models.Models {
if models.Providers[m.Provider].Kind == "local" && (free == "" || name < free) {
free = name
}
}
if free == "" {
t.Skipf("this deployment's %s declares no provider of kind `local`: the probe would have to buy its calls", modelsPath)
}
raw, err = os.ReadFile(pipelinePath)
if err != nil {
t.Fatalf("the template's pipeline cannot be read (%s): %v", pipelinePath, err)
}
var pipe map[string]any
if err := yaml.Unmarshal(raw, &pipe); err != nil {
t.Fatal(err)
}
stages, ok := pipe["stages"].([]any)
if !ok || len(stages) == 0 {
t.Fatalf("the template's pipeline declares no stages: %s", pipelinePath)
}
for _, s := range stages {
stage, ok := s.(map[string]any)
if !ok {
t.Fatalf("a stage of %s is not a mapping", pipelinePath)
}
stage["model"] = free
delete(stage, "escalate_to")
delete(stage, "label_models")
}
// ⚠ THE GATES CARRY MODELS TOO, and the helper's own promise — «the probe runs on the deployment's
// ZERO-COST pair» — is not kept without them. It went unnoticed for the same reason the contrast
// path did: until the bank contour was turned on in the shipping pipeline (unified backlog row
// 140) a deployment template named no gate models at all, and the stage loop above was the whole
// of the deployment's spending. On a `pipeline-c1` template it is not: the terminologist and the
// classifier resolve to a paid provider, and the engine refuses the run for a missing key long
// before the guard this probe is about could fire.
if gates, ok := pipe["gates"].(map[string]any); ok {
for _, g := range gates {
gate, ok := g.(map[string]any)
if !ok {
continue
}
if _, has := gate["model"]; has {
gate["model"] = free
}
// ⚠ AND THE SECOND MODEL IN THE SAME GATE. `classify_model` is the classifier phase's own
// model and falls back to `model` only when it is EMPTY (backend/internal/config/pipeline.go,
// `ClassifyModel`) — so a deployment whose pipeline sets it would resolve a paid model here
// with `model` already zeroed, which is the same failure this helper was just fixed for, one
// key over. Today's shipping config does not set it; a deployment's may.
if _, has := gate["classify_model"]; has {
gate["classify_model"] = free
}
delete(gate, "escalate_to")
}
}
if esc, ok := pipe["escalation"].(map[string]any); ok {
esc["budget_usd"] = 0
delete(esc, "chains")
}
// ⚠ THE BANK CONTOUR'S ONE DEPLOYMENT-PROVIDED INPUT has to be re-pointed, because this rendered
// pipeline is about to MOVE. `mining.contrast_path` is resolved against the directory of the
// pipeline file (backend/internal/config/pipeline.go, resolvePrompt), the artefact is a
// multi-megabyte word-frequency list that is deliberately NOT in git, and the mirror below copies
// neither it nor anything else of that size — so a relative path silently becomes a path into an
// empty temp directory, and every live test on such a template dies at the engine's write-path
// guard with a message that reads like a defect of this zone.
//
// It appeared the day the contour was turned on in the SHIPPING pipeline (unified backlog row
// 140): before that a deployment template pointing at `pipeline-c1.yaml` named no contrast at all,
// which is why this harness had never had to think about it.
if mining, ok := pipe["mining"].(map[string]any); ok {
if raw, ok := mining["contrast_path"].(string); ok && raw != "" {
abs := raw
if !filepath.IsAbs(abs) {
abs = filepath.Join(filepath.Dir(pipelinePath), abs)
}
if _, err := os.Stat(abs); err != nil {
// A HOST CONDITION and not a failure: the artefact is deployment-provided, and a clone
// that has not been given one cannot run a write-path probe. Loud, and it names the
// file, because the alternative is a red test whose message is about somebody else.
t.Skipf("this deployment's pipeline enables the bank contour and names %s, which is not on this host: "+
"a live write-path probe cannot run (put the jieba-style contrast list there — deploy/README.md, "+
"«Артефакт контраста»)", abs)
}
mining["contrast_path"] = abs
}
}
out, err := yaml.Marshal(pipe)
if err != nil {
t.Fatal(err)
}
// The rendered pipeline lives in a MIRROR of the deployment's configuration directory, not next
// to the book: everything else a pipeline pulls in is resolved relative to it — the prompt pack
// by convention (`<config dir>/pairs/<pair>.yaml`, whose own default root is `../../prompts`) and
// the pair's chunking calibration by the same path. Writing the file anywhere else silently
// changes those two, and the engine then refuses to load at all («no prompt for pair zh-ru»).
// Linking rather than copying keeps the probe honest about WHOSE prompts it ran on.
// A directory of its OWN, never the book's: what lands beside book.yaml is the book, and one
// probe here inventories that directory file by file to prove a preview wrote nothing.
root := t.TempDir()
cfgDir := filepath.Join(root, "cfg")
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatal(err)
}
origCfg := filepath.Dir(pipelinePath)
for target, link := range map[string]string{
filepath.Join(origCfg, "..", "prompts"): filepath.Join(root, "prompts"),
filepath.Join(origCfg, "pairs"): filepath.Join(cfgDir, "pairs"),
filepath.Join(origCfg, "langpacks"): filepath.Join(cfgDir, "langpacks"),
} {
if _, err := os.Stat(target); err != nil {
continue // this deployment does not carry that layer; the engine's own fallback applies
}
if err := os.Symlink(target, link); err != nil {
t.Skipf("the probe cannot mirror the deployment's %s (%v): it would run on different conventions than the deployment does", target, err)
}
}
path := filepath.Join(cfgDir, "pipeline-zero-cost.yaml")
if err := os.WriteFile(path, out, 0o600); err != nil {
t.Fatal(err)
}
return path
}
// writeProbeBook renders a minimal live book: the deployment template with the identity, languages
// and source the intake would have filled in (the four keys books.Service.provision sets).
func writeProbeBook(t *testing.T, tpl, dir, bookID string) {
t.Helper()
raw, err := os.ReadFile(tpl)
if err != nil {
t.Fatal(err)
}
var cfg map[string]any
if err := yaml.Unmarshal(raw, &cfg); err != nil {
t.Fatal(err)
}
cfg["book_id"] = bookID
cfg["source_lang"] = "zh"
cfg["target_lang"] = "ru"
cfg["source_file"] = "source.txt"
// The probe runs on the deployment's ZERO-COST pair, and it is rendered here rather than assumed
// of the template: a deployment template is an OPERATOR's artefact whose pipeline points at
// whatever that deployment translates with — a paid model, on every stand built by the recipe in
// STACK_DECISIONS — and the engine refuses to load a configuration whose reachable models have no
// key long before any guard these tests are about could fire (config.checkKeysFor, which skips a
// provider of kind `local`). So the probe keeps the template's every other key and swaps the
// stage models for the local one, derived from the same models.yaml the book points at.
cfg["pipeline"] = zeroCostPipeline(t, str(t, cfg, "pipeline"), str(t, cfg, "models"))
out, err := yaml.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ConfigFile), out, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("第1章 测试\n这是一个测试文本。\n"), 0o600); err != nil {
t.Fatal(err)
}
}
// dirListing is the book directory as facts: every file name with a digest of its bytes.
func dirListing(t *testing.T, dir string) map[string]string {
t.Helper()
out := map[string]string{}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.IsDir() {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatal(err)
}
out[e.Name()] = string(rune(len(b))) + "-" + string(b[:min(len(b), 64)])
}
return out
}
// The rendering itself, on a configuration written for the purpose and with no engine involved: what
// makes the live probes free is that NO paid model stays reachable, and "reachable" includes the
// escalation hop of a stage. On the stand's own template that hop costs nothing to leave in — its
// escalation budget is zero, so the engine does not ask for its key — but a deployment configured for
// real work sets that budget, and then one leftover hop buys the probe a bill.
//
// Mutation caught: keeping `escalate_to` or `label_models` on a stage; leaving the escalation budget
// as the template set it; choosing a model that is not the local provider's.
func TestTheProbePipelineLeavesNoPaidModelReachable(t *testing.T) {
dir := t.TempDir()
models := filepath.Join(dir, "models.yaml")
if err := os.WriteFile(models, []byte(`
providers:
paid: { kind: openai_compatible, api_key_env: SOME_KEY }
bench: { kind: local, base_url: http://127.0.0.1:11434/v1 }
models:
expensive: { provider: paid }
free-one: { provider: bench }
`), 0o600); err != nil {
t.Fatal(err)
}
pipeline := filepath.Join(dir, "pipeline.yaml")
if err := os.WriteFile(pipeline, []byte(`
core: T
version: 1
stages:
- name: draft
role: translator
model: expensive
escalate_to: expensive
- name: edit
role: editor
model: expensive
label_models: { violence: expensive }
escalation:
budget_usd: 5
chains: { a: [expensive] }
`), 0o600); err != nil {
t.Fatal(err)
}
var rendered map[string]any
raw, err := os.ReadFile(zeroCostPipeline(t, pipeline, models))
if err != nil {
t.Fatal(err)
}
if err := yaml.Unmarshal(raw, &rendered); err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "expensive") {
t.Fatalf("a paid model survived the rendering:\n%s", raw)
}
stages, _ := rendered["stages"].([]any)
if len(stages) != 2 {
t.Fatalf("the rendering lost the stages: %v", rendered["stages"])
}
for _, s := range stages {
stage := s.(map[string]any)
if stage["model"] != "free-one" {
t.Errorf("stage %v runs on %v, want the local provider's model", stage["name"], stage["model"])
}
if _, ok := stage["escalate_to"]; ok {
t.Errorf("stage %v kept an escalation hop", stage["name"])
}
}
esc, _ := rendered["escalation"].(map[string]any)
if esc == nil || esc["budget_usd"] != 0 {
t.Errorf("the escalation budget is %v, want it spent down to nothing", esc["budget_usd"])
}
// The template's own shape is otherwise untouched — the probe runs the deployment's pipeline,
// not a pipeline of the test's invention.
if rendered["core"] != "T" || rendered["version"] != 1 {
t.Errorf("the rendering changed more than the models: %v", rendered)
}
}