153 lines
6.8 KiB
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)
|
|
}
|
|
}
|