textmachine/backend/internal/pipeline/maxtokens_floor_test.go

196 lines
7.9 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"
"fmt"
"path/filepath"
"strings"
"testing"
"time"
)
// maxtokens_floor_test.go pins the per-model max_tokens FLOOR (D24.3,
// capabilities.min_max_tokens). The golden fixture uses only unfloored fake models,
// so it does NOT exercise this behaviour — these dedicated tests do, mutation-verified:
// - the floor lifts a small derived budget on the PRIMARY call (revert the stagerun
// applyModelFloor line and TestMinMaxTokensFloorPrimary goes red);
// - the escalation hop re-floors against ITS OWN model, not the inherited primary
// budget (revert the maybeEscalate hopMaxTokens line and TestMinMaxTokensFloorHop
// goes red — the exact stage-A bug where draft→deepseek-v4-pro hops inherited
// 2048/3291 and returned finish=length);
// - editing a floor is a loud --resnapshot (the floor is folded into the snapshot via
// the resolved Capability), so TestMinMaxTokensFloorFoldedIntoSnapshot proves the
// snapshotID moves when only the floor changes (invariant #2).
// setupFloorProject writes a ja→ru book whose draft model carries a per-model floor and,
// optionally, escalates to a floored fallback. Both models sit on the same fake provider;
// the mock distinguishes them by the "model" field. A tiny source keeps the derived base
// budget (EstimateTokens × ratio, floored at the small global min_max_tokens) well BELOW
// the per-model floors, so a wire max_tokens at the floor can only come from the floor.
func setupFloorProject(t *testing.T, providerURL string, draftFloor, hopFloor int, withEscalation bool) 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}}")
draftCaps := ""
if draftFloor > 0 {
draftCaps = fmt.Sprintf("\n capabilities: { min_max_tokens: %d }", draftFloor)
}
hopCaps := ""
if hopFloor > 0 {
hopCaps = fmt.Sprintf("\n capabilities: { min_max_tokens: %d }", hopFloor)
}
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 }%s
fake-hop:
provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }%s
`, time.Now().UTC().Format("2006-01-02"), providerURL, draftCaps, hopCaps))
escLine, escBudget := "", ""
if withEscalation {
escLine = ", escalate_to: fake-hop"
escBudget = "escalation: { budget_usd: 1.0 }"
}
// A small global min_max_tokens (512) is the floor the model-level floors must beat.
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 }
stages:
- { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: "off"%s }
- { name: edit, role: editor, model: fake-model, prompt: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off" }
%s
`, escLine, escBudget))
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")
}
// maxTokensForModel returns the max_tokens of the FIRST recorded body whose "model" is
// modelName (the wire proof — the floor is applied before the request_hash, so what the
// provider receives is the floored value).
func maxTokensForModel(t *testing.T, rec *reqRec, modelName string) int {
t.Helper()
for _, body := range rec.all() {
if strings.Contains(body, `"model":"`+modelName+`"`) {
return maxTokensOf(t, body)
}
}
t.Fatalf("no recorded request for model %q", modelName)
return 0
}
// The primary call's derived budget is lifted to the model's min_max_tokens floor.
func TestMinMaxTokensFloorPrimary(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupFloorProject(t, srv.URL, 8000, 0, false)
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
// The tiny source derives far below 8000 (and below the 512 global floor too), so a
// wire max_tokens of exactly 8000 can only be the per-model floor.
if got := maxTokensForModel(t, rec, "fake-model"); got != 8000 {
t.Fatalf("primary draft wire max_tokens = %d, want the model floor 8000 (D24.3)", got)
}
}
// The escalation hop is floored against ITS OWN model (fake-hop), NOT the smaller,
// floor-less primary budget it inherits — the stage-A fix. The primary draft stays at the
// small derived budget, proving the floor is per-call-model, not global.
func TestMinMaxTokensFloorHop(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
if strings.Contains(body, `"model":"fake-hop"`) {
return "Тихое утро.", "stop" // the hop translates cleanly → the re-gate passes
}
return "静かな図書館の朝。", "stop" // the primary echoes CJK → cjk_artifact → escalate
})
defer srv.Close()
// draft floor 0 (primary keeps the small derived budget), hop floor 9000.
bookPath := setupFloorProject(t, srv.URL, 0, 9000, true)
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
if !res.Chunks[0].Stages[0].Escalated {
t.Fatalf("premise broken: the primary must escalate for the hop budget to be observable, got %+v", res.Chunks[0].Stages[0])
}
// The hop's own floor (9000) reaches the wire...
if got := maxTokensForModel(t, rec, "fake-hop"); got != 9000 {
t.Fatalf("escalation hop wire max_tokens = %d, want the HOP model floor 9000 — a hop must re-floor against its own model, not inherit the primary budget (D24.3)", got)
}
// ...while the floor-less primary stays at the small derived budget (global floor 512),
// so the hop's 9000 is not merely an inherited value.
if got := maxTokensForModel(t, rec, "fake-model"); got != 512 {
t.Fatalf("primary draft wire max_tokens = %d, want the small derived/global budget 512 (proves the hop floor is per-call, not shared)", got)
}
}
// Editing a floor is a loud --resnapshot: the floor is folded into the snapshot via the
// resolved Capability (snapshot.go marshals ResolveCapability for the stage AND its hop),
// so a floor-only change must move the snapshotID — else a stale checkpoint false-hits a
// diverged wire request (invariant #2 / D5.2).
func TestMinMaxTokensFloorFoldedIntoSnapshot(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
snapFor := func(draftFloor int) string {
r := newRunner(t, setupFloorProject(t, srv.URL, draftFloor, 0, false))
defer r.Close()
id, _, err := r.snapshotID()
if err != nil {
t.Fatal(err)
}
return id
}
none, f8000, f16000 := snapFor(0), snapFor(8000), snapFor(16000)
if none == f8000 {
t.Fatalf("adding a min_max_tokens floor must change the snapshotID (wire-affecting input, D24.3), got %s for both", none)
}
if f8000 == f16000 {
t.Fatalf("changing the floor value must change the snapshotID, got %s for both", f8000)
}
}