textmachine/backend/internal/pipeline/golden_test.go

445 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"testing"
"time"
"textmachine/backend/internal/obs"
)
// golden_test.go — the refactor determinism guard (бэкенд-пакет №4, железное
// ограничение №1): on the static fixture book (testdata/golden/) it pins BIT-FOR-BIT
// the snapshotID + payload, every stage's request_hash and wire body, the chunk_status
// / retrieval_state rows and the final rendered texts — for a fresh run AND a resume
// run. Any refactoring step that changes a single byte of the wire or of a resolved
// verdict fails this test loudly: a changed request_hash = a missed checkpoint =
// --resnapshot = the whole book re-billed (D15), which no code-health win can pay for.
//
// The fixture deliberately walks the money-relevant paths in one book: a 2-chunk
// chapter (sticky window + injection), a spoiler-blocked term (since_ch: 2), a nested
// glossary key (紋章 inside 竜の紋章) exercising the collision/longest-match path, a
// CJK-echo draft cured by the single-hop escalation re-gate (ch2), and a hard refusal
// where the fallback also refuses → flag + skipped edit + exit 2 (ch3).
//
// D28.2 fixture expansion (D30.9 diff-class, this package): three ratified behaviours now
// PINNED by golden, not only by the dedicated unit tests —
// - FLOOR: fake-model floors max_tokens to 4000, fake-fallback to 6000 (per-model
// capabilities.min_max_tokens) → the wire max_tokens and the resolved capability move,
// and the escalation HOP takes ITS OWN model's floor (ch2 hop = 6000, primary = 4000);
// - UNION: ch5 fires an OBLIQUE-ONLY decl term (老人→старик) whose FINAL text carries the
// NOMINATIVE «старик» → postcheck_miss=0 only because the base-dstdecl union checks the
// base (revert the union → this chunk false-flags a miss);
// - SANITIZER (SUBSTANTIVE): ch6 edit leaks a service preamble → the output-sanitizer gate (ON
// in the fixture pipeline) flags it FlagSanitizerDefect and the edit is DROPPED (final_text "").
//
// D38 infra-pack cells (pin the cosmetic strip-and-export path, D35.4a — final_hash points at a
// derived $0 sanitized checkpoint so the export contract yields the cleaned text; resume re-serves
// it identically at $0):
// - SANITIZER (COSMETIC markdown): ch7 edit leaks a leading «### Глава 7» → flagged
// sanitizer_stripped, final_text = «Глава 7\n\n…» (the «### » stripped), a derived checkpoint holds it;
// - SANITIZER (COSMETIC CJK): ch8 edit leaks a sparse «特产» → flagged sanitizer_stripped, final_text
// = the run removed and the seam tidied.
//
// A cell with AMBIGUOUS>0 (an auto/draft candidate forcing a post-check) remains an OPTIONAL
// future extension, deliberately NOT added here.
//
// Update (ONLY on a deliberate, ratified behaviour change — never to "fix" a red
// refactor): TM_UPDATE_GOLDEN=1 go test ./internal/pipeline/ -run TestGolden
const goldenFile = "testdata/golden/capture.golden"
// goldenEchoMarker / goldenRefusalMarker / goldenUnionMarker / goldenSanitizerMarker are
// unique substrings of chapters 2/3/5/6 of the fixture source; the mock keys its
// echo / refusal / union / sanitizer behaviour off them.
const (
goldenEchoMarker = "響動計画"
goldenRefusalMarker = "拒絶計画"
goldenUnionMarker = "老人の秘密" // ch5 — oblique-only decl union case
goldenSanitizerMarker = "序文漏洩" // ch6 — sanitizer preamble-leak case (SUBSTANTIVE → dropped)
goldenMarkdownMarker = "見出漏洩" // ch7 — cosmetic markdown «### Глава» leak (STRIPPED + exported)
goldenCJKMarker = "漢字漏洩" // ch8 — cosmetic CJK-leak «特产» (STRIPPED + exported)
)
// goldenRespond is the deterministic mock provider brain. The response text is a pure
// function of the request body (no time, no randomness): default drafts/edits carry a
// body-hash suffix so every chunk×stage output is distinct, plus glossary dst forms so
// the post-check exercises both hit and miss.
func goldenRespond(body string) (text, finish string) {
var req struct {
Model string `json:"model"`
}
_ = json.Unmarshal([]byte(body), &req)
sum := sha256.Sum256([]byte(body))
tag := hex.EncodeToString(sum[:])[:12]
switch {
case strings.Contains(body, goldenRefusalMarker):
// Both the primary and the fallback refuse → the chunk stays flagged (1 hop).
return "Не могу помочь с этим фрагментом.", "refusal"
case strings.Contains(body, goldenSanitizerMarker) && !isEditBody(body):
// ch6 draft ALSO leaks a preamble AND carries a keying token («ПРЕАМБУЛА-СЦЕНА»). The
// sanitizer runs ONLY on the FINAL (edit) output (адверсариальное ревью: не санитайзить
// промежуточный черновик), so with isFinal this draft stays OK and the edit is the one
// flagged. This PINS isFinal: revert «isFinal &&» in classifyOutput and the DRAFT flags
// instead (edit skipped) → golden diff.
return "Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь.", "stop"
case strings.Contains(body, goldenUnionMarker) && !isEditBody(body):
// ch5 draft carries a keying token («СТАРИК-СЦЕНА») the edit branch below keys on.
return "ЧЕРНОВИК ПЕРЕВОДА " + tag + ". СТАРИК-СЦЕНА Судзуки увидел старика.", "stop"
case strings.Contains(body, goldenMarkdownMarker) && !isEditBody(body):
// ch7 draft carries a keying token («МАРКДАУН-СЦЕНА») the cosmetic edit branch keys on.
return "ЧЕРНОВИК ПЕРЕВОДА " + tag + ". МАРКДАУН-СЦЕНА Судзуки открыл седьмую дверь.", "stop"
case strings.Contains(body, goldenCJKMarker) && !isEditBody(body):
// ch8 draft carries a keying token («ИЕРОГЛИФ-СЦЕНА») the cosmetic edit branch keys on.
return "ЧЕРНОВИК ПЕРЕВОДА " + tag + ". ИЕРОГЛИФ-СЦЕНА Судзуки нашёл камень.", "stop"
case strings.Contains(body, goldenEchoMarker) && !isEditBody(body):
if req.Model == "fake-fallback" {
// The escalation hop returns a clean translation → the re-gate passes it.
return "ЭСКАЛАЦИОННЫЙ ПЕРЕВОД " + tag + ". Судзуки вынес Драконью печать из Академии магии.", "stop"
}
// The primary draft echoes CJK instead of translating → cjk_artifact.
return "夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。", "stop"
case isEditBody(body) && strings.Contains(body, "ПРЕАМБУЛА-СЦЕНА"):
// ch6 edit leaks a service preamble → the output-sanitizer flags the FINAL stage
// (FlagSanitizerDefect). Colon after «фрагмента» is the high-precision signal.
return "Вот перевод фрагмента: Судзуки открыл дверь книгохранилища.", "stop"
case isEditBody(body) && strings.Contains(body, "СТАРИК-СЦЕНА"):
// ch5 edit — the FINAL text carries the NOMINATIVE «старик» (not an oblique decl
// form of 老人→старик), exercising the base-dstdecl union → postcheck_miss=0.
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД " + tag + ". Судзуки увидел, как в тени ждал старик.", "stop"
case isEditBody(body) && strings.Contains(body, "МАРКДАУН-СЦЕНА"):
// ch7 edit leaks a leading markdown «### Глава» header — a COSMETIC class: the sanitizer
// STRIPS it and exports the remainder flagged sanitizer_stripped (D35.4a). This PINS the
// strip-and-export path: final_text = «Глава 7\n\n…» (no «### »), disposition=flagged.
return "### Глава 7\n\nСудзуки открыл седьмую дверь книгохранилища.", "stop"
case isEditBody(body) && strings.Contains(body, "ИЕРОГЛИФ-СЦЕНА"):
// ch8 edit leaks a sparse CJK run «特产» in otherwise-Russian prose (below the 15% echo
// threshold classify() catches) — a COSMETIC class: stripped + exported flagged. PINS the
// CJK-leak strip: «Судзуки нашёл 特产 древний камень.» → «Судзуки нашёл древний камень.»
return "Судзуки нашёл 特产 древний камень на каменном алтаре.", "stop"
case isEditBody(body):
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД " + tag + ". Судзуки шёл по коридорам Академии магии.", "stop"
default:
return "ЧЕРНОВИК ПЕРЕВОДА " + tag + ". Судзуки шёл по коридорам Академии магии.", "stop"
}
}
// setupGoldenProject copies the static fixture into a temp dir and writes models.yaml
// (the only runtime-generated file: it carries the live mock URL and a fresh
// prices_checked date — neither enters the snapshot, request hashes or wire bodies).
func setupGoldenProject(t *testing.T, providerURL string) string {
t.Helper()
dir := t.TempDir()
src := filepath.Join("testdata", "golden")
for _, f := range []string{"book.yaml", "pipeline.yaml", "source.txt", "glossary-seed.yaml",
filepath.Join("prompts", "translator.md"), filepath.Join("prompts", "editor.md")} {
data, err := os.ReadFile(filepath.Join(src, f))
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, f), string(data))
}
writeFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
prices_checked: %q
default_model: fake-model
providers:
fake:
kind: openai
base_url: %q
timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
models:
fake-model:
provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
capabilities: { min_max_tokens: 4000 } # D28.2: floor the PRIMARY model — pins floored max_tokens on the wire
fake-fallback:
provider: fake
price: { input_per_m: 3.0, cached_per_m: 0.3, cache_write_per_m: 0, output_per_m: 6.0 }
capabilities: { min_max_tokens: 6000 } # D28.2: a DISTINCT hop floor — pins that the hop re-floors by ITS own model
`, time.Now().UTC().Format("2006-01-02"), providerURL))
return filepath.Join(dir, "book.yaml")
}
// captureGolden renders the full deterministic state of a run as one canonical text
// blob. Everything time/host-dependent (latency, timestamps, temp paths, provider
// URL) is deliberately excluded; everything byte/verdict-relevant is included.
func captureGolden(t *testing.T, label string, r *Runner, res *BookResult, wireBodies []string) string {
t.Helper()
var b strings.Builder
w := func(format string, args ...any) { fmt.Fprintf(&b, format+"\n", args...) }
fl := func(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }
w("==== run %s ====", label)
// R1: the driver pins each wave's jobs/rows to its OWN snapshot (draft rows → the draft-wave snapshot
// over the base bank, edit rows → the edit-wave snapshot over the enriched bank), so the golden pins
// BOTH — and the base/enriched split that keeps «переоплата ОДНА».
draftSnap, draftSnapPayload, err := r.snapshotIDForWave(waveDraft)
if err != nil {
t.Fatal(err)
}
editSnap, editSnapPayload, err := r.snapshotIDForWave(waveEdit)
if err != nil {
t.Fatal(err)
}
w("snapshot_draft: %s", draftSnap)
w("snapshot_draft_payload: %s", draftSnapPayload)
w("snapshot_edit: %s", editSnap)
w("snapshot_edit_payload: %s", editSnapPayload)
w("brief_hash: %s", r.Book.BriefHash())
w("memory_version: %s", r.memoryVersion())
w("base_memory_version: %s", r.baseMemoryVersion())
// stageWaveSnap maps a stage NAME to its wave's snapshot, so each stored row's snap_match compares
// against the RIGHT per-wave snapshot (a draft row against the draft-wave snapshot, an edit row against
// the edit-wave one). retrieval_state rows are draft-basis (written in the draft wave), so they compare
// against the draft-wave snapshot.
stageWaveSnap := map[string]string{}
for _, ws := range r.waveStagesIndexed(waveDraft) {
stageWaveSnap[ws.st.Name] = draftSnap
}
for _, ws := range r.waveStagesIndexed(waveEdit) {
stageWaveSnap[ws.st.Name] = editSnap
}
w("-- book result --")
w("chunks=%d flagged=%d exit=%d total_usd=%s", len(res.Chunks), res.Flagged, res.ExitCode(), fl(res.TotalUSD))
for _, ch := range res.Chunks {
w("chunk ch%d/%d disposition=%s flag=%q final_text=%q cost=%s",
ch.Chapter, ch.ChunkIdx, ch.Disposition, ch.FlagReason, ch.FinalText, fl(ch.CostUSD))
for _, st := range ch.Stages {
w(" stage=%s role=%s model=%s resume=%t disp=%s flag=%q attempts=%d escalated=%t esc_model=%q finish=%q cum_usd=%s detail=%q",
st.Stage, st.Role, st.Model, st.FromResume, st.Disposition, st.FlagReason,
st.Attempts, st.Escalated, st.EscalationModel, st.FinishReason, fl(st.CumCostUSD), st.Detail)
w(" stage_text=%q recovered=%q", st.Text, st.RecoveredText)
}
}
w("-- chunk_status --")
css, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
t.Fatal(err)
}
sort.Slice(css, func(i, j int) bool {
a, c := css[i], css[j]
if a.Chapter != c.Chapter {
return a.Chapter < c.Chapter
}
if a.ChunkIdx != c.ChunkIdx {
return a.ChunkIdx < c.ChunkIdx
}
return a.Stage < c.Stage
})
for _, cs := range css {
w("ch%d/%d %s snap_match=%t content_hash=%s disp=%s flag=%q attempts=%d final_hash=%s cost=%s escalated=%t esc_model=%q detail=%q",
cs.Chapter, cs.ChunkIdx, cs.Stage, cs.SnapshotID == stageWaveSnap[cs.Stage], cs.ContentHash, cs.Disposition,
cs.FlagReason, cs.Attempts, cs.FinalHash, fl(cs.CostUSD), cs.Escalated, cs.EscalationModel, cs.Detail)
}
w("-- request_log (insertion order) --")
rls, err := r.Store.RequestLogRows(r.Book.BookID)
if err != nil {
t.Fatal(err)
}
for _, rl := range rls {
w("ch%d/%d %s role=%s req=%s actual=%s hash=%s tm_hit=%d ok=%d finish=%q degraded=%q cost=%s tokens=%d/%d/%d/%d/%d err=%q",
rl.Chapter, rl.ChunkIdx, rl.Stage, rl.Role, rl.ModelRequested, rl.ModelActual, rl.RequestHash,
rl.TMHit, rl.OK, rl.FinishReason, rl.Degraded, fl(rl.CostUSD),
rl.PromptTokens, rl.CachedTokens, rl.CacheCreationTokens, rl.CompletionTokens, rl.ReasoningTokens, rl.Err)
}
w("-- retrieval_state --")
rss, err := r.Store.RetrievalStatesForBook(r.Book.BookID)
if err != nil {
t.Fatal(err)
}
sort.Slice(rss, func(i, j int) bool {
if rss[i].Chapter != rss[j].Chapter {
return rss[i].Chapter < rss[j].Chapter
}
return rss[i].ChunkIdx < rss[j].ChunkIdx
})
for _, rs := range rss {
w("ch%d/%d snap_match=%t exact=%d sticky=%d ambiguous=%d spoiler=%d evicted=%d postcheck_miss=%d style_flags=%d trust_gated=%d",
rs.Chapter, rs.ChunkIdx, rs.SnapshotID == draftSnap, rs.NExactHits, rs.NSticky, rs.NAmbiguousFlagged,
rs.NSpoilerBlocked, rs.NEvicted, rs.NPostcheckMiss, rs.NStyleFlags, rs.NTrustGatedSuppress)
w(" injected_ids=%s postcheck_detail=%s style_detail=%s trust_gate_detail=%s", rs.InjectedIDs, rs.PostcheckDetail, rs.StyleDetail, rs.TrustGateDetail)
}
w("-- wire bodies (call order) --")
for i, body := range wireBodies {
w("[%d] %s", i, body)
}
return b.String()
}
// TestGoldenDeterminism is the refactor guard: fresh run + resume run against the
// static fixture must match testdata/golden/capture.golden byte-for-byte.
func TestGoldenDeterminism(t *testing.T) {
rec := &reqRec{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
rec.record(string(body))
text, finish := goldenRespond(string(body))
tb, _ := json.Marshal(text)
var req struct {
Model string `json:"model"`
}
_ = json.Unmarshal(body, &req)
fmt.Fprintf(w, `{"id":"fake","model":%q,"choices":[{"message":{"content":%s},"finish_reason":%q}],
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`,
req.Model, tb, finish)
}))
defer srv.Close()
bookPath := setupGoldenProject(t, srv.URL)
ctx := obs.WithReqInfo(t.Context(), obs.ReqInfo{TraceID: "golden-trace"})
// Run 1: fresh book, every call is a fresh reserve→call→settle.
r1 := newRunner(t, bookPath)
res1, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
capture := captureGolden(t, "1 (fresh)", r1, res1, rec.all())
freshCalls := rec.count()
r1.Close()
// Run 2: resume from checkpoints/chunk_status — must make ZERO provider calls
// and reproduce the identical texts/verdicts (the wire-bodies section stays as
// captured in run 1; a resume that re-called the provider would append to it).
r2 := newRunner(t, bookPath)
defer r2.Close()
res2, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if rec.count() != freshCalls {
t.Fatalf("resume made %d extra provider calls", rec.count()-freshCalls)
}
capture += captureGolden(t, "2 (resume)", r2, res2, nil)
if os.Getenv("TM_UPDATE_GOLDEN") == "1" {
// Ш-1: print the masked structural diff BEFORE writing, so a re-capture is safe-by-construction —
// a version-only re-capture shows 0 verdict/wire changes; a ratified structural change (the R1 wave
// switch) shows them for a human to confirm (attached to the landing report).
if prev, rerr := os.ReadFile(goldenFile); rerr == nil {
t.Logf("%s", goldenMaskedDiff(string(prev), capture))
}
if err := os.WriteFile(goldenFile, []byte(capture), 0o644); err != nil {
t.Fatal(err)
}
t.Logf("golden updated: %s (%d bytes, %d provider calls)", goldenFile, len(capture), freshCalls)
return
}
want, err := os.ReadFile(goldenFile)
if err != nil {
t.Fatalf("golden file missing (generate once with TM_UPDATE_GOLDEN=1): %v", err)
}
if string(want) != capture {
t.Fatalf("golden mismatch: the run's bytes diverged from %s.\nЭто значит, что рефакторинг изменил wire-байты, хеши или вердикты — "+
"любое такое изменение = --resnapshot = переоплата книги (D15). Найди и убери причину; обновлять golden можно только "+
"на осознанной, ратифицированной смене поведения.\n%s", goldenFile, diffHint(string(want), capture))
}
}
// goldenHexRE matches a hash-like run (≥12 hex chars) — snapshot ids, request/content/final hashes, the
// memory versions — everything that moves on a --resnapshot without a verdict change.
var goldenHexRE = regexp.MustCompile(`[0-9a-f]{12,}`)
// goldenWireIdxRE matches a wire-body line's call index prefix ("[12] ") — normalised so a call reorder
// (the wave driver runs all drafts, then all edits) is not counted as a wire change.
var goldenWireIdxRE = regexp.MustCompile(`^\[\d+\] `)
// maskGoldenLines masks the volatile hash/version fields of a golden capture: a snapshot PAYLOAD line
// (metadata whose version strings + field set move on a toolchain/table/schema bump) is blanked whole; every
// other line has its hash-like runs replaced. What survives is the verdict/wire content — final texts,
// dispositions, flags, counts, and the request BODIES (whose bytes are the wire).
func maskGoldenLines(s string) []string {
lines := strings.Split(s, "\n")
for i, ln := range lines {
if strings.HasPrefix(ln, "snapshot_draft_payload:") || strings.HasPrefix(ln, "snapshot_edit_payload:") || strings.HasPrefix(ln, "snapshot_payload:") {
lines[i] = "snapshot_payload: ⟨masked⟩"
continue
}
// Normalise the wire-body call index: the wave driver reorders the calls (all drafts, then all
// edits) vs the sequential interleave, so a byte-identical request body carries a different [N].
// Masking the index lets a reordered-but-identical body match, so the diff shows only GENUINE
// wire changes (the concatenated edit input of a multi-chunk unit), not the reorder.
ln = goldenWireIdxRE.ReplaceAllString(ln, "[⟨i⟩] ")
lines[i] = goldenHexRE.ReplaceAllString(ln, "⟨hex⟩")
}
return lines
}
// multisetDiff returns the lines of `a` that are not covered by `b` (multiset semantics), i.e. lines added
// or changed going b→a. Order-insensitive, so it survives the row insert/delete a structural change makes.
func multisetDiff(a, b []string) []string {
counts := map[string]int{}
for _, ln := range b {
counts[ln]++
}
var out []string
for _, ln := range a {
if counts[ln] > 0 {
counts[ln]--
continue
}
out = append(out, ln)
}
return out
}
// goldenMaskedDiff (Ш-1) makes a golden re-capture SAFE-BY-CONSTRUCTION: before overwriting the golden it
// masks every volatile hash/version field in BOTH the old golden and the new capture, then reports how many
// lines changed ONLY in masked fields ("version-only") vs in real verdict/wire content. A clean toolchain/
// version re-capture reports ZERO verdict/wire changes; a ratified structural change (the R1 wave switch)
// reports them so a human confirms each is intended (the masked diff attached to the landing report). The
// masked lines are compared as multisets, so row insertions/deletions (the wave switch drops per-unit edit
// rows) do not smear the count across every following line.
func goldenMaskedDiff(oldGolden, newCapture string) string {
rawAdded := multisetDiff(strings.Split(newCapture, "\n"), strings.Split(oldGolden, "\n"))
rawRemoved := multisetDiff(strings.Split(oldGolden, "\n"), strings.Split(newCapture, "\n"))
maskedAdded := multisetDiff(maskGoldenLines(newCapture), maskGoldenLines(oldGolden))
maskedRemoved := multisetDiff(maskGoldenLines(oldGolden), maskGoldenLines(newCapture))
verdictChanges := len(maskedAdded) + len(maskedRemoved)
versionOnly := (len(rawAdded) + len(rawRemoved)) - verdictChanges
var b strings.Builder
fmt.Fprintf(&b, "golden masked-diff (Ш-1): %d verdict/wire change line(s), %d version-only line(s)\n", verdictChanges, versionOnly)
show := maskedAdded
if len(maskedRemoved) > len(show) {
show = maskedRemoved
}
for i, ln := range show {
if i >= 30 {
fmt.Fprintf(&b, " … (+%d more masked changes)\n", len(show)-i)
break
}
fmt.Fprintf(&b, " ~ %s\n", ln)
}
return b.String()
}
// diffHint points a human at the first diverging line — the full capture is too big
// for a useful t.Fatalf dump.
func diffHint(want, got string) string {
wl, gl := strings.Split(want, "\n"), strings.Split(got, "\n")
n := len(wl)
if len(gl) < n {
n = len(gl)
}
for i := 0; i < n; i++ {
if wl[i] != gl[i] {
return fmt.Sprintf("first divergence at line %d:\n want: %s\n got: %s", i+1, wl[i], gl[i])
}
}
return fmt.Sprintf("line counts differ: want %d, got %d", len(wl), len(gl))
}