Take in the engine zone's work on calls the engine cuts itself, as the zone built it: a delivered break is asked about once, paid once, and never bought twice

This commit is contained in:
heaven 2026-09-10 18:50:01 +03:00
parent 6ee6c61eaa
commit 3f05fab941
28 changed files with 7621 additions and 142 deletions

View file

@ -1,6 +1,8 @@
package main
import (
"textmachine/backend/internal/llm"
"context"
"encoding/json"
"errors"
@ -486,3 +488,45 @@ func TestACrashJoinedWithACeilingStillExitsAsACrash(t *testing.T) {
t.Fatalf("a crash carrying a ceiling exited %d, want 1 — exit 4 would tell a supervisor to resume a process that died", code)
}
}
// TestTheCutErrorTypeKeepsItsExitCode pins the linkage between the pack's new error type and the number
// a caller reads. Nothing held it: `AttemptCutError` appeared 0 times in this package's tests against 26
// calls of the exit mapping, so every claim about «a stop exits 5, a cut exits 1» rested on reading the
// switch rather than on running it.
//
// The distinction is the whole point of the type. All three causes are one Go type, and two of them are
// ordinary failures while the third is a person pressing stop — if the mapping stopped telling them
// apart, an operator's stop would report as an engine failure, or an engine failure as a stop.
func TestTheCutErrorTypeKeepsItsExitCode(t *testing.T) {
cut := func(cause llm.CutCause, parent error) error {
return &llm.AttemptCutError{Provider: "p", Cause: cause, Delivered: true,
Err: errors.New("transport"), Parent: parent}
}
cases := []struct {
name string
err error
want int
}{
{"a person stopped the run", cut(llm.CutByParent, context.Canceled), 5},
{"our own deadline cut the call", cut(llm.CutBySelfDeadline, nil), 1},
{"the socket died after delivery", cut(llm.CutByConnection, nil), 1},
// Wrapped the way the engine actually hands it up: through fmt.Errorf and a join.
{"a stop wrapped by the pipeline", fmt.Errorf("pipeline: stage edit call: %w",
cut(llm.CutByParent, context.Canceled)), 5},
{"a stop joined with what it interrupted", errors.Join(context.Canceled,
cut(llm.CutByConnection, nil)), 5},
}
for _, c := range cases {
if got := exitCode(c.err); got != c.want {
t.Errorf("%s: exit %d, want %d — the three causes share one Go type and two of them are "+
"ordinary failures while the third is a person pressing stop; a mapping that stops "+
"telling them apart reports a stop as an engine failure or the reverse", c.name, got, c.want)
}
}
// The control: a ceiling still outranks a cut that rode along with it. A money stop is a different
// event from a transport one, and the caller's runbook branches on it.
ceiling := errors.Join(&pipeline.CeilingHalt{}, cut(llm.CutByConnection, nil))
if got := exitCode(ceiling); got != 4 {
t.Errorf("a ceiling halt carrying a cut must still exit 4, got %d", got)
}
}

View file

@ -517,6 +517,11 @@ func errTail(degraded, errText string) string {
}
s += errText
}
// ⛔ ONE ROW IS ONE LINE. `errors.Join` separates its members with a newline, and an engine error
// that carries both a cut and what ended the chain is exactly such a join — so a table row printed
// its tail across two lines, breaking the column alignment of everything under it and pushing the
// second half where no reader looks for it.
s = strings.Join(strings.Fields(s), " ")
if len(s) > 120 {
cut := 120
for cut > 0 && !utf8.RuneStart(s[cut]) {
@ -875,6 +880,13 @@ func renderStatusHuman(w io.Writer, rep *pipeline.StatusReport, cfgPath string)
}
fmt.Fprintf(w, "Money: committed=$%.6f reserved=$%.6f%s · book forecast ~$%.6f\n",
rep.CommittedUSD, rep.ReservedUSD, ceil, rep.ProjectedBookUSD)
// The estimated share of that committed figure, on the surface an operator reads before deciding
// whether to keep paying. The same pair rides in --json for the platform; printing it here too is
// what makes «committed» readable as a range rather than as a measurement.
if rep.EstimatedRows > 0 {
fmt.Fprintf(w, " of which ESTIMATED: $%.6f over %d call(s) — booked at the reservation estimate because the provider never reported usage for them (a body that would not decode, a call our own deadline or a stop cut short, a 2xx with no usage)\n",
rep.EstimatedUSD, rep.EstimatedRows)
}
if rep.ETASeconds > 0 {
fmt.Fprintf(w, "ETA: ~%.0fs (mean throughput of fresh calls, NOT EWMA — D12-deviation; secondary)\n", rep.ETASeconds)
}

View file

@ -588,3 +588,109 @@ func TestRenderSignatureStopShowsBothDisagreements(t *testing.T) {
t.Errorf("control: an unmarked row must print neither marker:\n%s", quiet.String())
}
}
// TestStatusJSONPublishesTheEstimatedShareBesideCommitted is the SEAM contract, not a formatting test.
// The platform bills a user `committed_usd` from this JSON, and until the pair below existed it could
// say «the run cost at least this much» and never «at least X, up to Y» (PD-441). The owner's word on
// charging a reader for a call we cut short came with a condition — that the estimate be visible as an
// estimate (D39.230 п.1) — and for the platform «visible» means a NUMBER, not a line of screen text.
//
// ⛔ ZERO MUST PUBLISH AS ZERO. Rendered with `omitempty` the fields would vanish on a clean book, and a
// consumer cannot tell «none of it was estimated» from «this engine is too old to know»: the safe read
// of an absent field is the pessimistic one, so a perfectly measured book would be billed as uncertain.
func TestStatusJSONPublishesTheEstimatedShareBesideCommitted(t *testing.T) {
decode := func(t *testing.T, rep *pipeline.StatusReport) map[string]any {
t.Helper()
var b strings.Builder
if err := renderStatusJSON(&b, rep); err != nil {
var flagged *pipeline.CompletedWithFlags
if !errors.As(err, &flagged) {
t.Fatalf("render: %v", err)
}
}
var decoded map[string]any
if jerr := json.Unmarshal([]byte(b.String()), &decoded); jerr != nil {
t.Fatalf("output must be valid JSON: %v", jerr)
}
return decoded
}
cut := decode(t, &pipeline.StatusReport{TotalUnits: 1, Done: 1, CommittedUSD: 0.5, EstimatedRows: 2, EstimatedUSD: 0.25})
for field, want := range map[string]any{"committed_usd": 0.5, "estimated_usd": 0.25, "estimated_rows": float64(2)} {
if cut[field] != want {
t.Fatalf("the platform reads %q from this JSON: got %v, want %v (full: %v)", field, cut[field], want, cut)
}
}
clean := decode(t, &pipeline.StatusReport{TotalUnits: 1, Done: 1, CommittedUSD: 0.5})
for _, field := range []string{"estimated_usd", "estimated_rows"} {
v, present := clean[field]
if !present {
t.Fatalf("%q vanished on a book with nothing estimated — a consumer cannot tell that from an "+
"engine that does not publish it, and the safe reading of the absence is the wrong one", field)
}
if v != float64(0) {
t.Fatalf("%q on a fully measured book must be 0, got %v", field, v)
}
}
}
// TestStatusHumanShowsTheEstimatedShare is the human twin of the JSON seam, and it had no pin at all:
// removing the whole block left both packages green. It is the surface an operator reads before
// deciding whether to keep paying, and the number beside `committed` is what makes that figure a RANGE
// rather than a measurement — the condition the owner's word came with (D39.230 п.1).
//
// Both sides, because a line that always prints is as useless as one that never does.
func TestStatusHumanShowsTheEstimatedShare(t *testing.T) {
render := func(t *testing.T, rep *pipeline.StatusReport) string {
t.Helper()
var b strings.Builder
if err := renderStatusHuman(&b, rep, "book.yaml"); err != nil {
t.Fatalf("render: %v", err)
}
return b.String()
}
withEst := render(t, &pipeline.StatusReport{TotalUnits: 1, Done: 1, CommittedUSD: 0.5, EstimatedRows: 2, EstimatedUSD: 0.25})
if !strings.Contains(withEst, "ESTIMATED") {
t.Fatalf("an operator deciding whether to keep paying must see which part of `committed` is an "+
"estimate; the screen said nothing:\n%s", withEst)
}
if !strings.Contains(withEst, "0.250000") || !strings.Contains(withEst, "2 call") {
t.Fatalf("the line must carry BOTH the money and the call count — one expensive call and twenty "+
"cheap ones are different problems with the same figure:\n%s", withEst)
}
// The control: on a book with nothing estimated the line must be absent, or it becomes noise an
// operator learns to scroll past — and then it is not there on the day it matters.
clean := render(t, &pipeline.StatusReport{TotalUnits: 1, Done: 1, CommittedUSD: 0.5})
if strings.Contains(clean, "ESTIMATED") {
t.Fatalf("a fully measured book must print no estimate line:\n%s", clean)
}
}
// TestTheOperatorTailIsOneLineAndKeepsTheNoteThatMatters pins the column an operator actually reads.
// Two independent ways it failed: an engine error built by errors.Join carries a NEWLINE, so one table
// row printed as two and broke the alignment of everything under it; and the note the cut path appends —
// how many times the provider was asked, and what was booked — sat at the END of a transport error
// routinely longer than the 120-byte bound, so it reached nobody.
func TestTheOperatorTailIsOneLineAndKeepsTheNoteThatMatters(t *testing.T) {
long := strings.Repeat("провайдер разорвал соединение на середине тела ответа. ", 6)
note := "[the provider was asked 3 times; NOTHING is booked: the provider acknowledged none of them]"
// The newline sits right behind the note, INSIDE the bound: put it past 120 bytes and the truncation
// removes it for free, and the fixture would pass on a renderer that collapses nothing.
tail := errTail("connection_lost", note+"\n"+long)
if strings.ContainsAny(tail, "\n\r") {
t.Fatalf("one row is one line: a joined error put a newline into the column and the table split "+
"under it. got %q", tail)
}
if !strings.Contains(tail, "asked 3 times") {
t.Fatalf("the note is the only place the gap between what was generated and what was booked is "+
"visible; the bound must not be what removes it. got %q", tail)
}
if !strings.Contains(tail, "NOTHING is booked") {
t.Fatalf("the note must survive whole enough to be read, not just start: %q", tail)
}
// The control: the bound is still a bound, or this test would pass on a renderer that stopped
// truncating and let a whole provider body into the table.
if len([]rune(tail)) > 121 {
t.Fatalf("the tail must still be bounded: %d runes", len([]rune(tail)))
}
}

File diff suppressed because it is too large Load diff

View file

@ -56,14 +56,44 @@ providers:
# xai в ДАННЫЕ не вносил осознанно — это сняло бы grok-off-выключатель, чья цена (grok думает по
# дефолту, additive) — решение владельца, а не сессии.
echoes_when_thinking_off: true
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
# ⚠️ attempt_s — ПОЛ дедлайна, а не сам дедлайн: реальный считается от бюджета вызова
# (queue_slack_s + max_tokens/tok_s_floor, зажатый между attempt_s и attempt_max_s). Прежнее
# чтение «240 — это дедлайн» было верно ровно для ЧЕРНОВИКА и перенесено на редактора вслепую:
# 240 с — то, что вендорская формула 128 000 ток/час даёт для ~8.5k, а редактор бюджетируется на
# 16 000 с удвоением до 32 000, то есть на вызов, который в 240 с не уложится ни на какой скорости.
#
# tok_s_floor: 50 — ЗАМЕРЕНО по нашему же request_log, а не назначено. p10 скорости
# deepseek-v4-pro (самая медленная модель этого провайдера) = 51.40 ток/с на n=1745 строках
# 162 баз с непустыми completion_tokens и latency_ms; округление ВНИЗ до 50 объявлено здесь.
# Контроль: deepseek-v4-flash на тех же данных даёт p10 = 88.15 (n=6361), то есть пол держит обе.
# ⚠️ Замер берёт latency КОНЦА В КОНЕЦ, вместе с ожиданием в очереди, — значит он ЗАНИЖАЕТ
# реальную скорость генерации, и пол от него консервативен в правильную сторону.
#
# queue_slack_s: 600 — число ВЕНДОРА, не наше: DeepSeek документирует ранний 200 с пустыми
# строками, пока запрос ждёт планирования, и закрытие соединения, если инференс не начался за
# 10 минут (api-docs.deepseek.com/quick_start/rate_limit). Меньше ставить нельзя — это молча
# переигрывало бы принятый владельцем размен «ждать до ~20 минут» в сторону обрыва оплаченного.
#
# attempt_max_s: 1240 = 600 + 32000/50 — слак плюс максимальный грант С УДВОЕНИЕМ на полу.
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60, tok_s_floor: 50, queue_slack_s: 600, attempt_max_s: 1240 }
zai: # GLM, международный контур (docs.z.ai)
kind: openai
base_url: https://api.z.ai/api/paas/v4
api_key_env: ZAI_API_KEY
reasoning: subset
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
# tok_s_floor: 35 — ЗАМЕРЕНО тем же прибором, что и deepseek: p10 скорости glm-5 = 37.51 ток/с на
# n=647 строках request_log (≥100, как требует правило вывода), округление ВНИЗ до 35 объявлено
# здесь. Ниже вендорского дефолта 35.5, то есть ставит вызову чуть БОЛЬШЕ времени, а не меньше.
# queue_slack_s НЕ ставится: документированного z.ai числа ожидания до инференса у меня нет, а
# гадать правило запрещает — слак остаётся нулевым, как и был.
# ⚠ ОТСТУПЛЕНИЕ, ОБЪЯВЛЕНО: пол замерен по glm-5, а НЕ по glm-5.1, которая тоже ходит через этого
# провайдера и наследует то же число без собственного замера. Отступление принято потому, что
# направление ошибки безопасно: 35 ниже вендорского дефолта 35.5, то есть даже неверный для 5.1 пол
# даёт вызову БОЛЬШЕ времени, чем дефолт, а не меньше — то есть режет не вызовы, а только запас.
# Убрать отступление можно одним способом: собрать n≥100 строк request_log по glm-5.1 и объявить её
# собственный p10 здесь. До тех пор число читать как «пол провайдера по измеренной модели».
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60, tok_s_floor: 35 }
kimi: # API-хост — api.moonshot.ai (intl, НЕ .cn). Веб-консоль переехала
# platform.moonshot.ai → platform.kimi.ai, но API-эндпоинт остаётся
@ -73,7 +103,13 @@ providers:
base_url: https://api.moonshot.ai/v1
api_key_env: KIMI_API_KEY
reasoning: subset
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
# tok_s_floor НЕ ставится: строк этого провайдера в нашем request_log НОЛЬ (замер по 162 базам —
# deepseek 8106, glm 647, mistral 14, kimi 0), а назначать скорость без замера правило вывода
# запрещает. Дедлайн считается от вендорского дефолта 128 000 ток/час, который медленнее всего,
# что мы мерили, и потому только УДЛИНЯЕТ вызов; движок пишет об этом WARN на первом же вызове.
# attempt_max_s: 1200 — не замер, а ПОЛИТИКА: ратифицированный владельцем предел ожидания ~20 мин.
# Он ничего не режет — дефолтный пол на удвоенном гранте 16 000 даёт 900 с.
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60, attempt_max_s: 1200 }
xai: # Grok — главный тир канала B (18+) + судья 18+; из редакторских ролей СНЯТ (D30.1 — reasoning-off no-op).
# ⚠️ КЛЮЧЕВАЯ ПОЛИТИКА (D27, супрсидит «чистый ключ» D19.4/D20.3): ЕДИНЫЙ XAI_API_KEY С data-sharing
@ -135,7 +171,10 @@ providers:
# Поэтому склеиваем сами — это единственная форма, которая потерять не может.
capabilities:
system_messages: single
timeouts: { attempt_s: 300, max_attempts: 3, backoff_cap_s: 60 } # апекс думает дольше
# attempt_s 300 — апекс думает дольше; как и везде, это ПОЛ. tok_s_floor не ставится по той же
# причине, что у kimi: строк gemini в нашем request_log ноль. attempt_max_s — предел ожидания,
# ратифицированный владельцем (~20 мин); дефолтный пол на удвоенном гранте 16 000 даёт 900 с.
timeouts: { attempt_s: 300, max_attempts: 3, backoff_cap_s: 60, attempt_max_s: 1200 }
openai: # OpenAI прямой ключ (D3: Anthropic убран, OpenAI ОСТАЁТСЯ). gpt-5-nano альт-черновик,
# gpt-5-mini кандидат-редактор/second-opinion судья — НЕ в дефолтной цепочке Ф1 (слот под Ф2).

View file

