textmachine/backend/internal/pipeline/echoregen_test.go

372 lines
17 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 (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// echoregen_test.go: the OPT-IN echo re-generation — `retries.regenerate_echo_before_escalate`
// (config.Retries.RegenerateEchoBeforeEscalate), read at stagerun.go's `echoRegen`.
//
// WHY THIS FILE EXISTS AT ALL. The knob shipped with D39.64 and, until this file, had ZERO tests:
// `grep -rn RegenerateEchoBeforeEscalate --include=*_test.go` returned nothing. It is a knob on the
// money path — it decides whether an echo costs a same-model re-generation or a hop to the expensive
// model — and nothing held it. Turning it on in the shipping configs without a test would have been
// changing a paid path on the strength of a code read.
//
// The two tests are the two halves a default change needs: the knob does what it claims (the
// re-generation replaces the hop), and turning it on moves NO snapshot, so nothing already bought is
// re-bought by the change.
// echoOnFirstDraft answers an OpenAI-compatible completion as a provider whose echo is STOCHASTIC PER
// CALL (D39.61): the first PRIMARY draft call returns the CJK source verbatim — an echo, which classify
// reads as cjk_artifact — and every later primary call translates cleanly. That is the measured shape
// the knob exists for: on such a provider a re-generation recovers the chunk without the hop, and the
// premise the knob inverts ("a same-model retry just re-produces the echo", disposition.go) is false.
type echoOnFirstDraft struct {
mu sync.Mutex
primaryDrafts int
}
func (e *echoOnFirstDraft) respond(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
if strings.Contains(body, "fake-fallback") {
return "Тихое утро в библиотеке (дорогая модель).", "stop"
}
e.mu.Lock()
defer e.mu.Unlock()
e.primaryDrafts++
if e.primaryDrafts == 1 {
return "静かな図書館の朝。", "stop" // the echo
}
return "Тихое утро в библиотеке.", "stop"
}
func (e *echoOnFirstDraft) count() int { e.mu.Lock(); defer e.mu.Unlock(); return e.primaryDrafts }
// newPricedProvider is newJSONProvider with ONE difference, and the difference is the point: it answers
// with the model the REQUEST asked for instead of the constant "fake-model".
//
// Money is priced off the model that actually ANSWERED (internal/ledger.PriceForResponse, «цена — по
// фактически ответившей модели», backend/README.md invariant 1), so a fake that always names one model
// prices an escalation hop at the primary's rate. On the shared fixture that is harmless; here it would
// silently erase the only thing this file measures — the price difference between a hop and a
// re-generation — and the cost assertion below would pass on two equal numbers. Found by writing the
// assertion and watching it report regen=escalate=0.00546000 on identical prices.
func newPricedProvider(rec *reqRec, respond func(body string) (text, finish string)) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
rec.record(string(body))
var req struct {
Model string `json:"model"`
}
_ = json.Unmarshal(body, &req)
if req.Model == "" {
req.Model = "fake-model"
}
text, finish := respond(string(body))
if finish == "" {
finish = "stop"
}
tb, _ := json.Marshal(text)
mb, _ := json.Marshal(req.Model)
fmt.Fprintf(w, `{"id":"fake","model":%s,"choices":[{"message":{"content":%s},"finish_reason":%q}],
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`,
mb, tb, finish)
}))
}
// setupEchoRegen writes a ONE-chunk book whose draft escalates to `fake-fallback`, with the echo
// re-generation budget set to echoRegen.
//
// The fallback is priced TEN TIMES the primary on output. That is not decoration: the whole claim of
// the knob is economic — an escalation hop is the expensive answer to an echo and a re-generation is
// the cheap one — and a fixture where both models cost the same would pin the ROUTING while saying
// nothing about the money, which is the half the shipping default is being changed for.
func setupEchoRegen(t *testing.T, providerURL string, echoRegen int) string {
t.Helper()
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "prompts", "translator.md"),
"Переводи с {{source_lang}} на {{target_lang}}.\n---USER---\n{{text}}")
writeFile(t, filepath.Join(dir, "prompts", "editor.md"),
"Редактируй перевод.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}")
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: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 20.0 }
`, time.Now().UTC().Format("2006-01-02"), providerURL))
writeFile(t, filepath.Join(dir, "pipeline.yaml"), fmt.Sprintf(`
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
retries: { regenerate_before_escalate: 0, regenerate_echo_before_escalate: %d }
stages:
- { name: draft, role: translator, model: fake-model, prompt_override: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: "off", escalate_to: fake-fallback }
- { name: edit, role: editor, model: fake-model, prompt_override: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off" }
escalation: { budget_usd: 5.0 }
`, echoRegen))
writeFile(t, filepath.Join(dir, "source.txt"), "静かな図書館の朝。")
writeFile(t, filepath.Join(dir, "book.yaml"), `
book_id: test-book
title: Тест
source_lang: ja
target_lang: ru
genre: ранобэ
audience: тест
venuti: 0.5
honorifics: keep
transcription: polivanov
footnotes: minimal
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 5.0, day_usd: 10.0 }
`)
return filepath.Join(dir, "book.yaml")
}
// TestEchoRegenReplacesTheHop is the knob's whole claim, run twice on the SAME provider: with the
// budget at 0 an echo goes straight to the expensive hop; with it at 1 the same echo is re-generated
// on the primary and the hop never happens. Both runs ship a clean chunk — the knob changes WHAT WAS
// BOUGHT, not what the reader gets, which is exactly why it can be defaulted on evidence.
//
// Mutation this catches: delete the `att.cls.Reason == FlagCJKArtifact && attempt < echoRegen` branch
// in stagerun.go and the echoRegen=1 arm escalates → its hop count becomes 1 and the assertion on
// "no fallback call" goes RED. Weakening it to `attempt <= echoRegen` doubles the primary calls → also RED.
func TestEchoRegenReplacesTheHop(t *testing.T) {
type arm struct {
echoRegen int
wantPrimary int // fresh calls on fake-model's DRAFT stage
wantEscalated bool // did the unit ride the hop
}
arms := []arm{
{echoRegen: 0, wantPrimary: 1, wantEscalated: true}, // the shipped-until-now behaviour
{echoRegen: 1, wantPrimary: 2, wantEscalated: false}, // the behaviour the default change buys
}
var spend [2]float64
for i, a := range arms {
t.Run(fmt.Sprintf("echo_regen=%d", a.echoRegen), func(t *testing.T) {
prov := &echoOnFirstDraft{}
rec := &reqRec{}
srv := newPricedProvider(rec, prov.respond)
defer srv.Close()
bookPath := setupEchoRegen(t, srv.URL, a.echoRegen)
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
if len(res.Chunks) != 1 {
t.Fatalf("want 1 chunk, got %d", len(res.Chunks))
}
oc := res.Chunks[0]
if oc.Disposition != DispOK {
t.Fatalf("both arms must SHIP the chunk — the knob changes the route, not the result; got %s/%s",
oc.Disposition, oc.FlagReason)
}
if got := prov.count(); got != a.wantPrimary {
t.Fatalf("primary draft calls = %d, want %d (echo_regen=%d)", got, a.wantPrimary, a.echoRegen)
}
if got := oc.Stages[0].Escalated; got != a.wantEscalated {
t.Fatalf("draft escalated = %t, want %t (echo_regen=%d) — with a re-generation budget the "+
"echo must be recovered on the SAME model and the hop must never fire", got, a.wantEscalated, a.echoRegen)
}
committed, _, err := r.Store.SpentUSD(r.Book.BookID)
if err != nil {
t.Fatal(err)
}
spend[i] = committed
})
}
// The economic half, structural rather than incidental: the fallback costs 10× on output, so the
// re-generating arm must come out CHEAPER. Asserted as a strict inequality, not as a ratio — the
// ratio is a property of this fixture's price table, the DIRECTION is the property of the knob.
if !(spend[1] < spend[0]) {
t.Fatalf("the re-generating arm must cost LESS than the escalating one, got regen=%.8f escalate=%.8f",
spend[1], spend[0])
}
}
// TestEchoRegenBudgetMovesNoSnapshot is the machine gate the default change is allowed by, and it is
// permanent rather than a one-off measurement: the re-generation budgets are deliberately NOT folded
// into buildSnapshotID (backend/README.md, «`Retries` НЕ фолдится в снапшот … сознательно так же для
// ручки `RegenerateEchoBeforeEscalate`, D39.64»), so turning the knob on cannot invalidate a single
// paid checkpoint. Grepping snapshot.go for `Retries` proves only that a NAME is absent; this renders
// the ID on a fixture with the knob off and on and compares the bytes.
//
// ⛔ IT MEASURES THE CONFIG PATH FIRST, and that ordering is not cosmetic. An earlier version of this
// test only poked `r.Pipeline.Retries` on a live runner, which pins the STRUCT FIELD — and this codebase
// computes most snapshot inputs at LOAD (PromptSHA256, LangpackVersion, EmbeddedVersion, MemoryVersion
// are all resolved before a Runner exists). A fold derived at load from the same YAML key would sail
// straight through an in-memory poke while two BOOKS differing only in that key rendered different ids,
// i.e. the «--resnapshot re-buys every paid checkpoint» catastrophe shipping green. Demonstrated by an
// adversarial pass over this pack, which built exactly that fold and watched the old gate stay green.
// So the load path is measured on two real fixtures, and the in-memory arm is kept only as the cheap
// second axis it always was.
//
// ⚠ WHAT THIS PINS IS A TRADE, NOT A FREE LUNCH, and the trade is named so a later reader does not
// read the green as "nothing depends on this". Because the budget is outside the snapshot, two runs
// that made a DIFFERENT NUMBER of re-generations share one snapshot id and are externally
// indistinguishable by it. That is sound for MONEY — every attempt carries its own request_hash
// (attempt is in it), so a resume replays exactly what was paid for and nothing is re-bought — and it
// is a real gap for COMPARABILITY, whose carrier belongs in the report (the attempt>0 slice of the
// paid tail), never in the snapshot. Folding it would make every existing book re-payable to buy a
// number that is already durable in `checkpoints.attempt`.
//
// Mutation this catches: fold the KNOB'S VALUE into the payload — `EchoRegen int` set from
// r.Pipeline.Retries.RegenerateEchoBeforeEscalate, or a string derived from it at load. ⚠ NOT "add any
// field": a CONSTANT added to the payload shifts both ids identically and this test stays green, which is
// correct — the question it asks is whether the KNOB reaches the id, not whether the payload changed.
func TestEchoRegenBudgetMovesNoSnapshot(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// --- ARM 1, THE LOAD PATH: two BOOKS whose pipeline.yaml differs in this key and nothing else. ---
// This is the measurement the pack was ordered to show: the snapshot id before and after the diff the
// session actually shipped, which is a change to YAML files and not to a struct field.
renderFor := func(t *testing.T, echoRegen int) (string, string) {
t.Helper()
rr := newRunner(t, setupEchoRegen(t, srv.URL, echoRegen))
defer rr.Close()
if got := rr.Pipeline.Retries.RegenerateEchoBeforeEscalate; got != echoRegen {
t.Fatalf("the fixture's YAML did not reach the loaded config: want %d, got %d", echoRegen, got)
}
d, _, err := rr.snapshotIDForWave(waveDraft)
if err != nil {
t.Fatalf("render the draft snapshot at echo_regen=%d: %v", echoRegen, err)
}
e, _, err := rr.snapshotIDForWave(waveEdit)
if err != nil {
t.Fatalf("render the edit snapshot at echo_regen=%d: %v", echoRegen, err)
}
return d, e
}
dOff, eOff := renderFor(t, 0)
dOn, eOn := renderFor(t, 1)
if dOn != dOff || eOn != eOff {
t.Fatalf("the shipped config change moved a wave snapshot — every paid checkpoint of every book "+
"would be re-bought (--resnapshot).\n draft %s -> %s\n edit %s -> %s", dOff, dOn, eOff, eOn)
}
// --- ARM 2, THE STRUCT FIELD: cheaper, and it also covers the sibling budget. ---
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "静かな図書館の朝。"})
r := newRunner(t, bookPath)
defer r.Close()
if err := r.seedGlossary(context.Background()); err != nil {
t.Fatal(err)
}
read := func(what string) (string, string) {
t.Helper()
d, _, err := r.snapshotIDForWave(waveDraft)
if err != nil {
t.Fatalf("render the draft snapshot (%s): %v", what, err)
}
e, _, err := r.snapshotIDForWave(waveEdit)
if err != nil {
t.Fatalf("render the edit snapshot (%s): %v", what, err)
}
return d, e
}
if r.Pipeline.Retries.RegenerateEchoBeforeEscalate != 0 {
t.Fatalf("fixture drifted: this book must start with the knob OFF, got %d", r.Pipeline.Retries.RegenerateEchoBeforeEscalate)
}
draftOff, editOff := read("knob off")
// Both re-generation budgets, because both are outside the snapshot for the same reason and a
// future fold of EITHER is the thing this gate exists to make loud.
for _, n := range []int{1, 7} {
r.Pipeline.Retries.RegenerateEchoBeforeEscalate = n
r.Pipeline.Retries.RegenerateBeforeEscalate = n
draftOn, editOn := read(fmt.Sprintf("knob=%d", n))
if draftOn != draftOff || editOn != editOff {
t.Fatalf("a re-generation budget of %d moved a wave snapshot — turning it on would re-buy every "+
"paid checkpoint of every book (--resnapshot).\n draft %s -> %s\n edit %s -> %s",
n, draftOff, draftOn, editOff, editOn)
}
}
}
// TestEchoRegenFiresONLYForEcho holds the REASON half of the branch, which the sibling test above does
// not: it varies the budget while the flag is always an echo, so it cannot tell "re-generate the echo"
// from "re-generate anything".
//
// The distinction is money, not tidiness. `cjk_artifact` is the ONE non-retryable flag a same-model call
// can plausibly recover, precisely because D39.61 measured this provider's echo stochastic per call.
// Every other non-retryable reason — hard_refusal, soft_refusal, content_filter, loop_degenerate,
// decode_error — is deterministic for the model that produced it, and disposition.go says what a retry
// buys there: «a same-model retry would just re-refuse and re-bill» (D2.2). With the knob now ON in five
// shipping configs, an implementation that re-generated on ANY flag would buy that re-refusal on every
// refused chunk of every book, and until this test nothing in the repo went red on it.
//
// Mutation this catches: drop the `att.cls.Reason == FlagCJKArtifact` half and keep only
// `attempt < echoRegen` — the whole battery stays green today; here the primary call count goes 1 → 2 → RED.
func TestEchoRegenFiresONLYForEcho(t *testing.T) {
var mu sync.Mutex
primary := 0
rec := &reqRec{}
srv := newPricedProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
if strings.Contains(body, "fake-fallback") {
return "Тихое утро в библиотеке (дорогая модель).", "stop"
}
mu.Lock()
defer mu.Unlock()
primary++
// A provider REFUSAL — deterministic for this model, and therefore exactly the reason a
// same-model re-generation must NOT be spent on.
return "", "refusal"
})
defer srv.Close()
bookPath := setupEchoRegen(t, srv.URL, 1) // the knob is ON — that is the point
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
if len(res.Chunks) != 1 {
t.Fatalf("want 1 chunk, got %d", len(res.Chunks))
}
// The premise: the primary really did flag and the run really did take the ESCALATION route — which is
// the correct answer for a deterministic refusal, and the route a wrong implementation would replace
// with a same-model re-generation.
if !res.Chunks[0].Stages[0].Escalated {
t.Fatal("fixture drifted: a refused primary must escalate, so the hop is what recovers this chunk")
}
mu.Lock()
got := primary
mu.Unlock()
if got != 1 {
t.Fatalf("the echo re-generation budget must be spent on ECHO and nothing else: a refusal is "+
"deterministic for this model, so a same-model retry only re-refuses and re-bills (D2.2). "+
"primary calls = %d, want 1", got)
}
}