textmachine/backend/internal/pipeline/repair_budget_test.go

118 lines
4.8 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"
"strings"
"testing"
)
// repair_budget_test.go: backlog row 135 — "the ceiling gate on EVERY paid call, not on a unit of work".
//
// The ceilings themselves (book and day) were already per call: every fresh attempt reserves, and Reserve
// compares the book's cumulative committed+reserved against them (store/ledger.go, from stagerun.go). The
// gate that genuinely sat at a UNIT boundary was the repair sub-budget. It asked ONE question before the
// candidate loop — "is `spent` already at or over the budget?" — and never priced the calls it then
// authorised, so a unit that found the budget merely not-yet-exhausted could buy up to
// gates.repair.max_calls_per_unit fresh calls on the strength of that single answer, and every parallel
// worker could do the same. The number in the config was not the bound it looked like.
func TestABudgetSmallerThanOneRepairCallBuysNone(t *testing.T) {
// The clean statement of the old defect: with nothing spent yet, `spent >= budget` is false for ANY
// positive budget, so the loop admitted a call it could not afford and overshot the configured bound
// by that call's entire cost. Pricing the call is what makes the config number the bound.
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
switch {
case isRepairBody(body):
return "Он прождал час и вошёл внутрь.", "stop"
case isEditBody(body):
return "Он прождал полчаса и вошёл внутрь.", "stop"
}
return "ЧЕРНОВИК ПЕРЕВОДА.", "stop"
})
defer srv.Close()
bookPath := setupRepairProject(t, srv.URL)
// What one repair call would cost, measured from the code under test rather than from a constant that
// drifts with the price table.
price := repairCallEstimate(t, bookPath)
r := newRepairRunner(t, bookPath)
defer r.Close()
r.Pipeline.Gates.Repair.BudgetUSD = price / 2 // positive, and smaller than one call
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
t.Logf("one repair call reserves $%.6f; budget $%.6f — before row 135 the unit bought the call anyway (%.1f× the budget), now it buys none",
price, r.Pipeline.Gates.Repair.BudgetUSD, price/r.Pipeline.Gates.Repair.BudgetUSD)
for _, body := range rec.all() {
if isRepairBody(body) {
t.Fatalf("a repair call was bought under a budget of $%.6f that a $%.6f call cannot fit", r.Pipeline.Gates.Repair.BudgetUSD, price)
}
}
// The defect stays in the shipped text, flagged for a human — the sub-step is optional, and refusing
// it is not a failure.
if !strings.Contains(res.Chunks[0].FinalText, "полчаса") {
t.Fatalf("with the repair refused the unit must ship the editor's own bytes, got %q", res.Chunks[0].FinalText)
}
}
func TestABudgetThatFitsTheCallStillBuysIt(t *testing.T) {
// The other side of the same gate: pricing the call must not turn the sub-step off. Without this the
// test above passes for the wrong reason.
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
switch {
case isRepairBody(body):
return "Он прождал час и вошёл внутрь.", "stop"
case isEditBody(body):
return "Он прождал полчаса и вошёл внутрь.", "stop"
}
return "ЧЕРНОВИК ПЕРЕВОДА.", "stop"
})
defer srv.Close()
bookPath := setupRepairProject(t, srv.URL)
price := repairCallEstimate(t, bookPath)
r := newRepairRunner(t, bookPath)
defer r.Close()
r.Pipeline.Gates.Repair.BudgetUSD = price * 1.5
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
repairs := 0
for _, body := range rec.all() {
if isRepairBody(body) {
repairs++
}
}
if repairs != 1 {
t.Fatalf("a budget of $%.6f must buy the one $%.6f call, got %d", r.Pipeline.Gates.Repair.BudgetUSD, price, repairs)
}
if strings.Contains(res.Chunks[0].FinalText, "полчаса") {
t.Fatalf("the affordable repair must have applied, shipped %q", res.Chunks[0].FinalText)
}
}
// repairCallEstimate is what one repair call on this fixture would reserve — the same figure the gate
// now weighs, taken through the same helper the attempt uses.
func repairCallEstimate(t *testing.T, bookPath string) float64 {
t.Helper()
r := newRepairRunner(t, bookPath)
defer r.Close()
st := r.Pipeline.Stages[len(r.Pipeline.Stages)-1]
msgs, err := Messages(r.repairTemplates["dc1_fractional"],
RenderVars{Book: r.Book, Text: "他等了半个时辰。", Draft: "Он прождал полчаса и вошёл внутрь."})
if err != nil {
t.Fatal(err)
}
model, maxTokens := r.repairCallBudget(msgs)
price := r.callEstimateUSD(r.repairStage(st, model), model, msgs, maxTokens)
if price <= 0 {
t.Fatalf("a repair call must have a positive estimate, got %v", price)
}
return price
}