@ -122,16 +122,36 @@ type ReasoningCapCfg struct {
// Timeouts is the retry profile per provider (the per-provider profile from the
// validation verdict; per-role overrides — Phase 1).
type Timeouts struct {
// AttemptS is the FLOOR of one attempt's deadline, not the deadline itself: the real one is derived
// from the output budget the call carries (llm/attemptcut.go). Read as a fixed value it was wrong by
// construction for the editor — 240 s is what the vendor's own 128 000-tokens-per-hour figure gives
// for the DRAFT's ~8.5k budget, and the same number was carried to a stage budgeted at 16 000 with a
// doubling to 32 000, i.e. to a call that could not finish inside it at any speed the model holds.
AttemptS int `yaml:"attempt_s"`
MaxAttempts int `yaml:"max_attempts"`
BackoffCapS int `yaml:"backoff_cap_s"`
// TokSFloor is the slowest generation speed this provider has been OBSERVED to hold — below the p10
// of its own request_log, rounded down, and the rounding declared where it is set. Unset ⇒ the
// vendor default (128 000 tokens/hour), which is slower than any provider we have measured and
// therefore only ever grants a call MORE time than it needs.
TokSFloor float64 `yaml:"tok_s_floor"`
// QueueSlackS is how long the VENDOR documents a request may wait before generation starts. It is
// the vendor's number and not a guess: a slack smaller than what the vendor publishes silently
// re-decides how long we are willing to wait, in the direction of cutting calls we have paid for.
QueueSlackS int `yaml:"queue_slack_s"`
// AttemptMaxS caps the derived deadline — the longest a single call may hold a reservation. 0 =
// uncapped, i.e. the derivation stands on its own.
AttemptMaxS int `yaml:"attempt_max_s"`
}
func (t Timeouts) Profile() llm.RetryProfile {
return llm.RetryProfile{
AttemptTimeout: time.Duration(t.AttemptS) * time.Second,
MaxAttempts: t.MaxAttempts,
BackoffCap: time.Duration(t.BackoffCapS) * time.Second,
AttemptTimeout: time.Duration(t.AttemptS) * time.Second,
MaxAttempts: t.MaxAttempts,
BackoffCap: time.Duration(t.BackoffCapS) * time.Second,
TokensPerSecFloor: t.TokSFloor,
QueueSlack: time.Duration(t.QueueSlackS) * time.Second,
AttemptMax: time.Duration(t.AttemptMaxS) * time.Second,
}
}
@ -238,6 +258,7 @@ func LoadModels(path string) (*Models, error) {
if p.Kind == "anthropic" && p.CacheTTL != "" && p.CacheTTL != "5m" && p.CacheTTL != "1h" {
bad("provider %s: cache_ttl must be 5m|1h, got %q", name, p.CacheTTL)
}
validateTimeouts(bad, name, p.Timeouts)
if p.Kind == "local" && p.Model == "" {
bad("provider %s: local kind requires model (its own tag)", name)
}
@ -462,6 +483,17 @@ func (m *Models) ResolveCapability(modelName string) llm.Capability {
return resolved
}
// AttemptDeadline is how long ONE call to this model may run: the provider's retry profile applied to
// the call's own output budget (llm.RetryProfile.DeadlineFor). It resolves model→provider→timeouts the
// same way ResolveCapability and MinMaxTokens resolve their fields, so a caller that needs to SAY how
// long a call may wait reads the same answer the transport will act on rather than assembling one.
//
// An unknown model yields the zero profile, whose derivation still returns the vendor default — the
// same direction every fallback on this path takes: too long, never too short.
func (m *Models) AttemptDeadline(modelName string, maxTokens int) time.Duration {
return m.Providers[m.Models[modelName].Provider].Timeouts.Profile().DeadlineFor(maxTokens)
}
// thinkingControlExtraKeys are keys whose purpose is to toggle a provider's thinking
// on the wire — top-level (GLM/DeepSeek {"thinking":…}, Qwen {"enable_thinking":…}, a
// raw {"reasoning_effort":…}) OR nested (DeepSeek-V3.1+ disables via
@ -620,6 +652,37 @@ func containsLabel(set []string, want string) bool {
// how "accepts none" is written), and a duplicate is a config smell worth naming rather than absorbing
// silently. Labels are compared verbatim, so leading/trailing space would make two spellings of one
// permission — rejected rather than trimmed, because a permission must not be guessed at.
// maxAttemptSeconds bounds every deadline knob a provider may set. It is a TYPO GUARD, not a policy: the
// owner ratified waiting up to about twenty minutes for one call, and this sits an order of magnitude
// above that so a legitimate configuration is never refused. What it refuses is a slipped digit —
// `queue_slack_s: 6000` reads as an hour and forty minutes of waiting nobody chose, and the only place
// that shows up is a run that looks hung.
const maxAttemptSeconds = 4 * 60 * 60
// validateTimeouts checks the deadline knobs AGAINST EACH OTHER, which is the part no per-field check
// can do. The derivation clamps the derived deadline up to `attempt_s` and then down to `attempt_max_s`,
// so a cap below the floor wins — and the floor's own doccomment, which calls it a FLOOR, quietly stops
// being true for every call that provider makes. That is a config a person can write in one keystroke
// and cannot see afterwards: nothing logs it, and the calls simply get less time than the file says.
func validateTimeouts(bad func(string, ...any), name string, t Timeouts) {
if t.AttemptS < 0 || t.AttemptS > maxAttemptSeconds {
bad("provider %s: attempt_s %d is out of range (0..%d seconds)", name, t.AttemptS, maxAttemptSeconds)
}
if t.QueueSlackS < 0 || t.QueueSlackS > maxAttemptSeconds {
bad("provider %s: queue_slack_s %d is out of range (0..%d seconds)", name, t.QueueSlackS, maxAttemptSeconds)
}
if t.AttemptMaxS < 0 || t.AttemptMaxS > maxAttemptSeconds {
bad("provider %s: attempt_max_s %d is out of range (0..%d seconds)", name, t.AttemptMaxS, maxAttemptSeconds)
}
if t.TokSFloor < 0 {
bad("provider %s: tok_s_floor %g is negative — a speed floor below zero grants a call infinite time", name, t.TokSFloor)
}
if t.AttemptMaxS > 0 && t.AttemptMaxS < t.AttemptS {
bad("provider %s: attempt_max_s %d is BELOW attempt_s %d — the cap would win and every call would "+
"get less time than the declared floor, silently", name, t.AttemptMaxS, t.AttemptS)
}
}
func validateLabelSet(bad func(string, ...any), where string, set *[]string) {
if set == nil {
return

View file

@ -185,3 +185,166 @@ models:
t.Fatalf("a declared `single` MUST move the snapshot bytes — that is what makes the flip a loud --resnapshot: %s", singleJSON)
}
}
// TestAProviderThatWillWaitLongSaysSoInTheCatalog is the second half of the derived deadline, and it
// is the half that answers the standing review question — «does a provider the repository has never
// seen work without editing Go».
//
// It does, and this is the price: a provider whose models declare a big enough max_tokens FLOOR is
// declaring that its calls are big, and a big call under the vendor DEFAULT speed derives a deadline
// far past whatever attempt_s the config carries. That is not a failure — the derivation is
// deliberately slower than any provider we have measured, so it only ever grants MORE time — but it
// is a quarter of an hour of waiting that the config never mentions, and an operator who reads
// `attempt_s: 240` and watches a call run for fifteen minutes has been told something untrue by his
// own configuration.
//
// So the catalogue has to say ONE of two things about such a provider, and either is a line of DATA:
// - `tok_s_floor` — the speed it actually holds, measured from its own request_log; or
// - `attempt_max_s` — how long we are willing to wait for it, which is a policy and needs no measurement.
//
// ⛔ IT DOES NOT DEMAND THE MEASUREMENT. Requiring `tok_s_floor` would force a number to be invented
// for every provider nobody has run yet, and a fabricated floor is worse than an honest default: it
// would cut real calls we had already paid for. The alternative is the point.
func TestAProviderThatWillWaitLongSaysSoInTheCatalog(t *testing.T) {
m, err := LoadModels(shippedModelsYAML)
if err != nil {
t.Fatalf("load: %v", err)
}
// The largest budget one call to each provider can carry: its biggest declared floor, doubled once
// by a regeneration. It is a LOWER bound on what a real run may ask for (a long chunk derives more
// than the floor), which is the conservative direction for a gate: it can only under-report who
// needs a line, never demand one nobody needs.
biggest := map[string]int{}
for name, mod := range m.Models {
if floor := m.MinMaxTokens(name); floor*2 > biggest[mod.Provider] {
biggest[mod.Provider] = floor * 2
}
}
checked, flagged := 0, 0
for prov, grant := range biggest {
if grant == 0 {
continue // no model of this provider declares a floor: nothing to say
}
p := m.Providers[prov]
derived := p.Timeouts.Profile().DeadlineFor(grant)
configured := time.Duration(p.Timeouts.AttemptS) * time.Second
if derived <= configured {
continue // every call fits inside the deadline the config already names
}
checked++
if p.Timeouts.TokSFloor <= 0 && p.Timeouts.AttemptMaxS <= 0 {
flagged++
t.Errorf("provider %q can be asked for %d output tokens, which derives a %s deadline against "+
"its configured attempt_s of %s — declare timeouts.tok_s_floor (measure it from this "+
"provider's own request_log) or timeouts.attempt_max_s (how long we are willing to wait)",
prov, grant, derived, configured)
}
}
// The control beside the negative: «0 providers need a line» and «the loop never ran» print the
// same on a green test, and only one of them means anything.
t.Logf("providers in the catalogue: %d; with a declared token floor: %d; whose biggest call outgrows "+
"its own attempt_s (and therefore must declare one of the two fields): %d; missing both: %d",
len(m.Providers), len(biggest), checked, flagged)
if checked == 0 {
t.Fatalf("no provider in the shipped catalogue outgrows its own attempt_s — this gate is asking a " +
"question of nothing, and would stay green through any regression in the derivation")
}
}
// TestEveryDeclaredDeadlineKnobStaysDeclared pins the deadline data as data. Each of these numbers was
// PAID FOR — a speed floor is the p10 of that provider's own request_log over hundreds of rows, a queue
// slack is the vendor's published figure — and none of them is reachable by the gate above: it walks the
// LOADED struct, where a deleted line and a never-declared field are the same zero, and it skips a
// provider whose models declare no token floor at all. So deleting `tok_s_floor: 35` from zai passed
// every case in this package.
//
// The consequence of losing one is quiet by construction. A missing speed floor falls back to the vendor
// default, which is within two percent of zai's measured value — the run does not break, the measurement
// is simply gone, and the next person re-derives it from scratch or does without.
func TestEveryDeclaredDeadlineKnobStaysDeclared(t *testing.T) {
m, err := LoadModels(shippedModelsYAML)
if err != nil {
t.Fatalf("load: %v", err)
}
type knobs struct {
tokSFloor float64
queueSlackS int
attemptMaxS int
}
// The catalogue as it ships. A change here is a change to a measured or vendor-published number and
// must be made deliberately, with the measurement said beside it in models.yaml.
want := map[string]knobs{
"deepseek": {tokSFloor: 50, queueSlackS: 600, attemptMaxS: 1240},
"zai": {tokSFloor: 35},
"kimi": {attemptMaxS: 1200},
"gemini": {attemptMaxS: 1200},
}
declared := 0
for name, p := range m.Providers {
got := knobs{p.Timeouts.TokSFloor, p.Timeouts.QueueSlackS, p.Timeouts.AttemptMaxS}
if got != (knobs{}) {
declared++
}
w, pinned := want[name]
if !pinned {
if got != (knobs{}) {
t.Errorf("provider %q declares deadline knobs %+v that this pin does not know about — add "+
"them here with the measurement that produced them, or the next edit loses them silently", name, got)
}
continue
}
if got != w {
t.Errorf("provider %q: deadline knobs moved from %+v to %+v. These are measured numbers (a "+
"speed floor is the p10 of this provider's own request_log) — losing one returns the "+
"provider to the vendor default without a word anywhere", name, w, got)
}
}
// The control beside the count: «every knob is where it was» and «the catalogue declares none» print
// the same on a green test.
if declared != len(want) {
t.Fatalf("premise broken: %d providers declare deadline knobs, the pin names %d — the two must be "+
"the same set or one side is not being read", declared, len(want))
}
t.Logf("providers in the catalogue: %d; declaring deadline knobs: %d, all pinned", len(m.Providers), declared)
}
// TestACapBelowTheFloorIsRefusedAtLoad pins the one relation between the deadline knobs that no
// per-field check can see. The derivation clamps UP to `attempt_s` and then DOWN to `attempt_max_s`, so
// a cap below the floor wins: every call to that provider quietly gets less time than the file declares,
// and `attempt_s`'s own doccomment — which calls it a FLOOR — stops being true. Nothing logs it; the
// calls simply come back cut.
func TestACapBelowTheFloorIsRefusedAtLoad(t *testing.T) {
load := func(timeouts string) error {
t.Helper()
body := "prices_checked: " + time.Now().UTC().Format("2006-01-02") + `
default_model: fake
providers:
p: { kind: openai, base_url: http://x, timeouts: ` + timeouts + ` }
models:
fake: { provider: p, price: { input_per_m: 1, output_per_m: 2 } }
`
_, err := LoadModels(writeTmp(t, filepath.Join(t.TempDir(), "models.yaml"), body))
return err
}
// The control FIRST: a cap ABOVE the floor is an ordinary configuration and must load, or the check
// below would be satisfied by a loader that refuses everything.
if err := load("{ attempt_s: 240, max_attempts: 2, attempt_max_s: 1200 }"); err != nil {
t.Fatalf("a cap above the floor is a legitimate configuration and must load: %v", err)
}
if err := load("{ attempt_s: 240, max_attempts: 2 }"); err != nil {
t.Fatalf("an unset cap means «the derivation stands on its own» and must load: %v", err)
}
err := load("{ attempt_s: 240, max_attempts: 2, attempt_max_s: 120 }")
if err == nil {
t.Fatal("a cap BELOW the floor must be refused at load: the cap wins in the clamp, so every call " +
"gets less time than the declared floor and nothing anywhere says so")
}
if !strings.Contains(err.Error(), "attempt_max_s") || !strings.Contains(err.Error(), "attempt_s") {
t.Fatalf("the refusal must name BOTH knobs, or the operator cannot see which pair is wrong: %v", err)
}
// And the typo guard, which is the other half of the same field's danger: a slipped digit turns a
// ten-minute wait into an afternoon, and the only place it shows is a run that looks hung.
if err := load("{ attempt_s: 240, max_attempts: 2, queue_slack_s: 600000 }"); err == nil {
t.Fatal("a queue slack of a week must be refused as a typo — nobody chose to wait that long")
}
}

View file

@ -0,0 +1,343 @@
package llm
import (
"context"
"errors"
"fmt"
"net/http/httptrace"
"strings"
"sync/atomic"
"time"
)
// attemptcut.go: what OUR OWN deadline does to a call, and the deadline itself.
//
// A provider that has accepted a request generates whether or not we are still listening. When the
// attempt deadline fires mid-generation the connection drops on our side, the provider never learns
// it, and the call is billed all the same — so «we stopped waiting» is a MONEY event, not a transport
// one. The transport used to lose it twice over: the body read's error went to `_`, and truncation was
// judged by SIZE, so a body our own deadline cut short (small) was indistinguishable from a whole one
// and went down the retryable «broken connection» branch that the same code's comment forbids for a
// billed 2xx («each retry is a new billed 2xx»).
//
// THE ONE FACT COMMON TO EVERY SUCH CASE IS THAT THE REQUEST WAS DELIVERED, and it is observable
// without a byte on the wire: net/http/httptrace reports when the request has been WRITTEN and when
// the first response byte arrived.
//
// ⚠ NO SURVEYED HARNESS DRAWS THE BOUNDARY HERE, and that is a fact rather than a claim of novelty:
// research/21 covers eleven of them and mentions httptrace nowhere (0 hits against 20 for openai-go).
// The reason is what they are: five are stream-only and four do both (§Q6), so «did anything arrive» is
// answered by the first event and the question never comes up; the remaining two are generic SDKs whose
// non-streaming answer is to REFUSE a long call outright. On a non-streaming path with no refusal to
// fall back on it has to be answered another way, and the stdlib already answers it — so the mechanism
// is stdlib rather than ours (research/21's own rule: an industrial primary source before a home-made one).
//
// Those two booleans separate the three cases that a status line cannot:
//
// delivered, headers still pending, our deadline WroteRequest=true GotFirstResponseByte=false
// TLS handshake hanging — NOT delivered WroteRequest=false GotFirstResponseByte=false
// early 200 with empty lines, our deadline WroteRequest=true GotFirstResponseByte=true
//
// The middle row is the ONLY one where «nothing was bought» is true by construction. On DeepSeek the
// first and third are the live ones: the vendor documents an early 200 with empty lines while a
// request waits to be scheduled, so a 200 there means ACCEPTED, not GENERATED, and the branch that
// reads «No 2xx ever arrived: nothing was billed» is unreachable on it — every self-cut hid under
// decode_error instead.
// CutCause says WHO ended an attempt that had already been delivered. The three are dispositions,
// not shades of one error: our own deadline is not retried and is flagged, a run the operator
// stopped is re-done on resume at the same budget, and a broken connection gets one retry.
type CutCause string
const (
// CutBySelfDeadline: our per-attempt deadline fired while the provider was still working.
// Retrying buys the same generation a second time — the whole point of typing this.
CutBySelfDeadline CutCause = "attempt_timeout"
// CutByParent: the RUN ended (stop, Ctrl-C) under a call that had already gone out. The call was
// healthy; a human stopped it.
CutByParent CutCause = "cancelled"
// CutByConnection: the connection broke after delivery (unexpected EOF, an h2 stream reset) with
// both deadlines still alive. Transport-shaped, so it is worth exactly one retry.
CutByConnection CutCause = "connection_lost"
)
// AttemptCutError is a DELIVERED request whose reply we did not receive whole.
//
// ⚠ It is deliberately NOT named for the timeout: two of its three causes are not one (a run somebody
// stopped, a connection that broke), and the runner routes money, flag and resume differently for each.
// A type named `AttemptTimeoutError` carrying `Cause: cancelled` would be a name lying about a cause,
// which is the thing the flag vocabulary forbids outright (D39.93 п.2).
//
// Delivered is the money boundary and is always true on a value that reaches a caller — the
// constructor refuses to build one otherwise, because «not delivered» is the case that must keep
// costing nothing.
type AttemptCutError struct {
Provider string
Cause CutCause
// Delivered: the request bytes reached the provider (httptrace WroteRequest, no write error).
Delivered bool
// AfterHeaders: the first BYTE of a response had arrived when the call was cut (httptrace's
// GotFirstResponseByte) — the early-200 case.
//
// ⚠ IT IS A BYTE, NOT A REPLY, and the distinction is the whole reason `Billable` is not this field.
// The first byte fires for a 1xx, for a header block that never terminated, for a broken proxy's
// garbage — none of which is a provider acknowledging anything. Read as «a response arrived» it
// tells an operator about a reply that did not exist; read as what it is, it is the evidence that
// something came back down the socket, and the money question is answered by `Billable`, which also
// requires a 2xx object in hand.
AfterHeaders bool
// BytesRead is how much of the body we did get. It is EVIDENCE, never the criterion: judging
// truncation by size is the defect this type replaces.
BytesRead int
// WhitespaceOnly says the bytes we got carry no content at all (DeepSeek's documented empty lines
// while a request waits for scheduling). It distinguishes «the provider was still queueing» from
// «the provider was mid-answer», which is the difference between a wait worth extending and a
// generation worth not buying twice.
WhitespaceOnly bool
Elapsed time.Duration
// Billable is the MONEY boundary, and it is deliberately NARROWER than Delivered.
//
// ⛔ TWO PREDICATES, BECAUSE THEY ANSWER TWO QUESTIONS. `WroteRequest` says our bytes left for the
// peer's TCP window; it says nothing about the APPLICATION behind it — a load balancer can accept
// while the backend never sees the request. Measured on a stopped run: 3 of 25 cancelled in-flight
// calls settled an estimate for a request no handler ever entered. On a non-streaming wire the one
// signal that a provider's application acknowledged the request is a 2xx response object reaching
// US, so that is what money is drawn on. Delivery still drives RETRY — a written request is not
// automatically re-sent — and that boundary is unchanged.
//
// The direction of what remains is the tolerable one: on a provider that HOLDS its headers, a
// pre-header self-cut books zero, i.e. an under-count. On DeepSeek nothing is lost at all — its 200
// arrives on acceptance, so every self-cut there is post-header.
Billable bool
// Deliveries is how many times THIS request reached the provider inside one retry chain — counted by
// DELIVERY, not by cause, so a chain that was cut once and answered an undecodable 2xx once reports
// two. It is almost always 1; a broken connection is worth one retry, and then the provider has been
// asked to generate TWICE while the ledger books ONE estimate — the store cannot write two settles
// under one key. The number is carried rather than acted on: making the gap visible is this pack's
// business, deciding what it costs is the owner's.
Deliveries int
// Err is the transport error that ended the attempt.
Err error
// Parent is the parent context's own cause, set ONLY for CutByParent. It rides here so that
// errors.Is(err, context.Canceled) keeps deciding the process exit code while errors.As still
// finds this type: the exit contract and the money both need to be true of one error.
Parent error
}
func (e *AttemptCutError) Error() string {
where := "before any response byte"
if e.AfterHeaders {
where = fmt.Sprintf("after %d body bytes", e.BytesRead)
if e.WhitespaceOnly {
where += " (whitespace only)"
}
}
return fmt.Sprintf("%s: delivered request cut by %s %s after %s: %v",
e.Provider, e.Cause, where, e.Elapsed.Round(time.Millisecond), e.Err)
}
// Unwrap returns both truths of a cancelled attempt. Go's errors.Is/As walk every branch, so the
// caller that asks «did the run end» and the caller that asks «what did it interrupt» both get a
// straight answer from the same value.
func (e *AttemptCutError) Unwrap() []error {
if e.Parent != nil {
return []error{e.Err, e.Parent}
}
return []error{e.Err}
}
// deliveryTrace records the two httptrace facts the money boundary is drawn from. The callbacks run
// on the transport's goroutine while the caller may already be reading the fields (a deadline fires
// concurrently with a write completing), so both are atomics rather than plain bools.
type deliveryTrace struct {
wrote atomic.Bool
firstByte atomic.Bool
}
func (t *deliveryTrace) clientTrace() *httptrace.ClientTrace {
return &httptrace.ClientTrace{
// info.Err non-nil means the write itself failed — the request did NOT reach the provider,
// and treating that as delivery would settle money for a call nobody received.
WroteRequest: func(info httptrace.WroteRequestInfo) {
if info.Err == nil {
t.wrote.Store(true)
}
},
GotFirstResponseByte: func() { t.firstByte.Store(true) },
}
}
// delivered reports that the request reached the provider. `answered` says the transport handed us an
// actual 2xx response, and it is the second half of the evidence — needed, and needed CONDITIONALLY.
//
// ⛔ A RESPONSE BYTE IS DELIVERY EVIDENCE, BUT ONLY WHEN A REPLY ACTUALLY CAME BACK TO US. net/http does
// not wait for the write loop before handing back a response: Request.write fires WroteRequest from a
// deferred call on the writeLoop goroutine (net/http/request.go) while roundTrip returns as soon as the
// response channel fires (net/http/transport.go), and golang.org/x/net/http2 has the same shape. So a
// provider answering EARLY — precisely what DeepSeek documents doing while a request waits to be
// scheduled — can have its 200 overtake our own «the request was written» callback whenever the body is
// still draining. Read as «not delivered», that call books $0 and is retried: measured at three re-asks.
//
// ⛔⛔ AND THE OTHER READING COSTS MORE. Taking the response BYTE alone as proof, whether or not a reply
// reached us, pays for refusals: a provider that answers 401/403/413 and resets the connection while our
// body is still writing gives GotFirstResponseByte=true and a WRITE ERROR, so http.Client.Do returns the
// write failure and we never see the status at all. Measured on a copy of this tree: 22 refusals in 25
// booked a paid cut, one of them $0.80 for a request the provider declined — and the delivered-cut retry
// sent the whole body a second time. When Do fails we hold no status line and cannot tell a refusal from
// an early success, so only the write counts; when Do SUCCEEDS with a 2xx, the reply is proof by itself.
func (t *deliveryTrace) delivered(answered bool) bool {
return t.wrote.Load() || (answered && t.firstByte.Load())
}
func (t *deliveryTrace) afterHeaders() bool { return t.firstByte.Load() }
// causeOf names who ended a delivered attempt. The order is the meaning: the PARENT is asked first,
// because a run that is ending has cancelled the attempt context too, and reading our own deadline
// first would file every stopped run as a self-cut and flag chunks nobody's provider misbehaved on.
func causeOf(ctx, attemptCtx context.Context) CutCause {
switch {
case ctx.Err() != nil:
return CutByParent
case errors.Is(attemptCtx.Err(), context.DeadlineExceeded):
return CutBySelfDeadline
default:
return CutByConnection
}
}
// cutError builds the typed error for a delivered attempt, or NIL when the request never went out.
// The «not delivered» path is the one case where nothing was bought, and it must stay exactly what it
// was: a plain retryable transport failure that releases the reservation. Nil rather than «the error
// unchanged» so the caller branches on a value instead of on error identity — the shape that survives
// somebody wrapping the transport error one layer deeper.
// `answered` is true only where a 2xx response object is in hand — see delivered.
func (c *openAIClient) cutError(ctx, attemptCtx context.Context, tr *deliveryTrace, started time.Time, body []byte, err error, answered bool) *AttemptCutError {
if !tr.delivered(answered) {
return nil
}
cause := causeOf(ctx, attemptCtx)
cut := &AttemptCutError{
Provider: c.name, Cause: cause,
Delivered: true, AfterHeaders: tr.afterHeaders(),
Billable: answered && tr.afterHeaders(),
BytesRead: len(body), WhitespaceOnly: len(body) == 0 || strings.TrimSpace(string(body)) == "",
Elapsed: time.Since(started), Err: err,
}
if cause == CutByParent {
cut.Parent = ctx.Err()
}
return cut
}
// retryable is the retry half of the disposition, kept beside the causes so the two cannot drift.
// Only a broken connection is worth another call: our own deadline firing means the provider is STILL
// GENERATING what we just stopped listening to, and retrying buys that generation a second time — the
// defect this file exists to remove. A cancelled run has nothing to retry into.
func (e *AttemptCutError) retryable() bool { return e.Cause == CutByConnection }
// --- the deadline itself (backlog row 360 point 5, row 369) ---
// The industry reference for sizing a non-streaming call, and the source of the default rate:
// anthropic-sdk-go's CalculateNonStreamingTimeout budgets 1h · max_tokens / 128 000 and REFUSES a
// request whose expected time exceeds ten minutes («streaming is required»). Ours is the same
// arithmetic with the rate made per-provider data and the vendor's queue wait added, because we have
// no streaming path to fall back to and must wait instead of refusing (research/21 §Q6 + its 08.09
// errata; openai-go's ResponseHeaderTimeout is the same idea applied to time-to-headers alone).
//
// It is the default because it is the one number that is not ours to invent — and it is the number
// `attempt_s: 240` was silently standing in for: 240 s at this rate is ~8.5k tokens, the DRAFT's
// budget, and the same 240 was carried to an editor budgeted at 16 000 with a doubling to 32 000.
const (
vendorHourlyTokenBudget = 128000
vendorBudgetWindow = time.Hour
)
// defaultTokensPerSecFloor is DERIVED from the pair above and never typed as a decimal: writing
// 35.5 here would be a second carrier of a number the vendor states as 128 000 per hour, and the two
// would drift the day either moved.
func defaultTokensPerSecFloor() float64 {
return float64(vendorHourlyTokenBudget) / vendorBudgetWindow.Seconds()
}
// deriveDeadline is the UNCLAMPED time this profile says a call for maxTokens needs: the vendor's
// documented wait before generation starts, plus the generation itself at the slowest speed the
// model has been observed to hold. It is a pure function of the profile and the budget — the budget
// the call actually carries, so an escalated attempt that doubled its max_tokens doubles its time
// instead of inheriting the budget of an attempt that asked for half as much.
//
// An unset or nonsensical floor falls back to the vendor default rather than to a division by zero
// or a zero deadline: a config that forgot the field must wait too long, never not at all.
func (p RetryProfile) deriveDeadline(maxTokens int) time.Duration {
floor := p.TokensPerSecFloor
if floor <= 0 {
floor = defaultTokensPerSecFloor()
}
if maxTokens < 0 {
maxTokens = 0
}
return p.QueueSlack + time.Duration(float64(maxTokens)/floor*float64(time.Second))
}
// DeadlineFor clamps the derivation between the configured attempt_s and attempt_max_s. It is exported
// because it answers a question about a CONFIGURATION rather than about a call in flight — «how long
// will one call of this size wait under this profile» — and the catalogue gate over models.yaml has to
// ask it of a provider nobody has called yet.
//
// ⛔ attempt_s is the FLOOR, not the value. That is what makes this change safe to land on every
// existing config at once: a call whose derived time is shorter than the configured deadline keeps
// the configured one, so no provider loses a second it has today, and only calls that provably
// could not finish get more. attempt_max_s bounds the other end — an operator's ceiling on how long
// one call may hold a reservation — and is inert when unset.
func (p RetryProfile) DeadlineFor(maxTokens int) time.Duration {
d := p.deriveDeadline(maxTokens)
if d < p.AttemptTimeout {
d = p.AttemptTimeout
}
if p.AttemptMax > 0 && d > p.AttemptMax {
d = p.AttemptMax
}
return d
}
// attemptDeadline is the client's own wrapper: the same clamp, plus the ONE notice an operator needs
// when a provider is running on the vendor default. Waiting a quarter of an hour for a call is a
// legitimate configuration — the owner ratified waiting up to ~20 minutes — but it must never be a
// surprise, and «why is nothing happening» is the pain this line answers before it is felt.
//
// The condition is structural rather than a threshold in tokens: it fires exactly when the derived
// time OVERRIDES the configured attempt_s, i.e. when this call is one the configured deadline could
// not have covered. A provider whose calls all fit inside its own attempt_s never sees it.
func (c *openAIClient) attemptDeadline(ctx context.Context, maxTokens int) time.Duration {
d := c.profile.DeadlineFor(maxTokens)
if c.profile.TokensPerSecFloor <= 0 && d > c.profile.AttemptTimeout {
c.floorWarned.Do(func() {
if c.log == nil {
return
}
c.log.WarnContext(ctx, "no measured generation speed for this provider; the call deadline is derived from the vendor default and is longer than the configured attempt_s — set timeouts.tok_s_floor from this provider's own request_log after the first run",
"provider", c.name, "default_tok_s", fmt.Sprintf("%.1f", defaultTokensPerSecFloor()),
"max_tokens", maxTokens, "derived_deadline", d.String(), "attempt_s", c.profile.AttemptTimeout.String())
})
}
return d
}
// cancelledDuring is what a stopped run returns: BOTH the cancellation, which decides the process exit
// code, and whatever the attempt underneath it was — which is what decides the money.
//
// ⛔ IT IS errors.Join AND NOT A CHOICE BETWEEN THEM. Returning the attempt's error alone loses
// `errors.Is(err, context.Canceled)` for every attempt error that does not happen to wrap the
// cancellation — a stopped run would then leave with a foreign exit code. Returning the cancellation
// alone loses `errors.As`, and with it the money for a call already on the wire, which is the defect
// this whole file exists to remove. Join keeps both true of one value, and it does so whatever the
// attempt's cause was: an earlier form of this guard preserved only the errors that already carried the
// parent's own error, so a connection_lost or attempt_timeout whose run was stopped a moment later was
// still silently reduced to a bare cancellation.
func cancelledDuring(ctxErr, attemptErr error) error {
if attemptErr == nil {
return ctxErr
}
if errors.Is(attemptErr, ctxErr) {
return attemptErr // it already carries both; joining would only duplicate the sentence
}
return errors.Join(ctxErr, attemptErr)
}

File diff suppressed because it is too large Load diff

View file

@ -10,8 +10,10 @@ import (
"log/slog"
"math/rand"
"net/http"
"net/http/httptrace"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/net/http2"
@ -43,10 +45,23 @@ import (
// defaults; the profile comes from models.yaml per provider (per-role
// overrides — Phase 1).
type RetryProfile struct {
AttemptTimeout time.Duration // deadline for ONE HTTP attempt
AttemptTimeout time.Duration // FLOOR for one HTTP attempt's deadline (see deadlineFor)
MaxAttempts int
BackoffBase time.Duration // first backoff; doubles per attempt
BackoffCap time.Duration
// The three fields below turn the per-attempt deadline from a constant into a function of the
// budget the call carries (attemptcut.go). All three are optional and all three are DATA: a
// provider the repository has never seen gets a working deadline from the vendor default and a
// startup warning naming what to measure, with no Go edit.
//
// TokensPerSecFloor is the slowest generation speed this provider has been OBSERVED to hold —
// below the p10 of its own request_log, rounded down. Unset ⇒ the vendor default.
TokensPerSecFloor float64
// QueueSlack is how long the VENDOR documents a request may wait before generation starts. It is
// added whole rather than amortized: the wait is not proportional to the budget.
QueueSlack time.Duration
// AttemptMax bounds the derived deadline — the longest one call may hold a reservation. 0 = unbounded.
AttemptMax time.Duration
}
func (p RetryProfile) withDefaults() RetryProfile {
@ -90,14 +105,50 @@ const (
// reliability bonus, never a hard dependency. The local provider passes its OWN
// no-proxy client (httpc != nil), so it is untouched — localhost needs no h2 keepalive.
func keepAliveHTTPClient() *http.Client {
base := http.DefaultTransport.(*http.Transport).Clone()
if h2, err := http2.ConfigureTransports(base); err == nil && h2 != nil {
h2.ReadIdleTimeout = h2ReadIdleTimeout
h2.PingTimeout = h2PingTimeout
}
return &http.Client{Transport: base}
c, _ := buildCloudClient()
return c
}
// buildCloudClient is the construction itself, handing back BOTH the client and the h2 transport it
// installed the bounds on. The second return is what makes the bounds checkable at all: reading them off
// a finished client is impossible (ConfigureTransports answers an error the second time), so a check
// written against a client would have to re-derive them on a transport of its own and would then be
// asserting about a transport nobody uses.
func buildCloudClient() (*http.Client, *http2.Transport) {
base := http.DefaultTransport.(*http.Transport).Clone()
h2 := tuneHTTP2(base)
return &http.Client{Transport: base, CheckRedirect: doNotFollowRedirects}, h2
}
// tuneHTTP2 installs the keepalive pair and RETURNS the h2 transport it configured, so a test can read
// what was actually set rather than re-deriving it. Asking ConfigureTransports a second time answers an
// error and no transport, so a check written against an already-built client asserts nothing at all.
// nil on the (unexpected) configure error: keepalive is a reliability bonus, never a hard dependency.
func tuneHTTP2(base *http.Transport) *http2.Transport {
h2, err := http2.ConfigureTransports(base)
if err != nil || h2 == nil {
return nil
}
h2.ReadIdleTimeout = h2ReadIdleTimeout
h2.PingTimeout = h2PingTimeout
return h2
}
// doNotFollowRedirects stops the client at a 3xx instead of chasing it, so the redirect surfaces as the
// terminal non-2xx it is and the operator is told the base_url is wrong.
//
// ⛔ IT IS A MONEY GUARD BEFORE IT IS A HYGIENE ONE. `Do` spans the WHOLE redirect chain, and the
// delivery trace does not reset between its legs: the first leg reaching a redirector sets WroteRequest
// (and GotFirstResponseByte) for good, so a second leg whose connect is REFUSED still looked delivered.
// Measured through the ledger: $0.001056 booked for a `connect: connection refused` that never put a byte
// on any wire, with AfterHeaders true and zero bytes from the target. A stale `http://` or a normalised
// slash in base_url is enough to trigger it.
//
// The second reason is the one a security review would raise first: net/http drops Authorization only
// across a host change it considers unsafe, and a provider endpoint that redirects is a misconfiguration
// in every case — there is no shape in which silently following one is what an operator wanted.
func doNotFollowRedirects(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
// maxResponseBytes caps one completion body read. A translated chapter chunk
// is ~1050 KiB; 16 MiB leaves two orders of magnitude of headroom while
// keeping a misbehaving endpoint from exhausting memory.
@ -126,6 +177,13 @@ const maxRetryAfterWait = 5 * time.Minute
func retryLoop[T any](ctx context.Context, profile RetryProfile, name string, log *slog.Logger, attempt func() (T, bool, error)) (T, error) {
var zero T
var lastErr error
// ⛔ A DELIVERED CUT MUST OUTLIVE THE ATTEMPT THAT MADE IT. Only the LAST error left this loop, so a
// chain that cut a delivered request and then met a terminal 4xx — a 400/401/403/413 on the retry,
// entirely reachable — returned the status alone: the runner saw «the request never went out», gave
// the reservation back and booked $0 for a generation the provider had made, leaving the position
// with no mark either. The first cut is kept and joined onto whatever ends the chain, so the money
// and the reason a caller finally reports are both true of one error.
var owedCut error
for att := 0; att < profile.MaxAttempts; att++ {
if log != nil {
log.DebugContext(ctx, name+" attempt start", "attempt", att+1, "max", profile.MaxAttempts,
@ -136,11 +194,12 @@ func retryLoop[T any](ctx context.Context, profile RetryProfile, name string, lo
return resp, nil
}
lastErr = err
owedCut = moreOwed(owedCut, err)
if ctx.Err() != nil {
return zero, ctx.Err()
return zero, chainError(cancelledDuring(ctx.Err(), err), owedCut)
}
if !retryable {
return zero, err
return zero, chainError(err, owedCut)
}
if att+1 >= profile.MaxAttempts {
break // attempts exhausted — no retry remains, no sleep
@ -152,11 +211,72 @@ func retryLoop[T any](ctx context.Context, profile RetryProfile, name string, lo
}
select {
case <-ctx.Done():
return zero, ctx.Err()
// ⛔ THE SECOND CANCELLATION EXIT, and it used to throw the evidence away where the first one
// no longer does. A run stopped DURING a backoff has an attempt behind it that may already
// have been delivered and billed — a cut connection, an undecodable 2xx — and returning a
// bare ctx.Err() here left the runner with nothing to settle and the chunk with no mark at
// all. Measured: a delivered connection_lost plus a stop inside the backoff booked $0 and
// wrote no chunk_status row. The window is the whole sleep, up to a minute on the shipping
// config, and it opens precisely on the runs where a provider is flapping and an operator is
// therefore reaching for the stop.
//
// ⚠ THIS CLOSES THE MONEY HALF ONLY. What leaves here carries the cut with the strongest
// money claim, which is deliberately NOT the stop when an earlier paid break outranks it — so
// the runner's mark cannot be decided by this error's first cause. The mark asks its own
// question (pipeline's recordCancelledStage: was the run stopped, and did ANY cut deliver),
// and a guard that read the first cause instead left the position with no row at all.
return zero, chainError(cancelledDuring(ctx.Err(), lastErr), owedCut)
case <-time.After(backoff):
}
}
return zero, fmt.Errorf("%s: exhausted %d attempts: %w", name, profile.MaxAttempts, lastErr)
return zero, chainError(fmt.Errorf("%s: exhausted %d attempts: %w", name, profile.MaxAttempts, lastErr), owedCut)
}
// ⛔ THE ERROR IS A SCALAR AND THE CHAIN IS A LIST, and every defect this pair exists to stop came from
// that mismatch. A retry chain can deliver, and be billed for, an attempt that is NOT the attempt whose
// error ends it: a cut, then a 503, then a stop. Whatever one error the loop finally returns is the ONLY
// thing the caller settles from — so it must carry the cut the chain owes money for, from every exit,
// not from the two that happened to be written with it in mind.
//
// moreOwed is the accumulator, and it keeps the FIRST cut deliberately rather than ranking them here.
//
// ⚠ RANKING BELONGS AT THE EXIT, and putting it here as well would be a guard that cannot fire. A chain
// holds at most TWO cuts: only CutByConnection is retryable (AttemptCutError.retryable), and the
// delivered-cut cap makes the second one terminal (`retryable && deliveredCutSeen > 1` → not retryable).
// So whenever two cuts exist, the second one IS the error ending the chain and is in `chainError`'s hand
// already; whenever only one exists, there is nothing to rank. A billable-beats-free branch here would
// read like a money guard and never execute — the shape this pack has spent a shift removing.
func moreOwed(kept, candidate error) error {
var cc *AttemptCutError
if kept != nil || !errors.As(candidate, &cc) {
return kept // already holding one, or not a cut at all — a 503 owes nobody anything
}
return candidate
}
// chainError attaches what the chain owes to whatever error ends it. It returns `final` untouched when
// nothing is owed, when the owed cut IS what ended it (joining an error to itself prints the sentence
// twice), or when `final` already carries a cut with a money claim at least as strong.
//
// ⚠ THE TEST IS THE MONEY, NOT THE TYPE. The version this replaces asked `errors.As(final, &cut)` and
// returned early on any cut at all — true while money was drawn on `Delivered`, false the moment it was
// drawn on `Billable`: a later FREE cut then erased an earlier PAID one and the engine booked $0.
func chainError(final, owed error) error {
if owed == nil || errors.Is(final, owed) {
return final
}
var fc *AttemptCutError
if errors.As(final, &fc) {
var oc *AttemptCutError
if errors.As(owed, &oc) && oc.Billable && !fc.Billable {
// ⛔ THE OWED CUT GOES FIRST, and the order is the whole assertion. `errors.As` hands back the
// FIRST match it meets walking the tree, so joining the free cut ahead of the paid one leaves
// the caller settling from the free one — the very masking this branch exists to undo.
return errors.Join(owed, final)
}
return final
}
return errors.Join(final, owed)
}
// nextBackoff computes the wait before attempt att+1 (0-based att just failed):
@ -212,6 +332,10 @@ type openAIClient struct {
profile RetryProfile
headers map[string]string // extra static headers (provider-specific), may be nil
log *slog.Logger
// floorWarned fires the «this provider has no measured speed» notice ONCE per client rather than
// once per call: the fact is about the configuration, and a wave of forty chunks would otherwise
// print it forty times and teach the operator to scroll past it.
floorWarned sync.Once
}
// newOpenAIClient builds the shared transport. httpc may be nil (default
@ -350,8 +474,49 @@ func (c *openAIClient) complete(ctx context.Context, reqBody openAIRequest) (*op
return nil, err
}
billedDecodeSeen := 0
deliveredCutSeen := 0
// ⛔ TWO DIFFERENT COUNTS, and they were one field. `deliveredCutSeen` is a RETRY CAP keyed on cuts.
// `askedToGenerate` is what the operator is told — how many times this request reached the provider
// inside one chain — and a chain delivers in more ways than by being cut: an undecodable 2xx that
// already billed, a terminal 4xx, a 503. Counting only the cuts made the ledger line understate a
// mixed chain while calling itself «the provider was asked N times».
askedToGenerate := 0
// ⛔ EVERY CUT THE CHAIN MADE IS RE-STAMPED, not only the newest. The count is a property of the
// CHAIN, and chainError deliberately hands up an EARLIER cut when that is the one that owes money —
// carrying a number frozen at the moment it was born. A chain that cut once and was then answered
// two 503s delivered three times and reported one, under-stating the very gap the line exists to
// show. The later deliveries are not cuts, so nothing in the error tree knows about them; only this
// counter does.
var chainCuts []*AttemptCutError
return retryLoop(ctx, c.profile, c.name, c.log, func() (*openAIResponse, bool, error) {
resp, retryable, err := c.attempt(ctx, payload)
resp, retryable, err := c.attempt(ctx, payload, reqBody.maxTokens)
if deliveredAttempt(resp, err) {
askedToGenerate++
}
defer func() {
for _, cc := range chainCuts {
cc.Deliveries = askedToGenerate
}
}()
// Cap a DELIVERED cut's re-calls at ONE, for the same reason and by the same shape as the
// billed-decode cap above — but keyed on DELIVERY rather than on a 2xx. That is the whole
// correction: on a provider that answers 200 while the request is still queued (DeepSeek
// documents exactly that), «did a 2xx arrive» says nothing about whether a generation was
// bought, while «did the request go out» says it exactly. A broken connection after delivery
// is worth one more call; a second is a dead provider, not a flaky socket.
if err != nil {
var cut *AttemptCutError
if errors.As(err, &cut) {
deliveredCutSeen++
// The provider has now been asked to generate this many times, and the caller is told:
// one settle will cover all of them, because the store writes spend only through a
// checkpoint and they share one key.
chainCuts = append(chainCuts, cut)
if retryable && deliveredCutSeen > 1 {
return resp, false, err
}
}
}
// Cap billed-decode re-bills at ONE (research/21 §1.18в): an undecodable 2xx
// has ALREADY billed, so re-running it under the full MaxAttempts turns a
// provider emitting 2xx garbage into a paid retry STORM (grok-build's
@ -371,15 +536,44 @@ func (c *openAIClient) complete(ctx context.Context, reqBody openAIRequest) (*op
})
}
// deliveredAttempt reports whether ONE attempt's request reached the provider — the question the
// operator's «asked N times» line answers, and a different question from «did it end in a cut».
//
// A status of any kind is delivery by definition: the peer had to read the request to answer it. So is a
// 2xx whose body would not parse — that one has already billed. A cut says so itself. What is NOT a
// delivery is everything that failed before the bytes left: a refused connect, a DNS failure, a write
// that died mid-body.
func deliveredAttempt(resp *openAIResponse, err error) bool {
if err == nil {
return resp != nil
}
var cut *AttemptCutError
if errors.As(err, &cut) {
return cut.Delivered
}
var hse *HTTPStatusError
var bde *BilledDecodeError
return errors.As(err, &hse) || errors.As(err, &bde)
}
// attempt performs one HTTP call. Returns retryable=true for 429/5xx and
// network errors, false for other non-2xx (terminal 4xx). The per-attempt
// deadline bounds a single hung connection; the overall per-request deadline
// (set by the caller via ctx) bounds the whole retry loop.
func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResponse, bool, error) {
attemptCtx, cancel := context.WithTimeout(ctx, c.profile.AttemptTimeout)
// deadline is derived from THIS call's output budget (attemptcut.go); the
// overall per-request deadline (set by the caller via ctx) bounds the whole retry loop.
//
// maxTokens is the budget the body already carries. It is passed rather than re-parsed so the
// deadline and the request can never describe different calls.
func (c *openAIClient) attempt(ctx context.Context, payload []byte, maxTokens int) (*openAIResponse, bool, error) {
deadline := c.attemptDeadline(ctx, maxTokens)
attemptCtx, cancel := context.WithTimeout(ctx, deadline)
defer cancel()
req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, c.base+"/chat/completions", bytes.NewReader(payload))
// The delivery facts are collected by the transport itself: a request is DELIVERED once its bytes
// are written, which is knowable before any reply exists and is the boundary the money is drawn on.
var tr deliveryTrace
started := time.Now()
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(attemptCtx, tr.clientTrace()),
http.MethodPost, c.base+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return nil, false, err
}
@ -395,18 +589,26 @@ func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResp
resp, err := c.http.Do(req)
if err != nil {
// Network error / timeout — retryable (unless the parent ctx is done). A
// per-attempt deadline is annotated with the configured timeout: the bare
// «context deadline exceeded» doesn't tell the operator WHOSE deadline it is —
// the attempt timeout (cured by timeouts.attempt_s) or the whole run cancelled.
// A DELIVERED request that never answered: the provider has it and is (or was) working, so
// this is a money event and carries its cause. An UNDELIVERED one stays exactly what it was —
// a plain retryable transport failure whose reservation is released, the one case where
// «nothing was bought» is true by construction.
if cut := c.cutError(ctx, attemptCtx, &tr, started, nil, err, false); cut != nil {
return nil, cut.retryable(), cut
}
// The bare «context deadline exceeded» doesn't tell the operator WHOSE deadline it is —
// this call's own (derived from its budget) or the whole run cancelled.
if errors.Is(attemptCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil {
err = fmt.Errorf("attempt timed out after %s (timeouts.attempt_s): %w", c.profile.AttemptTimeout, err)
err = fmt.Errorf("attempt timed out after %s (derived from max_tokens=%d; timeouts.attempt_s is its floor) before the request was delivered: %w", deadline, maxTokens, err)
}
return nil, ctx.Err() == nil, err
}
defer resp.Body.Close()
// Read one byte past the limit to DISTINGUISH truncation from a whole body.
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
// Read one byte past the limit to DISTINGUISH truncation from a whole body — for the >16 MiB case
// alone. Whether the read COMPLETED is a separate question with a separate answer, and dropping
// readErr here is what made a body our own deadline cut short (small) indistinguishable from a
// whole one, so it went down the retryable branch and bought the same generation twice.
data, readErr := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
truncated := len(data) > maxResponseBytes
if truncated {
data = data[:maxResponseBytes]
@ -414,15 +616,35 @@ func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResp
obs.LogLLMExchange(ctx, c.log, c.name, payload, resp.StatusCode, data)
// ⛔ THE STATUS LINE IS ASKED BEFORE THE READ ERROR, and the order is the money. A non-2xx says the
// provider refused or failed — nothing was generated and nothing is owed — so a body cut short
// under it is a detail of a failure, not a purchase. Reading them the other way round would settle
// an estimate for every 4xx whose tiny body happened to land on the deadline. The partial body is
// still what the retry/terminal split reads (a truncated marker just fails to match and the status
// stays retryable, the conservative direction), and the read error rides in the message so a
// misclassified 429 leaves a trace instead of none.
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
retryable := retryableStatus(resp.StatusCode, data)
e := &HTTPStatusError{Provider: c.name, Status: resp.StatusCode, Body: snippet(data)}
if readErr != nil {
e.Body = snippet(data) + fmt.Sprintf(" [body read incomplete: %v]", readErr)
}
if retryable {
e.RetryAfter = parseRetryAfter(resp.Header) // only a retryable status will honour it
}
return nil, retryable, e
}
if readErr != nil {
// A 2xx whose body we did not receive whole. Headers had arrived, so the request was written
// by definition — but ask the trace rather than assume it, and let an undelivered
// impossibility fall through to the old shape instead of settling money on a deduction.
if cut := c.cutError(ctx, attemptCtx, &tr, started, data, readErr, true); cut != nil {
return nil, cut.retryable(), cut
}
return nil, ctx.Err() == nil, readErr
}
var out openAIResponse
if err := json.Unmarshal(data, &out); err != nil {
// A 2xx with an unreadable body: the provider has ALREADY charged. We type it

View file

@ -73,9 +73,16 @@ func NewAnthropicClient(cfg AnthropicConfig, logger *slog.Logger) LLMClient {
base = "https://api.anthropic.com"
}
return &anthropicClient{
base: base,
key: cfg.APIKey,
http: &http.Client{},
base: base,
key: cfg.APIKey,
// ⚠ THE ONE THING THIS DEPRECATED ADAPTER GETS FROM THE CUT-CALL PACK, and it is here because it
// is a CREDENTIAL leak rather than adapter work: this client sends `x-api-key`, and net/http
// strips only Authorization/Cookie/WWW-Authenticate across a host change — so a 3xx from a
// mistyped base_url carried the key to a host nobody chose. Measured: the redirect target
// received the key and the call returned nil error. The rest of the pack's money boundary is
// deliberately NOT here (this adapter has no delivery trace and a constant deadline); a key
// walking off is not a scope question.
http: &http.Client{CheckRedirect: doNotFollowRedirects},
profile: cfg.Profile.withDefaults(),
cacheTTL: cfg.CacheTTL,
log: logger,

View file

@ -45,8 +45,16 @@ type LocalConfig struct {
// NoProxyClient is an http.Client that bypasses any environment proxy.
// Exported so the failover prober uses the same transport discipline.
//
// ⚠ IT REFUSES REDIRECTS FOR THE SAME REASON THE CLOUD CLIENT DOES, and it needed saying separately:
// the guard first went only on keepAliveHTTPClient, whose comment says the local provider «passes its
// OWN no-proxy client, so it is untouched» — true of keepalive, and read as permission for redirects
// too. `Do` spans a whole redirect chain without resetting the delivery trace, so a leg whose connect
// is refused still looks delivered. On the local stand the money is $0 today only because the local
// model is priced at zero; the BEHAVIOUR is wrong either way — a mistyped base_url would reach an
// operator as a flapping socket instead of as the 3xx it is.
func NoProxyClient() *http.Client {
return &http.Client{Transport: &http.Transport{Proxy: nil}}
return &http.Client{Transport: &http.Transport{Proxy: nil}, CheckRedirect: doNotFollowRedirects}
}
type localClient struct {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,255 @@
package pipeline
import (
"context"
"errors"
"fmt"
"time"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/config"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/store"
)
// cutcall.go: the money and the disposition of a call THE ENGINE ITSELF cut short (backlog row 360;
// the owner's word on the amount is D39.230 п.1). The transport half — how a cut is detected and told
// apart from an undelivered request — lives in internal/llm/attemptcut.go; this file decides what it
// COSTS and what happens to the chunk.
//
// A delivered request is generated whether or not we are still on the line. Three things end our side
// of it, they share the money, and they differ in everything else:
//
// attempt_timeout our deadline fired mid-generation. 1 call, flagged, NOT retried. Resume: 0 calls.
// cancelled a person stopped the run. 1 call, marked `cancelled`. Resume: 1 call, SAME budget.
// connection_lost the socket broke after delivery. 2 calls, then an infra pause. Resume: 1 call, SAME budget.
//
// Until this file existed all three arrived at a branch commented «No 2xx ever arrived: nothing was
// billed», which released the reservation and booked ZERO. On the provider that ships today that
// comment is false by construction: DeepSeek answers 200 with empty lines while a request waits to be
// scheduled, so a status line says nothing about whether a generation was bought.
// cutCall is the identity of the call being settled — the same tuple runAttempt already holds, passed
// as one value because thirteen positional arguments is how a chapter index and a chunk index end up
// swapped without the compiler noticing.
type cutCall struct {
stage config.Stage
chunk chunk.Chunk
job *store.Job
model string
reqHash string
resv store.Reservation
estimate float64
attempt int
escalation bool
}
// cancelledPosition is the chunk×stage a stopped run was working on. It is a struct for the reason
// cutCall is: `snapID` and `contentHash` are adjacent strings, and a swap between them compiles,
// writes a row addressed to nothing, and is found by nobody.
type cancelledPosition struct {
stage config.Stage
chunk chunk.Chunk
snapshotID string
contentHash string
cumCostUSD float64
attempts int
}
// settleUSDForCutCall is the ONE place that answers «what does a call we cut short cost».
//
// The answer is the reservation's own estimate (D39.230 п.1). Nobody knows what the provider actually
// billed, and whether it bills a cut call at all is deliberately OPEN — the paid balance probe that
// would answer it was declined — so the estimate is an upper bound under the assumption that the
// vendor charges for what it generated. Booking zero understates the book against its own ceiling and
// hands the platform a margin it cannot measure.
//
// It is a function and not a YAML field because a money policy behind a config knob can be changed by
// an operator who is not deciding a money policy, silently, between two runs of one book — leaving the
// ledger with two answers to one question and nothing on the row saying which.
func settleUSDForCutCall(estimate float64) float64 { return estimate }
// settleCutCall books the money for a delivered call the engine cut short, records the row, and
// returns the disposition that cause deserves.
//
// The money is written FIRST, before anything decides what happens to the chunk: the provider's side
// of the transaction already happened, and a process that dies between here and the verdict must leave
// the spend recorded. The checkpoint is what makes booking it possible at all — the store cannot write
// spend without one — and for two of the three causes it is also what stops the next run from buying
// the same call again.
func (r *Runner) settleCutCall(ctx context.Context, c cutCall, cut *llm.AttemptCutError, err error, att stageAttempt) (stageAttempt, error) {
// ⛔ THE MONEY IS DRAWN ON THE PROVIDER'S OWN ACKNOWLEDGEMENT, not on our write. A request whose
// bytes entered the peer's TCP window may never reach the application behind it — a load balancer
// accepts, the backend never sees it — and settling for that charges a reader for a call nobody
// ran, which is the one direction the canon forbids (D39.196 п.2а). A 2xx object in our hands is
// the acknowledgement; without it the same checkpoint is written for ZERO.
//
// The row is identical either way: same key, same class, same flag, same resume. Only the number
// and the `estimated` mark move, so an operator sees the class and the reader pays only for what a
// provider confirmed taking.
cost := 0.0
if cut.Billable {
cost = settleUSDForCutCall(c.estimate)
}
finish := string(cut.Cause)
if serr := r.Store.SettleWithCheckpoint(c.resv, cost, store.Checkpoint{
RequestHash: c.reqHash, JobID: c.job.ID, ChunkIdx: c.chunk.ChunkIdx, Attempt: c.attempt,
Stage: c.stage.Name, Role: c.stage.Role, ModelRequested: c.model, ModelActual: c.model,
ResponseText: "", UsageJSON: "{}", CostUSD: cost, FinishReason: finish,
Escalation: c.escalation,
}, r.events.spendLine()); serr != nil {
// The rule of every settle on this path: money state falling behind reality is a loud infra
// fault, never something to continue on top of.
return att, fmt.Errorf("pipeline: settle after a %s cut a delivered call: %w", finish, serr)
}
r.events.flush()
att.finish = finish
// `att` arrives carrying the money of any burned keys runAttempt walked over, and that money is
// this chunk's just as much as this call's — dropping it would under-state a position that was cut
// twice. `runCost` takes only what THIS run bought.
att.cumCost, att.runCost = att.cumCost+cost, cost
rl := r.baseRequestLog(c.stage, c.chunk, c.model, c.reqHash)
rl.CostUSD, rl.LatencyMS, rl.FinishReason = cost, att.latency, finish
rl.Degraded, rl.Err, rl.OK = finish, err.Error(), false
if cut.Deliveries > 1 {
// ⚠ THE LEDGER IS SHORT BY DESIGN HERE, AND THE ROW SAYS SO. The provider was asked to generate
// more than once inside one retry chain, and the store books spend only through a checkpoint —
// they share one key, so one settle covers all of them. Under-counting is the ratified
// direction (D39.196 п.2а: the deploy absorbs it, the user's balance is never overstated), so
// this pack does NOT quietly multiply the charge; it makes the gap readable instead, because a
// silent under-count is what row 360 was opened about.
// ⛔ THE NOTE GOES FIRST, and that is not cosmetics. The column an operator reads is bounded —
// `errTail` keeps 120 bytes — and the transport error in front of it is routinely longer than
// that, so a note appended to the end reached nobody, ever. It also says what was ACTUALLY
// booked: on a cut the provider never acknowledged, the answer is nothing, and a line claiming
// «one estimate is booked» beside a $0 row is a sentence that has to be disbelieved to be used.
rl.Err = cutErrLine(err, cut.Deliveries, cost)
}
// «Never write a silent $0 usage; estimate and FLAG it» is the one discipline every surveyed
// harness converges on (research/21 §5.3: crush `EstimatedUsage`, goose `CostSource`). The flag is
// what the operator's legend, the machine-readable pair beside committed_usd and the platform's
// «≥ X, up to Y» all read.
rl.Estimated, rl.EstTokens = cost > 0, r.estOutTokens(c.chunk.Text)
r.Store.LogRequest(ctx, r.Log, rl)
r.Log.WarnContext(ctx, "we cut a delivered call; the reservation estimate is charged as an ESTIMATE only when the provider had acknowledged it with a reply",
"stage", c.stage.Name, "chapter", c.chunk.Chapter, "chunk", c.chunk.ChunkIdx, "attempt", c.attempt,
"cause", finish, "after_headers", cut.AfterHeaders, "bytes_read", cut.BytesRead,
"whitespace_only", cut.WhitespaceOnly, "elapsed", cut.Elapsed.Round(time.Millisecond).String(),
"deliveries", cut.Deliveries, "estimate_usd", fmt.Sprintf("%.6f", cost))
if cut.Cause == llm.CutBySelfDeadline {
// Our deadline, the run is healthy: a disposition, not an infra failure. Nothing retries it —
// a retry asks the provider to generate, and bill, the very thing it is generating right now.
att.cls = classification{FlagAttemptTimeout, cut.Error()}
r.setJobStatus(ctx, c.job.ID, "done")
return att, nil
}
// A stopped run and a dead socket both end this stage. The money above is recorded either way; the
// error travels so the run pauses instead of walking the rest of the book against a dead provider.
r.setJobStatus(ctx, c.job.ID, "failed")
return att, fmt.Errorf("pipeline: stage %s call (ch%d/chunk%d, model %s): %w",
c.stage.Name, c.chunk.Chapter, c.chunk.ChunkIdx, c.model, err)
}
// cutErrLine is the sentence an operator reads when one retry chain asked the provider to generate more
// than once. It is a named function rather than a Sprintf at the call site because both of its
// properties are load-bearing and neither is visible from there.
//
// ⛔ THE NOTE COMES FIRST. The column that prints this is bounded (cmd/tmctl's errTail keeps 120 bytes)
// and the transport error is routinely longer, so a note appended at the end reached nobody — while
// being the only place the gap between what the provider generated and what the ledger booked is
// visible at all.
//
// ⛔ AND IT SAYS WHAT WAS ACTUALLY BOOKED. On a cut the provider never acknowledged, the answer is
// nothing; «one estimate is booked» printed beside a $0 row is a sentence a reader has to disbelieve
// before they can use it.
func cutErrLine(err error, deliveries int, cost float64) string {
booked := "ONE estimate is booked for all of them"
if cost <= 0 {
booked = "NOTHING is booked: the provider acknowledged none of them"
}
return fmt.Sprintf("[the provider was asked %d times; %s] %s", deliveries, booked, err.Error())
}
// recordCancelledStage marks a position whose call a HUMAN stopped, so the stop leaves no unexplained
// gap. It never changes the outcome it is called on: the run is ending, and this says what it was
// doing when it did.
//
// The mark carries its own reason. The call was healthy — the stop button ended it — so
// `flagged(cancelled)` reads as «stopped; the resume re-does it», while a shared flag would send an
// operator hunting for a defect that is not there and no mark at all would let the chapter export a
// hole nobody knows about. A flag lying about its cause is forbidden in its own right (D39.93 п.2).
//
// A write failure is logged, not returned: the caller is already returning the error that stopped the
// run, and replacing it with a bookkeeping failure would hide why the run stopped.
func (r *Runner) recordCancelledStage(ctx context.Context, p cancelledPosition, err error) {
// ⛔ THE STOP IS ASKED OF THE ERROR AS A WHOLE, and the delivery of ANY cut in it — not of whichever
// cut `errors.As` happens to reach first. A retry chain hands up the cut with the strongest money
// claim (llm's chainError), which is deliberately the EARLIER one when a later free cut would mask
// it — so a run stopped over an attempt behind a paid `connection_lost` arrives here carrying that
// cause, and a guard reading only the first cut's cause decided «this is not a stop» and returned in
// silence. The money was settled, the position got no row, and the export showed an unexplained gap:
// the hole §4.2 forbids «at any moment».
// ⚠ «A cut» IS «a delivered cut», by construction and in one place: cutError returns NIL when the
// request never went out, and the single construction of the type sets Delivered true. So one
// errors.As answers both halves, and a walk over the error tree looking for a delivered one would be
// asking a question that cannot come back different.
var cut *llm.AttemptCutError
if !errors.Is(err, context.Canceled) || !errors.As(err, &cut) {
return
}
if uerr := r.Store.UpsertChunkStatus(store.ChunkStatus{
BookID: r.Book.BookID, Chapter: p.chunk.Chapter, ChunkIdx: p.chunk.ChunkIdx, Stage: p.stage.Name,
SnapshotID: p.snapshotID, ContentHash: p.contentHash,
Disposition: string(DispFlagged), FlagReason: string(FlagCancelled),
Attempts: p.attempts, FinalHash: "", CostUSD: p.cumCostUSD,
Detail: "the run was stopped while this call was in flight; it was paid for at the reservation estimate and the resume re-does it on the same budget",
}); uerr != nil {
r.Log.ErrorContext(ctx, "could not mark the stopped position; its money is recorded but the chunk will read as never started",
"stage", p.stage.Name, "chapter", p.chunk.Chapter, "chunk", p.chunk.ChunkIdx, "err", uerr)
return
}
r.Log.WarnContext(ctx, "the run was stopped over a call that had already gone out; the position is marked cancelled and the resume re-does it on the same budget",
"stage", p.stage.Name, "chapter", p.chunk.Chapter, "chunk", p.chunk.ChunkIdx, "cost_usd", fmt.Sprintf("%.6f", p.cumCostUSD))
}
// paidAfterBurns answers «was this call already paid for AND answered», asked THE WAY THE FUNNEL WILL
// ASK IT. It is the single definition of that question for every pre-gate that decides money before a
// call, and it exists because asking it any other way has now been wrong in both directions.
//
// ⛔ A FIXED ATTEMPT INDEX CANNOT ANSWER IT. runAttempt does not stop at a burned key: it walks to the
// next index at the SAME budget and buys there, so after a stopped run a position reads «attempt 0
// burned, attempt 1 paid and answered». A probe that looks only at the starting index sees the burn and
// says «not paid» — and the caller, finding no budget left, discards a translation that was already
// bought. A probe that ignores burns says «paid» — and the caller skips its budget check while the
// funnel goes and buys the work again. Both were measured, one after the other, on this very code.
//
// So the probe walks exactly as the funnel walks (stagerun.go, the burn loop) and answers about the key
// the funnel will actually use. Two questions asked one way cannot disagree; that is the whole point of
// this function existing rather than three call sites each getting the walk right.
func (r *Runner) paidAfterBurns(st config.Stage, model, snapID string, ch chunk.Chunk, attempt, maxTokens int, msgs []llm.Message) (bool, error) {
for {
cp, err := r.Store.GetCheckpoint(RequestHash(r.attemptRequest(st, model, snapID, ch, attempt, maxTokens, msgs)))
if err != nil {
return false, err
}
if cp == nil {
return false, nil // the walk ran out of keys: nothing here is paid for
}
if !burnedByCut(cp) {
return true, nil // an answer — this is what the funnel would serve for $0
}
attempt++
}
}
// resolvedForResume says a stored disposition is an ANSWER the resume may serve without calling
// anybody. Everything terminal is; `cancelled` is the one row that is not, because it records a stop
// rather than a verdict and the work behind it was never done. Reading it as terminal degenerates the
// whole construction into its opposite — never re-doing anything that was interrupted — which is the
// failure mode this design is most at risk of, since it would look perfectly green.
func resolvedForResume(cs *store.ChunkStatus) bool {
return FlagReason(cs.FlagReason) != FlagCancelled
}

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ import (
"textmachine/backend/internal/lang"
"textmachine/backend/internal/langscreen"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/store"
)
// disposition.go: the per-chunk×stage verdict machinery of the Milestone-2 runner
@ -78,6 +79,27 @@ const (
// Emitted by the runner's billed-decode path.
FlagDecodeError FlagReason = "decode_error" // 2xx with an unreadable body — billed, conservatively settled, flagged
// FlagAttemptTimeout and FlagCancelled are the two verdicts of a call THE ENGINE ITSELF cut short
// (backlog row 360). Both name their cause exactly, and they are two constants rather than one
// because the disposition differs on every axis that matters:
//
// - attempt_timeout — OUR deadline fired while the provider was generating. Deterministic on the
// same budget, so it is NOT retryable and NOT escalatable: a same-model retry re-buys the same
// generation, which is the defect row 360 names. The remedy is a bigger deadline (now derived
// from the budget) or a redrive, not another call inside this run.
// - cancelled — a HUMAN stopped the run over a call that was already on the wire. Nothing was
// wrong with it, so the chunk is not «needs a person»; it is «stopped, resume will re-do it».
// It still marks the position: a hole with no mark at all would export as an unexplained gap.
//
// ⚠ The vocabulary is CONVERTED from the transport's causes, not re-typed beside them: one carrier
// for the three names the wire, the checkpoint, the flag and the operator all read.
FlagAttemptTimeout FlagReason = FlagReason(llm.CutBySelfDeadline)
FlagCancelled FlagReason = FlagReason(llm.CutByParent)
// ⚠ A LOST CONNECTION HAS NO FLAG, and that is a statement about the mechanism rather than an
// omission. It is an infra pause, not a chunk that needs a human, and the checkpoint it leaves
// records money with no result — which burnedByCut catches BEFORE anything classifies it, so no
// replay of that row ever reaches a disposition. The guard is that predicate, not a name.
// Reserved for later steps — DEFINED for contract stability, NOT emitted by
// Milestone 2. coverage_fail / excision_suspect are verdicts of the configurable
// coverage gate (step 6); hard_block / upstream_not_ok are for HTTP-level
@ -144,6 +166,44 @@ var classifierVersion = "classify-v3-refusal+srcscript-echo015+" + langscreen.Ve
// path assigned — live and resume must agree (determinism of the resolve).
const decodeErrorFinish = "decode_error"
// The finish_reason a cut call's checkpoint carries. They come from the transport's own cause
// vocabulary (llm.CutCause) so that the wire fact, the stored row and the flag cannot be named three
// different things by three files.
//
// ⛔ TWO OF THEM ARE NOT RESULTS, and that is the load-bearing distinction. attemptTimeoutFinish IS a
// verdict — classify resolves it below and a resume serves it without calling anybody, which is the
// point: we already paid for that generation and will not buy it again. The other two record MONEY for
// a call that has no outcome at all, so replaying them would hand the stage an empty answer it never
// received; runAttempt burns them instead (burnedByCut) and the loop re-asks at the SAME budget.
const (
attemptTimeoutFinish = string(llm.CutBySelfDeadline)
cancelledFinish = string(llm.CutByParent)
connectionLostFinish = string(llm.CutByConnection)
)
// burnedByCut says a checkpoint records what a call COST without recording what it produced. Such a
// row is money only: it must never be served as a result, and its attempt index is spent — the next
// index re-asks the same budget under a fresh request_hash.
//
// This is the whole reason the money can be booked at all. The store has exactly three money verbs
// and none of them writes spend without a checkpoint, while the checkpoint IS the resume key
// (`ON CONFLICT (request_hash) DO NOTHING`) — so «pay and re-do under the same key» is impossible by
// construction. Separating the ATTEMPT INDEX from the count of budget DOUBLINGS (runStage) is what
// dissolves that: the re-done call is a different key at the same budget, its reserve→settle is
// fresh, and `committed == sum(checkpoints)` never wobbles.
//
// ⚠ THE TEXT IS PART OF THE TEST, and the finish_reason alone was not enough. That string shares a
// namespace with whatever a vendor decides to print — the adapter already normalises invented values
// like «sensitive» — so a provider answering 200 with a real translation and finish_reason
// «cancelled» would have had its answer thrown away and re-bought. A genuine burn is written here and
// is always textless (cutcall.go), so asking for both costs nothing and closes the namespace.
func burnedByCut(cp *store.Checkpoint) bool {
if cp.ResponseText != "" {
return false // a reply with content is an answer, whatever it calls its finish reason
}
return cp.FinishReason == cancelledFinish || cp.FinishReason == connectionLostFinish
}
// retryable reports whether a flag may be re-attacked on the SAME model along the
// attempt axis. Only length/empty: both are budget symptoms a bigger max_tokens
// can cure. Everything else is deterministic (refusal/filter/echo/loop/decode) —
@ -242,6 +302,23 @@ func classify(in classifyInput) classification {
switch finish {
case decodeErrorFinish:
return classification{FlagDecodeError, "billed 2xx with an unreadable body"}
case attemptTimeoutFinish:
// Live and resume must agree, exactly as for a decode checkpoint: the row was written by the
// live path with this finish and no text, and re-reading it has to resolve to the same verdict
// instead of falling through to «empty completion», which is retryable and would re-buy the
// generation this whole class exists to stop buying twice.
//
// ⚠ It does NOT move classifierVersion, and that is provable rather than hoped: the constant
// guards a change that could RE-VERDICT a stored checkpoint, and no checkpoint written before
// this line can carry this finish_reason — the string did not exist and no provider emits it.
// Bumping it would re-snapshot every book in the world to change the verdict of nothing.
// ⚠ ONLY OVER AN EMPTY OUTPUT. The engine writes this finish_reason with no text at all
// (cutcall.go); the string itself, though, shares a namespace with whatever a vendor prints —
// the adapter already normalises invented values — and a provider answering 200 with a real
// translation under this name would otherwise have its answer thrown away as a lost call.
if out == "" {
return classification{FlagAttemptTimeout, "our own deadline cut a delivered call; the provider generated and billed it, so it is not retried"}
}
case llm.FinishContentFilter:
return classification{FlagContentFilter, "provider finish_reason=content_filter"}
case llm.FinishRefusal:
@ -383,19 +460,30 @@ func degenerateLoop(text string) bool {
// --- max_tokens on the attempt axis (D2.3) ---
// maxTokensForAttempt is the PURE output-token budget for a retry attempt:
// attempt 0 = base, each regeneration DOUBLES it (D2.3 remedy for a length cut —
// the previous budget was too small). Purity is load-bearing: the value enters
// request_hash, so resume must reproduce the identical per-attempt budget. Its
// FORMULA is versioned into the snapshot (maxTokensPolicyVersion) so a change is
// a loud --resnapshot, not a silent checkpoint miss on retried chunks (the same
// discipline as estimatorVersion).
func maxTokensForAttempt(base, attempt int) int {
if attempt <= 0 {
// maxTokensForAttempt is the PURE output-token budget for a retry attempt: no doublings = base, each
// regeneration DOUBLES it (D2.3 remedy for a length cut — the previous budget was too small). Purity
// is load-bearing: the value enters request_hash, so resume must reproduce the identical budget. Its
// FORMULA is versioned into the snapshot (maxTokensPolicyVersion) so a change is a loud --resnapshot,
// not a silent checkpoint miss on retried chunks (the same discipline as estimatorVersion).
//
// ⛔ IT COUNTS DOUBLINGS, NOT ATTEMPTS, and the two used to be the same number by accident. Every
// regeneration is a new attempt, so «attempt index» read as «times the budget was doubled» and both
// callers agreed — until a call the ENGINE cut short had to be re-done. That re-do must NOT be paid
// for with a doubled budget: nothing was wrong with the answer, nobody saw it, and doubling would buy
// twice the call for a health nobody lost. Since a checkpoint cannot be written twice under one key,
// the re-do has to be a NEW attempt index — so the two dimensions had to come apart, and this
// parameter is the one that is about money.
//
// ⚠ THE FORMULA AND ITS OUTPUT ARE UNCHANGED FOR EVERY PATH THAT EXISTS TODAY: both loops that
// regenerate increment the doubling count with the attempt index, so on a run with no cut call the
// two are identical and the snapshot does not move. That equality is the reason this could land on
// books that are mid-translation at all — moving maxTokensPolicyVersion would re-pay every one of them.
func maxTokensForAttempt(base, escalations int) int {
if escalations <= 0 {
return base
}
if attempt > 20 { // defensive: never shift by a runaway amount (overflow guard)
attempt = 20
if escalations > 20 { // defensive: never shift by a runaway amount (overflow guard)
escalations = 20
}
return base << uint(attempt)
return base << uint(escalations)
}

View file

@ -138,20 +138,21 @@ func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID stri
// and flipping the verdict OK→flagged. Only a FRESH hop is budget-gated. The identity is BUILT by the
// same helper the hop's own runAttempt uses (attemptRequest), so "mirrors runAttempt" is structural
// rather than a promise two field lists keep by vigilance.
fbHash := RequestHash(r.attemptRequest(st, st.ResolvedHop, snapID, ch, 0, hopMaxTokens, msgs))
fbExists, err := r.Store.GetCheckpoint(fbHash)
mayHop, err := r.paidAfterBurns(st, st.ResolvedHop, snapID, ch, 0, hopMaxTokens, msgs)
if err != nil {
return out, err
}
mayHop := fbExists != nil
if !mayHop {
// Fresh hop: serialize the budget admission THROUGH the paid settle across the parallel the draft wave draft
// workers (R1, escMu). escalationBudgetRemains is a non-atomic read-then-act over EscalationSpentUSD,
// so without this N concurrent draft chunks could each read spent<budget and all be admitted →
// overshoot by up to N-1 hops. Holding escMu until this function returns (past runAttempt's settle)
// makes a later worker read the UPDATED spend, restoring the sequential soft-cap (overshoot ≤ 1 hop).
// The $0 replay path above (fbExists != nil) stays lock-free; escalations are the rare content-failure
// exception, so the contention is negligible.
// The $0 replay path stays lock-free: paidAfterBurns answering `true` returns an ANSWER the store
// already holds, and serving it takes no budget and no admission. Everything reaching here is a
// purchase — including the one behind a burned key, where the money was spent and the result was
// not received, so the next index is bought fresh and belongs under the same admission as any
// other fresh hop.
r.escMu.Lock()
defer r.escMu.Unlock()
if mayHop, err = r.escalationBudgetRemains(); err != nil {
@ -165,6 +166,10 @@ func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID stri
// life — a hop that queued for headroom would block every other worker's escalation while spending
// the money the primary wave needs to finish what it has already started.
fb, err := r.runAttempt(ctx, st, st.ResolvedHop, snapID, ch, job, 0, hopMaxTokens, msgs, true, isFinal, false)
// ⛔ THE MONEY TRAVELS WHATEVER THE OUTCOME. A hop the engine CUT still cost what it cost, and the
// caller adds `out.fb`'s money to the chunk row; returning the error with an empty outcome left that
// money in the ledger and nowhere on the row — a position that reads as cheaper than it was.
out.fb = fb
if err != nil {
// An OPTIONAL hop that trips a USD ceiling must NOT abort the whole book
// (and re-abort on every resume): the chunk is already flagged, so keep
@ -176,7 +181,7 @@ func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID stri
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "reason", string(primary.cls.Reason))
return out, nil
}
out.attempted, out.fb = true, fb
out.attempted = true
r.Log.WarnContext(ctx, "stage escalated to a fallback model",
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx,
"primary_reason", string(primary.cls.Reason), "fallback", st.ResolvedHop,

View file

@ -1,13 +1,17 @@
package pipeline
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"go/types"
"os"
"strconv"
"strings"
"testing"
"textmachine/backend/internal/llm"
)
// flagseverity_test.go pins the chapter passport's "worst flag" ranking.
@ -81,23 +85,23 @@ func TestEveryFlagReasonIsRanked(t *testing.T) {
if len(vs.Values) == 0 {
continue
}
var lit *ast.BasicLit
switch {
case isFlagReasonIdent(vs.Type):
lit, _ = vs.Values[0].(*ast.BasicLit)
default:
call, isCall := vs.Values[0].(*ast.CallExpr)
if !isCall || !isFlagReasonIdent(call.Fun) || len(call.Args) != 1 {
continue
}
lit, _ = call.Args[0].(*ast.BasicLit)
}
if lit == nil || lit.Kind != token.STRING {
// ⚠ AND A THIRD SPELLING, which this walk used to drop on the floor: the value can be a
// conversion of a constant from ANOTHER package — `X FlagReason = FlagReason(llm.Y)` — used so
// that a vocabulary the wire, the checkpoint, the flag and the operator all read has ONE
// carrier. There is no literal to unquote there, the old code hit `lit == nil` and `continue`d,
// and both flags declared that way landed in the unknown bucket while THIS TEST STAYED GREEN.
// Measured when it happened: 18 ranks against 16 constants the walk could see.
//
// So an unreadable value is now a FAILURE, not a skip. A silent `continue` in an
// exhaustiveness test is the same defect the test exists to catch, one level up.
decl, isDecl := flagReasonValue(vs)
if !isDecl {
continue
}
val, err := strconv.Unquote(lit.Value)
val, err := decl.resolve()
if err != nil {
t.Fatalf("%s: %v", vs.Names[0].Name, err)
t.Errorf("%s: %v", vs.Names[0].Name, err)
continue
}
if val == "" {
continue // reasonOK — the sentinel for "not a flag", deliberately unranked
@ -152,3 +156,116 @@ func isFlagReasonIdent(e ast.Expr) bool {
id, ok := e.(*ast.Ident)
return ok && id.Name == "FlagReason"
}
// flagValueDecl is a FlagReason constant declaration the walk has recognised, in whichever of the three
// spellings it was written.
type flagValueDecl struct {
lit *ast.BasicLit // `X FlagReason = "y"` or `X = FlagReason("y")`
conv string // `X FlagReason = FlagReason(pkg.Y)` — the rendered source expression
}
// convertedFlagValues resolves the conversion spelling. The keys are SOURCE EXPRESSIONS and the values
// are compile-time references to the very constants those expressions name, so the map cannot drift in
// VALUE — only in membership, and a member it lacks fails loud below instead of being skipped.
var convertedFlagValues = map[string]FlagReason{
"llm.CutBySelfDeadline": FlagReason(llm.CutBySelfDeadline),
"llm.CutByParent": FlagReason(llm.CutByParent),
"llm.CutByConnection": FlagReason(llm.CutByConnection),
}
func (d flagValueDecl) resolve() (string, error) {
if d.lit != nil {
if d.lit.Kind != token.STRING {
return "", fmt.Errorf("declared with a non-string value %s — a FlagReason is a string", d.lit.Value)
}
return strconv.Unquote(d.lit.Value)
}
v, ok := convertedFlagValues[d.conv]
if !ok {
return "", fmt.Errorf("declared as a conversion of %s, whose value this test cannot read from the "+
"source; add it to convertedFlagValues (a compile-time reference, not a retyped string) so the "+
"exhaustiveness check can see it instead of skipping it", d.conv)
}
return string(v), nil
}
// flagReasonValue recognises a FlagReason declaration in any of its three spellings and reports how its
// value can be read. It returns false for a spec that is not a FlagReason at all — an ordinary string
// constant sitting in the same block is not this test's business.
func flagReasonValue(vs *ast.ValueSpec) (flagValueDecl, bool) {
typed := isFlagReasonIdent(vs.Type)
if lit, ok := vs.Values[0].(*ast.BasicLit); ok {
return flagValueDecl{lit: lit}, typed
}
call, isCall := vs.Values[0].(*ast.CallExpr)
if !isCall || !isFlagReasonIdent(call.Fun) || len(call.Args) != 1 {
return flagValueDecl{}, false
}
if lit, ok := call.Args[0].(*ast.BasicLit); ok {
return flagValueDecl{lit: lit}, true
}
return flagValueDecl{conv: types.ExprString(call.Args[0])}, true
}
// TestTheSeverityTableMeansWhatItsCommentsSay pins the ORDER, not the membership. The exhaustiveness
// test beside it proves every reason has a rank; it says nothing about what the ranks are, so the whole
// table could be re-ordered — `cancelled` declared a chapter's worst problem, a lost chunk declared its
// mildest — and the battery would stay green.
//
// Every assertion below is a sentence the table already writes about itself, turned into a check. That
// is the point: a rank is a claim, and a claim with no carrier is a comment.
func TestTheSeverityTableMeansWhatItsCommentsSay(t *testing.T) {
rank := func(r FlagReason) int {
s, ok := flagSeverity[r]
if !ok {
t.Fatalf("premise broken: %s carries no rank, so the comparisons below compare nothing", r)
}
return s
}
// The control first: the table is not empty and not all one value, or every «is milder than» below
// would hold trivially.
seen := map[int]bool{}
for _, s := range flagSeverity {
seen[s] = true
}
if len(flagSeverity) < 5 || len(seen) < 3 {
t.Fatalf("premise broken: %d reasons across %d distinct ranks — an ordering test needs an order",
len(flagSeverity), len(seen))
}
// «The mildest mark there is» — a passport that reported `cancelled` as a chapter's worst problem
// would hide a durable finding behind a state the next run erases.
for r, s := range flagSeverity {
if r == FlagCancelled {
continue
}
if rank(FlagCancelled) <= s {
t.Fatalf("`cancelled` must be milder than every other mark — it is the only one the engine "+
"removes by itself — but it ranks %d against %s at %d", rank(FlagCancelled), r, s)
}
}
// «They rank together because they are the same thing to a reader — the chunk is lost and the money
// is spent.» A lost connection is not a third member: it reaches no disposition, so it wears no rank.
if rank(FlagDecodeError) != rank(FlagAttemptTimeout) {
t.Fatalf("the paid-and-nothing-came-back reasons must share one rank: decode=%d timeout=%d",
rank(FlagDecodeError), rank(FlagAttemptTimeout))
}
// «An unrecognised string must not out-rank a diagnosis the engine actually made.»
for r, s := range flagSeverity {
if severityUnknown <= s {
t.Fatalf("severityUnknown (%d) must be milder than every diagnosis the engine makes, but %s "+
"ranks %d", severityUnknown, r, s)
}
}
// «Ranked below a budget symptom (the chunk is not lost)» — a stripped chunk SHIPPED.
if rank(FlagSanitizerStripped) <= rank(FlagLength) || rank(FlagSanitizerStripped) <= rank(FlagEmpty) {
t.Fatalf("an auto-cleaned chunk that SHIPPED must be milder than a budget symptom that lost one: "+
"stripped=%d length=%d empty=%d", rank(FlagSanitizerStripped), rank(FlagLength), rank(FlagEmpty))
}
// «Ranked with the deterministic content failures, above a mere budget symptom» — a DROPPED
// contaminated output is unreadable as shipped.
if rank(FlagSanitizerDefect) >= rank(FlagLength) {
t.Fatalf("a contaminated output that was DROPPED must be more severe than a budget symptom: "+
"defect=%d length=%d", rank(FlagSanitizerDefect), rank(FlagLength))
}
}

View file

@ -1,8 +1,10 @@
package pipeline
import (
"encoding/json"
"fmt"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/store"
)
@ -199,3 +201,59 @@ func (p pos) name() string {
}
return fmt.Sprintf("ch%d/chunk%d/%s", p.chapter, p.chunkIdx, p.stage)
}
// estimatedSpend is how much of a book's committed money is an ESTIMATE rather than a figure the
// provider reported, and over how many calls. It sits beside paidTail because it asks the same kind of
// question of the same rows: that one decomposes committed spend by what it BOUGHT, this one by how
// well it is KNOWN.
//
// It is the engine's half of PD-441 and the condition attached to D39.230 п.1. Without it the platform
// bills committed_usd and can say «at least this much», never «at least X, up to Y».
//
// ⛔ THE TEST IS «PAID WITH NO TOKENS TO SHOW FOR IT», not a list of causes. Cost is derived from usage
// everywhere else in the engine, so a settled row with zero tokens and a non-zero cost can only have
// come from a reservation estimate — whatever ended the call: a body that would not decode, a deadline
// of ours, a stopped run, a broken socket, a paid 2xx that reported no usage. A list of finish_reasons
// would answer the same question today and quietly stop answering it the day a sixth way to pay
// without a token count is added.
//
// Reading the checkpoints rather than request_log is what makes the pair a decomposition of
// committed_usd instead of a second, telemetry-shaped opinion about it: the two are the same money, and
// a resumed run writes no new estimate row while its money stands.
//
// ⛔ `committed == SUM(checkpoints)` HOLDS ONLY UNTIL A REDRIVE, and the difference is money this
// figure would otherwise disown. A redrive DELETES the checkpoints of the stages it re-attacks and
// leaves their spend committed — «after a redrive committed(spend) >= SUM(checkpoints), the safe
// direction» (store/chunkstatus.go). Derived from the surviving rows alone, the published share then
// falls to zero and tells the platform that money nobody can account for was measured. So the gap is
// carried INTO the estimate by the same rule the rows are: we hold a cost and have no token count to
// justify it. Measured on a live redrive: committed unchanged at $0.001056, estimated dropped to $0.
func estimatedSpend(usage []store.CheckpointUsage, committedUSD float64) (rows int, usd float64) {
var accounted float64
for _, u := range usage {
accounted += u.CostUSD
}
for _, u := range usage {
if u.CostUSD <= 0 {
continue // derived $0 checkpoints are not calls and cost nothing
}
var tok llm.Usage
if err := json.Unmarshal([]byte(u.UsageJSON), &tok); err != nil {
// Unreadable usage on a paid row is the same state as absent usage: a cost we cannot
// justify from tokens. Counting it as measured publishes the more comfortable answer.
rows, usd = rows+1, usd+u.CostUSD
continue
}
if tok.PromptTokens == 0 && tok.CompletionTokens == 0 && tok.ReasoningTokens == 0 {
rows, usd = rows+1, usd+u.CostUSD
}
}
// Committed money with no checkpoint behind it at all: a redrive threw the evidence away and kept
// the spend. It is counted as ONE more unaccounted line rather than as a per-call figure, because
// how many calls it stood for is exactly what was deleted. The float slack keeps a sum of prices
// from inventing a nanodollar of «unaccounted».
if gap := committedUSD - accounted; gap > 1e-9 {
rows, usd = rows+1, usd+gap
}
return rows, usd
}

View file

@ -349,8 +349,13 @@ func (r *Runner) stepMaxForUnit(p *pricePlan, u editUnit, up unitPrice) float64
max := 0.0
consider := func(sp stagePrice, sizing, prompt int) {
base := r.baseMaxTokensFor(sp.st, sizing)
for attempt := 0; attempt <= p.maxRegen; attempt++ {
if usd := ledger.EstimateUSD(sp.price, prompt, maxTokensForAttempt(base, attempt), sp.reasoning); usd > max {
// The walk is over DOUBLINGS, which is what it always was and now says so: the regeneration cap
// bounds how many times a budget may double, and the largest single reservation is the last of
// them. The projection is untouched by splitting the doubling count off the attempt index —
// re-doing a call the engine cut short adds an attempt at an EXISTING budget, so it buys no
// reservation this walk has not already priced.
for escalations := 0; escalations <= p.maxRegen; escalations++ {
if usd := ledger.EstimateUSD(sp.price, prompt, maxTokensForAttempt(base, escalations), sp.reasoning); usd > max {
max = usd
}
}

View file

@ -300,6 +300,12 @@ func (r *Runner) maybeRepair(ctx context.Context, st config.Stage, snapID string
}
}
att, err := r.runRepairAttempt(ctx, st, snapID, ch, job, i, msgs)
// ⛔ THE MONEY IS REPORTED BEFORE THE OUTCOME IS JUDGED. A repair call the engine CUT still cost
// what it cost, and the caller's chunk row is the only place that cost can be recorded — the
// error path used to return before this and left the row below the ledger for the position.
res.CostUSD += att.runCost
res.CumUSD += att.cumCost
res.Fresh = res.Fresh || att.freshCall
if err != nil {
// A ceiling denial must NOT abort the book: this step is optional and sits BEFORE the
// chunk_status write, so propagating would discard the row of an already-paid, successful
@ -312,9 +318,6 @@ func (r *Runner) maybeRepair(ctx context.Context, st config.Stage, snapID string
return finalText, res, err
}
res.Calls++
res.CostUSD += att.runCost
res.CumUSD += att.cumCost
res.Fresh = res.Fresh || att.freshCall
spent += att.runCost // a replay costs 0, so only fresh calls consume the budget
reply, verdict := repairReplyVerdict(att, dstSpan, c.Class, cfg.Checkers)
switch verdict {
@ -361,11 +364,16 @@ func (r *Runner) maybeRepair(ctx context.Context, st config.Stage, snapID string
// mirrors runRepairAttempt's request identity BY CONSTRUCTION — same derived stage, same helper — because
// the two must address the same checkpoint; the doccomment used to promise that mirroring while the two
// field lists were maintained apart, which held only while the dropped fields were zero.
//
// ⛔ A BURNED KEY IS NOT A PAID REPAIR. A row that records money and no result cannot be replayed, so
// runAttempt walks past it and buys the repair again — and «already paid» is what skips the sub-budget
// comparison two lines up from the call, so that purchase would happen with nothing bounding it. The key
// can hold such a row only because cut calls are settled there now; before that, «a checkpoint exists»
// and «this repair is done» were the same statement. Same reading as the bank probe's.
func (r *Runner) repairCheckpointExists(st config.Stage, snapID string, ch chunk.Chunk, ordinal int, msgs []llm.Message) (bool, error) {
model, maxTokens := r.repairCallBudget(msgs)
rst := r.repairStage(st, model)
cp, err := r.Store.GetCheckpoint(RequestHash(r.attemptRequest(rst, model, snapID, ch, ordinal, maxTokens, msgs)))
return cp != nil, err
return r.paidAfterBurns(rst, model, snapID, ch, ordinal, maxTokens, msgs)
}
// repairStage derives the stage of a repair call from the stage whose output is being repaired. It takes

View file

@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"sync"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/config"
"textmachine/backend/internal/ledger"
@ -35,7 +36,7 @@ import (
// able to tell that a repaired span did not drop a canonical glossary form (with the post-check gate on that
// would flip the unit to flagged and ship an EMPTY export). It is threaded rather than re-derived so the
// re-gate judges the SAME selection the injection was rendered from.
func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, snapID string, ch chunk.Chunk, prev, injection string, injected []membank.PickedEntry) (*StageResult, error) {
func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, snapID string, ch chunk.Chunk, prev, injection string, injected []membank.PickedEntry) (res *StageResult, err error) {
// Enrich ReqInfo FIRST, PRESERVING the admission decisions (LogBodies) —
// a from-scratch overwrite would sever the documented debug channel (review
// finding). Earlier the enrichment sat AFTER the resume-fast-path — and every
@ -89,6 +90,7 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
if cs, err := r.Store.GetChunkStatus(r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name); err != nil {
return nil, fmt.Errorf("pipeline: read chunk_status %s/ch%d/chunk%d/%s: %w", r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, err)
} else if cs != nil && cs.ContentHash == contentHash && cs.Disposition != string(DispSkipped) &&
resolvedForResume(cs) &&
(cs.SnapshotID == snapID || r.repinnable(cs.SnapshotID, snapID, waveOfStage(r.Pipeline.Stages, st.Name))) {
// POINTWISE RE-EDIT (pack-20 point 5). The exact-snapshot case is the ordinary resume. The second
// case is the one that makes signing a term affordable: the snapshot moved ONLY because the BANK
@ -137,45 +139,79 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
var last stageAttempt
anyFresh := false
attemptsMade := 0
// ⛔ THE MARK FOR A STOPPED POSITION IS ATTACHED TO EVERY EXIT, not to the attempt loop's. It was
// written at the loop's error return, and the hop and the repair sub-step both leave this function
// through OTHER returns — so a run stopped over an escalation hop settled the
// money and left NO chunk_status row at all: the invisible hole §4.2 forbids, measured as
// `committed=0.001176` with `chunk_status_rows=0`. A deferred rule sees whichever return fires, and
// closes over the counters so it reports what had accumulated by then. recordCancelledStage is a
// no-op for every error that is not a delivered call a person stopped, so this costs nothing on the
// ordinary paths.
defer func() {
r.recordCancelledStage(ctx, cancelledPosition{
stage: st, chunk: ch, snapshotID: snapID, contentHash: contentHash,
cumCostUSD: cumCost, attempts: attemptsMade,
}, err)
}()
// firstFlagReason keeps the FIRST attempt's failure when a later attempt (a regenerate, or the
// single-hop escalation below) recovers the chunk. Without it the recovered row is written `ok`
// with an empty flag_reason and the primary failure leaves no durable trace at all — which is how
// the mini-run of 25.07 reported echo_draft=0.0% on a run where one draft in twenty had echoed.
// It is telemetry, never the verdict: `disposition`/`FlagReason` below are untouched by it.
firstFlagReason := FlagReason("")
// escalations counts BUDGET DOUBLINGS; attempt counts KEYS. They move together on every
// regeneration and come apart on exactly one path: re-doing a call the engine itself cut short,
// which needs a fresh request_hash (the old one already holds that call's money) at the budget it
// was already granted. See maxTokensForAttempt.
escalations := 0
judged := 0
for attempt := 0; ; attempt++ {
maxTokens := maxTokensForAttempt(baseMaxTokens, attempt)
maxTokens := maxTokensForAttempt(baseMaxTokens, escalations)
att, err := r.runAttempt(ctx, st, st.ResolvedModel, snapID, ch, job, attempt, maxTokens, msgs, false, isFinal, true)
if err != nil {
return nil, err // infra failure
}
attemptsMade = attempt + 1
cumCost += att.cumCost
runCost += att.runCost
// ⛔ THE COUNT IS A FACT ABOUT WHAT HAPPENED, not about whether it succeeded — and it is recorded
// before the error check for the same reason the money above is. The deferred mark reports this
// number BESIDE money that was really paid; leaving it behind the check wrote `attempts=0` on a
// position whose ledger said 0.001056. runAttempt may also have walked over burned keys, so the
// loop follows its index and the next regeneration does not re-address a key that is spent.
attempt = att.attempt
attemptsMade = attempt + 1
if err != nil {
return nil, err // infra failure; the cancelled-position mark is the defer above
}
anyFresh = anyFresh || att.freshCall
last = att
if attempt == 0 && !att.cls.ok() {
if judged == 0 && !att.cls.ok() {
firstFlagReason = att.cls.Reason
}
judged++
if att.cls.ok() {
break
}
// Flagged: re-attack only the retryable subset, only while regenerations
// remain (a bigger budget on the attempt axis, D2.3). Everything else is
// deterministic — a same-model retry would re-refuse and re-bill (D2.2).
if att.cls.Reason.retryable() && attempt < maxRegen {
if att.cls.Reason.retryable() && escalations < maxRegen {
r.Log.WarnContext(ctx, "stage flagged, regenerating with a larger budget",
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx,
"attempt", attempt, "reason", string(att.cls.Reason), "next_max_tokens", maxTokensForAttempt(baseMaxTokens, attempt+1))
"attempt", attempt, "reason", string(att.cls.Reason), "next_max_tokens", maxTokensForAttempt(baseMaxTokens, escalations+1))
escalations++
continue
}
// Echo (cjk_artifact) OPT-IN re-generation before escalation (row 77 / D39.61): on a provider whose
// echo is STOCHASTIC per call, a same-model re-gen recovers ~7.6× cheaper than the escalation hop.
// Default 0 ⇒ this never fires and echo escalates straight away (the prior behaviour); the echo GATE
// is untouched — only the RESPONSE changes.
if att.cls.Reason == FlagCJKArtifact && attempt < echoRegen {
if att.cls.Reason == FlagCJKArtifact && escalations < echoRegen {
r.Log.WarnContext(ctx, "echo flagged, regenerating before escalation (echo is stochastic per call, D39.61)",
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "attempt", attempt)
// ⚠ THE ECHO RE-GEN COUNTS AS A DOUBLING TOO, and it must. It is a fresh roll of a
// stochastic die rather than a bigger-budget remedy, so counting it here looks like a
// detail — but this loop has always given it `base << attempt`, and taking that away would
// move max_tokens, and with it request_hash, and with it every echo-regenerated
// checkpoint on disk. The doubling axis was split to add a case, not to re-price one.
escalations++
continue
}
break
@ -200,13 +236,17 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
// column.
escalated, escModel := false, ""
esc, err := r.maybeEscalate(ctx, st, snapID, ch, job, baseMaxTokens, msgs, last, isFinal)
// ⛔ MONEY FIRST, VERDICT SECOND — and the order is the whole point. A hop that was CUT reports its
// cost through `esc.fb` and an error at the same time; adding the cost only on the success path left
// the deferred `cancelled` mark carrying the primary's money alone (measured: ledger 0.001176, row
// 0.000120). `esc.fb` is a zero value when no hop ran, so this adds nothing when nothing happened.
cumCost += esc.fb.cumCost
runCost += esc.fb.runCost
anyFresh = anyFresh || esc.fb.freshCall
if err != nil {
return nil, err
}
if esc.attempted {
cumCost += esc.fb.cumCost
runCost += esc.fb.runCost
anyFresh = anyFresh || esc.fb.freshCall
escalated = true
if esc.fb.cls.ok() || esc.fb.cls.Reason == FlagSanitizerStripped {
// The fallback is authoritative when it passed the re-gate OR when it is a cosmetic
@ -246,12 +286,14 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
// intermediate artifact rather than the final one.
if isFinal {
repaired, rr, rerr := r.maybeRepair(ctx, st, snapID, ch, job, prev, last.text, injected)
// Same order as the hop above: a repair call the engine cut reports its cost together with
// the error, and the row is the only place that cost can land.
cumCost += rr.CumUSD // honest total: a replayed repair still costs what it cost
runCost += rr.CostUSD // this run's spend only
if rerr != nil {
return nil, rerr
}
repairRes = rr
cumCost += rr.CumUSD // honest total: a replayed repair still costs what it cost
runCost += rr.CostUSD // this run's spend only
anyFresh = anyFresh || rr.Fresh
if rr.Applied > 0 {
dh, derr := r.commitRepairExport(st, ch, job, last, repaired)
@ -433,8 +475,42 @@ func (r *Runner) callEstimateUSD(st config.Stage, model string, msgs []llm.Messa
// dollars would turn «do not start anything new» into «the optional spends what the mandatory needed»,
// and the hop holds escMu while it waits, so it would also block every other worker's escalation.
func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID string, ch chunk.Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message, escalation, isFinal, mandatory bool) (stageAttempt, error) {
reqHash := RequestHash(r.attemptRequest(st, model, snapID, ch, attempt, maxTokens, msgs))
att := stageAttempt{reqHash: reqHash, attempt: attempt, modelActual: model}
// ⛔ BURNED KEYS ARE WALKED OVER HERE, AND NOT BY THE CALLER. A checkpoint that records money and
// no result cannot be replayed as an answer, so its attempt index is spent and the call has to be
// re-asked under the next one at the SAME budget. That rule first lived in runStage's loop, which
// left the three callers OUTSIDE the loop with a key burnt forever: the escalation hop addresses a
// fixed attempt 0, so a run stopped over a hop could never re-do it — measured as `committed`
// booked, `chunk_status_rows = 0` and a resume that made ZERO fresh calls. The bank batches and the
// repair sub-step have the same shape. Walking here gives every caller the behaviour without any of
// them knowing the rule exists.
//
// The walk terminates by construction: it advances only while a checkpoint EXISTS at the key, and
// the first key without one takes the fresh-call path below. The money of every key it steps over
// stays in the chunk's honest total — it was really spent — while `runCost` does not move, because
// a burn can only have been settled by an EARLIER run (both causes end this one).
var burnedCost float64
var reqHash string
var att stageAttempt
for {
reqHash = RequestHash(r.attemptRequest(st, model, snapID, ch, attempt, maxTokens, msgs))
att = stageAttempt{reqHash: reqHash, attempt: attempt, modelActual: model}
cp, cerr := r.Store.GetCheckpoint(reqHash)
if cerr != nil {
return att, fmt.Errorf("pipeline: read checkpoint for %s/ch%d/chunk%d/%s attempt %d: %w", r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, attempt, cerr)
}
if cp == nil || !burnedByCut(cp) {
break
}
burnedCost += cp.CostUSD
// «Spent», not «paid for»: the key is used up either way, but what it COST is printed beside it
// and is legitimately zero when the provider never acknowledged the call. A line calling a $0
// row «paid for» reads as a billing bug to whoever finds it in a log at three in the morning.
r.Log.InfoContext(ctx, "the key of this attempt is SPENT — the call was cut off before its answer arrived and cannot be replayed; re-asking under a fresh key at the SAME budget",
"stage", st.Name, "attempt", attempt, "hash", reqHash[:12], "cause", cp.FinishReason,
"max_tokens", maxTokens, "cost_usd", fmt.Sprintf("%.6f", cp.CostUSD))
attempt++
}
att.cumCost = burnedCost
// Resume on the attempt axis: a checkpoint means THIS attempt already happened
// and was billed — classify its text and never re-bill (kill -9 loses ≤1 call;
@ -451,7 +527,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
att.usage = usage
att.finish = cp.FinishReason
att.modelActual = cp.ModelActual
att.cumCost = cp.CostUSD
att.cumCost = burnedCost + cp.CostUSD
// Banknote: slice the block off the RAW checkpoint text BEFORE classify + before feeding the
// editor (WS4 points 1-3,8). The checkpoint stores the RAW draft; resume re-derives the clean
// text deterministically (a no-op returning the raw text for a channel-off / no-separator draft).
@ -551,6 +627,24 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
estimate, r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, ctx.Err())
}
}
// ⛔ A RUN THAT IS ENDING DID NOT STOP ON MONEY, whatever the ledger says at this instant, and the
// check belongs HERE rather than at the top of the loop — that is where it was first written, and
// it missed. `waitForSettle` answers «nothing is in flight» BEFORE it looks at the context, so a
// worker whose wait ended because the cancelled call had already settled and left falls straight
// through the switch above to the halt below, with its context long dead and the top-of-loop
// check minutes behind it. The decision point is the only place a guard on it is complete.
//
// Why it matters now: the hole was unreachable while a cancelled in-flight call gave its
// reservation back. Paying for such a call (D39.230 п.1) makes it live — a stop commits every
// flying call's estimate, which can carry the book past its own ceiling — and the next worker
// would publish a `ceiling` event for a run a person had stopped themselves. The platform lets
// that event survive any exit code, so it would record `paused` and ask for money that would
// change nothing.
if ctx.Err() != nil {
r.setJobStatus(ctx, job.ID, "failed")
return att, fmt.Errorf("pipeline: reserve $%.6f for %s/ch%d/chunk%d/%s: the run ended before the reservation was granted: %w",
estimate, r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, ctx.Err())
}
if verdict == store.ReserveDeniedBook {
// Name WHICH ceiling stopped the run: a caller who passed --ceiling-usd and is told to "raise
// ceilings.book_usd" would edit a file that is not in force (row 145).
@ -595,6 +689,12 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
r.Log.InfoContext(ctx, "calling model", "model", model, "attempt", attempt,
"max_tokens", maxTokens, "escalation", escalation, "estimate_usd", fmt.Sprintf("%.6f", estimate))
// A call may now legitimately run for a quarter of an hour: the deadline is derived from the
// budget, and the owner ratified waiting for a provider that has our request. That makes the ONE
// line above ("calling model") insufficient — a fifteen-minute silence after it is
// indistinguishable from a wedged process, which is exactly the pain a visible in-flight marker was
// added for in the first place. So the wait says so while it lasts, and stops the moment the call does.
stopHeartbeat := r.logWaitingForProvider(ctx, st, ch, model, attempt, maxTokens)
start := time.Now()
resp, err := client.Complete(ctx, llm.LLMRequest{
Model: model,
@ -604,6 +704,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
ReasoningEffort: st.Reasoning,
})
att.latency = int(time.Since(start).Milliseconds())
stopHeartbeat()
if err != nil {
var bde *llm.BilledDecodeError
if errors.As(err, &bde) {
@ -621,7 +722,11 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
}
r.events.flush()
att.finish = decodeErrorFinish
att.cumCost, att.runCost = estimate, estimate
// `burnedCost +`, not `=`: the walk above may have stepped over keys this position already
// paid for, and that money is this chunk's whatever this attempt turns out to be. Overwriting
// it puts chunk_status.cost_usd below SUM(checkpoints) for the position — the ledger's own
// invariant, read by the projection a person decides on.
att.cumCost, att.runCost = burnedCost+estimate, estimate
att.cls = classification{FlagDecodeError, "billed 2xx with an unreadable body: " + err.Error()}
rl := r.baseRequestLog(st, ch, model, reqHash)
rl.CostUSD, rl.LatencyMS, rl.FinishReason = estimate, att.latency, decodeErrorFinish
@ -631,9 +736,22 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
r.setJobStatus(ctx, job.ID, "done")
return att, nil
}
// No 2xx ever arrived: nothing was billed. Release and surface as an INFRA
var cut *llm.AttemptCutError
if errors.As(err, &cut) && cut.Delivered {
return r.settleCutCall(ctx, cutCall{
stage: st, chunk: ch, job: job, model: model, reqHash: reqHash,
resv: resv, estimate: estimate, attempt: attempt, escalation: escalation,
}, cut, err, att)
}
// The request never went out: nothing was billed. Release and surface as an INFRA
// failure — a long book pauses/resumes on an outage or terminal 4xx (D4),
// rather than flag-storming every remaining chunk on a dead provider.
//
// ⚠ THE CONDITION IS DELIVERY, NOT A STATUS LINE, and it used to be neither: the branch read
// «No 2xx ever arrived ⇒ nothing was billed», which is false on a provider that answers 200
// while the request is still queued. Every call our own deadline cut fell in here or into the
// decode-error branch beside it, and this one gave the ledger a zero for a generation the
// provider had made and billed.
r.releaseReservation(ctx, resv)
rl := r.baseRequestLog(st, ch, model, reqHash)
rl.LatencyMS, rl.Err, rl.OK = att.latency, err.Error(), false
@ -679,7 +797,8 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
r.setJobStatus(ctx, job.ID, "failed")
return att, err
}
att.cumCost, att.runCost = cost, cost
// Same as the billed-decode branch above: the burn walk's money is added, never replaced.
att.cumCost, att.runCost = burnedCost+cost, cost
// Money: settle + the raw response — one transaction (§3.3). ALWAYS, even for
// an empty/truncated/refused response: it was billed by the provider, the
@ -814,3 +933,66 @@ func (r *Runner) baseMaxTokensFor(st config.Stage, sizingTokens int) int {
}
return base
}
// --- the visible life of a call in flight ---
// waitHeartbeat bounds how often a call still in flight says so: often enough that an operator learns
// within a minute, rarely enough that a healthy wave prints nothing.
const waitHeartbeat = time.Minute
// heartbeatEvery is the interval for a call that may run for `deadline` — a quarter of the wait the
// engine has actually granted, capped at a minute. Derived rather than fixed so the line exists on
// every call the engine is willing to wait for, including short ones.
func heartbeatEvery(deadline time.Duration) time.Duration {
q := deadline / 4
if q <= 0 {
// A wait of zero or less is a caller that could not resolve one, not a call that finishes
// instantly. Falling to a millisecond here would print this line a thousand times a second,
// which is the opposite of what it is for; the cap is the honest answer to «no idea how long».
return waitHeartbeat
}
if q < waitHeartbeat {
return q
}
return waitHeartbeat
}
// logWaitingForProvider says, while a call is still out, that it is — and returns the function that
// stops it. With a deadline derived from the budget a call may legitimately run for a quarter of an
// hour, and a single «calling model» line then leaves an operator watching a silence he cannot tell
// from a wedged process (the in-flight marker's original reason, met again at a longer timescale).
func (r *Runner) logWaitingForProvider(ctx context.Context, st config.Stage, ch chunk.Chunk, model string, attempt, maxTokens int) (stop func()) {
done := make(chan struct{})
var once sync.Once
go func() {
t := time.NewTicker(heartbeatEvery(r.Models.AttemptDeadline(model, maxTokens)))
defer t.Stop()
started := time.Now()
for {
select {
case <-done:
return
case <-ctx.Done():
return
case <-t.C:
// ⚠ IT DOES NOT CLAIM DELIVERY, and it used to. The runner cannot see the transport's
// trace, so «a call that has been delivered» was an assertion nobody had checked — and on
// a request that never went out it printed eighteen times, telling the operator the
// opposite of what happened. A line stating a fact it did not ask about is the same
// defect a flag naming the wrong cause is (D39.93 п.2).
// ⚠ THE FIELDS ARE NAMED FOR WHAT THEY ACTUALLY ARE, and the first version's were not.
// This timer wraps client.Complete — the WHOLE retry chain — while the deadline it can
// name is one ATTEMPT's, so `waited=4s of=1s` printed a contradiction and read as the
// hung process the line exists to rule out. `attempt_deadline` says which of the two it
// is, and a `waited` past it means the transport has retried. `stage_attempt` likewise:
// the transport logs an `attempt` of its own, and two different numbers under one key in
// one stream is a question an operator cannot answer.
r.Log.InfoContext(ctx, "still waiting for the provider on a call in flight",
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "model", model,
"stage_attempt", attempt, "waited", time.Since(started).Round(time.Millisecond).String(),
"attempt_deadline", r.Models.AttemptDeadline(model, maxTokens).String())
}
}
}()
return func() { once.Do(func() { close(done) }) }
}

View file

@ -306,7 +306,20 @@ type StatusReport struct {
// disposition — a nonzero count is "attention worth a human glance", not a failed chunk.
StyleFlags int `json:"style_flags"`
CommittedUSD float64 `json:"committed_usd"`
CommittedUSD float64 `json:"committed_usd"`
// EstimatedRows / EstimatedUSD are the part of CommittedUSD that is an ESTIMATE — money booked for
// a call whose token count we never received, because the body would not decode, because our own
// deadline or a stopped run cut a delivered request short, or because the provider answered 2xx
// with no usage at all (estimatedSpend, cutcall.go).
//
// They ride HERE, beside the figure they qualify, because this is the JSON the platform reads and
// bills a user from. The engine has printed an estimated-cost legend for a human since pack-13;
// what nobody could read was a NUMBER, so the platform could only ever say «the run cost at least
// this much» (PD-441). Zero rows publish as zero rather than as absence: «none of it was
// estimated» is an answer, and omitting the field would make it indistinguishable from an engine
// too old to have one.
EstimatedRows int `json:"estimated_rows"`
EstimatedUSD float64 `json:"estimated_usd"`
ReservedUSD float64 `json:"reserved_usd"`
BookCeilingUSD float64 `json:"book_ceiling_usd,omitempty"`
CeilingPct float64 `json:"ceiling_pct,omitempty"` // 100·(committed+reserved)/book_ceiling
@ -408,22 +421,38 @@ var flagSeverity = map[FlagReason]int{
FlagSanitizerDefect: 2,
FlagLoopDegenerate: 3,
FlagDecodeError: 4,
FlagGlossaryMiss: 5,
FlagLength: 6,
FlagEmpty: 6,
FlagUpstreamNotOK: 6,
// A cosmetic leak the sanitizer STRIPPED and exported (D35.4a): the chunk shipped cleaned, so it is
// the least alarming flag — an "auto-cleaned, glance to verify" signal, ranked below a budget symptom
// (the chunk is not lost; a human need only spot-check the auto-clean).
// Paid, and nothing usable came back. `decode_error` is a 2xx whose body would not parse;
// `attempt_timeout` is a call OUR deadline cut while the provider was still generating it. They rank
// together because they are the same thing to a reader — the chunk is lost and the money is spent —
// and because neither is a verdict about the TEXT: both say the transport or its deadline needs
// fixing, and both are re-driveable once it is. A lost connection is NOT a third member: it never
// reaches a disposition at all (see disposition.go), so a rank for it would be a rank nothing wears.
FlagDecodeError: 4,
FlagAttemptTimeout: 4,
FlagGlossaryMiss: 5,
FlagLength: 6,
FlagEmpty: 6,
FlagUpstreamNotOK: 6,
// A cosmetic leak the sanitizer STRIPPED and exported (D35.4a): the chunk shipped cleaned — an
// "auto-cleaned, glance to verify" signal, ranked below a budget symptom (the chunk is not lost).
FlagSanitizerStripped: 7,
// The mildest mark there is, and the only one the engine removes by itself: a person stopped the run
// over a call that had already gone out, so the position is paid for and NOT done — and the next
// resume re-does it on the same budget. It ranks below the auto-clean because that one is a durable
// property of shipped text a human should look at, while this is a state the next run erases; a
// passport that reported «cancelled» as a chapter's worst problem would hide the durable finding
// behind a transient one.
FlagCancelled: 8,
}
// severityUnknown is where a reason this build has never heard of lands — a row written by an older
// schema, or junk. Last on purpose: an unrecognised string must not out-rank a diagnosis the engine
// actually made. It is NOT a resting place for new flags; the exhaustiveness test is what keeps it empty.
const severityUnknown = 8
const severityUnknown = 9
func flagReasonSeverity(reason string) int {
if s, ok := flagSeverity[FlagReason(reason)]; ok {
@ -536,6 +565,17 @@ func resolveChunkState(rows []store.ChunkStatus, stagesTotal int) chunkStateReso
case string(DispOK):
ok++
case string(DispFlagged):
// ⛔ A ROW THAT IS NOT AN ANSWER DECIDES NOTHING. `cancelled` records a position the engine was
// stopped over: the work was never done, the resume re-does it and pays again. Reading it as a
// decided unit puts it in the extrapolation's DENOMINATOR (rebill.go's projectBookUSD takes
// every done-or-flagged unit as processed), so one stop makes the book look further along and
// cheaper than it is — and the re-bill consent threshold, min($0.50, 5% × projected), shrinks
// with it. Before cut calls were settled here a stop left NO row at all and the denominator was
// honest; the mark must not cost that. Same question as the resume gate's, asked through the
// same predicate.
if !resolvedForResume(&cs) {
continue
}
// The FIRST flagged stage decides the unit; keep its reason (the rows arrive in member/stage order
// with the leader bucket first, so first-wins aligns status with translate/export, which both take
// the first flag — a member drop's reason, or the edit's own when the edit flagged). Without the
@ -861,6 +901,15 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
return nil, err
}
rep.CommittedUSD, rep.ReservedUSD = committed, reserved
// How much of that committed figure is an estimate. A failed read DEGRADES loudly — the pair stays
// zero and the reason is logged — rather than failing the whole read-only projection; but it is
// logged, because «0 estimated» read off a failure would be the most expensive silence on this
// surface: it says the committed figure is fully measured when nobody looked.
if usage, uerr := r.Store.CheckpointUsageForBook(r.Book.BookID); uerr != nil {
r.Log.WarnContext(ctx, "status: the estimated share of the committed spend could not be read; it is UNKNOWN, not zero", "err", uerr)
} else {
rep.EstimatedRows, rep.EstimatedUSD = estimatedSpend(usage, committed)
}
// The ceiling IN FORCE (row 145): a read path never carries a run-scoped override, so this is the
// book's own number there — but reading it through the single definition means status can never quote
// a ceiling the ledger is not admitting against.

View file

@ -476,7 +476,13 @@ func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []te
// here twice — on "the book has paid", which every ordinary resume of a filtered book satisfies, and on
// a probe taken after the passes, which a freshly-paid first run satisfies trivially.
if res.BankSettled > 0 && run.fresh && paidBefore {
r.Log.WarnContext(ctx, "terminology: this book had already paid for the bank role BEFORE this run, and this run bought the pass again — those earlier calls were made under a batch composition the already-banked filter does not reproduce, so their checkpoints could not be found. This is a ONE-TIME cost; every later run replays for $0",
// ⚠ THE CAUSE IS NAMED AS A PAIR, because the code cannot tell which of the two happened and a
// message that picks one is wrong half the time. The old wording asserted the composition change
// alone; a book whose only earlier bank row was BURNED — money settled, no result, unreplayable —
// gets the same warning with a reason that is simply false for it, and a message lying about its
// own cause is what D39.93 п.2 forbids. Both branches leave the operator with the same action, so
// naming both costs nothing and claiming one costs the truth.
r.Log.WarnContext(ctx, "terminology: this book had already paid for the bank role BEFORE this run, and this run bought the pass again — the earlier calls' checkpoints could not be reused, either because the batch composition changed (the already-banked filter does not reproduce it) or because they recorded money with no result and cannot be replayed. This is a ONE-TIME cost; every later run replays for $0",
logKeyReconsolidated, true,
"book", r.Book.BookID, "skipped", res.BankSettled, "of_candidates", res.Candidates, "cost_usd", fmt.Sprintf("%.6f", run.costUSD))
}
@ -484,8 +490,8 @@ func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []te
out := map[string]string{}
res.Conf = map[string]int{}
for i, b := range batches {
if i >= run.attempted {
break // the budget or a ceiling stopped the pass here; runBankRoleBatches already said so
if !run.ran[i] {
continue // this batch was never called; runBankRoleBatches already said why
}
if run.texts[i] == "" {
// An EMPTY completion on a call that was actually made. It used to `continue` in silence, which is
@ -664,8 +670,8 @@ func (r *Runner) runClassifier(ctx context.Context, snapID string, cands []termi
types, genders = map[string]string{}, map[string]string{}
bad, badGender, noGender := 0, 0, 0
for i, b := range batches {
if i >= run.attempted {
break // the budget stopped the pass here, and it already said so
if !run.ran[i] {
continue // this batch was never called, and the pass already said why
}
if run.texts[i] == "" {
// The same silence the render phase carried: a paid classify batch that returned NOTHING left every
@ -781,10 +787,15 @@ func (r *Runner) bankCallEstimateUSD(st config.Stage, msgs []llm.Message) float6
// request-hash axis — the SAME identity runBankAttempt will address (attemptRequest), never a hand-rebuilt
// copy of it: this probe gates the role sub-budget, so an identity that drifts from the attempt's turns the
// gate off (see attemptRequest).
//
// ⛔ A BURNED CHECKPOINT IS NOT A PAID BATCH. One that records money and no result cannot be replayed,
// so runAttempt walks past it and buys the batch again — and a probe answering «already paid» would let
// that purchase escape the role sub-budget entirely, which is the one thing this gate exists to bound.
// The key can only hold such a row because cut calls are now settled there; before that, «a checkpoint
// exists» and «this batch is done» were the same statement.
func (r *Runner) bankCheckpointExists(st config.Stage, snapID string, ch chunk.Chunk, msgs []llm.Message) (bool, error) {
_, maxTokens := r.bankCallBudget(st.Model, msgs)
cp, err := r.Store.GetCheckpoint(RequestHash(r.attemptRequest(st, st.Model, snapID, ch, 0, maxTokens, msgs)))
return cp != nil, err
return r.paidAfterBurns(st, st.Model, snapID, ch, 0, maxTokens, msgs)
}
// runBankAttempt performs ONE bank-role call on the shared money path: reserve → call → settle+checkpoint,
@ -869,10 +880,15 @@ type bankRolePlan struct {
// budget cut or that failed soft), plus the cost accounting for the report.
type bankRoleRun struct {
texts []string
// attempted is how many batches the pass actually reached before a budget ceiling or a soft denial stopped
// it. Without it "" is ambiguous — an EMPTY completion the run paid for and a batch never called look the
// same — and the caller cannot warn about the first without crying wolf about the second.
attempted int
// ran says, PER BATCH, whether the pass actually reached it. Without it "" is ambiguous — an EMPTY
// completion the run paid for and a batch never called look the same — and the caller cannot warn
// about the first without crying wolf about the second.
//
// ⛔ IT CANNOT BE A COUNT. Admission is not a prefix: an already-paid batch costs nothing and is
// admitted whatever the budget says, so a batch the budget refuses can sit BEFORE batches that are
// free to serve. A count answers «how many from the start», which silently drops every paid batch
// behind the first unaffordable one — their replay was $0 and their result was already bought.
ran []bool
estimateUSD float64
costUSD float64
cumUSD float64
@ -889,7 +905,7 @@ type bankRoleRun struct {
// a budget ceiling or a soft denial stops the pass and leaves the remaining terms untouched — the run never
// aborts on this optional step. logKind names the phase (render|classify) in the logs.
func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan bankRolePlan, batches [][]terminology.Candidate, logKind string) (bankRoleRun, error) {
run := bankRoleRun{texts: make([]string, len(batches))}
run := bankRoleRun{texts: make([]string, len(batches)), ran: make([]bool, len(batches))}
// ONE stage for the whole pass — the estimate, the checkpoint probe and the attempt all read it, so the
// three can never be sized against different knobs (the estimate is the reservation's own upper bound).
st := r.bankStage(plan.role)
@ -933,17 +949,22 @@ func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan ban
//
// So: decide up front, say what was cut, and run only that. Already-paid batches cost nothing and are
// admitted regardless — holding them back would save no money and lose their result.
// ⛔ ADMISSION IS PER BATCH, NOT A PREFIX. An already-paid batch costs nothing, so refusing one
// unaffordable batch must not take the paid batches BEHIND it: their replay is $0 and their result is
// already bought, and dropping them buys nothing while losing a consolidated bank. The loop used to
// `break` here and hand the consumer a prefix bound, which made the comment above («admitted
// regardless») false for every paid batch that happened to sit after a refused one.
fits, plannedUSD := 0, 0.0
probe := spent
paidBatch := make([]bool, len(batches))
admit := make([]bool, len(batches))
for i := range batches {
ch := chunk.Chunk{Chapter: 0, ChunkIdx: i}
paid, perr := r.bankCheckpointExists(st, snapID, ch, msgsPer[i])
if perr != nil {
return run, perr
}
paidBatch[i] = paid
if paid {
admit[i] = true
fits++
continue
}
@ -951,8 +972,9 @@ func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan ban
// the conservative side and the config number is the bound it looks like.
want := r.bankCallEstimateUSD(st, msgsPer[i])
if probe+want > plan.budgetUSD {
break
continue // this one cannot be afforded; the ones after it may still be free
}
admit[i] = true
probe += want
plannedUSD += want
fits++
@ -967,7 +989,10 @@ func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan ban
"estimate_usd", fmt.Sprintf("%.6f", run.estimateUSD))
}
run.planned, run.dropped = len(batches), len(batches)-fits
for i := 0; i < fits; i++ {
for i := range batches {
if !admit[i] {
continue
}
// The synthetic chunk addresses the batch: chapter 0 is the BOOK level (no real chapter is 0), and the
// batch ordinal is the chunk index, so two batches can never collide on one checkpoint.
ch := chunk.Chunk{Chapter: 0, ChunkIdx: i}
@ -986,7 +1011,7 @@ func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan ban
run.fresh = run.fresh || att.freshCall
spent += att.runCost
run.texts[i] = att.text
run.attempted = i + 1
run.ran[i] = true
}
return run, nil
}

View file

@ -1,11 +1,11 @@
#
# <dynamic> stands for a message built at run time — the site is pinned, the words cannot be.
# The operator messages internal/pipeline emits without stopping the run — one line per call site,
# `file<TAB>function<TAB>quoted message`, sorted. Pinned by TestEveryOperatorMessageIsCatalogued, whose
# comment holds the boundary (what is covered, what is deliberately not, and why a catalogue rather than
# more substring asserts).
#
# ⚠ THIS FILE IS NOT REGENERATED. A wording change is meant to arrive here as a one-line diff a reviewer
# reads against the code it now describes: paste the line the failure prints, and keep the file sorted.
# <dynamic> stands for a message built at run time — the site is pinned, the words cannot be.
# ⚠ THIS FILE IS NOT REGENERATED. A wording change is meant to arrive here as a one-line diff a reviewer
bankexport.go exportBank "bank export: could not marshal the bank; the export artifact was NOT refreshed and may be stale"
bankexport.go exportBank "bank export: could not read the bank; the export artifact was NOT refreshed and may be stale"
bankexport.go exportBank "bank export: could not write the export artifact; it was NOT refreshed and may be stale"
@ -20,6 +20,9 @@ bookbuild.go staleUnits "build: the stale check could not be made for every ship
bookbuild.go staleUnits "build: the stale check could not run; whether the source moved under the shipped rows is UNKNOWN (reported as unknown, not as none)"
bookrun.go translateBook "the run ended before its VOLUME grant was used up — the stop below is NOT the volume ceiling"
chunkrun.go reportEvicted "memory: the injection token budget DROPPED bank rows before the model saw them — these terms had no canon on the wire for those units"
cutcall.go recordCancelledStage "could not mark the stopped position; its money is recorded but the chunk will read as never started"
cutcall.go recordCancelledStage "the run was stopped over a call that had already gone out; the position is marked cancelled and the resume re-does it on the same budget"
cutcall.go settleCutCall "we cut a delivered call; the reservation estimate is charged as an ESTIMATE only when the provider had acknowledged it with a reply"
escalation.go maybeEscalate "escalation hop denied by a USD ceiling; keeping the primary flag"
escalation.go maybeEscalate "stage escalated to a fallback model"
events.go beginRunEvents "could not read the stored dispositions for the run-event counters; this run publishes no progress (the run continues; resync channel: `tmctl status --json`)"
@ -92,6 +95,7 @@ status.go Status "config-drift not checked: the bank could not be folded, so dri
status.go Status "config-drift not checked: the rows carry more than one snapshot within a wave (snapshot_drift), so config drift is UNKNOWN, not none"
status.go Status "re-bill projection failed; the re-payment cost of the drift is unknown (reported as unknown, not as none)"
status.go Status "status: no bank could be materialized; the unsigned-term count is unknown, not zero"
status.go Status "status: the estimated share of the committed spend could not be read; it is UNKNOWN, not zero"
status.go Status "the bank fold refused, so the projections below are computed against the glossary the LAST run stored — they are a fact about the past, not a projection of the next run"
terminologist.go glossaryRows "terminology: could not read the bank — whatever this call feeds goes silent (the canon anchor, the bank conflict check, or both), and its zero then means «not asked» rather than «nothing found»"
terminologist.go loadTargetScript "the banknote channel is on but no gates.terminology.target_script is declared: draft-side proposals are NOT screened for the answer language, so a rendering in another script can enter the signature map and the auto-bank"
@ -112,7 +116,7 @@ terminologist.go runTerminologist "terminology: some families were NOT co-batche
terminologist.go runTerminologist "terminology: the TYPE classifier ended short — its OWN budget cut a pass, so some candidates keep the draft heuristic type; the bank's renderings are unaffected and this alone does not make the bank partially consolidated"
terminologist.go runTerminologist "terminology: the model answered in another script; those lines are REFUSED (the terms stay unconsolidated) — a book with few signed rows gives the model no target-language anchor"
terminologist.go runTerminologist "terminology: this bank is PARTIALLY consolidated — a budget cut the RENDER pass, so some terms were never offered to the role at all; `unanswered` below counts them together with terms the role saw and did not answer"
terminologist.go runTerminologist "terminology: this book had already paid for the bank role BEFORE this run, and this run bought the pass again — those earlier calls were made under a batch composition the already-banked filter does not reproduce, so their checkpoints could not be found. This is a ONE-TIME cost; every later run replays for $0"
terminologist.go runTerminologist "terminology: this book had already paid for the bank role BEFORE this run, and this run bought the pass again — the earlier calls' checkpoints could not be reused, either because the batch composition changed (the already-banked filter does not reproduce it) or because they recorded money with no result and cannot be replayed. This is a ONE-TIME cost; every later run replays for $0"
volume.go deliveredUnits <dynamic>
volume.go planVolume "a VOLUME ceiling on a book that MINES its bank: each purchase drafts more, so the bank-mining stop writes a larger auto-bank, and the next purchase moves the edit-wave snapshot the previous purchase's edit jobs are pinned to. Expect that run to need --resnapshot and to re-pay the units the new terms actually touch (the re-payment consent gate still bounds it)"
volume.go rescopeEditWave "the bank moved between planning and the edit wave, so units judged FREE are no longer free: they have been re-judged against the snapshot the edit wave actually uses"

View file

@ -818,6 +818,18 @@ func (r *Runner) rowsResumeFree(rows []store.ChunkStatus, draftNames, editNames
if cs.SnapshotID != cur && !r.repinnable(cs.SnapshotID, cur, w) {
return false
}
// ⛔ THE SAME QUESTION runStage ASKS, ASKED THROUGH THE SAME PREDICATE. A `cancelled` row records
// a stop, not an answer: the resume re-does that call, for money. Read as free, a unit whose last
// row is one needed no slot — so a grant of one unit paid for two and the volume report, which
// speaks only when something was carried, said nothing at all.
//
// ⚠ AND IT IS ASKED LAST, AFTER the switch above has dropped the stages this pipeline no longer
// runs. Asked first — where it was written — it made a unit PAID for a cancelled row of a stage
// that will never be called again, which is the same error in the opposite direction: an operator
// who edits the pipeline over a stopped run would spend volume slots on nothing.
if !resolvedForResume(&cs) {
return false
}
}
return true
}

View file

@ -476,7 +476,7 @@ post-header и оплачены. Три ряда девятки «до заго
| поля утверждены двусторонне | да — `WhitespaceOnly`, `Delivered`, `estimated`, оба условных сообщения |
| тест-тождество §4.4 пинит формулу, не константу | да — запрещённый набор ВЫВОДИТСЯ из формулы: «banned set [238 239 240 899 900 901] → 0 hit(s); control → 2 hit(s)» |
| числа сняты ПОСЛЕ последней правки, со счётом скипов | да — секция «ЧИСЛА» выше |
| таблица мутаций ПОЛНАЯ, выжившие названы поимённо | да — 114 посадок, **выживших НЕТ**, свип 0 из 325. ⚠ Выжившие БЫЛИ и названы поимённо: `CUTCALL-the-redo-doubles-the-budget` (пин перестал держать верное свойство), `CUTBANK-a-burned-batch-counts-as-paid` и `CUTDEADLINE-the-h2-write-bound-is-removed` (оба — мои вакуумные пины). Все три закрыты и пере-проверены поштучной посадкой |
| таблица мутаций ПОЛНАЯ, выжившие названы поимённо | да — 114 посадок, **выживших НЕТ**, свип 0 из 325. ⚠ Выжившие БЫЛИ и названы поимённо: `CUTCALL-the-redo-doubles-the-budget` (пин перестал держать верное свойство), `CUTBANK-a-burned-batch-counts-as-paid` и `CUTDEADLINE-the-h2-write-bound-is-removed` (оба — мои вакуумные пины). Все три закрыты и пере-проверены поштучной посадкой. ⟨Первых двух в дереве БОЛЬШЕ НЕТ: `CUTDEADLINE-…` снята вместе с бондом 10.09, `CUTBANK-…` — когда банковый зонд перешёл на общее определение. Имена — хроника⟩ |
| дифф `^func Test` снят ИСПОЛНЕНИЕМ, прибор назван | да — секция «Дифф `^func Test`» |
| всё живое — в ДЕРЕВЕ, а не в письме | да — 23 пути, все в зоне (`backend/` + своя секция `docs/PROGRESS.md`); вне зоны 0; индекс пуст; `books` не тронуты |
@ -661,23 +661,349 @@ retry-loop 6→3 · bank-and-counters 6→6 · transport-and-config 9→7
Восстановленный файл — снова в скратчпаде, то есть снова в tmpfs; **воспроизводится из журнала за один
прогон, отдельного хранения не требует.**
#### ⚠ ДИСПОЗИЦИИ ОРКЕСТРАТОРА `a8` — НОСИТЕЛЯ В РЕПОЗИТОРИИ НЕ ИМЕЮТ
#### ДИСПОЗИЦИИ ОРКЕСТРАТОРА, ЖИВШИЕ ТОЛЬКО В КАНАЛЕ — ✅ ЗАКРЫТО В ТОТ ЖЕ ДЕНЬ, `D39.231`
Оркестратор смены №23 (`textmachine-a8`) перед смертью сессии закрыл три пинга из четырёх. Его слово
жило в межсессионных сообщениях, то есть нигде: **сессия мертва, канала нет.** Записываю сюда дословно по
смыслу, чтобы следующий оркестратор не решал это заново, — и отмечаю, что ратификации в журнале решений у
этих трёх НЕТ.
Оркестратор смены №23 закрыл три пинга из четырёх **межсессионными сообщениями**. Его слово жило в
канале, то есть нигде: канал лежит в tmpfs и умер вместе с окружением. Записала сюда дословно по смыслу,
чтобы следующий оркестратор не решал это заново, и отдельно объявила, что носителя в репозитории у этих
трёх НЕТ.
**ЭТО ЗАКРЫТО В ТОТ ЖЕ ДЕНЬ — `D39.231`, коммит `aad50a7`, и там же усилена НОРМА:** диспозиция
оркестратора по пингу сессии ратифицируется ИЛИ получает строку носителя **тем же движением, каким
отправляется ответ**; ответ в канале — уведомление о решении, а не само решение. Ниже три пункта
оставлены как были записаны, к каждому добавлен носитель.
**И моя собственная ошибка вывода, снимаю сама:** я написала «сессия мертва» и «пункты без владельца»,
прочитав исчезновение ИМЕНИ `textmachine-a8` из `ListAgents` как смерть смены. Умерло имя — рестарт
окружения его не переживает; **смена №23 и её контекст целы**, тот же оркестратор работает под именем
`textmachine-11`. Отсутствие в списке имён не есть отсутствие сессии — тот же класс, что «ноль строк в
выдаче» против «прибор не спросил существующее».
1. **h2-write-бонд (`transport-and-config#6`, `#9`) — УДАЛЯТЬ.** Принял мой довод против собственного
заказа: механизм рвёт ВСЮ h2-связь, а не застрявший стрим, и для тел, которые движок реально шлёт,
выстрелить не может (замер разводит обещание и предмет на два порядка). Сослался на `D39.216`
«где форма не тянет, её не подпирают»; носитель класса остаётся строкой бэклога **373**.
✅ Ратифицировано `D39.231` п.2.
2. **Вердикт ГЛАВЫ по остановленной позиции (`критик#3`) — забрал СЕБЕ**, продуктовый вопрос владельцу.
✅ Заведён строкой бэклога **374** (`D39.231` п.3) — больше не зависит от того, жива ли чья-то сессия.
3. **`memberDrops` читает `cancelled` как выпавшего члена (`критик#4`) — забрал СЕБЕ**, семантика экспорта.
✅ Заведён строкой бэклога **375** (`D39.231` п.3).
**Пункты 2 и 3 сейчас без владельца:** сессия, обещавшая отнести их владельцу, не существует.
Обе строки, 374 и 375, **исчезнут сами, если денежную половину пака урежут**: они следствия класса
`cancelled`, а не самостоятельные дефекты.
#### Вопрос владельцу и оркестратору — один, и он прежний
#### 10.09 — h2-WRITE-БОНД УДАЛЁН (ратификация `D39.231` п.2). Отдельное движение, наряда НЕ касается
Оркестратор велел снять бонд СЕЙЧАС, не дожидаясь вердикта о останове: предмет независим (бонд — про
таймаут записи на транспорте, останов — про волю человека), ратификация уже есть, а удаление уменьшает
поверхность, а не растит.
**Что ушло вместе с ним — полный список носителей, прибор назван.**
Греп `WriteByteTimeout|h2WriteByteTimeout` по `backend/` (359 go-файлов прочтено) и по `docs/` (120 .md,
без `prompts`/`reports`) дал ЧЕТЫРЕ носителя в коде и ни одного скрытого:
| носитель | что сделано |
|---|---|
| `internal/llm/httpllm.go` — константа `h2WriteByteTimeout` и её 15-строчный комментарий | снята |
| `internal/llm/httpllm.go``h2.WriteByteTimeout = …` в `tuneHTTP2` | снята; доккомментарий «installs the three liveness bounds» → «the keepalive pair» |
| `internal/llm/attemptcut_test.go` — пин `TestTheWriteSideKeepaliveIsDerivedFromTheReadSideOne` ⟨имени в дереве нет: это ПРЕЖНЕЕ имя⟩ | **переписан, не удалён** — см. ниже |
| `cmd/tmmutate/mutations.json``CUTDEADLINE-the-h2-write-bound-is-removed` | снята; заведена замена |
**Пин держал НЕ ТОЛЬКО бонд, и это единственное место, где удаление было не механическим.** Из четырёх
его утверждений три — про бонд (выведенность из read-side пары, ненулевость, установленность на
транспорте) и умирают вместе с предметом. Четвёртое — что `ReadIdleTimeout`/`PingTimeout` стоят на
транспорте, который клиент СТРОИТ, — самостоятельная гарантия, и она бы утекла при простом удалении
файла-теста. ⇒ тест переписан под неё: `TestTheKeepalivePairSitsOnTheTransportTheCloudClientBuilds`,
контроль на ненулевые константы поднят в начало (иначе сравнение нуля с нулём проходит на голом
транспорте). **Объявляю по `D39.183`:** правка теста вызвана ЗАКАЗАННОЙ сменой поведения, а не желанием
зелени; гарантия про бонд снята вместе с бондом, гарантия про keepalive-пару сохранена под новым именем.
**ПОПРАВКА 10.09, вечер: абзац выше был НЕВЕРЕН в момент написания, снимаю сама.** Гарантия НЕ была
сохранена. Тело нового пина брало СВЕЖИЙ клон (`tuneHTTP2(http.DefaultTransport…Clone())`) и утверждало
про него — то есть про транспорт, которым никто не пользуется. Снятие `tuneHTTP2` из боевой конструкции
переживало и пин, и весь пакет; замер круга: 0 из 220 тест-файлов строили облачный клиент и читали его
h2. Это ТОТ ЖЕ дефект, который наряд записал про СТАРЫЙ пин (`transport-and-config#1`), воспроизведённый
мной в замене. Починено: `keepAliveHTTPClient` разделён на себя и `buildCloudClient`, отдающую клиент И
транспорт, на котором поставила границы; пин читает её. Мутация
`CUTKEEPALIVE-the-cloud-client-is-not-tuned-at-all`**RED** текстом «the cloud client could not be
configured for h2 — this test measured nothing».
**Каталог: 325 → 325.** Снятая запись была ЕДИНСТВЕННОЙ, сажавшей мутацию в `tuneHTTP2`; простое
удаление оставило бы пере-писанный пин без носителя в каталоге. Заведена
`CUTKEEPALIVE-the-read-idle-bound-is-not-installed` (`battery: true`), которая сносит установку
`ReadIdleTimeout`. **Это добавление, а не удаление — если оркестратор считает его выходом за рамки
движения, оно снимается одной строкой.**
**ПРЕДЪЯВЛЕНИЕ МУТАЦИЕЙ — три замера на КОПИИ дерева** (`rsync` без `.env*` и `bin/`; контроль копии:
файлов `.env*` внутри **0**, go-файлов скопировано **359**):
1. **Дерево БЕЗ бонда** (как сейчас) → `./internal/llm/` **ok**. Удаление ничего не уронило.
2. **Бонд ВОЗВРАЩЁН в копию** (константа + установка) → `./internal/llm/` **ok**. Ничего не пинит и его
отсутствие. ⇒ бонд был для батареи НЕВИДИМ в обе стороны: он не держал ничего.
3. ⛔ **КОНТРОЛЬ, без которого два зелёных выше означали бы «прибор не спросил»:** посадка новой мутации
тем же инструментом → **RED**, и засчитана ПО ТЕКСТУ, а не по цвету:
`attemptcut_test.go:852: the read-idle bound is not on the transport: got 0s want 15s`.
Тот же пакет, тот же прогон, тот же пин — краснеть он умеет.
**Строка бэклога 373 остаётся** носителем класса «наш дедлайн инертен на h2» (`D39.231` п.2 прямо это
говорит): удалён МЕХАНИЗМ, который класс не закрывал, а не сам класс.
⚠ Прежние записи этой секции про `WriteByteTimeout` (числа §4.4 и находка `transport-and-config#1`)
оставлены как были: они верны на момент, когда писались, и заменяются этой строкой, а не переписыванием.
#### 10.09 — ДВА ПРЕДГЕЙТА НАУЧЕНЫ ЧИТАТЬ ОЖОГ. Заказ оркестратора, отдельное движение
**Поправка к заказу, и она о весе наряда, а не о работе.** Оркестратор передал это как находку
консилиума со словами «этого не видела ни одна линза наряда». Замер: наряд несёт её ТРЕМЯ позициями,
все P0 — `burn-walk#2` (ремонт), `burn-walk#3` (хоп) и их дубль `bank-and-counters#3`, где прямо
записано «два ДРУГИХ зонда про ожог не знают». То есть независимый вердикт ЧТЕНИЕМ пере-открыл то, что
линза нашла исполнением. Для решения о судьбе остальных 29 это улика: наряд и консилиум сошлись на
одном месте, придя к нему разными путями.
**Предмет.** Контракт «чекпойнт есть ⇒ отвечено и оплачено» спрашивают ТРИ предгейта до воронки.
Сожжённый ключ — деньги без результата: воронка его воспроизвести не может, она шагает мимо и покупает
работу заново. Предгейт, читающий такую строку как «уже оплачено», поэтому НЕ пропускает покупку — он её
ВЫПУСКАЕТ, мимо той единственной проверки, которую сам же и охраняет.
| предгейт | было | стало |
|---|---|---|
| `terminologist.go:793` (банк) | `cp != nil && !burnedByCut(cp)` | учён ещё в паке |
| `repair.go:374` | `cp != nil` | `cp != nil && !burnedByCut(cp)` |
| `escalation.go:152` | `fbExists != nil` | `fbExists != nil && !burnedByCut(fbExists)` |
**ВОСПРОИЗВЕДЕНО ИСПОЛНЕНИЕМ ДО ПОЧИНКИ — следствие, а не совпадение строк.** Оркестратор передал
следствие как выведенное чтением и просил проверить. Обе фикстуры написаны первыми и на непочиненном
дереве упали ТЕКСТОМ ПРО ДЕНЬГИ:
- ремонт: `want 2 repair calls, got 3 — one of them was bought outside gates.repair.budget_usd`;
- эскалация: `want 2 hop calls, got 3` (мимо `escalation.budget_usd` И мимо `escMu`).
**Четыре фикстуры, парами.** Каждая правка закреплена ДВУМЯ: одна держит свойство (сожжённый ключ не
покупает), вторая — его противоположность (настоящий ключ по-прежнему воспроизводится бесплатно на
исчерпанном бюджете). Без второй предикат, отвечающий «не оплачено» на всё, прошёл бы первую и тихо
выключил контракт бесплатного резюма, который `escalation.go` обещает в своём же доккомментарии.
**Как строится ожог — единственное тонкое место, и первая редакция была ФЛЕЙКОВОЙ.** Наш денежный
предикат книжит оборванный вызов только после подтверждения провайдером, поэтому сервер, который просто
держит молчащее соединение, даёт строку на $0 — настоящую, но не ту. Первая редакция слала заголовки,
флашила и внешним сигналом отменяла прогон. Замер: **2 прогона из 5** отменялись раньше, чем клиент
разобрал заголовки, вызов читался как неотвеченный и книжился в ноль — то есть фикстура была бы зелёной,
не измеряя ничего. Заменено на обрыв, которым правит СЕРВЕР (`connection_lost` жжёт ключ ровно так же и
внешней синхронизации не требует): 8 прогонов из 8 одинаковы.
**Мутации — по одной на правку, засчитаны ПО ТЕКСТУ:**
`CUTBURN-the-repair-pregate-forgets-the-burn` и `CUTBURN-the-escalation-pregate-forgets-the-burn`,
⟨обе ПОЗЖЕ СНЯТЫ из каталога тем же днём: когда зонд стал ОДНИМ определением, их предмет — по-строчная
правка на каждой площадке — перестал существовать, и их место заняли пять записей семьи. Имена оставлены
здесь как хроника, в дереве их нет⟩
обе `battery: true`, обе **RED** своим тестом, и текст падения называет свежий вызов, а не транспорт.
Каталог 325 → 327, подмножество battery 114 → 116.
**Знаменатель класса — прибор спросил существующее.** `GetCheckpoint` живёт в **10** местах живого кода
(прочитано 360 go-файлов): 4 — вопрос «оплачено ли» (три предгейта + воронка `stagerun.go:492`), все
учены; 6 читают `cs.FinalHash`, то есть указатель на АВТОРИТЕТНЫЙ ответ, а сожжённая строка им стать не
может — путь обрыва пишет `FinalHash: ""` и флагует позицию. ⇒ **четвёртого необученного читателя нет**,
строка бэклога по правилу остановки не понадобилась.
⚠ Правило остановки соблюдено: движение — два предиката и их пины, больше ничего. Наряд не начат.
#### ⛔ 10.09 — АДВЕРСАРИАЛЬНЫЙ КРУГ ПО СВОЕМУ ЖЕ ДВИЖЕНИЮ: МОЯ ПОЧИНКА БЫЛА РАЗМЕНОМ
Круг: 4 направленные линзы, у каждой свой опровергатель, плюс критик полноты. **Заявлено 20, пережило
опровержение 10, плюс 2 у критика.** Главная находка — про починку, которую я сдала часом раньше как
сделанную, при зелёной батарее и красных мутациях.
**ЧТО БЫЛО НЕ ТАК.** Оба зонда — и мои два, и банковский, с которого я их писала, — спрашивали
**фиксированный индекс попытки**. Воронка на ожоге не останавливается: она уходит на следующий индекс при
том же бюджете и покупает ТАМ. Значит после оборванного прогона позиция читается «attempt 0 сожжён,
attempt 1 ОПЛАЧЕН и отвечен». Зонд, смотрящий на стартовый ключ, видит ожог, отвечает «не оплачено» — и
вызывающий, не найдя бюджета, **выбрасывает уже купленный перевод**. Навсегда, на каждом резюме.
**Доказано откатом ОДНОЙ строки на копии, не рассуждением:**
| дерево | «ожог не покупает» | «оплаченное за ожогом реплеится» |
|---|---|---|
| ДО моей починки | **FAIL** | **PASS** |
| ПОСЛЕ моей починки | **PASS** | **FAIL** |
⇒ два дефекта, один бит, противоположные стороны. **Третий раз за смену эта форма** (раньше — $0.80 на
отказах провайдера).
**Починка правильной формы: чинить ВОПРОС, а не ответы.** Одно определение `paidAfterBurns`
(`internal/pipeline/cutcall.go:196`) шагает ожоги ровно как воронка и отвечает про тот ключ, который
воронка возьмёт. Три площадки зовут его: `escalation.go:141` · `repair.go:373` · `terminologist.go:792`.
Два вопроса, заданные одним способом, разойтись не могут — в этом смысл функции против трёх площадок,
каждая из которых «шагает правильно» (`D39.216` п.3б).
**Третий член семьи был поражён тем же корнем, и это код ПАКА, а не сегодняшнего движения:** банковский
зонд, тот самый «образец». Идёт в наряд НОВОЙ позицией.
**Семья предъявлена целиком — три члена, обе стороны бита, шесть пинов:**
| член | «ожог не покупает» | «оплаченное за ожогом реплеится» |
|---|---|---|
| `escalation.go:141` | `TestABurnedHopKeyDoesNotBuyAHopOutsideTheEscalationBudget` | `TestAPaidHopBehindABurnedKeyStillReplaysFree` |
| `repair.go:373` | `TestABurnedRepairKeyDoesNotBuyARepairOutsideTheSubBudget` | `TestAPaidRepairBehindABurnedKeyStillReplaysFree` |
| `terminologist.go:792` | `TestTheBankPaidProbeSeesThroughABurnedCheckpoint` | `TestTheBankProbeFindsThePaidBatchBehindABurnedKey` |
**Каталог: 3 протухших якоря сняты, 6 заведено** (327 → 330, battery 116 → 119). Две на сам контракт
(`ignores-the-burn` краснит три «не покупает», `stops-at-the-first-key` — три «реплеится»), три на то,
что каждая площадка им пользуется, одна на тюнинг боевого клиента. Все **RED по тексту**.
**Первая редакция двух записей каталога НИЧЕГО НЕ УТВЕРЖДАЛА:** список тестов через запятую там, где
`-run` берёт регексп. Поймал инструмент, дословно: «the run filter … matched no test — it was renamed or
removed, and this entry has been asserting nothing». Переписано альтернацией, пере-проверено посадкой.
**Остальные находки круга — все пять починены в этом же движении:**
1. комментарий лока в `escalation.go` описывал прежний код и называл мёртвую переменную;
2. обе Real-фикстуры не утверждали СВОЮ ГЛАВНУЮ ПРЕМИССУ («на исчерпанном бюджете») — с холостым
exhaust-хелпером оставались зелёными, то есть держали не то, что обещали именем;
3. burned-фикстура ремонта при сломанной премиссе обвиняла ПРЕДГЕЙТ в выпуске платного вызова мимо
суб-бюджета — правый цвет, неправый текст, и следующая смена пошла бы чинить `repair.go`;
4. мёртвые поля `arrived`/`once` — остаток снятой флейковой схемы, с доккомментарием о несуществующем
назначении; ни `vet`, ни `gofmt` их не видят;
5. пин keepalive не читал боевой клиент — см. поправку выше по тексту.
**И собственная ошибка замера, названная тут же:** проверяя пункт 2, я делала холостыми ОБА
exhaust-хелпера, но патч ремонтного не применился из-за экранирования — ремонтные фикстуры прошли
законно, и я чуть не записала «премисса не срабатывает». Пере-делала: все три падают текстом
«premise broken: the repair sub-budget is NOT exhausted (spent 0.001063, budget 1.000000)».
**Что этот круг доказал про сам метод.** Знаменатель читателей был ВЕРЕН — их правда три. Дефект сидел
не в их числе, а в ФОРМЕ ВОПРОСА, одинаковой у всех трёх, и никакой счёт читателей его не ловит. Ловит
направленный второй читатель. ⇒ знаменатель закрывает одну ОСЬ, а не работу.
#### 10.09 — НАРЯД ОТРАБОТАН ЦЕЛИКОМ: 36 позиций, 34 сделано, 2 пингом
Слово владельца через оркестратора: доводить наряд своей рукой. Отработан целиком, по семьям, в порядке
«деньги → числа → строки». **Единственный носитель исходов — сам наряд**
(`docs/archive/reports/CUT_CALLS_DOFIX_WORK_ORDER_2026-09-08.md`, правится по зонному исключению в его
шапке): у каждой позиции поля `исход` и `предъявлено`, и сверяются они машинно, а не глазами.
```
позиций в наряде: 36 (P0=12 P1=12 P2=8 PING=4)
ИСХОД НЕ ИЗ ТРЁХ: 0 · «сделано» БЕЗ поля «предъявлено»: 0
ЗАКРЫТО 34 из 36 · ИТОГ: все позиции имеют исход и предъявление
```
**Каждая семья закрыта ОДНИМ контрактом, а не пачкой правок** — это и есть ответ на «семь кругов не
сходились». Знаменатель каждого класса посчитан командой, а не памятью:
| класс | контракт | закрыт |
|---|---|---|
| читатели денежного контракта | одно определение `paidAfterBurns` шагает ожоги ровно как воронка | **3 из 3** (`grep -c 'r.paidAfterBurns('` = 3) |
| выходы цепочки `retryLoop` | `moreOwed` копит, `chainError` выносит, решение по ДЕНЬГАМ, а не по типу | **4 из 4** (все четыре `return` идут через него) |
| носители `resolvedForResume` | один предикат на все вопросы «это уже ответ?» | **3 площадки + четвёртый спрашивающий через третью** (`projectBookUSD``resolveChunkState`) |
| инвариант строки чанка | строка сходится с суммой чекпойнтов СВОЕЙ позиции на ЛЮБОМ выходе | **4 фикстуры** держат `assertRowsMatchTheLedger` |
**Что нашлось по ходу и чего в наряде не было:**
1. **Моя же починка предгейтов оказалась РАЗМЕНОМ** — доказано откатом одной строки: до неё «оплаченное
за ожогом реплеится» проходило, а «ожог не покупает» падало; после — ровно наоборот. Третий раз за
смену один бит в две стороны.
2. **Наряд ПРЕДСКАЗАЛ эту регрессию** позицией `bank-and-counters#2`: «зонд зашит на попытку 0, а обход
ожога идёт по возрастающим индексам… зонд должен спрашивать ту же ось, что и обход». Я чинила по
заказу письмом, не открыв наряд.
3. **Знаменатель, посчитанный по именам функций, — не знаменатель.** «Пять глаголов со словом
`Checkpoint`» превратилось в **12 функций**, когда прибор спросил ТАБЛИЦУ, а не словарь имён.
4. **Мой пин инварианта был флейковым: 2 красных из 8 на мутанте** — отмена обгоняла разбор заголовков,
обрыв выходил на $0, строка сходилась «ноль к нулю». Перестроен так, что деньги НЕИЗБЕЖНЫ.
5. **Две мутации СНАЧАЛА ВЫЖИЛИ** (ремонтная и счётная) — носителей не было вовсе, и без посадки я бы
этого не узнала.
6. **Снята СВОЯ недостижимая ветка** в накопителе цепочки и **чужая недостижимая константа**
`FlagConnectionLost`обе выглядели стражами и не могли выстрелить.
7. **Шесть чужих якорей каталога** протухли от моих правок (один разорван моим же комментарием) —
пере-нацелены поштучно, каждый пере-проверен посадкой. Каталогизированный ВЫЖИВШИЙ с доводом «ветка
недостижима» не тронут: это улика, а не протухший якорь.
**Заказанные смены поведения, объявляю отдельно (`D39.183`):**
- голден операторских сообщений обновлён ОДНОЙ строкой (126 → 126) — формулировка причины повторной
оплаты банка изменена заказанной правкой руг8#1`;
- пин write-бонда заменён пином keepalive-пары — предмет удалён ратификацией `D39.231` п.2;
- поле `run.attempted` (счётчик) заменено на `run.ran []bool` — счётчик не может описать несплошное
множество приёма партий.
#### ⛔ 10.09 — ОХОТНИК ОРКЕСТРАТОРА: ДВА ВЫЖИВШИХ МУТАНТА, ОБА НА ЛОЖНЫХ ПРЕДЪЯВЛЕНИЯХ
Верификатор «вне карты» по сданной работе. Блокеров нет, батарея и каталог у него сошлись с моими. Но две
посадки пережили батарею, и обе — не новые предметы, а **утверждения о закрытии, которые не держались**.
Обе воспроизвела своим прогоном, прежде чем классифицировать.
**1. Выход по отмене — ЛОЖНОЕ «предъявлено» позиции наряда.** Снятие `cancelledDuring` на ПЕРВОМ выходе
(`httpllm.go:199`) оставляло батарею зелёной, при том что близнец на `:222` краснел. Позиции
`money-predicate#1` и `bank-and-counters#4` утверждали «оба выхода идут через `chainError`» и называли
пин, где стоит проверка `errors.Is(err, context.Canceled)` именно про этот выход.
**Механизм — класс, которого у нас не было: пин удовлетворялся ЧУЖОЙ уликой.** Фикстура гонит петлю
ЧЕРЕЗ ПРОВОД, а там стоп приходит по вызову в полёте — значит ошибка самой попытки уже родительски-
отменённый обрыв, несущий `context.Canceled` в поле `Parent`. Замер на мутанте: `isCanceled=true
cause=connection_lost` в **6 прогонах из 6**. То есть `errors.As` находил обрыв ПЕРВОЙ попытки, а
`errors.Is` — отмену внутри ВТОРОЙ. **Это не флейк, а детерминированная пустота: повторный прогон такое
не ловит.** ⇒ на мутанте спрашивать надо не «покраснело ли», а ЧТО ИМЕННО удовлетворяло утверждение.
Починка: `TestTheStopExitStillReadsAsCancelledWhenTheAttemptDidNot` гонит `retryLoop` НАПРЯМУЮ — попытка
падает обычной 503, стоп приходит на её возврате, и опереться не на что, кроме самого выхода. Провод
такой порядок создать не может: окно между возвратом попытки и чтением контекста в несколько инструкций,
и фикстура, гоняющаяся за ним, мерила бы планировщик. Заодно закрыта связка «новый тип ↔ код выхода»:
`AttemptCutError` давал **0 хитов** в тестах `cmd/tmctl` при **26** вызовах отображения — заведён
`TestTheCutErrorTypeKeepsItsExitCode` (пять форм + контроль «потолок старше обрыва»). Мутации
`CUTCHAIN-the-stop-exit-drops-the-cancellation` и `CUTEXIT-a-stop-stops-mapping-to-five` — RED.
**2. Вендорская пара, из которой считается КАЖДЫЙ дедлайн — ложное предъявление §4.4 ПАКА.**
`vendorHourlyTokenBudget` можно учетверить (128000 → 512000), и зелены и `internal/llm`, и
`internal/config`, и вся батарея. Причина: `vendorSeconds` (тест) берёт ожидание из ТЕХ ЖЕ
внутрипакетных констант, что и `deriveDeadline`, — порча двигает обе стороны, и тождество сходится.
Пин держал РАСПОЛОЖЕНИЕ формулы и не держал ЧИСЛА, а §4.4 требовала неформальной исполнимости.
**Классификацию я сначала дала В СВОЮ ПОЛЬЗУ и тут же привела довод против себя.** Разбор всех 36
полей «предъявлено» показал: пин деривации не назван ни одной позицией наряда ⇒ по букве правила
остановки это строка бэклога. Но цена денежная и ровно про предмет пака — вчетверо короче дедлайн
означает, что живые генерации становятся self-cut'ами, за которые движок теперь ПЛАТИТ, и пять
провайдеров из восьми сидят на этом дефолте. Оркестратор пере-провёл СВОЮ границу («опровергает любое
утверждение о закрытии, которое мы вот-вот ратифицируем, а не только позицию наряда») и вернул починку
в круг.
Починка: `TestTheVendorsPublishedPairIsWhatTheVendorPublishes` цитирует вендорскую пару против источника
(`CalculateNonStreamingTimeout`: час на 128 000 токенов), с контролем «грант ровно в бюджет обязан
вывестись ровно в окно» — иначе константы были бы украшением рядом с деривацией, а не её источником.
⚠ Это НЕ нарушает `TestTheDeadlineTestQuotesNoDeadline`: тот банит ПРОИЗВОДНЫЕ секунды (его собственный
банлист печатается прогоном: `[238 239 240 899 900 901]`), а вендорская пара — исходные числа, то есть
единственное место, где арифметика касается внешнего мира. Мутации `CUTDEADLINE-the-vendor-budget-moves`
и `-window-moves` — RED. **И этикетка прибора исправлена тем же движением:** комментарий `vendorSeconds`
утверждал о себе «a re-derivation from the source numbers and not a copy of the code under test» —
теперь он говорит, что пинует РАСПОЛОЖЕНИЕ и не пинует числа, и называет, кто пинует их.
#### 10.09 — ЧИСЛА ПОСЛЕ ВСЕХ ПОЧИНОК. Работа завершена, править не планирую
Сняты ПОСЛЕ последней правки, по одному прогону за раз (параллельный запуск ронял машину по памяти):
```
make battery → MAKE-EXIT=0 · 19 ok · 0 FAIL · 4 «no test files» · 4 скипа, названы:
TestMinerFullBookParity · TestCorpusBankKeyConflicts
TestHelperEventsRun · TestHelperKillLoop (тот же список, что в baseline смены)
make mutations → MAKE-EXIT=0 · 149 посадок · 149 RED · 0 выживших
0 NOTHING · 0 ROTTED · 0 INCONCLUSIVE · якорей протухших 0 из 360
counts.py → литералы сходятся (8 проверок)
```
Каталог за пак: **276 → 360**, батарейное подмножество **65 → 149**. Дерево: 28 путей, вне зоны 0.
**Самопроверка отчёта, механическая.** Из полей «предъявлено» наряда вынуто 25 имён тестов и 24
идентификатора мутаций: **несуществующих ноль** (контроль: тестов в дереве 1369, записей в каталоге 360).
Все 24 — в батарейном подмножестве, с `run`-фильтром, и **все 24 покраснели** в финальном прогоне.
**Тот же прибор нашёл в ЖУРНАЛЕ пять имён без предмета** — прежний пин write-бонда и четыре записи
каталога, снятые по ходу смены. Все пять были верны на момент записи, но читатель, грепнувший имя,
не нашёл бы его и решил, что отчёт лжёт. ⇒ каждое помечено как хроника прямо на месте, с указанием,
когда и почему предмет исчез. Непомеченных: 0.
**Инцидент со своим деревом — назван и пере-проверен ДВУМЯ приборами.** `cd` в несозданный каталог
провалился, `set -e` не удержал, и мутация ушла в настоящее дерево; поймана следующей командой и
восстановлена. Пере-проверка не грепом по порче (у мутаций-усечений порча есть ПРЕФИКС цели и
присутствует всегда), а поиском пропавшей ЦЕЛИ: **370 правок каталога в 62 файлах, целей не на месте —
ноль**. Оркестратор снял то же число своим прибором независимо. Норма записана в `CLAUDE.md`
(`6ee6c61`): копия под мутацию защищается ПОСТРОЕНИЕМ — `test -f go.mod` плюс сверка `pwd` перед любой
правкой, копия вне общего скретчпада.
#### Вопрос владельцу и оркестратору — один, и он ЖДЁТ вердикта о мягком останове
Чинить ли 31 позицию наряда моей рукой в этом контексте. Довод ПРОТИВ я предъявила сама и снимать его не
буду: семь кругов, частота находок не падает (7 → 3 → 2 → 2 → 3 → 29), шесть из семи находили дефекты в
@ -685,6 +1011,13 @@ retry-loop 6→3 · bank-and-counters 6→6 · transport-and-config 9→7
заменяет пак, а канон прямо разрешает дофикс по СВОЕМУ паку отработавшей сессии («отработавшей пишут
ТОЛЬКО по её же паку — вопросы, ревью, диспозиции, дофиксы»).
**Но отвечать на него сейчас НЕЛЬЗЯ, и причина не в моём контексте.** Владелец предложил конструкцию,
которой в паке не было: **два останова** — жёсткий гасит всё немедленно, мягкий не рвёт летящие вызовы
(его довод: обрывать соединение и посылать заново значит терять до трети стоимости). Если пользовательская
остановка перестанет рвать вызовы, класс «деньги без ответа» не возникает вовсе — и чинить придётся не 31
позицию, а другое их число. ⇒ **вопрос ждёт вердикта консилиума** (`D39.231` п.4), правки не начаты,
дерево не трогается.
⚠ И отдельно — **против отката денежной части**, если он будет рассматриваться. Откат разведения
предикатов вернул бы состояние, где отклонённые провайдером запросы становятся ПЛАТНЫМИ (замер: 22 из 25,
$0.80 на боевом пути) — списание с читателя за вызов, которого никто не выполнял. Это направление

View file

@ -8,6 +8,32 @@
> **Довод против раздвоения:** вести поправки отдельно от наряда значит завести второй носитель одного
> знания — ровно то, из-за чего сегодня терялись решения (`D39.216` п.3б).
> ⛔ **АМЕНДМЕНТ 10.09 (шаг 0 наряда работ). Наряд писан ДО вердикта консилиума; ниже он приведён в
> соответствие, и КАЖДАЯ правка помечена как правка.** Внесено движковой сессией по зонному исключению
> выше. Что изменилось:
> **(1)** вписан ЗНАМЕНАТЕЛЬ — новый раздел «Знаменатель класса» перед «ЧТО ЧИНЮ»;
> **(2)** позиции получили поля `исход` и `предъявлено` — по ним сверяется завершённость, машинно;
> **(3)** P0 размечены по СЕМЬЯМ (читатели контракта · выходы цепочки `retryLoop` · локальные) — семья
> решает порядок работы, потому что чинить членов семьи поодиночке уже пробовали семь раз;
> **(4)** отмечено, что КНОПКА — не главный источник отмен, и это меняет вес нескольких позиций;
> **(5)** заведена ОДНА новая позиция, которой в наряде не было: руг8#1`.
>
> ⛔ **И вторая поправка, более важная: наряд НЕС форму правильной починки, а я её не прочла.** Восьмой
> круг нашёл, что все три зонда спрашивают ФИКСИРОВАННЫЙ индекс попытки, тогда как воронка шагает ожоги и
> покупает на следующем. Это ровно позиция `bank-and-counters#2`, P1, где дословно записано: «Зонд зашит
> на попытку 0, а обход ожога идёт по ВОЗРАСТАЮЩИМ индексам… Зонд должен спрашивать ту же ось, что и
> обход». ⇒ **наряд ПРЕДСКАЗАЛ регрессию, которую внесла моя починка**, а я чинила по заказу письмом, не
> открыв наряд. Если бы работа шла по наряду в его порядке, верная форма была бы названа ДО написания
> неверной. Это второй за день довод в пользу наряда как носителя — и первый, где цена невнимания к нему
> измерена: одна регрессия, пойманная только направленным вторым читателем.
>
> ⚠ **Поправка к тому, как этот наряд был передан.** Заказ на два предгейта пришёл со словами «этого не
> видела ни одна линза наряда». Это НЕВЕРНО, и оркестратор поправку принял: наряд несёт находку ТРЕМЯ
> позициями — `burn-walk#2`, `burn-walk#3` и дубль `bank-and-counters#3`, где дословно стоит «два ДРУГИХ
> зонда «чекпойнт есть ⇒ уже оплачено» про ожог не знают». ⇒ **консилиум пере-открыл ЧТЕНИЕМ то, что
> линза нашла ИСПОЛНЕНИЕМ.** Это улика в пользу НАРЯДА, а не в пользу консилиума, и она же объясняет,
> почему в заказе была переоценена новизна находки.
> ⚠ **РЕВЬЮ-ШАПКА ОРКЕСТРАТОРА (перенос в репозиторий 08.09).** Это наряд СЕДЬМОГО адверсариального круга
> движковой сессии `textmachine-79` по её же дофиксу пака «ВЫЗОВ, КОТОРЫЙ ОБОРВАЛИ МЫ». Перенесён
> ДОСЛОВНО и без правок: он жил в `/tmp` и умер бы вместе с сессией, а по канону доказательная база
@ -66,11 +92,65 @@
«дубль» и чинятся вместе. Дубли НЕ выброшены: каждая оставлена со своим ключом, чтобы при проверке
было видно, что ни одна не потеряна.
## Знаменатель класса — ВПИСАН АМЕНДМЕНТОМ 10.09, в наряде его не было
Наряд перечислял дефекты, но не отвечал на вопрос «сколько их вообще может быть». Вердикт консилиума
ответил «читателей контракта три»; пере-проверка исполнением показала, что это верно **про один глагол**
и ложно про предмет. Формулировка, утверждённая оркестратором дословно:
> **денежных читателей контракта ТРИ, закрыты 3 из 3; читателей таблицы `checkpoints` — двенадцать
> функций, ожог лжёт четырём площадкам в двух классах.**
**Голое «три» публиковать нельзя:** следующая смена прочтёт его как «мест всего три» и не пере-проверит.
**Как считалось.** Не по именам функций, а по обращению к таблице: `checkpoints` трогают **12** функций
`store` (прибор прочёл 12 нетестовых файлов `internal/store`). Счёт по именам дал бы 5 и пропустил
`EscalationHops` · `EscalationSpentUSD` · `RoleResponsesForBook` · `SpendByModel` · `ResetChunkStages`
у них предмета в названии нет.
| читатель | площадок | врёт ли ему ожог |
|---|---|---|
| `GetCheckpoint` | 10 | **3 денежных предгейта — ДА (закрыты 3 из 3)** · 2 воронка (определение) · 5 под `FinalHash` — недостижимы |
| `HasCheckpointForStage` | 1 | **ДА, но не деньгами, а ПРИЧИНОЙ в строке оператора** → позиция руг8#2` |
| `RoleResponsesForBook` | 1 | НЕТ: зовётся с непустым `mustContain`, запрос фильтрует `instr(response_text, ?) > 0`, пустой текст ожога не совпадёт никогда |
| `EscalationHops` | 1 | НЕТ: его док — «counts the escalation CALLS a book has PAID for», а ожог именно оплаченный вызов |
| `CheckpointUsageForBook` | 3 | НЕТ: ожог несёт настоящие деньги при нулевых токенах ⇒ попадает в оценочную долю, как задумано |
| `RepairStats` | 1 | НЕТ: ожог — настоящий вызов; `declined` матчит по сентинелу, пустой текст ≠ сентинел |
| `EscalationSpentUSD` · `RoleSpentUSD` · `SpendByModel` | 1 · 2 · 1 | НЕТ: суммируют деньги, а деньги ожога настоящие |
**Пять площадок под `FinalHash` недостижимы ТРЕМЯ независимыми доводами, а не одним** (общий довод на
пять случаев был бы слабее любого из них): все пять под охраной `Disposition == ok`; писателей `FinalHash`
ровно ДВА, и `cutcall.go:171` пишет пустую строку явно, а `stagerun.go:321` берёт значение, которое
инициализировано `""` и присваивается только под `DispOK`; и даже на `DispOK` берётся хеш ОТВЕТИВШЕЙ
попытки, потому что прогулка по ожогу уже увела индекс. Исполнением это держит существующая фикстура
`TestABurnedCheckpointIsNeverReadAsAnAnswer` (`cutcall_test.go:802`) — второй носитель не заводился.
**Расхождение счёта, названное, а не «исправленное»:** у `RoleSpentUSD` вызывающих в движке **2**
(`terminologist.go:916`, `rebill.go:557`), а площадок вообще **3** — третья это `store/ledger.go:102`, тело
`RepairSpentUSD`, то есть глагол, выраженный через глагол. Оба числа верны о разных вопросах.
**И ГЛАВНОЕ, что показал восьмой круг: знаменатель закрывает одну ОСЬ, а не работу.** Читателей правда
три — а дефект сидел не в их ЧИСЛЕ, а в ФОРМЕ ВОПРОСА, одинаковой у всех трёх. Никакой счёт читателей
его не ловит; поймал направленный второй читатель. ⇒ находка вида «дефект одинаков у всех членов семьи»
означает, что неверен ВОПРОС, а не позиции, и чинится определением, а не пунктами наряда.
## Источники отмен — ВПИСАНО АМЕНДМЕНТОМ 10.09
**Кнопка — не главный источник `cancelled`, и это меняет вес позиций.** Волна гасит собственный контекст
изнутри на первой же ошибке или панике (`waverun.go:404`, `:414` — единственные `cancel()` вне `defer` при
140 нетестовых файлах), то есть ОДНА инфра-ошибка в одном воркере метит `cancelled` все летящие вызовы
соседей. Позиции, чья мотивация в наряде звучала как «это редкий случай нажатия кнопки», надо читать как
класс, который приходит пачками. Мягкий останов (обсуждается владельцем) делает вторую половину РЕДКОЙ,
а не ненужной.
## ЧТО ЧИНЮ — по приоритету, в порядке работы
### P0 · `bank-and-counters#1` — Ожог в банковой партии режет УЖЕ ОПЛАЧЕННЫЕ партии: проход, стоящий $0.000000, дропается целиком
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Приём партий переведён с ПРЕФИКСА на попартийное решение (`terminologist.go:941-970`): отказ в одной партии больше не уносит те, что позади и стоят $0. Поле `run.attempted` (счётчик «сколько с начала») заменено на `run.ran []bool` — счётчик не может описать несплошное множество, и оба его читателя (`:487`, `:667`) переведены с `break` на `continue`. Попутно снято мёртвое `paidBatch` (писалось, никем не читалось). Пин `TestAnUnaffordableBatchDoesNotTakeThePaidBatchesBehindIt` с контролем «провайдера не спросили ни разу»; мутация `CUTBANK-admission-is-a-prefix-again` RED текстом «an ALREADY-PAID batch sitting behind a refused one was dropped»
- **семья:** читатели контракта ⟨вписано амендментом 10.09⟩
- **где:** `internal/pipeline/terminologist.go:947-961`
- **как:** Префиксный `break` роняет УЖЕ ОПЛАЧЕННЫЕ партии, когда одна из ранних сожжена: проход, стоящий $0, дропается целиком. Пропускать сожжённую, а не обрывать префикс.
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
@ -82,6 +162,9 @@
### P0 · `bank-and-counters#3` — Два ДРУГИХ зонда «чекпойнт есть ⇒ уже оплачено» про ожог не знают: перепокупка уходит мимо суб-бюджета эскалации и ремонта
- **действие:** ЧИНЮ
- **семья:** читатели контракта ⟨вписано амендментом 10.09⟩
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** дубль `burn-walk#2` и `#3`, закрыт той же правкой — одним определением на три площадки, а не двумя строками
- **где:** `escalation.go:146, repair.go:368`
- **как:** дубль burn-walk#2 и #3, чинится теми же двумя правками
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
@ -93,6 +176,9 @@
### P0 · `bank-and-counters#4` — Доставленный обрыв ТЕРЯЕТСЯ на обоих выходах retryLoop по отмене: до вызывающего не доходит ни Deliveries, ни сам обрыв
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** дубль `money-predicate#1`: оба выхода по отмене теперь идут через тот же `chainError`. Предъявлено тем же пином и мутацией `CUTCHAIN-the-stop-exit-drops-what-is-owed`**ПОПРАВКА 10.09, вечер: прежнее предъявление было ЛОЖНЫМ, снимаю сама.** Охотник оркестратора посадил снятие `cancelledDuring` на ПЕРВОМ выходе по отмене (`httpllm.go:199`) — батарея осталась зелёной, воспроизвела сама. Механизм: мой пин гонит петлю ЧЕРЕЗ ПРОВОД, и там стоп приходит по вызову в полёте, поэтому ошибка самой попытки — уже родительски-отменённый обрыв, несущий `context.Canceled` в поле `Parent`. Утверждение «прогон обязан читаться как отменённый» удовлетворялось ЧУЖОЙ уликой, а не тем выходом, который позиция объявляла закрытым (замер: `isCanceled=true cause=connection_lost` в 6 прогонах из 6 на мутанте). ⇒ заведён `TestTheStopExitStillReadsAsCancelledWhenTheAttemptDidNot`: гонит `retryLoop` НАПРЯМУЮ, попытка падает обычной 503 и стоп приходит на её возврате — опереться не на что, кроме самого выхода. Провод такой порядок создать не может: окно между возвратом попытки и чтением контекста в несколько инструкций, и фикстура, гоняющаяся за ним, мерила бы планировщик. Мутация `CUTCHAIN-the-stop-exit-drops-the-cancellation` RED. Заодно закрыта названная охотником связка «новый тип ↔ код выхода»: `AttemptCutError` давал **0 хитов** в тестах `cmd/tmctl` при 26 вызовах отображения — заведён `TestTheCutErrorTypeKeepsItsExitCode` (пять форм плюс контроль «потолок всё ещё старше обрыва»), мутация `CUTEXIT-a-stop-stops-mapping-to-five` RED.
- **семья:** выходы цепочки retryLoop ⟨вписано амендментом 10.09⟩
- **где:** `internal/llm/httpllm.go:211 и :234`
- **как:** дубль money-predicate#1: оба выхода по отмене зовут cancelledDuring БЕЗ withEarlierCut, поэтому доставленный обрыв до вызывающего не доходит.
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
@ -104,6 +190,9 @@
### P0 · `burn-walk#1` — Деньги сожжённых ключей ТЕРЯЮТСЯ на свежем вызове: burnedCost перезаписывается, а не прибавляется
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Семья закрыта ОДНИМ инвариантом, предъявленным на всех трёх: строка чанка обязана сходиться с суммой чекпойнтов СВОЕЙ позиции на ЛЮБОМ выходе. Носитель — `assertRowsMatchTheLedger` с контролем «прибору дали что сравнивать» (иначе «расхождений нет» на пустом леджере читается как успех). Обе ветки (`stagerun.go:717` billed-decode и `:789` свежий вызов) прибавляют `burnedCost`, а не затирают его. Пин `TestABurnedKeysMoneyReachesTheRow` (два обрыва подряд — один не даёт ожога, его ретраит транспорт; ожог рождается на КАПЕ второго — затем резюм). Мутация `CUTROW-the-burn-money-is-overwritten` RED: «says it cost 0.002000 while its own checkpoints add up to 0.003056» ⛔ **ПОПРАВКА 10.09, вечер: прежнее предъявление было ЛОЖНЫМ, снимаю сама.** Охотник оркестратора посадил снятие `cancelledDuring` на ПЕРВОМ выходе по отмене (`httpllm.go:199`) — батарея осталась зелёной, воспроизвела сама. Механизм: мой пин гонит петлю ЧЕРЕЗ ПРОВОД, и там стоп приходит по вызову в полёте, поэтому ошибка самой попытки — уже родительски-отменённый обрыв, несущий `context.Canceled` в поле `Parent`. Утверждение «прогон обязан читаться как отменённый» удовлетворялось ЧУЖОЙ уликой, а не тем выходом, который позиция объявляла закрытым (замер: `isCanceled=true cause=connection_lost` в 6 прогонах из 6 на мутанте). ⇒ заведён `TestTheStopExitStillReadsAsCancelledWhenTheAttemptDidNot`: гонит `retryLoop` НАПРЯМУЮ, попытка падает обычной 503 и стоп приходит на её возврате — опереться не на что, кроме самого выхода. Провод такой порядок создать не может: окно между возвратом попытки и чтением контекста в несколько инструкций, и фикстура, гоняющаяся за ним, мерила бы планировщик. Мутация `CUTCHAIN-the-stop-exit-drops-the-cancellation` RED. Заодно закрыта названная охотником связка «новый тип ↔ код выхода»: `AttemptCutError` давал **0 хитов** в тестах `cmd/tmctl` при 26 вызовах отображения — заведён `TestTheCutErrorTypeKeepsItsExitCode` (пять форм плюс контроль «потолок всё ещё старше обрыва»), мутация `CUTEXIT-a-stop-stops-mapping-to-five` RED.
- **семья:** локальные ⟨вписано амендментом 10.09⟩
- **где:** `internal/pipeline/stagerun.go:784 и :713`
- **как:** В обеих ветках писать `burnedCost + cost` / `burnedCost + estimate` вместо голого присваивания. Пин: прогон с ДВУМЯ сожжёнными ключами подряд, сверка chunk_status.cost_usd с SUM(checkpoints.cost_usd) SQL-запросом.
- **тяжесть после опровержения:** correctness · **источник:** линза+опровергатель
@ -115,6 +204,9 @@
### P0 · `burn-walk#2` — Сожжённый ключ РЕМОНТА читается как «уже оплачено» — суб-бюджет repair обходится целиком
- **действие:** ЧИНЮ
- **семья:** читатели контракта ⟨вписано амендментом 10.09⟩
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** одно определение `paidAfterBurns` (`cutcall.go:196`) + `repair.go:373`; пины `TestABurnedRepairKeyDoesNotBuyARepairOutsideTheSubBudget` и `TestAPaidRepairBehindABurnedKeyStillReplaysFree`; мутации `CUTBURN-the-paid-contract-ignores-the-burn`, `…-stops-at-the-first-key`, `…-the-repair-pregate-asks-a-fixed-index` — все RED по тексту. Воспроизведено красным ДО починки: «want 2 repair calls, got 3 — one of them was bought outside gates.repair.budget_usd»
- **где:** `internal/pipeline/repair.go:368`
- **как:** Зонд «чекпойнт есть ⇒ оплачено» обязан видеть ожог: `cp != nil && !burnedByCut(cp)`. Ровно та правка, что уже сделана для банка (terminologist.go). Пин — по образцу TestTheBankPaidProbeSeesThroughABurnedCheckpoint, на зонде, а не на предикате.
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
@ -126,6 +218,9 @@
### P0 · `burn-walk#3` — Сожжённый ключ ХОПА читается как «уже оплачено» — escalation.budget_usd не спрашивается (и escMu не берётся)
- **действие:** ЧИНЮ
- **семья:** читатели контракта ⟨вписано амендментом 10.09⟩
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** `escalation.go:141` через то же определение; пины `TestABurnedHopKeyDoesNotBuyAHopOutsideTheEscalationBudget` и `TestAPaidHopBehindABurnedKeyStillReplaysFree`; мутация `CUTBURN-the-escalation-pregate-asks-a-fixed-index` RED. Воспроизведено красным ДО починки: «want 2 hop calls, got 3»
- **где:** `internal/pipeline/escalation.go:146`
- **как:** То же для `mayHop := fbExists != nil`. Дополнительно проверить, что escMu берётся, раз хоп реально пойдёт на провод.
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
@ -137,6 +232,9 @@
### P0 · `cancelled-mark#1` — Метка над эскалационным хопом не несёт денег хопа: ledger 0.001176, строка 0.000120
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Семья закрыта ОДНИМ инвариантом, предъявленным на всех трёх: строка чанка обязана сходиться с суммой чекпойнтов СВОЕЙ позиции на ЛЮБОМ выходе. Носитель — `assertRowsMatchTheLedger` с контролем «прибору дали что сравнивать» (иначе «расхождений нет» на пустом леджере читается как успех). Деньги хопа едут в `out.fb` ВСЕГДА (`escalation.go`), а `runStage` прибавляет их ДО проверки ошибки. Пин `TestAStoppedRunLeavesEveryRowMatchingItsOwnLedger`; мутация `CUTROW-the-hop-money-waits-for-the-verdict` RED текстом «0.000120 против 0.001176» — ровно те числа, что замерила линза. ⚠ Первая редакция пина была ФЛЕЙКОВОЙ: 2 красных из 8 на мутанте, потому что отмена обгоняла разбор заголовков и обрыв выходил на $0, а строка сходилась «ноль к нулю». Перестроена так, что деньги хопа НЕИЗБЕЖНЫ (ожог на ключе хопа в прогоне 1): 8/8 зелёных на дереве, 8/8 красных на мутанте
- **семья:** локальные ⟨вписано амендментом 10.09⟩
- **где:** `internal/pipeline/stagerun.go:233-240 + escalation.go:173`
- **как:** Деньги хопа прибавлять к cumCost ДО возврата по ошибке (сейчас только внутри `if esc.attempted`, то есть после `if err != nil { return }`). Пин: отмена над хопом, сверка chunk_status.cost_usd с леджером.
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
@ -148,6 +246,9 @@
### P0 · `cancelled-mark#2` — Тот же провал на ремонте: ledger 0.001303, сумма всех строк 0.000240
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Семья закрыта ОДНИМ инвариантом, предъявленным на всех трёх: строка чанка обязана сходиться с суммой чекпойнтов СВОЕЙ позиции на ЛЮБОМ выходе. Носитель — `assertRowsMatchTheLedger` с контролем «прибору дали что сравнивать» (иначе «расхождений нет» на пустом леджере читается как успех). То же для ремонта: `res.CostUSD`/`res.CumUSD` заполняются ДО суждения об исходе (`repair.go`), `runStage` прибавляет их до `if rerr != nil`. Пин `TestAStoppedRunCarriesTheRepairsMoneyToTheRow`, построенный тем же приёмом неизбежных денег; мутация `CUTROW-the-repair-money-waits-for-the-verdict` RED: «0.002000 против 0.003063». ⚠ Мутация СНАЧАЛА ВЫЖИЛА — носителя не было, и это поймал инструмент, а не я
- **семья:** локальные ⟨вписано амендментом 10.09⟩
- **где:** `internal/pipeline/stagerun.go:281-287 + repair.go:302-316`
- **как:** То же для ремонта: замер линзы — леджер 0.001303, сумма строк 0.000240.
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
@ -159,6 +260,9 @@
### P0 · `money-predicate#1` — Оплаченный обрыв теряется целиком, если между ним и остановкой был ЛЮБОЙ не-cut ретраибл (503/429)
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Семья закрыта ОДНИМ контрактом, а не четырьмя правками: `moreOwed` копит то, что цепочка должна вынести, `chainError` цепляет это к любому из ЧЕТЫРЁХ выходов (`httpllm.go`), и решение принимается по ДЕНЬГАМ, а не по типу ошибки. Пин `TestAPaidCutSurvivesAPlainRetryableLaterInTheChain` (обрыв → 503 → исчерпание) и `TestTheCancellationExitCarriesWhatTheChainOwes` (второй выход по отмене, синхронизирован на СТОП, не на часы). Мутации `CUTCHAIN-a-plain-retryable-erases-the-paid-cut` и `CUTCHAIN-the-stop-exit-drops-what-is-owed` — RED по тексту
- **семья:** выходы цепочки retryLoop ⟨вписано амендментом 10.09⟩
- **где:** `internal/llm/httpllm.go:211 и :234`
- **как:** Оплаченный обрыв теряется, если между ним и концом цепочки был ЛЮБОЙ не-cut ретраибл (503/429). Причина та же, что у #2: firstCut не доносится. Чинить вместе с money-predicate#2 и bank#4 — это одна семья из трёх выходов.
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
@ -170,6 +274,9 @@
### P0 · `money-predicate#2` — Оплаченный обрыв маскируется НЕоплачиваемым обрывом той же цепочки: книжится $0
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Семья закрыта ОДНИМ контрактом, а не четырьмя правками: `moreOwed` копит то, что цепочка должна вынести, `chainError` цепляет это к любому из ЧЕТЫРЁХ выходов (`httpllm.go`), и решение принимается по ДЕНЬГАМ, а не по типу ошибки. Правило выхода сменено с «финальная ошибка несёт ЛЮБОЙ обрыв» на «несёт обрыв с денежным требованием не слабее». Пин `TestAFreeCutLaterDoesNotMaskThePaidCutEarlier`; мутация `CUTCHAIN-the-exit-ranks-cuts-by-type-not-money` RED. ⚠ Попутно найдена и снята МОЯ недостижимая ветка: предпочтение «платный над бесплатным» в накопителе сработать не может — ретраибл только `CutByConnection`, а кап делает ВТОРОЙ доставленный обрыв терминальным, значит обрывов в цепочке максимум два и второй всегда её заканчивает. Ранжирование живёт на выходе, где обе ошибки в руках
- **семья:** выходы цепочки retryLoop ⟨вписано амендментом 10.09⟩
- **где:** `internal/llm/httpllm.go:249-251 (withEarlierCut)`
- **как:** ОБЪЕДИНЕНО с retry-loop#1. `withEarlierCut` отдаёт финальную ошибку, если она несёт ЛЮБОЙ обрыв. После разведения предикатов это неверно: поздний Billable=false затирает ранний Billable=true. Правило должно быть «предпочесть ОПЛАЧИВАЕМЫЙ обрыв», а не «любой».
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
@ -181,6 +288,9 @@
### P0 · `retry-loop#1` — withEarlierCut выбрасывает ОПЛАЧЕННЫЙ обрыв первой попытки, если цепочку заканчивает ДРУГОЙ обрыв — движок книжит $0
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** дубль `money-predicate#2`, закрыт тем же контрактом. Дополнительный пин `TestAPaidCutAfterAFreeOneIsTheOneTheCallerSettlesFrom` держит обратный порядок (бесплатный, затем платный) с премиссой на РОВНО два вызова — если политика ретраев когда-нибудь пустит третий, премисса скажет об этом вслух, а не даст фикстуре молча мерить другую цепочку. ⚠ Три ЧУЖИХ якоря каталога протухли от переписи петли и пере-нацелены поштучно, каждый пере-проверен посадкой
- **семья:** выходы цепочки retryLoop ⟨вписано амендментом 10.09⟩
- **где:** `internal/llm/httpllm.go:248-251`
- **как:** дубль money-predicate#2
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
@ -192,6 +302,9 @@
### P0 · `критик#2``cancelled` читается прогнозом как ПОЛНОСТЬЮ отработанная позиция: одна остановка роняет projected_book_usd на 9.9% — носитель resolvedForResume правлен в двух местах из четырёх
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Предикат `resolvedForResume` подставлен в ЧЕТВЁРТЫЙ носитель — `resolveChunkState` (`status.go:576`): строка `cancelled` больше не решает судьбу единицы, и `projectBookUSD` не берёт её в знаменатель экстраполяции. Довод наряда точен и решил форму починки: ДО пака остановка не оставляла строки вовсе, знаменатель был честен, и ухудшила его именно новая метка — значит чинить надо восстановлением прежней правды, а не новым правилом. Пин `TestAStoppedPositionIsNotADecidedUnit` держит ОБЕ стороны (остановленная позиция не решает; обычный контентный флаг по-прежнему решает — иначе предикат, отвечающий «не решено» на всё, прошёл бы половину теста и тихо выключил флагирование). Мутация `CUTSTATE-a-stopped-position-decides-the-unit` RED по тексту. ⚠ ПИНГ оркестратору: правка соприкасается со строкой бэклога **374** (вердикт главы считается по СЧЁТУ флагов) — остановленная позиция теперь не попадает в этот счёт на уровне единицы, что 374 частично снимает; решение о вердикте ГЛАВЫ остаётся его
- **семья:** локальные ⟨вписано амендментом 10.09⟩
- **где:** `internal/pipeline/rebill.go:279 + status.go:564-573`
- **как:** resolvedForResume подставлен в ДВА носителя из ЧЕТЫРЁХ: прогноз и состояние чанка читают `cancelled` как полностью отработанную позицию ⇒ одна остановка роняет projected_book_usd на 9.9 %. Это число читает платформа.
- **тяжесть после опровержения:** money · **источник:** критик полноты
@ -201,6 +314,8 @@
### P1 · `bank-and-counters#2` — Зонд смотрит только на попытку 0: партия, уже ОТВЕЧЕННАЯ на попытке 1, требует полный `want` из суб-бюджета, который никогда не потратит
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** `terminologist.go:792` переведён на `paidAfterBurns`; пин `TestTheBankProbeFindsThePaidBatchBehindABurnedKey`, мутация `CUTBURN-the-bank-pregate-asks-a-fixed-index` RED текстом «the batch WAS bought and answered at the index the funnel walked to». ⚠ Эта позиция и есть та, что ПРЕДСКАЗАЛА регрессию двух других зондов — см. амендмент
- **где:** `internal/pipeline/terminologist.go:792`
- **как:** Зонд зашит на попытку 0, а обход ожога идёт по ВОЗРАСТАЮЩИМ индексам: партия, отвеченная на попытке 1, требует полный want из суб-бюджета, который не потратит. Зонд должен спрашивать ту же ось, что и обход.
- **тяжесть после опровержения:** money · **источник:** линза+опровергатель
@ -212,6 +327,8 @@
### P1 · `bank-and-counters#5``Deliveries` считает ОБРЫВЫ, а не доставки: и доккомментарий, и строка леджера утверждают число, которое замеряется неверным
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Выбран честный счёт, а не переименование: строку читает ОПЕРАТОР, и она единственное место, где виден разрыв между «сколько сгенерировали» и «сколько забукали». Счётчик разведён на два — кап ретраев остаётся на обрывах (`deliveredCutSeen`), а число для строки считается по ФАКТУ доставки (`askedToGenerate` + предикат `deliveredAttempt`): статус любого рода есть доставка по определению (пир прочитал запрос, чтобы ответить), оплаченный нечитаемый 2xx — тоже; не доставка — всё, что упало до ухода байтов. Доккомментарий поля приведён в соответствие. Пин `TestTheDeliveryCountCountsDeliveriesNotCuts` (503 → обрыв → его единственный ретрай = ТРИ доставки), число вызовов закреплено премиссой, чтобы смена политики ретраев не дала фикстуре молча мерить другую цепочку. Мутация `CUTCOUNT-deliveries-counts-cuts-again` RED. ⚠ Правка сломала ЧУЖОЙ якорь `CUTCALL-the-delivery-count-is-not-carried` — пере-нацелен и пере-проверен посадкой
- **где:** `internal/llm/attemptcut.go:104-109 + httpllm.go:459-470 + pipeline/cutcall.go:115-122`
- **как:** Deliveries инкрементируется только под `errors.As(err,&cut)`, то есть считает ОБРЫВЫ, а не доставки: доккомментарий и строка леджера называют неверно замеренное число. Либо считать доставки честно, либо переименовать поле и текст.
- **тяжесть после опровержения:** correctness · **источник:** линза+опровергатель
@ -223,6 +340,8 @@
### P1 · `burn-walk#4` — Строка остановленной позиции пишет attempts=0 при реально уплаченных деньгах
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** `attemptsMade` пишется ДО проверки ошибки (`stagerun.go`), по той же причине, что и деньги рядом: счёт — факт о случившемся, а не о том, удалось ли. Инвариант строки расширен: «стоила денег ⇒ обязана назвать хотя бы одну попытку». Пин `TestAStopOnAStagesFirstCallStillReportsAnAttempt` — остановка на ПЕРВОМ вызове стадии, деньги неизбежны через ожог; мутация `CUTROW-the-attempt-count-waits-for-the-verdict` RED, и текст падения называет **0.001056** — ровно число линзы. 6/6 зелёных на дереве, 6/6 красных на мутанте. ⚠ Мутация СНАЧАЛА ВЫЖИЛА: во всех прежних сценариях первый вызов стадии успевал отработать, и счёт уже не был нулём — носителя пришлось строить отдельно
- **где:** `internal/pipeline/stagerun.go:170-179`
- **как:** ОБЪЕДИНЕНО с cancelled-mark#3. `attemptsMade` присваивать ДО проверки ошибки, иначе метка пишет attempts=0 при уплаченных деньгах.
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
@ -234,6 +353,8 @@
### P1 · `cancelled-mark#3` — Метка врёт числом попыток: attempts=0 при оплаченном cost_usd=0.001056
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** дубль `burn-walk#4`, закрыт той же правкой и тем же пином
- **где:** `internal/pipeline/stagerun.go:170-179`
- **как:** дубль burn-walk#4, чинится одной правкой
- **тяжесть после опровержения:** correctness · **источник:** линза+опровергатель
@ -245,6 +366,8 @@
### P1 · `money-predicate#3` — Половина `answered &&` денежного предиката не закреплена НИЧЕМ: мутант выживает во всей батарее
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Половина `answered &&` закреплена фикстурой, где две половины РАСХОДЯТСЯ: пир пишет байты ответа, которые не являются ответом (сломанная статус-строка), так что первый байт трассы есть, а 2xx-объекта нет. Пин `TestResponseBytesWithoutAReplyAreNotAPurchase` с двумя премиссами (это обрыв; `AfterHeaders` истинно — иначе обе половины ложны и тест их не различает); мутация `CUTMONEY-response-bytes-alone-mean-payment` RED текстом «response BYTES are not a reply»
- **где:** `internal/llm/attemptcut.go:212`
- **как:** Половина `answered &&` денежного предиката не закреплена ничем — мутант переживает всю батарею. Нужна фикстура, где answered=false при firstByte=true (отказ + RST поверх пишущегося тела) И проверка ДЕНЕГ, а не только типа ошибки.
- **тяжесть после опровержения:** correctness · **источник:** линза+опровергатель
@ -256,6 +379,8 @@
### P1 · `transport-and-config#1` — Пин write-бонда проверяет НЕ тот транспорт, который уходит в бой: удаление тюнинга из боевого клиента переживает всю батарею пакета
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** предмет (пин write-бонда) удалён вместе с бондом. ⚠ КЛАСС при этом воспроизвёлся в моей ЗАМЕНЕ пина и закрыт отдельно: `keepAliveHTTPClient` разделён на себя и `buildCloudClient`, пин читает транспорт боевой конструкции, мутация `CUTKEEPALIVE-the-cloud-client-is-not-tuned-at-all` RED текстом «this test measured nothing»
- **где:** `internal/llm/attemptcut_test.go:853 против httpllm.go:124`
- **как:** Пин write-бонда конфигурирует СВОЙ транспорт, а не тот, что уходит в бой: удаление тюнинга из боевого клиента переживает всю батарею. Пин обязан читать транспорт, собранный keepAliveHTTPClient.
- **тяжесть после опровержения:** correctness · **источник:** линза+опровергатель
@ -267,6 +392,8 @@
### P1 · `transport-and-config#2` — Комментарий обещает закрыть awaitFlowControl-парковку — замер показывает, что она открыта: 3-секундный дедлайн держался >70 с на БОЕВОМ клиенте
- **действие:** ЧИНЮ КОММЕНТАРИЙ, механизм — ПИНГ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** предмет удалён вместе с бондом: комментарий обещал, что бонд закрывает парковку в `awaitFlowControl`, а замер это опровергал. Контроль после удаления: греп `awaitFlowControl|flow control|flow-control` по `backend/` даёт **0 хитов при 360 прочитанных go-файлах** — ложного обещания в дереве не осталось. Класс «дедлайн инертен на h2» остаётся строкой бэклога **373**
- **где:** `internal/llm/httpllm.go:97-111`
- **как:** Комментарий обещает, что бонд закрывает awaitFlowControl-парковку. ЗАМЕР ОПРОВЕРГАЕТ: 3-секундный дедлайн держался >70 с на боевом клиенте. Комментарий — мой и врёт, его правлю. Оставлять ли сам бонд — решение оркестратора (см. пинги ниже).
- **тяжесть после опровержения:** correctness · **источник:** линза+опровергатель
@ -278,6 +405,8 @@
### P1 · `transport-and-config#3` — Пол скорости zai — данные без носителя: каталожный гейт zai вообще не смотрит, удаление `tok_s_floor: 35` проходит все 78 кейсов internal/config
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Замер сначала: пол `zai` = 35 против вендорского дефолта 35.56, то есть влияет на 1.6 % — он ЗАМЕРЕН (p10 glm-5 = 37.51 ток/с на 647 строках лога) и осознанно чуть ниже дефолта. Прежний гейт его не видел структурно: он ходит по ЗАГРУЖЕННОЙ структуре, где удалённая строка и никогда не объявленное поле — один и тот же ноль, и пропускает провайдера, чьи модели не объявляют токенного пола. ⇒ заведён `TestEveryDeclaredDeadlineKnobStaysDeclared`: пинует сам ФАКТ объявления по всем четырём провайдерам, с контролем «объявляющих ровно столько, сколько названо в пине». Мутация `CUTCFG-a-measured-speed-floor-is-deleted` RED текстом «deadline knobs moved from {tokSFloor:35 …} to {tokSFloor:0 …}»
- **где:** `internal/config/models_catalog_test.go:219-225`
- **как:** Гейт смотрит на провайдеров через MinMaxTokens и `if grant == 0 { continue }`, поэтому zai не смотрит вовсе: удаление `tok_s_floor: 35` проходит все 78 кейсов. Расширить гейт на объявленные поля, а не только на выведенные гранты.
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
@ -289,6 +418,8 @@
### P1 · `transport-and-config#4`У zai объявлен пол без потолка, и связку никто не валидирует: опечатка в поле даёт 2 ч 32 мин на вызов, а потолок ниже пола обнуляет заявленное «attempt_s — это ПОЛ» до 10 с
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Заведена `validateTimeouts` (`internal/config/models.go`), вызываемая из провайдерского цикла `LoadModels`: диапазоны на все три секундных поля (типо-гвардия 4 часа — на порядок выше ратифицированных ~20 минут, чтобы законная настройка не отвергалась), запрет отрицательного `tok_s_floor` и ГЛАВНОЕ — связка `attempt_max_s >= attempt_s`. Пин `TestACapBelowTheFloorIsRefusedAtLoad` начинается с КОНТРОЛЯ (крышка выше пола и незаданная крышка обязаны грузиться — иначе проверку удовлетворил бы загрузчик, отвергающий всё) и требует, чтобы отказ называл ОБА поля. Мутации `CUTCFG-a-cap-below-the-floor-loads-quietly` и `CUTCFG-the-timeout-check-is-not-wired-in`обе RED; вторая существует потому, что проверка, которую никто не зовёт, — тот же класс, что я дважды снимала за смену
- **где:** `internal/config/models.go:122-146 + internal/llm/attemptcut.go:281-289`
- **как:** ОБЪЕДИНЕНО с критик#1. Ни одной проверки диапазона: опечатка в поле даёт 2 ч 32 мин на вызов, а attempt_max_s НИЖЕ attempt_s молча укорачивает каждый вызов — прямо вопреки моему же комментарию «attempt_s is the FLOOR … no provider loses a second it has today». Добавить валидацию в LoadModels и пин.
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
@ -300,6 +431,8 @@
### P1 · `критик#1` — attempt_max_s ниже attempt_s молча УКОРАЧИВАЕТ дедлайн каждого вызова — вопреки комментарию, который на этом обещании и стоит; ни валидации, ни теста, ни пина
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** дубль `transport-and-config#4`, закрыт той же правкой и тем же пином
- **где:** `internal/config/models.go:122-146 + internal/llm/attemptcut.go:281-289`
- **как:** ДУБЛЬ transport-and-config#4, чинится той же правкой: валидация связки tok_s_floor/attempt_s/attempt_max_s в LoadModels плюс пин на то, что attempt_max_s ниже attempt_s не проходит загрузку.
- **тяжесть после опровержения:** money · **источник:** критик полноты
@ -309,6 +442,8 @@
### P1 · `критик#5` — FlagConnectionLost объявлен, отранжирован и НЕДОСТИЖИМ: обоснование в его комментарии не реализовано ни одной строкой кода
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Снята — по прямому указанию наряда «либо сделать достижимым, либо снять константу и ранг». Замер подтвердил недостижимость: `classify` имеет ветви `decodeErrorFinish` и `attemptTimeoutFinish` и НЕ имеет `connectionLostFinish`; настоящую защиту даёт `burnedByCut`, который ловит такую строку ДО любой классификации. Снят и ранг; комментарий на её месте описывает МЕХАНИЗМ («у потери связи нет флага, и это утверждение, а не пропуск»), а не историю правки. Контроль: `FlagConnectionLost` в дереве — 0 хитов кроме объясняющего комментария, при 360 прочитанных go-файлах
- **где:** `internal/pipeline/disposition.go:98-104 + status.go:432`
- **как:** FlagConnectionLost объявлен, отранжирован и НЕДОСТИЖИМ: обоснование в его комментарии не реализовано ни одной строкой. Либо сделать достижимым, либо снять константу и ранг — но не оставлять словами.
- **тяжесть после опровержения:** correctness · **источник:** критик полноты
@ -318,15 +453,32 @@
### P1 · `критик#6` — Вся новая таблица тяжести флагов пинуется только на ЧЛЕНСТВО, а не на ЗНАЧЕНИЕ: cancelled можно объявить худшей бедой книги, и батарея пакета зелёная
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Заведён пин на ЗНАЧЕНИЯ, а не на членство: `TestTheSeverityTableMeansWhatItsCommentsSay`. Каждое утверждение — предложение, которое таблица уже пишет о себе сама, превращённое в проверку: `cancelled` мягче всех (его комментарий: «the mildest mark there is»); `decode_error` и `attempt_timeout` делят ранг («they rank together»); `severityUnknown` мягче любого диагноза («must not out-rank a diagnosis the engine actually made»); авто-очищенный кусок мягче бюджетного симптома; выброшенный загрязнённый — строже. Плюс КОНТРОЛЬ в начале: таблица не пуста и не одноцветна, иначе всякое «мягче» держится тривиально. Три мутации RED по тексту
- **где:** `internal/pipeline/status.go:430-449, пин flagseverity_test.go`
- **как:** Таблица тяжести пинуется на ЧЛЕНСТВО, а не на ЗНАЧЕНИЕ: cancelled можно объявить худшей бедой книги, и батарея зелёная. Нужен пин на ПОРЯДОК (кто кого обязан перевешивать), как у off_target_lang.
- **тяжесть после опровержения:** correctness · **источник:** критик полноты
- **в чём дефект:** Дофикс переписал порядок тяжести и подробно обосновал каждый ранг (decode_error и attempt_timeout вровень; connection_lost туда же; cancelled — «the mildest mark there is», ниже авто-очистки; unknown сдвинут 8→9). Единственный гейт над этой таблицей — TestEveryFlagReasonIsRanked — проверяет, что каждая ОБЪЯВЛЕННАЯ константа ПРИСУТСТВУЕТ в карте; порядок он не проверяет. Единственная новая мутация на этот предмет (CUTFLAG-a-cut-chunk-is-the-mildest-thing-that-can-happen) тоже бьёт по членству — её edit УДАЛЯЕТ строку FlagAttemptTimeout целиком. Значит вся содержательная часть правки (какой флаг тяжелее какого) не пинована ничем, и решение, которое пак объявляет ключевым для паспорта главы, любая следующая смена может переставить бесшумно. Отдельно: обратной проверки — «каждый отранжированный флаг движок умеет выдать» — тоже нет, и именно поэтому мёртвый FlagConnectionLost из находки №5 пр
- **улика линзы:** МУТАЦИЯ (посажена, выжила): status.go:449 `FlagCancelled: 8``FlagCancelled: 0` — остановленная позиция становится ХУДШЕЙ проблемой главы, обгоняя жёсткий отказ, то есть ровно то, что комментарий на :443-448 объявляет недопустимым. `go test ./internal/pipeline/` → ok textmachine/backend/internal/pipeline 34.853s. Ни один тест не прочёл значение. Файл восстановлен, cmp: RESTORED-OK. Тело объявленной мутации (cmd/tmmutate/mutations.json), доказывающее, что пин целит в членство, а не в значение: edits[0] = {"find": "\tFlagDecodeError: 4,\n\tFlagAttemptTimeout: 4,\n", "replace": "\tFlagDecodeError: 4,\n"} — строка удаляется, и её ловит проверка «объявлен, но не отранжирован».
### P2 · руг8#1` — Строка оператора о повторной оплате банка называет ЛОЖНУЮ причину, когда прежняя строка была сожжённой
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Причина названа ПАРОЙ, а не одной из двух: «либо композиция партий изменилась, либо прежние строки записали деньги без результата и невоспроизводимы». Код не может различить эти случаи, а сообщение, выбирающее один, неверно в половине из них — запрет `D39.93` п.2. Обе ветки оставляют оператору одно и то же действие, поэтому назвать обе ничего не стоит, а назвать одну стоит правды. ⚠ Гейт `TestEveryOperatorMessageIsCatalogued` поймал правку — голден обновлён ОДНОЙ строкой (126 → 126), и это ОБСЛУЖИВАНИЕ по `D39.183`: смена вызвана заказанной правкой формулировки, объявляю отдельно
- **семья:** читатели контракта ⟨вписано амендментом 10.09⟩
- **где:** `internal/pipeline/terminologist.go:394` (чтение) → `:478` (строка)
- **как:** Предупреждение обязано различать «прежние чекпойнты не нашлись из-за смены композиции партий» и «прежняя строка невоспроизводима, потому что она сожжена». Причина у сообщения одна, а фактов два.
- **тяжесть после опровержения:** слово оператору · **источник:** пере-проверка знаменателя 10.09 ⟨позиция ВПИСАНА АМЕНДМЕНТОМ, в наряде её не было⟩
- **в чём дефект:** `HasCheckpointForStage` — ЧЕТВЁРТЫЙ читатель существования чекпойнта, которого счёт по `GetCheckpoint` не видел. Он спрашивает «платила ли книга за эту стадию когда-либо», и сожжённая строка отвечает «да» — законно, деньги были потрачены. Но строка на `:478` объясняет оператору повторную оплату так: «those earlier calls were made under a batch composition the already-banked filter does not reproduce, so their checkpoints could not be found». Если единственная прежняя строка сожжена, чекпойнт не нашёлся НЕ из-за композиции: он невоспроизводим по построению. Сообщение, лгущее о СВОЕЙ причине, запрещено `D39.93` п.2 — тем самым пунктом, который пак цитирует сам (`cutcall.go:158`).
- **почему это НЕ ломает знаменатель:** замер: `grep -n paidBefore internal/pipeline/*.go` = 3 хита (чтение, комментарий, один `if`). Гейтит РОВНО строку лога, ничего не покупает и никакого бюджета не охраняет ⇒ денежных читателей по-прежнему три, основание наряда цело. Правило остановки не сработало и не должно было.
- **воспроизведение:** книга с единственной сожжённой банковой строкой + свежий проход ⇒ `reconsolidated=true` с текстом про композицию.
### P2 · `bank-and-counters#6` — Строка «asked N times» невидима там, где колонку читают: errTail режет на 120 байтах, и приписка стоит в отрезаемом хвосте
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Формулировка вынесена в именованную `cutErrLine` (`cutcall.go`), потому что оба её свойства несущие и с места вызова не видны. Приписка «asked N times» переставлена В НАЧАЛО: колонка оператора ограничена 120 байтами, транспортная ошибка перед ней регулярно длиннее, поэтому приписка в хвосте не доходила НИКОГДА. Пин `TestTheAskedNTimesNoteReachesTheReader`; мутация `CUTLINE-the-note-goes-back-to-the-end` RED
- **где:** `internal/pipeline/cutcall.go:122 против cmd/tmctl/render.go:512-528`
- **как:** ОБЪЕДИНЕНО с retry-loop#5. Приписка «asked N times» стоит в КОНЦЕ err, а errTail режет на 120 байтах ⇒ до оператора не доходит никогда. Плюс перевод строки от errors.Join печатает одну строку отчёта двумя. Ставить приписку в НАЧАЛО и убирать перевод строки.
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
@ -338,6 +490,8 @@
### P2 · `cancelled-mark#5` — Комментарий у defer называет носителем банковские батчи, которые в runStage не входят
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Из перечня носителей у `defer` убраны банковые батчи: в `runStage` они не входят, и комментарий врал о знаменателе. Осталось то, что там действительно есть, — хоп и под-шаг ремонта
- **где:** `internal/pipeline/stagerun.go:143-144`
- **как:** Комментарий у defer называет носителем банковские батчи, которые в runStage НЕ входят. Убрать их из перечня — иначе комментарий врёт о знаменателе дверей.
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
@ -349,6 +503,8 @@
### P2 · `money-predicate#5` — Лог сожжённого ключа называет «оплаченным» чекпойнт на $0
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Строка ожога переформулирована: «the key of this attempt is SPENT … cannot be replayed» вместо «attempt was paid for». Ключ потрачен в любом случае, а стоил он законно нуля, когда провайдер вызов не подтвердил; «оплачен» над `cost_usd=0.000000` читается ночью в логе как биллинговый баг
- **где:** `internal/pipeline/stagerun.go:496-498`
- **как:** Строка «attempt was paid for» печатается над cost_usd=0.000000. Переформулировать: «ключ потрачен» вместо «оплачен», и печатать сумму как есть.
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
@ -360,6 +516,8 @@
### P2 · `money-predicate#6` — AfterHeaders=true для 1xx и для незакрытого блока заголовков — оператору сообщают об ответе, которого не было
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Выбран второй путь наряда — описать поле тем, чем оно является. `AfterHeaders` теперь документирован как ПЕРВЫЙ БАЙТ ответа (`GotFirstResponseByte`), а не как «ответ начал приходить», с явным перечнем того, для чего он срабатывает и подтверждением ничего не значит: 1xx, незакрытый блок заголовков, мусор сломанного прокси. Денежный вопрос отвечает `Billable`, который дополнительно требует 2xx-объекта в руках, и этот раздел теперь закреплён пином `TestResponseBytesWithoutAReplyAreNotAPurchase` (см. `money-predicate#3`)
- **где:** `internal/llm/attemptcut.go:79-80, 211`
- **как:** AfterHeaders=true для 1xx и для незакрытого блока заголовков — оператору сообщают об ответе, которого не было. Либо считать только 2xx-объект, либо переименовать поле в лог-строке.
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
@ -371,6 +529,8 @@
### P2 · `retry-loop#4` — Строка операторского журнала утверждает «одна оценка забукана на все», когда забукан НОЛЬ
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Текст говорит о фактически забуканном: при `cost <= 0` — «NOTHING is booked: the provider acknowledged none of them» вместо «ONE estimate is booked». Строка рядом с $0-записью, утверждающая обратное, — предложение, которое надо не поверить, чтобы им пользоваться. Мутация `CUTLINE-the-note-claims-money-that-was-not-booked` RED
- **где:** `internal/pipeline/cutcall.go:115-122`
- **как:** Строка утверждает «ONE estimate is booked for all of them», когда забукан НОЛЬ (Billable=false). Условие текста должно смотреть на фактическую сумму.
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
@ -382,6 +542,8 @@
### P2 · `retry-loop#5` — errors.Join кладёт перевод строки в request_log.err: одна строка отчёта печатается ДВУМЯ, а заметка о числе доставок обрезается и до оператора не доходит никогда
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** `errTail` (`cmd/tmctl/render.go`) схлопывает пробельное: `errors.Join` разделяет членов переводом строки, и строка таблицы печаталась ДВУМЯ, ломая выравнивание всего под ней. Пин `TestTheOperatorTailIsOneLineAndKeepsTheNoteThatMatters` — перевод строки поставлен ВНУТРИ границы обрезки, иначе обрезка убрала бы его даром и фикстура прошла бы на рендерере, который ничего не схлопывает. Мутация `CUTLINE-the-tail-splits-across-rows` RED
- **где:** `cmd/tmctl/render.go:512-528`
- **как:** дубль bank-and-counters#6
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
@ -393,6 +555,8 @@
### P2 · `transport-and-config#5` — Пол zai замерен по одной модели из двух; вторая (флагман glm-5.1) наследует 35 ток/с без замера и без предупреждения
- **действие:** ЧИНЮ
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** Замерить вторую модель нельзя — пак $0, платных вызовов ноль. Поэтому объявлено ОТСТУПЛЕНИЕ там, где живёт число (`configs/models.yaml`): пол замерен по glm-5, glm-5.1 наследует его без собственного замера, и названо, почему это принято — направление ошибки безопасно, 35 ниже вендорского дефолта 35.5, то есть даже неверный для 5.1 пол даёт вызову БОЛЬШЕ времени, а не меньше. Назван и способ снять отступление: n≥100 строк request_log по glm-5.1 и её собственный p10
- **где:** `configs/models.yaml:85-90`
- **как:** Пол zai замерен по одной модели из двух; glm-5.1 наследует 35 ток/с без замера. Либо замерить вторую, либо объявить отступление в комментарии, как сделано у deepseek.
- **тяжесть после опровержения:** minor · **источник:** линза+опровергатель
@ -410,6 +574,8 @@
### `transport-and-config#6` — Write-бонд рвёт ВСЮ h2-связь, а не застрявший стрим: соседний вызов, чьё тело уже ушло, умирает вместе с ним
- **где:** `internal/llm/httpllm.go:140`
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** бонд удалён целиком, ратификация `D39.231` п.2 — оркестратор принял довод против собственного заказа. Предъявлено тройкой замеров на копии: дерево БЕЗ бонда `ok`; бонд ВОЗВРАЩЁН — тоже `ok`, значит батарее он невидим в обе стороны и не держал ничего; контроль — посадка новой мутации RED текстом «the read-idle bound is not on the transport: got 0s want 15s». Два зелёных без третьей строки означали бы «прибор не спросил»
- **почему не моё:** Write-бонд рвёт ВСЮ h2-связь (липкая cc.werr), а не застрявший стрим: соседний вызов, чьё тело уже ушло, умирает вместе с ним. Вместе с #2 и #9 это довод СНЯТЬ бонд, а не чинить — но снятие вернёт исходную дыру, и это решение оркестратора.
- **в чём дефект:** Ошибка записи липнет к соединению, а не к стриму, и h2 мультиплексирует параллельные стадии одного провайдера в один ClientConn. Один застрявший на записи вызов уносит все остальные in-flight на этом соединении. Деньги при этом не теряются (у соседа нет 2xx-объекта ⇒ Billable=false), но это лишний ретрай и лишняя задержка, и в комментарии к константе размен не назван.
- **воспроизведение:** `d=$(mktemp -d) && (cd /home/ubuntu-26/projects/textmachine/backend && tar -cf "$d/t.tar" --exclude=./bin --exclude='./.env*' .) && mkdir "$d/backend" && tar -xf "$d/t.tar" -C "$d/backend" && cp /tmp/claude-1000/-home-ubuntu-26-projects-textmachine/f88e2870-ec2e-48b8-9776-d0370e00bc0b/scratchpad/prob`
@ -417,6 +583,8 @@
### `transport-and-config#9` — Для тех тел, которые движок реально шлёт, write-бонд не может выстрелить — обещание «дедлайн был инертен» верно на два порядка выше нашего размера
- **где:** `internal/llm/httpllm.go:101-106`
- **исход:** сделано ⟨вписано амендментом 10.09⟩
- **предъявлено:** тот же предмет, снят вместе с механизмом. Носитель класса «наш дедлайн инертен на h2» остаётся строкой бэклога **373**: удалён МЕХАНИЗМ, который класс не закрывал, а не сам класс
- **почему не моё:** Для тел, которые движок реально шлёт, бонд выстрелить не может: обещание «дедлайн был инертен» верно на два порядка выше нашего размера. Кластер с #2 и #6.
- **в чём дефект:** Запись блокируется, только когда тело перестаёт помещаться в буферы сокета; до этого RoundTrip дописывает запрос, cleanupWriteRequest снова слушает ctx, и НАШ дедлайн работает штатно. Реальные тела запроса у нас — десятки KiB. Значит зафиксированный в комментарии класс зависаний достижим у нас только при теле в единицы MiB, чего пайплайн не отправляет; констатация полезна, чтобы следующая смена не считала бонд активной защитой боевых вызовов.
- **воспроизведение:** `d=$(mktemp -d) && (cd /home/ubuntu-26/projects/textmachine/backend && tar -cf "$d/t.tar" --exclude=./bin --exclude='./.env*' .) && mkdir "$d/backend" && tar -xf "$d/t.tar" -C "$d/backend" && cp /tmp/claude-1000/-home-ubuntu-26-projects-textmachine/f88e2870-ec2e-48b8-9776-d0370e00bc0b/scratchpad/prob`
@ -424,12 +592,16 @@
### `критик#3` — Ранг флага решили, а ВЕРДИКТ главы — нет: одна остановка делает главе «attention», две — «fail»
- **где:** `internal/pipeline/status.go:716, :735, :737`
- **исход:** пинг ⟨вписано амендментом 10.09⟩
- **предъявлено:** у оркестратора, заведено строкой бэклога **374** (`D39.231` п.3) — вердикт ГЛАВЫ по остановленной позиции, продуктовое решение. ⚠ Исчезнет само, если денежную половину пака урежут: следствие класса `cancelled`
- **почему не моё:** Одна остановка делает главе вердикт «attention», две — «fail». Ранг флага это не лечит: вердикт считается по СЧЁТУ флагов, а не по их тяжести. Что должна показывать глава, над которой нажали стоп, — продуктовое решение.
- **в чём дефект:** Пак сам сформулировал риск в комментарии к рангу (status.go:443-448): «a passport that reported «cancelled» as a chapter's worst problem would hide the durable finding behind a transient one» — и закрыл его РАНЖИРОВАНИЕМ (FlagCancelled: 8, последний перед unknown). Но ранг влияет только на выбор WorstFlagReason (status.go:717-718). Счётчик UnitsFlagged инкрементится для ЛЮБОГО ChunkFlagged без взгляда на причину (:716), а вердикт главы считается по СЧЁТУ, а не по тяжести: `case p.UnitsFlagged >= 2: fail` / `== 1: attention`. Итог: оператор жмёт стоп в момент, когда в полёте две единицы одной главы, и паспорт объявляет главу проваленной — при том что следующий резюм обе строки перезапишет. На
### `критик#4` — Строка `cancelled` черновой стадии читается как ВЫПАВШИЙ ЧЛЕН редакторской единицы — контент-вердикт, вынесенный по факту нажатия стопа
- **где:** `internal/pipeline/status.go:538-543 → export.go:327`
- **исход:** пинг ⟨вписано амендментом 10.09⟩
- **предъявлено:** у оркестратора, заведено строкой бэклога **375** (`D39.231` п.3) — семантика экспорта. ⚠ Та же оговорка
- **почему не моё:** memberDrops опознаёт выпавшего члена по (черновая стадия && flagged) без взгляда на причину, поэтому строка `cancelled` читается как КОНТЕНТ-вердикт. Семантика экспорта — не моё решение.
- **в чём дефект:** memberDrops — по собственной шапке «the SINGLE definition of the c-lite rule «a dropped member flags its unit»» — опознаёт выпавшего члена ровно по паре (стадия черновая) && (disposition == flagged), без взгляда на причину. Новая строка cancelled ровно такова. Следствие: остановка над черновиком одного члена помечает ВСЮ редакторскую единицу как «член выброшен» (export.go:327 droppedAny → DispFlagged), то есть выносится вердикт о ТЕКСТЕ там, где с текстом ничего не случилось. Это тот же самый частично правленный носитель, что в находке №2, в третьем его месте, и запрет «флаг, лгущий о своей причине» пак цитирует сам (cutcall.go:158, D39.93 п.2). Ни линз, ни тестов, ни мутаций на memberDrops