textmachine/backend/internal/pipeline/promptlabel_test.go

153 lines
6.8 KiB
Go

package pipeline
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"textmachine/backend/internal/config"
)
// promptlabel_test.go: `prompt_version` must track the BYTES of the prompt it labels.
//
// WHY THIS IS A GATE AND NOT A NORM. The norm already exists, written into the shipping config itself —
// pipeline-c2.yaml: «лейбл обязан следовать за новым SHA файла» — and it was broken anyway:
// prompts/zh-ru/editor.md was edited on 2026-08-01 while prompt_version stayed v3-discourse-reflow. The
// cold run of 31.08 then nearly built a causal conclusion on comparing itself against a July bench
// carrying that same label. Money was never at risk (PromptSHA256 folds into the snapshot, so the ENGINE
// always knew); what was at risk is the COMPARABILITY of two runs, which is what an experiment is made of.
// A rule that lives only in a comment inside a ratified decision, and is then skipped by the next ratified
// decision, is a rule that needs a machine.
//
// WHY A REPO GATE AND NOT A RUNTIME CHECK. A runtime check can only compare a label against the payloads
// THIS project database happens to hold, so it catches a label reused within one book. The incident was
// BETWEEN books — a bench project and a fresh one, different databases — which is exactly the case a
// runtime check cannot see. The repository is where both live, so the repository is where the pair is
// pinned. It is also $0, snapshot-neutral, and it fails at CI time rather than after a purchase.
//
// UPDATING IT IS THE POINT, NOT AN OBSTACLE: a deliberate prompt edit is expected to come with a new
// label, and `TM_UPDATE_PROMPT_LABELS=1 go test ./internal/pipeline/ -run TestPromptLabelsPinTheirBytes`
// re-writes the ledger — the same shape as TM_UPDATE_GOLDEN. What must never happen silently is the third
// case: the same label over different bytes.
// promptLabelLedger is the checked-in memory: "<pair>/<role>/<label>" → sha256 of the CANONICAL prompt
// (comments stripped — the same form the snapshot folds, so an edited comment costs nobody a re-purchase).
const promptLabelLedger = "testdata/prompt-labels.json"
// shippingPipelines are the configs whose prompts ship. The arms are included because an arm exists to
// isolate ONE variable, and a prompt that moved under a stale label puts a second one in the comparison.
var shippingPipelines = []string{
"pipeline-c1.yaml", "pipeline-c2.yaml",
"pipeline-arm-deepseek-pro.yaml", "pipeline-arm-glm.yaml", "pipeline-arm-mistral.yaml",
}
func TestPromptLabelsPinTheirBytes(t *testing.T) {
models, err := config.LoadModels(filepath.Join("..", "..", "configs", "models.yaml"))
if err != nil {
t.Fatalf("load the shipping models.yaml: %v", err)
}
const pair = "zh-ru" // the only pair with a prompt pack in the repo; a new pair joins this map by existing
seen := map[string]string{}
for _, pf := range shippingPipelines {
p, err := config.LoadPipeline(filepath.Join("..", "..", "configs", pf), models, pair, nil)
if err != nil {
t.Fatalf("load %s: %v", pf, err)
}
for _, st := range p.Stages {
key := fmt.Sprintf("%s/%s/%s", pair, st.Role, st.PromptVersion)
tpl, err := LoadPromptTemplate(st.PromptPath)
if err != nil {
t.Fatalf("%s stage %q: load its resolved prompt %s: %v", pf, st.Name, st.PromptPath, err)
}
// A label used by two stages must mean ONE file's bytes, or it is not a label.
if prev, dup := seen[key]; dup && prev != tpl.SHA256 {
t.Errorf("label %q names two different prompt bodies across the shipping configs (%s vs %s) — "+
"a label that is not a function of the bytes cannot make two runs comparable",
key, prev[:12], tpl.SHA256[:12])
}
seen[key] = tpl.SHA256
}
// The BANK ROLES resolve their prompts through the gate rather than through a stage, and they are
// paid calls whose comparability matters exactly as much: the terminologist's consolidation is what
// a book's bank ends up saying. They carry no prompt_version of their own, so the gate's own
// version string is the label — which is the honest answer, not a workaround: it is what a run logs
// as the identity of that contour.
if g := p.Gates.Terminology; g.Enabled && g.PromptPath != "" {
tpl, err := LoadPromptTemplate(g.PromptPath)
if err != nil {
t.Fatalf("%s: load the terminologist prompt %s: %v", pf, g.PromptPath, err)
}
seen[fmt.Sprintf("%s/terminologist/%s", pair, terminologyVersion)] = tpl.SHA256
}
}
if len(seen) == 0 {
t.Fatal("no shipping stage resolved a prompt — this gate would be enforcing nothing")
}
if os.Getenv("TM_UPDATE_PROMPT_LABELS") == "1" {
writePromptLedger(t, seen)
t.Logf("prompt-label ledger re-written with %d entr(ies) — review the diff: a NEW key is a "+
"deliberate bump, a CHANGED value under an existing key is the defect this gate exists for", len(seen))
return
}
raw, err := os.ReadFile(promptLabelLedger)
if err != nil {
t.Fatalf("read the prompt-label ledger (%s): %v — create it with TM_UPDATE_PROMPT_LABELS=1", promptLabelLedger, err)
}
var stored map[string]string
if err := json.Unmarshal(raw, &stored); err != nil {
t.Fatalf("the prompt-label ledger is not readable JSON: %v", err)
}
for _, key := range sortedKeys(seen) {
want, known := stored[key]
switch {
case !known:
t.Errorf("label %q is not in the ledger. If you BUMPED the label deliberately, re-write the "+
"ledger with TM_UPDATE_PROMPT_LABELS=1 and commit it — that is the whole point. If you did "+
"not, a stage is quoting a label nobody recorded.", key)
case want != seen[key]:
t.Errorf("⚠ THE PROMPT MOVED AND ITS LABEL DID NOT. %q was recorded over sha %s and now resolves "+
"to %s. Two runs carrying this label are NOT comparable, and nothing else in the repository "+
"would have said so (prompts/zh-ru/editor.md, 2026-08-01, is this defect's own history). "+
"Bump prompt_version in the shipping config and re-write the ledger with "+
"TM_UPDATE_PROMPT_LABELS=1 — or restore the bytes.", key, want[:12], seen[key][:12])
}
}
}
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// writePromptLedger renders the ledger deterministically: sorted keys, indented, trailing newline — so a
// re-write produces a diff a human reads rather than a re-ordering.
func writePromptLedger(t *testing.T, seen map[string]string) {
t.Helper()
var b strings.Builder
b.WriteString("{\n")
keys := sortedKeys(seen)
for i, k := range keys {
comma := ","
if i == len(keys)-1 {
comma = ""
}
fmt.Fprintf(&b, " %q: %q%s\n", k, seen[k], comma)
}
b.WriteString("}\n")
if err := os.MkdirAll(filepath.Dir(promptLabelLedger), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(promptLabelLedger, []byte(b.String()), 0o644); err != nil {
t.Fatal(err)
}
}