334 lines
13 KiB
Go
334 lines
13 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")
|
|
}
|
|
if esc, ok := pipe["escalation"].(map[string]any); ok {
|
|
esc["budget_usd"] = 0
|
|
delete(esc, "chains")
|
|
}
|
|
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)
|
|
}
|
|
}
|