196 lines
7.9 KiB
Go
196 lines
7.9 KiB
Go
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)
|
||
}
|
||
}
|