284 lines
12 KiB
Go
284 lines
12 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"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).
|
||
//
|
||
// D23.4 note: the nested key here resolves to CONFIRMED — the capture shows ambiguous=0
|
||
// on every chunk (both keys are longest-match approved surfaces, not collision-prone
|
||
// downgrades). A cell with AMBIGUOUS>0 (an auto/draft candidate forcing a post-check)
|
||
// is an OPTIONAL fixture extension, deliberately NOT added in this pass: it would be a
|
||
// fresh ratified golden capture of its own, and this package's re-capture is scoped to
|
||
// the D24.3/D24.4 diff-class (floor max_tokens + post-check verdicts).
|
||
//
|
||
// 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 are unique substrings of chapters 2/3 of the
|
||
// fixture source; the mock provider keys its echo/refusal behaviour off them.
|
||
const (
|
||
goldenEchoMarker = "響動計画"
|
||
goldenRefusalMarker = "拒絶計画"
|
||
)
|
||
|
||
// 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, 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):
|
||
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 }
|
||
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 }
|
||
`, 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)
|
||
snapID, payload, err := r.snapshotID()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
w("snapshot_id: %s", snapID)
|
||
w("snapshot_payload: %s", payload)
|
||
w("brief_hash: %s", r.Book.BriefHash())
|
||
w("memory_version: %s", r.memoryVersion())
|
||
|
||
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", st.Text)
|
||
}
|
||
}
|
||
|
||
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 == snapID, 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",
|
||
rs.Chapter, rs.ChunkIdx, rs.SnapshotID == snapID, rs.NExactHits, rs.NSticky, rs.NAmbiguousFlagged,
|
||
rs.NSpoilerBlocked, rs.NEvicted, rs.NPostcheckMiss, rs.NStyleFlags)
|
||
w(" injected_ids=%s postcheck_detail=%s style_detail=%s", rs.InjectedIDs, rs.PostcheckDetail, rs.StyleDetail)
|
||
}
|
||
|
||
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" {
|
||
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))
|
||
}
|
||
}
|
||
|
||
// 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))
|
||
}
|