Harden Phase 0 after self-review: snapshot coverage, response-price fallback, usage clamp, job-status lifecycle, config guards, source normalization, hash robustness

This commit is contained in:
Claude (backend session) 2026-07-04 14:49:43 +03:00
parent 2006989b9b
commit 214e005155
17 changed files with 490 additions and 32 deletions

View file

@ -3,6 +3,7 @@ module textmachine/backend
go 1.26.4 go 1.26.4
require ( require (
golang.org/x/text v0.38.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.53.0 modernc.org/sqlite v1.53.0
) )

View file

@ -14,11 +14,13 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View file

@ -113,8 +113,13 @@ func LoadBook(path string) (*Book, error) {
// проекта в другую директорию не должен пере-переводить книгу. JSON с // проекта в другую директорию не должен пере-переводить книгу. JSON с
// фиксированным порядком полей → детерминированный байтовый рендер. // фиксированным порядком полей → детерминированный байтовый рендер.
func (b *Book) BriefHash() string { func (b *Book) BriefHash() string {
// Title входит в бриф: это смысловое поле (используется промптами через
// {{title}} и попадает в рендер → request-hash), не wiring-идентификатор.
// Без него правка заголовка молча инвалидировала бы чекпоинты в обход
// snapshot-гейта (находка ревью).
canon := struct { canon := struct {
BookID string `json:"book_id"` BookID string `json:"book_id"`
Title string `json:"title"`
SourceLang string `json:"source_lang"` SourceLang string `json:"source_lang"`
TargetLang string `json:"target_lang"` TargetLang string `json:"target_lang"`
Genre string `json:"genre"` Genre string `json:"genre"`
@ -124,7 +129,7 @@ func (b *Book) BriefHash() string {
Honorifics string `json:"honorifics"` Honorifics string `json:"honorifics"`
Transcription string `json:"transcription"` Transcription string `json:"transcription"`
Footnotes string `json:"footnotes"` Footnotes string `json:"footnotes"`
}{b.BookID, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes} }{b.BookID, b.Title, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes}
data, err := json.Marshal(canon) data, err := json.Marshal(canon)
if err != nil { if err != nil {
// A struct of scalars cannot fail to marshal; keep the signature clean. // A struct of scalars cannot fail to marshal; keep the signature clean.

View file

@ -0,0 +1,71 @@
package config
import (
"strings"
"testing"
)
func TestCheckRunnableRejectsPhase2Mechanics(t *testing.T) {
cases := []struct {
name string
p Pipeline
want string // substring the error must mention
}{
{"c1 ok", Pipeline{Core: "C1", Stages: []Stage{{Name: "draft", Role: "translator"}}}, ""},
{"c0 ok", Pipeline{Core: "C0", Stages: []Stage{{Name: "draft", Role: "translator"}}}, ""},
{"c2 rejected", Pipeline{Core: "C2", Stages: []Stage{{Name: "draft", Role: "translator"}}}, "C2"},
{"fanout rejected", Pipeline{Core: "C1", Fanout: Fanout{Candidates: 3}, Stages: []Stage{{Name: "draft", Role: "translator"}}}, "fanout"},
{"judge rejected", Pipeline{Core: "C1", Stages: []Stage{{Name: "select", Role: "judge"}}}, "judge"},
}
for _, c := range cases {
err := c.p.CheckRunnable()
if c.want == "" {
if err != nil {
t.Errorf("%s: expected runnable, got %v", c.name, err)
}
continue
}
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: expected error mentioning %q, got %v", c.name, c.want, err)
}
}
}
func TestCheckKeysOnlyUsedNonLocalModels(t *testing.T) {
m := &Models{
Providers: map[string]Provider{
"cloud": {Kind: "openai", BaseURL: "http://x", APIKeyEnv: "TEST_CLOUD_KEY"},
"local": {Kind: "local", BaseURL: "http://127.0.0.1", Model: "q"},
},
Models: map[string]Model{
"cloud-model": {Provider: "cloud"},
"local-model": {Provider: "local"},
"unused-cloud": {Provider: "cloud"},
},
}
// local-only pipeline не должен падать на облачном ключе.
localPipe := &Pipeline{Stages: []Stage{{Model: "local-model"}}}
if err := m.CheckKeys(localPipe); err != nil {
t.Fatalf("local-only pipeline must not require cloud keys: %v", err)
}
// Пайплайн с облачной моделью без ключа — fail-fast.
t.Setenv("TEST_CLOUD_KEY", "")
cloudPipe := &Pipeline{Stages: []Stage{{Model: "cloud-model"}}}
if err := m.CheckKeys(cloudPipe); err == nil || !strings.Contains(err.Error(), "TEST_CLOUD_KEY") {
t.Fatalf("missing cloud key must fail-fast mentioning the env var, got %v", err)
}
// Ключ задан — проходит.
t.Setenv("TEST_CLOUD_KEY", "sk-xxx")
if err := m.CheckKeys(cloudPipe); err != nil {
t.Fatalf("present key must pass: %v", err)
}
// Escalation-цепочки тоже учитываются.
escPipe := &Pipeline{
Stages: []Stage{{Model: "local-model"}},
Escal: Escalation{Chains: map[string][]string{"default": {"cloud-model"}}},
}
t.Setenv("TEST_CLOUD_KEY", "")
if err := m.CheckKeys(escPipe); err == nil {
t.Fatal("escalation chain model without a key must fail-fast")
}
}

View file

@ -184,3 +184,41 @@ func (p Provider) APIKey() string {
} }
return os.Getenv(p.APIKeyEnv) return os.Getenv(p.APIKeyEnv)
} }
// CheckKeys verifies that every non-local model REFERENCED by the pipeline has
// its provider API key resolvable in the environment — preflight fail-fast,
// чтобы пустой ключ не всплывал 401-м уже ПОСЛЕ reserve/списания слота
// (находка ревью). Проверяются только используемые модели: local-only прогон
// не должен падать на незаполненных облачных ключах. Escalation-цепочки тоже
// учитываются — их модели вызываются в Фазе 1.
func (m *Models) CheckKeys(pipe *Pipeline) error {
needed := map[string]struct{}{}
for _, st := range pipe.Stages {
needed[st.Model] = struct{}{}
}
for _, chain := range pipe.Escal.Chains {
for _, mdl := range chain {
needed[mdl] = struct{}{}
}
}
var problems []string
seen := map[string]bool{} // dedupe per provider
for name := range needed {
mod, ok := m.Models[name]
if !ok {
continue // существование уже проверено LoadPipeline
}
prov := m.Providers[mod.Provider]
if prov.Kind == "local" || prov.APIKeyEnv == "" || seen[mod.Provider] {
continue
}
seen[mod.Provider] = true
if prov.APIKey() == "" {
problems = append(problems, fmt.Sprintf("provider %s: env %s не задан (нужен для модели %s)", mod.Provider, prov.APIKeyEnv, name))
}
}
if len(problems) > 0 {
return fmt.Errorf("отсутствуют API-ключи (заполните backend/.env):\n - %s", strings.Join(problems, "\n - "))
}
return nil
}

View file

@ -93,6 +93,29 @@ type Fanout struct {
Candidates int `yaml:"candidates"` Candidates int `yaml:"candidates"`
} }
// CheckRunnable rejects a config whose mechanics the Phase-0 runner does NOT
// implement — раздельно от schema-валидации (LoadPipeline), чтобы скелеты C2/C3
// парсились и валидировались как схема, но НЕ исполнялись молча. Без этого
// гейта запуск pipeline-c2.yaml прогнал бы стадии линейно: fanout.candidates
// игнорируется, вердикт судьи утёк бы в редактуру, а деньги Opus сгорели бы на
// мусор (находка ревью; Р2 требует закрывать дыру «конфиг vs код» fail-loud).
func (p *Pipeline) CheckRunnable() error {
switch p.Core {
case "C0", "C1":
default:
return fmt.Errorf("pipeline core %q не исполним в Фазе 0 (реализованы только C0/C1 — линейный проход стадий; C2/C3 selection/fusion — механика раннера Фазы 2)", p.Core)
}
if p.Fanout.Candidates > 1 {
return fmt.Errorf("pipeline fanout.candidates=%d не исполним в Фазе 0 (fan-out N кандидатов — механика раннера Фазы 2; C1 = один черновик)", p.Fanout.Candidates)
}
for _, st := range p.Stages {
if st.Role == "judge" {
return fmt.Errorf("pipeline stage %q role=judge не исполним в Фазе 0 (селектор получает N кандидатов — механика Фазы 2, а раннер гонит стадии линейно)", st.Name)
}
}
return nil
}
// LoadPipeline reads and validates a pipeline config against the models known // LoadPipeline reads and validates a pipeline config against the models known
// to models.yaml. // to models.yaml.
func LoadPipeline(path string, models *Models) (*Pipeline, error) { func LoadPipeline(path string, models *Models) (*Pipeline, error) {

View file

@ -52,6 +52,22 @@ func (p *Pricer) PriceFor(model string) ModelPrice {
return p.defaultPrice return p.defaultPrice
} }
// PriceForResponse prices a completion by the model that ACTUALLY answered,
// but if that id is unknown (провайдер вернул канонизированный/датированный
// слаг вроде claude-sonnet-5-2026xxxx) falls back to the REQUESTED model's
// price BEFORE the global anchor — иначе премиум-ответ книжился бы по дешёвому
// дефолту (систематический недоучёт, находка ревью). Только если неизвестны
// обе — дефолтный якорь (never $0).
func (p *Pricer) PriceForResponse(requested, actual string) ModelPrice {
if mp, ok := p.prices[actual]; ok {
return mp
}
if mp, ok := p.prices[requested]; ok {
return mp
}
return p.defaultPrice
}
// CostUSD prices one completion by its usage. Formula (invariant from // CostUSD prices one completion by its usage. Formula (invariant from
// llm.Usage: PromptTokens = total input = uncached + cached + cache-written): // llm.Usage: PromptTokens = total input = uncached + cached + cache-written):
// //
@ -61,17 +77,29 @@ func (p *Pricer) PriceFor(model string) ModelPrice {
// For providers without cache-write accounting the third term is 0 and the // For providers without cache-write accounting the third term is 0 and the
// formula degrades to vojo's computeUSD. // formula degrades to vojo's computeUSD.
func CostUSD(price ModelPrice, u llm.Usage) float64 { func CostUSD(price ModelPrice, u llm.Usage) float64 {
uncached := u.PromptTokens - u.CachedTokens - u.CacheCreationTokens // Каждое слагаемое зажимается в ≥0: провайдер с багом учёта, приславший
// отрицательные токены, иначе УМЕНЬШИЛ бы committed и ослабил потолок
// (находка ревью). Стоимость может только расти или быть нулевой.
nn := func(x int) int {
if x < 0 {
return 0
}
return x
}
cached := nn(u.CachedTokens)
cacheWrite := nn(u.CacheCreationTokens)
completion := nn(u.CompletionTokens) + nn(u.ReasoningTokens)
uncached := nn(u.PromptTokens) - cached - cacheWrite
if uncached < 0 { if uncached < 0 {
// Defensive: a provider reporting cached > prompt would otherwise // cached+write > prompt (провайдер отрапортовал подмножество больше
// produce a negative term and understate cost. // целого) — не уводим некэш-слагаемое в минус.
uncached = 0 uncached = 0
} }
perTok := func(perM float64) float64 { return perM / 1_000_000 } perTok := func(perM float64) float64 { return perM / 1_000_000 }
return float64(uncached)*perTok(price.InputPerM) + return float64(uncached)*perTok(price.InputPerM) +
float64(u.CachedTokens)*perTok(price.CachedPerM) + float64(cached)*perTok(price.CachedPerM) +
float64(u.CacheCreationTokens)*perTok(price.CacheWritePerM) + float64(cacheWrite)*perTok(price.CacheWritePerM) +
float64(u.CompletionTokens+u.ReasoningTokens)*perTok(price.OutputPerM) float64(completion)*perTok(price.OutputPerM)
} }
// EstimateUSD is the pre-call reservation estimate: assume the whole prompt // EstimateUSD is the pre-call reservation estimate: assume the whole prompt

View file

@ -38,6 +38,37 @@ func TestCostUSD(t *testing.T) {
u4 := llm.Usage{PromptTokens: 100, CachedTokens: 500, CompletionTokens: 10} u4 := llm.Usage{PromptTokens: 100, CachedTokens: 500, CompletionTokens: 10}
want4 := 0*2.0/1e6 + 500*0.2/1e6 + 10*10.0/1e6 want4 := 0*2.0/1e6 + 500*0.2/1e6 + 10*10.0/1e6
approx(t, CostUSD(price, u4), want4, "cached > prompt guard") approx(t, CostUSD(price, u4), want4, "cached > prompt guard")
// Отрицательные usage-числа (баг учёта провайдера) не должны УМЕНЬШАТЬ
// стоимость — каждое слагаемое зажимается в ≥0.
u5 := llm.Usage{PromptTokens: -1000, CachedTokens: -50, CompletionTokens: -200, ReasoningTokens: -5}
if got := CostUSD(price, u5); got != 0 {
t.Fatalf("negative usage must never reduce cost below 0, got %v", got)
}
}
func TestPriceForResponse(t *testing.T) {
def := ModelPrice{InputPerM: 0.14, OutputPerM: 0.28} // дешёвый якорь
p, err := NewPricer(map[string]ModelPrice{
"deepseek-v4-flash": def,
"claude-sonnet-5": {InputPerM: 2.0, OutputPerM: 10.0},
}, def)
if err != nil {
t.Fatal(err)
}
// Фактически ответившая известна — по ней.
if p.PriceForResponse("claude-sonnet-5", "claude-sonnet-5").InputPerM != 2.0 {
t.Fatal("known actual model must price by actual")
}
// Провайдер вернул датированный слаг премиум-модели — НЕ дешёвый якорь, а
// цена ЗАПРОШЕННОЙ модели (иначе систематический недоучёт премиума).
if got := p.PriceForResponse("claude-sonnet-5", "claude-sonnet-5-20260101").InputPerM; got != 2.0 {
t.Fatalf("unknown actual must fall back to requested model price (2.0), got %v", got)
}
// Обе неизвестны — дефолтный якорь (never $0).
if got := p.PriceForResponse("mystery", "also-mystery").InputPerM; got != 0.14 {
t.Fatalf("both unknown must fall back to default anchor, got %v", got)
}
} }
func TestPricerNeverZero(t *testing.T) { func TestPricerNeverZero(t *testing.T) {

View file

@ -297,7 +297,12 @@ func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResp
return nil, ctx.Err() == nil, err return nil, ctx.Err() == nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) // Читаем на 1 байт больше лимита, чтобы ОТЛИЧИТЬ усечение от целого тела.
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
truncated := len(data) > maxResponseBytes
if truncated {
data = data[:maxResponseBytes]
}
obs.LogLLMExchange(ctx, c.log, c.name, payload, resp.StatusCode, data) obs.LogLLMExchange(ctx, c.log, c.name, payload, resp.StatusCode, data)
@ -310,10 +315,13 @@ func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResp
var out openAIResponse var out openAIResponse
if err := json.Unmarshal(data, &out); err != nil { if err := json.Unmarshal(data, &out); err != nil {
// 2xx с нечитаемым телом (обрыв/прокси/обрезка LimitReader): провайдер // 2xx с нечитаемым телом: провайдер УЖЕ списал деньги. Типизируем, чтобы
// УЖЕ списал деньги. Retryable (повтор может прийти целым); типизировано, // раннер консервативно заселтлил оценку. Усечённое (>16 MiB) тело
// чтобы раннер на исчерпании попыток НЕ вернул резерв, а консервативно // детерминированно — ретраить бессмысленно (каждый ретрай — новый
// заселтлил оценку — недоучёт хуже переучёта для потолков. // оплаченный 2xx), поэтому терминально; обрыв/мусор — retryable.
if truncated {
return nil, false, &BilledDecodeError{Provider: c.name, Err: fmt.Errorf("response exceeded %d bytes (truncated, not retried): %w", maxResponseBytes, err)}
}
return nil, true, &BilledDecodeError{Provider: c.name, Err: err} return nil, true, &BilledDecodeError{Provider: c.name, Err: err}
} }
// A 2xx is a billed call even when the model returns empty content // A 2xx is a billed call even when the model returns empty content

View file

@ -203,7 +203,11 @@ func (c *anthropicClient) attempt(ctx context.Context, payload []byte) (*LLMResp
return nil, ctx.Err() == nil, err return nil, ctx.Err() == nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
truncated := len(data) > maxResponseBytes
if truncated {
data = data[:maxResponseBytes]
}
obs.LogLLMExchange(ctx, c.log, "anthropic", payload, resp.StatusCode, data) obs.LogLLMExchange(ctx, c.log, "anthropic", payload, resp.StatusCode, data)
@ -217,7 +221,11 @@ func (c *anthropicClient) attempt(ctx context.Context, payload []byte) (*LLMResp
var out anthropicResponse var out anthropicResponse
if err := json.Unmarshal(data, &out); err != nil { if err := json.Unmarshal(data, &out); err != nil {
// 2xx уже оплачен провайдером — типизируем, retryable (см. httpllm.go). // 2xx уже оплачен провайдером — типизируем (см. httpllm.go); усечение
// терминально, обрыв/мусор — retryable.
if truncated {
return nil, false, &BilledDecodeError{Provider: "anthropic", Err: fmt.Errorf("response exceeded %d bytes (truncated, not retried): %w", maxResponseBytes, err)}
}
return nil, true, &BilledDecodeError{Provider: "anthropic", Err: err} return nil, true, &BilledDecodeError{Provider: "anthropic", Err: err}
} }

View file

@ -6,6 +6,7 @@ package pipeline
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/binary"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"os" "os"
@ -13,6 +14,8 @@ import (
"strings" "strings"
"unicode" "unicode"
"golang.org/x/text/unicode/norm"
"textmachine/backend/internal/config" "textmachine/backend/internal/config"
"textmachine/backend/internal/llm" "textmachine/backend/internal/llm"
) )
@ -25,10 +28,11 @@ import (
// resume пере-переводит и пере-оплачивает главу, а кэш DeepSeek (byte prefix // resume пере-переводит и пере-оплачивает главу, а кэш DeepSeek (byte prefix
// match) промахивается. // match) промахивается.
// chunkerVersion versions the segmentation rules; it is part of the snapshot, // chunkerVersion versions the segmentation/ingestion rules (включая
// so re-chunking a book is an explicit re-translation, not a silent cache miss // нормализацию источника NormalizeSource); it is part of the snapshot, so
// re-chunking a book is an explicit re-translation, not a silent cache miss
// (§3.4 — [НУЖНО РЕШЕНИЕ] п.2в). // (§3.4 — [НУЖНО РЕШЕНИЕ] п.2в).
const chunkerVersion = "chunker-v0-wholefile" const chunkerVersion = "chunker-v1-wholefile-nfc"
// estimatorVersion versions EstimateTokens: его выход входит в max_tokens и // estimatorVersion versions EstimateTokens: его выход входит в max_tokens и
// через него в request-hash, поэтому перекалибровка весов — тоже явная // через него в request-hash, поэтому перекалибровка весов — тоже явная
@ -153,21 +157,43 @@ func Messages(tpl *PromptTemplate, v RenderVars) ([]llm.Message, error) {
// только что провалившийся чекпоинт и мгновенно эскалировала в премиум). // только что провалившийся чекпоинт и мгновенно эскалировала в премиум).
func RequestHash(bookID string, chapter, chunkIdx, attempt int, stage, role, model string, temperature float64, reasoning string, jsonOnly bool, maxTokens int, snapshotID string, msgs []llm.Message) string { func RequestHash(bookID string, chapter, chunkIdx, attempt int, stage, role, model string, temperature float64, reasoning string, jsonOnly bool, maxTokens int, snapshotID string, msgs []llm.Message) string {
h := sha256.New() h := sha256.New()
// Каждое поле длина-префиксовано (8 байт LE) вместо NUL-разделителя:
// контент с байтом \x00 больше не может сдвинуть границы полей или
// подделать лишние сообщения (находка ревью). Версия v2 фиксирует смену
// формата хеша.
var lb [8]byte
w := func(parts ...string) { w := func(parts ...string) {
for _, p := range parts { for _, p := range parts {
binary.LittleEndian.PutUint64(lb[:], uint64(len(p)))
h.Write(lb[:])
h.Write([]byte(p)) h.Write([]byte(p))
h.Write([]byte{0})
} }
} }
w("tm-request-v1", bookID, strconv.Itoa(chapter), strconv.Itoa(chunkIdx), strconv.Itoa(attempt), stage, role, model, w("tm-request-v2", bookID, strconv.Itoa(chapter), strconv.Itoa(chunkIdx), strconv.Itoa(attempt), stage, role, model,
strconv.FormatFloat(temperature, 'f', -1, 64), reasoning, strconv.FormatBool(jsonOnly), strconv.FormatFloat(temperature, 'f', -1, 64), reasoning, strconv.FormatBool(jsonOnly),
strconv.Itoa(maxTokens), snapshotID) strconv.Itoa(maxTokens), snapshotID)
// Явный счётчик сообщений: длина списка — тоже часть ключа.
binary.LittleEndian.PutUint64(lb[:], uint64(len(msgs)))
h.Write(lb[:])
for _, m := range msgs { for _, m := range msgs {
w(m.Role, m.Content, strconv.FormatBool(m.CacheBoundary)) w(m.Role, m.Content, strconv.FormatBool(m.CacheBoundary))
} }
return hex.EncodeToString(h.Sum(nil)) return hex.EncodeToString(h.Sum(nil))
} }
// NormalizeSource canonicalizes the source chunk so the request-hash is stable
// across editors and operating systems (находка ревью): strip a UTF-8 BOM,
// CRLF/CR → LF, Unicode NFC, trim surrounding whitespace. Без этого файл,
// сохранённый в другом редакторе (BOM) или на Windows (CRLF), давал бы иной
// hash и молча пере-переводил бы уже оплаченную книгу.
func NormalizeSource(s string) string {
s = strings.TrimPrefix(s, "\uFEFF") // UTF-8 BOM
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = norm.NFC.String(s)
return strings.TrimSpace(s)
}
// EstimateTokens is a cheap, deterministic token estimate for reservation // EstimateTokens is a cheap, deterministic token estimate for reservation
// sizing and max_tokens derivation (NOT for billing — billing uses the API's // sizing and max_tokens derivation (NOT for billing — billing uses the API's
// usage). Калибровка полигона: иероглиф ≈0.9 ток., русский/латиница ≈3 симв. // usage). Калибровка полигона: иероглиф ≈0.9 ток., русский/латиница ≈3 симв.

View file

@ -130,6 +130,45 @@ func TestTemplateRequiresUserSeparator(t *testing.T) {
} }
} }
func TestNormalizeSource(t *testing.T) {
// BOM, CRLF/CR и разложенная форма Unicode → канон; хеш стабилен между
// ОС/редакторами.
crlf := "\uFEFFпервая\r\nвторая\rтретья"
if got := NormalizeSource(crlf); got != "первая\nвторая\nтретья" {
t.Fatalf("BOM/CRLF/CR not normalized: %q", got)
}
// Явно строим NFD (e + U+0301) и NFC (U+00E9), чтобы формы точно различались.
decomposed := "cafe\u0301"
precomposed := "caf\u00e9"
if decomposed == precomposed {
t.Fatal("test setup: the two forms must differ before normalization")
}
if NormalizeSource(decomposed) != NormalizeSource(precomposed) {
t.Fatal("NFD and NFC forms must normalize to the same string")
}
// Разные представления одного текста → один request-hash.
tpl := writeTemplate(t, "S.\n---USER---\n{{text}}")
m1, _ := Messages(tpl, RenderVars{Book: testBook(), Text: NormalizeSource(decomposed)})
m2, _ := Messages(tpl, RenderVars{Book: testBook(), Text: NormalizeSource(precomposed)})
h1 := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m1)
h2 := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap", m2)
if h1 != h2 {
t.Fatal("normalized equivalent sources must yield the same request hash")
}
}
// Length-prefix устраняет NUL-коллизию: контент с \x00 не сдвигает границы
// полей и не подделывает лишние сообщения.
func TestRequestHashNulRobust(t *testing.T) {
a := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap",
[]llm.Message{{Role: "user", Content: "a\x00b"}})
bb := RequestHash("b", 1, 0, 0, "draft", "translator", "m", 0.3, "off", false, 1024, "snap",
[]llm.Message{{Role: "user", Content: "a"}, {Role: "\x00b", Content: ""}})
if a == bb {
t.Fatal("NUL byte in content must not collide with an extra message boundary")
}
}
func TestEstimateTokens(t *testing.T) { func TestEstimateTokens(t *testing.T) {
// Порядок величин по калибровке полигона: иероглиф ≈1 токен, кириллица ≈3 // Порядок величин по калибровке полигона: иероглиф ≈1 токен, кириллица ≈3
// символа на токен. Оценка — для резервов, не для биллинга. // символа на токен. Оценка — для резервов, не для биллинга.

View file

@ -58,6 +58,14 @@ func NewRunner(bookPath string, logger *slog.Logger) (*Runner, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Fail-fast ДО открытия store (взятия flock и side-effect'ов): исполнима
// ли механика конфига и заданы ли ключи используемых моделей.
if err := pipe.CheckRunnable(); err != nil {
return nil, err
}
if err := models.CheckKeys(pipe); err != nil {
return nil, err
}
pricer, err := models.Prices() pricer, err := models.Prices()
if err != nil { if err != nil {
return nil, err return nil, err
@ -121,25 +129,45 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
PromptSHA256 string `json:"prompt_sha256"` PromptSHA256 string `json:"prompt_sha256"`
Temperature float64 `json:"temperature"` Temperature float64 `json:"temperature"`
Reasoning string `json:"reasoning"` Reasoning string `json:"reasoning"`
// ExtraBody модели меняет ТЕЛО запроса (GLM thinking-off и т.п.) —
// без него правка ручки в models.yaml молча не инвалидировала бы
// чекпоинты (находка ревью). json.Marshal карты сортирует ключи →
// детерминированный рендер.
ModelExtra json.RawMessage `json:"model_extra,omitempty"`
} }
snap := struct { snap := struct {
BriefHash string `json:"brief_hash"` BriefHash string `json:"brief_hash"`
ChunkerVersion string `json:"chunker_version"` ChunkerVersion string `json:"chunker_version"`
EstimatorVersion string `json:"estimator_version"` EstimatorVersion string `json:"estimator_version"`
PipelineCore string `json:"pipeline_core"` PipelineCore string `json:"pipeline_core"`
Stages []stageSnap `json:"stages"` // Defaults влияют на maxTokens, а тот входит в request-hash: без них
// правка max_output_ratio молча инвалидировала бы все чекпоинты в
// обход snapshot-гейта (находка ревью).
MaxOutputRatio float64 `json:"max_output_ratio"`
MinMaxTokens int `json:"min_max_tokens"`
Stages []stageSnap `json:"stages"`
}{ }{
BriefHash: r.Book.BriefHash(), BriefHash: r.Book.BriefHash(),
ChunkerVersion: chunkerVersion, ChunkerVersion: chunkerVersion,
EstimatorVersion: estimatorVersion, EstimatorVersion: estimatorVersion,
PipelineCore: r.Pipeline.Core, PipelineCore: r.Pipeline.Core,
MaxOutputRatio: r.Pipeline.Defaults.MaxOutputRatio,
MinMaxTokens: r.Pipeline.Defaults.MinMaxTokens,
} }
for _, st := range r.Pipeline.Stages { for _, st := range r.Pipeline.Stages {
snap.Stages = append(snap.Stages, stageSnap{ ss := stageSnap{
Name: st.Name, Role: st.Role, Model: st.Model, Name: st.Name, Role: st.Role, Model: st.Model,
PromptVersion: st.PromptVersion, PromptSHA256: r.templates[st.Name].SHA256, PromptVersion: st.PromptVersion, PromptSHA256: r.templates[st.Name].SHA256,
Temperature: st.Temperature, Reasoning: st.Reasoning, Temperature: st.Temperature, Reasoning: st.Reasoning,
}) }
if extra := r.Models.Models[st.Model].ExtraBody; len(extra) > 0 {
raw, merr := json.Marshal(extra)
if merr != nil {
return "", "", merr
}
ss.ModelExtra = raw
}
snap.Stages = append(snap.Stages, ss)
} }
data, err := json.Marshal(snap) data, err := json.Marshal(snap)
if err != nil { if err != nil {
@ -180,7 +208,8 @@ func (r *Runner) TranslateOneChunk(ctx context.Context) (*RunResult, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("pipeline: read source: %w", err) return nil, fmt.Errorf("pipeline: read source: %w", err)
} }
source := strings.TrimSpace(string(srcRaw)) // Нормализуем (BOM/CRLF/NFC) — request-hash стабилен между ОС/редакторами.
source := NormalizeSource(string(srcRaw))
if source == "" { if source == "" {
return nil, fmt.Errorf("pipeline: source file %s is empty", r.Book.SourceFile) return nil, fmt.Errorf("pipeline: source file %s is empty", r.Book.SourceFile)
} }
@ -228,9 +257,10 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c
} }
r.Log.WarnContext(ctx, "job re-pinned to new snapshot (--resnapshot)", "stage", st.Name, "old", job.SnapshotID[:12], "new", snapID[:12]) r.Log.WarnContext(ctx, "job re-pinned to new snapshot (--resnapshot)", "stage", st.Name, "old", job.SnapshotID[:12], "new", snapID[:12])
} }
if err := r.Store.SetJobStatus(job.ID, "running"); err != nil { // Статус 'running' выставляется НЕ здесь, а прямо перед вызовом провайдера:
return nil, err // иначе отказ потолка/ошибка сборки клиента/промах гейта оставляли бы
} // джобу навсегда в 'running' (находка ревью). Отказ потолка — retriable,
// джоба остаётся 'pending'; настоящая ошибка — 'failed'.
msgs, err := Messages(r.templates[st.Name], RenderVars{Book: r.Book, Text: source, Draft: prev}) msgs, err := Messages(r.templates[st.Name], RenderVars{Book: r.Book, Text: source, Draft: prev})
if err != nil { if err != nil {
@ -293,10 +323,12 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c
BookUSD: r.Book.Ceilings.BookUSD, DayUSD: r.Book.Ceilings.DayUSD, BookUSD: r.Book.Ceilings.BookUSD, DayUSD: r.Book.Ceilings.DayUSD,
}) })
if err != nil { if err != nil {
_ = r.Store.SetJobStatus(job.ID, "failed")
return nil, err return nil, err
} }
switch verdict { switch verdict {
case store.ReserveDeniedBook: case store.ReserveDeniedBook:
// Джоба остаётся 'pending' (не 'failed'): поднимут потолок — продолжит.
return nil, fmt.Errorf("pipeline: book USD ceiling reached (%.2f$) — raise ceilings.book_usd or stop", r.Book.Ceilings.BookUSD) return nil, fmt.Errorf("pipeline: book USD ceiling reached (%.2f$) — raise ceilings.book_usd or stop", r.Book.Ceilings.BookUSD)
case store.ReserveDeniedDay: case store.ReserveDeniedDay:
return nil, fmt.Errorf("pipeline: daily USD ceiling reached (%.2f$)", r.Book.Ceilings.DayUSD) return nil, fmt.Errorf("pipeline: daily USD ceiling reached (%.2f$)", r.Book.Ceilings.DayUSD)
@ -305,9 +337,14 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c
client, err := r.client(st.Model) client, err := r.client(st.Model)
if err != nil { if err != nil {
_ = r.Store.Release(resv) _ = r.Store.Release(resv)
_ = r.Store.SetJobStatus(job.ID, "failed")
return nil, err return nil, err
} }
if err := r.Store.SetJobStatus(job.ID, "running"); err != nil {
_ = r.Store.Release(resv)
return nil, err
}
start := time.Now() start := time.Now()
resp, err := client.Complete(ctx, llm.LLMRequest{ resp, err := client.Complete(ctx, llm.LLMRequest{
Model: st.Model, Model: st.Model,
@ -357,7 +394,19 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c
if modelActual == "" { if modelActual == "" {
modelActual = st.Model modelActual = st.Model
} }
cost := ledger.CostUSD(r.Pricer.PriceFor(modelActual), resp.Usage) // Цена по фактически ответившей модели, с fallback на ЗАПРОШЕННУЮ (не на
// дешёвый глобальный якорь), если провайдер вернул канонизированный слаг.
price = r.Pricer.PriceForResponse(st.Model, modelActual)
cost := ledger.CostUSD(price, resp.Usage)
// Платная модель (InputPerM>0) вернула 2xx с нулевым usage — баг учёта
// провайдера/необычный ответ: $0 ослепил бы потолок, поэтому берём
// консервативную оценку и предупреждаем (находка ревью). Для local ($0
// цена) нулевой usage штатен — остаётся $0.
if cost == 0 && price.InputPerM > 0 && resp.Usage.PromptTokens == 0 && resp.Usage.CompletionTokens == 0 {
r.Log.WarnContext(ctx, "paid 2xx with zero usage; settling the reservation estimate to keep the ceiling honest",
"stage", st.Name, "model", modelActual, "estimate_usd", fmt.Sprintf("%.6f", estimate))
cost = estimate
}
usageJSON, err := json.Marshal(resp.Usage) usageJSON, err := json.Marshal(resp.Usage)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -225,6 +225,61 @@ func TestRunnerSnapshotPinning(t *testing.T) {
} }
} }
// Платный 2xx с нулевым usage не должен селтлиться в $0 (иначе потолок слепнет):
// берётся консервативная оценка резерва.
func TestRunnerZeroUsagePaidSettlesEstimate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"z","model":"deepseek-v4-flash","choices":[{"message":{"content":"перевод"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":0,"completion_tokens":0}}`)
}))
defer srv.Close()
bookPath := setupProject(t, srv.URL)
r, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
defer r.Close()
res, err := r.TranslateOneChunk(context.Background())
if err != nil {
t.Fatal(err)
}
if res.TotalUSD <= 0 {
t.Fatalf("zero-usage paid 2xx must settle a non-zero estimate, got $%.6f", res.TotalUSD)
}
committed, _, err := r.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
if committed != res.TotalUSD {
t.Fatalf("committed %v != total %v", committed, res.TotalUSD)
}
}
// NewRunner обязан отклонить конфиг с нереализованной механикой ДО открытия
// store и любых вызовов (guard границы «конфиг vs код»).
func TestRunnerRejectsUnrunnableConfig(t *testing.T) {
var calls atomic.Int32
srv := newFakeProvider(t, &calls)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
// Ломаем pipeline на fanout>1 (механика Фазы 2).
pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(pipePath)
if err != nil {
t.Fatal(err)
}
writeFile(t, pipePath, string(raw)+"\nfanout: { candidates: 3 }\n")
if _, err := NewRunner(bookPath, obs.NewLogger()); err == nil {
t.Fatal("fanout.candidates>1 must be rejected by NewRunner (Phase-2 mechanics)")
}
if calls.Load() != 0 {
t.Fatalf("rejected config must not reach the provider, calls=%d", calls.Load())
}
}
func TestRunnerCeilingDenies(t *testing.T) { func TestRunnerCeilingDenies(t *testing.T) {
var calls atomic.Int32 var calls atomic.Int32
srv := newFakeProvider(t, &calls) srv := newFakeProvider(t, &calls)

View file

@ -139,9 +139,10 @@ func (s *Store) recoverReservations(ctx context.Context) error {
return fmt.Errorf("store: recover reservations: %w", err) return fmt.Errorf("store: recover reservations: %w", err)
} }
if n, _ := res.RowsAffected(); n > 0 { if n, _ := res.RowsAffected(); n > 0 {
// The caller's logger isn't wired here; surface via the request_log-less // stderr, НЕ stdout: stdout — это полезный вывод перевода/отчёта, и
// path is overkill — a plain stderr note keeps it visible. // эта строка не должна засорять перенаправленный результат (находка
fmt.Printf("store: recovered %d stale reservation row(s) from a previous run\n", n) // ревью). Логгер сюда не прокинут (Open — до его построения).
fmt.Fprintf(os.Stderr, "store: recovered %d stale reservation row(s) from a previous run\n", n)
} }
return nil return nil
} }

View file

@ -73,6 +73,42 @@
> 5. **Р5 обновлён** сноской на токен-калибровку (×1.41.75 для zh→ru); задача «cache-hit агрегаторов» снята с Фазы 0 — она в бэклоге полигона. > 5. **Р5 обновлён** сноской на токен-калибровку (×1.41.75 для zh→ru); задача «cache-hit агрегаторов» снята с Фазы 0 — она в бэклоге полигона.
> **Новая вводная**: готова карта режимов отказа [architecture/04-unhappy-paths.md](architecture/04-unhappy-paths.md) (~70 режимов → механизмы). Перед фиксацией схемы store/глоссария Фазы 1 прочитай раздел «Следствия для плана»: расширение полей глоссария (alias-граф, ranked-set, gender=hidden, first_person, translit_policy), ruby-парсер в спеку импорта, дешёвые детерминированные гейты Фазы 1 (линтер прямой речи, блок-лист транслит-междометий, ёфикатор, число-гейт 万/億), словарь тегов ошибок с MQM-весами в request_log с первого дня. Пороги coverage-гейта уже сняты полигоном (см. его секцию, эксперимент 02). > **Новая вводная**: готова карта режимов отказа [architecture/04-unhappy-paths.md](architecture/04-unhappy-paths.md) (~70 режимов → механизмы). Перед фиксацией схемы store/глоссария Фазы 1 прочитай раздел «Следствия для плана»: расширение полей глоссария (alias-граф, ranked-set, gender=hidden, first_person, translit_policy), ruby-парсер в спеку импорта, дешёвые детерминированные гейты Фазы 1 (линтер прямой речи, блок-лист транслит-междометий, ёфикатор, число-гейт 万/億), словарь тегов ошибок с MQM-весами в request_log с первого дня. Пороги coverage-гейта уже сняты полигоном (см. его секцию, эксперимент 02).
### 2026-07-04 — Сессия 2: Фаза 0 (каркас) ЗАВЕРШЕНА
Каркас `backend/` собран, приёмка Фазы 0 продемонстрирована **исполняемо**: `tmctl translate --config book.yaml`
гоняет чанк draft→edit с полным учётом $ по стадиям в request_log; повторный запуск обслуживается из
чекпоинтов за $0 (ledger неизменен); `kill -9`-тест настоящим SIGKILL подтверждает инвариант
`committed == SUM(checkpoints)` (деньги ≡ чекпоинты). `go test ./...`, `go vet`, `-race` — зелёные.
- **Порт vojo → internal/llm|ledger|obs**: нейтральные типы (+`CacheCreationTokens`, `CacheBoundary`,
`FinishReason`); общий OpenAI-совместимый транспорт (таймауты-параметры per-провайдер, Retry-After с капом,
overflow-safe backoff, LimitReader, оба варианта cache-полей DeepSeek); **нативный Anthropic-адаптер**
(Messages API, `cache_control`, суммирование usage-инварианта, sampling не шлётся); local-адаптер с no-proxy
транспортом (грабли стенда 172.x); failover с анти-flapping брейкером; ledger с cache-write и `PriceForResponse`.
- **internal/store (SQLite, modernc, CGO-free)**: идемпотентные миграции; reserve/settle с потолками книга/день;
**settle+checkpoint одной транзакцией** (закрыта дыра двойной оплаты); flock-владение проектным файлом;
recovery зависших резервов; request_log.
- **internal/config (fail-fast)**: models.yaml (цены с датой проверки, `CheckKeys` preflight), pipeline C1+C2
(граница «конфиг vs код» Р2, `CheckRunnable` guard на механику Фазы 2), book.yaml + brief_hash.
- **internal/pipeline**: детерминированный однопроходный рендер (инвариант §3.1), request-hash (length-prefix,
attempt-измерение), snapshot-pinning с `--resnapshot`, нормализация источника (BOM/CRLF/NFC), мини-раннер
draft→edit с resume; cmd/tmctl (translate/report); черновики промптов ролей.
- **Финал — агентное селфревью**: `/code-review` (8 линз) + воркфлоу-критики (4 линзы задания) с адверсариальной
верификацией; подтверждённые находки исправлены двумя волнами (детали и остаточный техдолг —
[03-implementation-notes §6](architecture/03-implementation-notes.md)). Коммиты: `dc6ea8e` каркас →
`a32ec2e` волна 1 → волна 2.
Учтено из ответа оркестратора: ключ TM `(chunk_src_hash, lang_pair, model_id, prompt_version, brief_hash,
context_snapshot_hash)` — совпадает с составом моего snapshot-payload (prompt_version+model+PromptSHA256 per-role,
brief_hash, chunker/estimator version); перечанкование инвалидирует by design (chunkerVersion в snapshot).
Карту 04-unhappy-paths заберу в схему store/глоссария при старте Фазы 1.
**Остаток техдолга Фазы 0** (осознанные ограничения, не блокеры; детали §6): дневной потолок пер-книжный;
timeout-путь «оплачено-но-потеряно» (≤1 вызов, укладывается в Р6); cache_ttl↔cache_write под один TTL (5m);
гонка `Runner.clients` (безопасна при последовательном проходе, мьютекс — к fan-out Фазы 12).
Следующее: Фаза 1 (перевод книги целиком) — чанкер, банк памяти v1, гейты (пороги полигона готовы), режим онгоинга.
## Полигон ## Полигон
(секция параллельной сессии — записи добавлять сюда) (секция параллельной сессии — записи добавлять сюда)

View file

@ -192,3 +192,40 @@ C2, cache TTL per-стадия. В models.yaml: цены (+cache write/read), п
полигону (у него харнесс сравнения usage), бэкенд потребит результат в конфиге; TMX в диаграммах не полигону (у него харнесс сравнения usage), бэкенд потребит результат в конфиге; TMX в диаграммах не
помечен «(Фаза 3)»; морфодетектор/CometKiwi/NSFW-классификатор в диаграммах без фазовых пометок; помечен «(Фаза 3)»; морфодетектор/CometKiwi/NSFW-классификатор в диаграммах без фазовых пометок;
режим онгоинга отсутствует в диаграммах (и не определено, перегоняется ли Analyst при дозагрузке глав). режим онгоинга отсутствует в диаграммах (и не определено, перегоняется ли Analyst при дозагрузке глав).
## 6. Селфревью Фазы 0 (агентное, 2026-07-04)
Финал Фазы 0: `/code-review` (8 finder-линз + верификация) + воркфлоу-критики (4 линзы задания: архитектура/
соответствие v2, корректность и деньги, идиоматичность Go, устойчивость к сбоям) с адверсариальной проверкой
каждой находки. Подтверждённые исправлены **двумя волнами** (коммиты `dc6ea8e``a32ec2e` → фиксы v2):
**Волна 1 (коммит a32ec2e):** общий retry-движок (устранена копипаста Anthropic↔OpenAI, кап Retry-After
`maxRetryAfterWait` 5м, overflow-safe backoff); `BilledDecodeError` — 2xx с нечитаемым телом консервативно
селтлит оценку, не возвращает резерв; пустой оплаченный ответ — громкая остановка, не отравляет редактуру;
snapshot-pinning гейт с `--resnapshot`; flock-владение проектным файлом; цены локальных тегов $0; однопроходный
рендер; `temperature` без omitempty (Anthropic вообще не шлёт sampling — 400); `attempt` в ключе и схеме.
**Волна 2 (эта):** snapshot-покрытие расширено (Defaults ratio/min, Title→BriefHash, модельный ExtraBody —
правки конфига больше не инвалидируют чекпоинты в обход гейта); `PriceForResponse` — неизвестный слаг ответа
книжится по ЗАПРОШЕННОЙ модели, не по дешёвому якорю (устранён недоучёт премиума); usage-кламп в `CostUSD`
(отрицательные числа не уменьшают committed); zero-usage платный 2xx → settle оценки + WARN; жизненный цикл
статуса джобы (не остаётся `running` на отказе потолка/ошибке клиента); `CheckRunnable` — fail-fast на
нереализованную механику (C2/C3, fan-out>1, роль judge — иначе `pipeline-c2.yaml` прогнал бы стадии линейно и
сжёг деньги Opus на мусор); `CheckKeys` — preflight API-ключей used-моделей (пустой ключ ловится до reserve, не
401-м после); request-hash — length-prefix полей + счётчик сообщений (устранена NUL-коллизия); нормализация
источника BOM/CRLF/NFC (детерминизм хеша между ОС/редакторами); LimitReader-усечение терминально (не ретраит
оплаченный >16 MiB); recoverReservations → stderr.
**Остаточный техдолг (осознанные ограничения Фазы 0, не блокеры):**
- **Дневной потолок — пер-книжный** (ledger в файле проекта одной книги): `day_usd` сбрасывается в UTC-полночь
и ограничивает burn-rate одной книги, но не «на оператора за день по всем проектам». Общий потолок оператора
потребует shared spend-файла — отложено (раскладка «файл БД на книгу» из Р3/Р6); пометить в UI при мультикниге.
- **Timeout-путь «оплачено, но потеряно»**: если провайдер начал биллить 2xx, а соединение оборвалось по
таймауту до получения тела, вызов повторяется — недоучёт на один вызов. Это неустранимо для любой системы с
retry-on-timeout и укладывается в честные «≤1 вызов» (гарантия Р6 — про kill -9, не про billing на брошенном
соединении). `BilledDecodeError` закрывает случай, когда тело ЧАСТИЧНО дошло; чистый таймаут до тела — нет.
- **cache_ttl ↔ cache_write_per_m**: цена записи в кэш в models.yaml задаётся под ОДИН TTL (у нас 5m). Если
оператор поставит `cache_ttl: 1h`, реальная запись стоит ×2 (не ×1.25) — недоучёт. Боевой конфиг 5m совпадает;
при переходе на 1h требуется отдельная цена. Валидацию/вторую цену — Фаза 1 (когда включим 1h осознанно).
- **Гонка `Runner.clients` map**: ленивая инициализация клиентов не синхронизирована — безопасна в Фазе 0
(последовательный проход стадий), но параллельные чанки/fan-out Фазы 12 потребуют мьютекса/предзагрузки.