textmachine/backend/internal/pipeline/attemptladder.go

264 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"context"
"fmt"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/config"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/store"
)
// attemptladder.go: ONE walk of the attempt axis — render is already done, the budget ladder is here.
//
// WHY IT IS ITS OWN FUNCTION. The engine has two kinds of paid call: a STAGE of a chunk, and a BANK ROLE's
// batch. They differ in what surrounds the call — a stage resumes from chunk_status, escalates and writes
// a disposition; a bank batch has no chunk, no status row and no template — and they were identical in what
// happens BETWEEN the call and the verdict: ask, classify, and if the answer is a budget symptom, ask again
// with a bigger budget. Only the stage had that second half. The bank roles called runAttempt once and read
// the text, dropping the classification runAttempt had already computed for them — measured on the cold run
// of file B as 42 terms of 66 left with no machine type, two paid batches, and no regeneration
// (docs/experiments/25-door-to-file-b.md §7).
//
// ⛔ WHAT THIS IS NOT: a second copy of the loop, and not runStage made callable. runStage is «one stage of
// one CHUNK to a terminal disposition» — a unit of work with a durable row. Threading a bank batch through
// it behind flags would make every one of those steps conditional. The ladder is the part that is genuinely
// the same, extracted whole; everything around it stays where it belongs.
// ladderStep is the step the ladder is ABOUT to buy, offered to the caller's own admission rule before any
// money moves. It carries the budget AND its price, because the two questions a caller can have — «is this
// step affordable» and «what would it cost» — must be answered about the SAME step.
type ladderStep struct {
// attempt is the request-hash index this step would be bought under. It is NOT the ladder rung:
// runAttempt walks over burned keys, so the index can run ahead of the number of doublings.
attempt int
// doublings is the rung — how many times the base budget has been doubled for this step. It is what
// maxTokens is a function of, and the two counters are deliberately separate (see maxTokensForAttempt).
doublings int
maxTokens int
// estimateUSD is the step's price, from the ONE definition every gate prices a call with
// (callEstimateUSD), so an admission rule can never admit a call the reservation books differently.
estimateUSD float64
// cause is what the PREVIOUS attempt came back as — the reason this step exists at all.
cause FlagReason
}
// ladderCall is everything one walk needs to know. The rendered messages arrive already built: what varies
// between the two callers is how a request is COMPOSED, and that stays with each of them.
type ladderCall struct {
stage config.Stage // the call's identity; its Reasoning is where the effort ladder STARTS
model string // the model actually called (a stage's resolved model, a bank role's own)
snapID string
ch chunk.Chunk
job *store.Job
msgs []llm.Message
baseMaxTokens int
maxRegens int // regenerations the caller may buy for a retryable flag
echoRegens int // extra re-rolls for a stochastic echo (D39.61); 0 = echo escalates straight away
mandatory bool // may a refused reservation WAIT for headroom (see runAttempt)
isFinal bool
// afford is the caller's OWN admission rule for a step beyond the first, asked before the purchase and
// never for a step that is already paid for.
//
// ⛔ IT EXISTS BECAUSE THE TWO CALLERS BOUND THEIR MONEY IN DIFFERENT PLACES, and a ladder that did not
// ask would spend outside both. A stage's ceiling is the book's and lives inside the reservation, which
// refuses the step itself; a bank role's ceiling is its own per-role sub-budget, decided BEFORE the pass
// starts and over a plan of first steps only. A rung the ladder adds is a purchase that plan never saw,
// so without this hook a regenerating bank batch escapes its phase budget entirely.
//
// nil means «no rule of my own» — the stage's case, where the reservation is the rule.
afford func(step ladderStep) bool
// ⛔ AND THERE IS DELIBERATELY NO SECOND HOOK FOR THE VERDICT. A bank role's reply is a TABLE, so
// «the model answered, and answered almost nothing» is a real failure the intrinsic classifier cannot
// see — but it is not a failure THIS ladder can cure. Every rung here re-asks the SAME messages with a
// bigger budget, and a batch that came back whole-but-sparse at finish=stop did not run out of budget:
// the cure for it is to ask again for the REMAINDER, which is a different request, a different purchase
// key, and the owner's own separate decision (D39.254 п.2). A hook that let such a verdict onto this
// ladder would buy a doubled budget for a shortage that was never about budget.
}
// ladderRun is what one walk accumulated. Every field is a FACT ABOUT WHAT HAPPENED rather than about
// whether it succeeded, which is why the walk returns it even alongside an error: the caller's stop mark
// reports money and attempts that are real either way, and a struct that zeroed them on the error path
// would have the runner lie to the row it writes (the ordering runStage already keeps for the hop).
type ladderRun struct {
last stageAttempt // the terminal attempt: the one whose verdict stands
attempts int // attempt indices this position consumed, burned keys included
judged int // attempts that came back and were CLASSIFIED (freshly paid or replayed)
cumCost float64 // everything this position has cost, ever
runCost float64 // what THIS run paid
anyFresh bool // at least one call reached a provider this run
firstFlag FlagReason // the FIRST attempt's failure, kept when a later one changes the verdict
regens int // regenerations actually bought
doublings int // rungs climbed — what the last step's budget was a function of
// refusedStep is the step an admission rule turned down, if one did. It is carried rather than only
// logged because «the ladder stopped because the answer was final» and «the ladder stopped because the
// money ran out» are different facts about the same flagged result, and only the caller can say which
// of its counters each belongs in.
refusedStep *ladderStep
}
// stageAtEffort is the call's identity AS IT WILL BE ASKED at a given thinking level.
//
// ⛔ IT EXISTS BECAUSE THE EFFORT IS IN THE PURCHASE KEY, and the admission below reads that key. The probe
// «is the next rung already paid for» and the price «what would the next rung cost» must both be asked
// about the stage the next attempt will ACTUALLY use — which is not lc.stage the moment any rung has
// lowered the effort. Asking under the configured effort instead answers about a key nobody will buy: a
// false «paid» lets the rung past the caller's sub-budget, a false «unpaid» throws away a rung already
// bought. That is the same two-directional error attemptRequest was written to end, one layer up.
func stageAtEffort(st config.Stage, effort string) config.Stage {
st.Reasoning = effort
return st
}
// walkAttemptLadder asks, classifies, and re-asks a retryable failure with a bigger budget, until the
// answer is good, the remedies run out, or an admission rule refuses the next step. It returns an error
// ONLY on an infra failure; a bad completion is a verdict on `last`, never an error.
func (r *Runner) walkAttemptLadder(ctx context.Context, lc ladderCall) (ladderRun, error) {
var run ladderRun
// effort is the thinking level THIS attempt is asked at. It starts as the call's configured value and
// only ever goes down, one ladder step per regeneration, so the loop cannot circle: the ladder is
// finite and each step is strictly lower than the last (config.Models.ReducedEffort).
effort := lc.stage.Reasoning
for attempt := 0; ; attempt++ {
maxTokens := maxTokensForAttempt(lc.baseMaxTokens, run.doublings)
// The attempt's stage is the stage AS CALLED — a copy carrying this attempt's effort. Copying
// rather than threading an extra argument keeps ONE definition of the call's identity
// (attemptRequest reads the stage), so the wire, the request hash, the reservation estimate and
// the snapshot description can never disagree about which effort was asked for.
attemptStage := stageAtEffort(lc.stage, effort)
// escalation=false: this axis is the RETRY one. The single hop to another model is a different
// purchase with its own call site, and marking these calls as escalations would put them in the
// escalation ledger a different budget is metered from.
att, err := r.runAttempt(ctx, attemptStage, lc.model, lc.snapID, lc.ch, lc.job, attempt, maxTokens, lc.msgs, false, lc.isFinal, lc.mandatory)
run.cumCost += att.cumCost
run.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 caller's stop 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
run.attempts = attempt + 1
if err != nil {
return run, err // infra failure; the caller's deferred mark reports what accumulated
}
run.anyFresh = run.anyFresh || att.freshCall
cls := att.cls
run.last = att
if run.judged == 0 && !cls.ok() {
run.firstFlag = cls.Reason
}
run.judged++
if 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 cls.Reason.retryable() && run.regens < lc.maxRegens {
// An EMPTY reply used the whole budget before writing anything, so on a model whose thinking
// shares that budget the cure is less thinking, not more room — the same rule D2.3 states for
// a repetition loop, where a bigger budget only buys more loop. Opt-in
// (Retries.LowerEffortOnEmpty) and only while the ladder has a step left; otherwise this falls
// through to the doubling below, which is what recovered these chunks before.
if cls.Reason == FlagEmpty && r.Pipeline.Retries.LowerEffortOnEmpty {
if lower, ok := r.Models.ReducedEffort(lc.model, effort); ok {
// A step at the SAME budget still costs money, so it is admitted like any other — and
// it is admitted UNDER THE EFFORT IT WILL BE BOUGHT AT, which is the lowered one.
if step, ok := r.admitLadderStep(ctx, lc, stageAtEffort(lc.stage, lower), attempt+1, run.doublings, cls.Reason); !ok {
run.refusedStep = &step
break
}
r.Log.WarnContext(ctx, "the call returned nothing at the full budget, regenerating with less thinking at the SAME budget",
"stage", lc.stage.Name, "chapter", lc.ch.Chapter, "chunk", lc.ch.ChunkIdx,
"attempt", attempt, "reason", string(cls.Reason),
"effort", effort, "next_effort", lower, "max_tokens", maxTokens)
effort = lower
run.regens++
continue
}
}
step, ok := r.admitLadderStep(ctx, lc, stageAtEffort(lc.stage, effort), attempt+1, run.doublings+1, cls.Reason)
if !ok {
run.refusedStep = &step
break
}
// The price rides on the line that announces the purchase: money is visible from the first
// call (goal 5), and admitLadderStep has already computed this figure against the same budget
// the reservation will book.
r.Log.WarnContext(ctx, "the call was flagged, regenerating with a larger budget",
"stage", lc.stage.Name, "chapter", lc.ch.Chapter, "chunk", lc.ch.ChunkIdx,
"attempt", attempt, "reason", string(cls.Reason), "next_max_tokens", step.maxTokens,
"estimate_usd", fmt.Sprintf("%.6f", step.estimateUSD))
run.doublings++
run.regens++
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 cls.Reason == FlagCJKArtifact && run.regens < lc.echoRegens {
// ⚠ 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.
if step, ok := r.admitLadderStep(ctx, lc, stageAtEffort(lc.stage, effort), attempt+1, run.doublings+1, cls.Reason); !ok {
run.refusedStep = &step
break
}
r.Log.WarnContext(ctx, "echo flagged, regenerating before escalation (echo is stochastic per call, D39.61)",
"stage", lc.stage.Name, "chapter", lc.ch.Chapter, "chunk", lc.ch.ChunkIdx, "attempt", attempt)
run.doublings++
run.regens++
continue
}
break
}
return run, nil
}
// admitLadderStep prices the next step and asks the caller's rule whether it may be bought — unless it is
// already paid for, in which case there is nothing to admit.
//
// ⛔ THE PAID PROBE COMES FIRST, AND IT IS THE SAME PROBE THE FUNNEL USES. A step whose checkpoint already
// holds an answer costs nothing to take: refusing it on a budget would spend nothing and lose a reply that
// was already bought — the rule the bank pass's own pre-flight states for its first steps («already-paid
// batches cost nothing and are admitted regardless»), applied to the rungs above them. paidAfterBurns is
// the ONE definition of that question and it walks burned keys exactly as runAttempt will, so the admission
// and the purchase can never be talking about different keys.
//
// A probe that fails to READ is not a refusal: the step is admitted and runAttempt meets the same store
// error one line later, where it is an infra failure rather than a silent money decision.
func (r *Runner) admitLadderStep(ctx context.Context, lc ladderCall, next config.Stage, attempt, doublings int, cause FlagReason) (ladderStep, bool) {
maxTokens := maxTokensForAttempt(lc.baseMaxTokens, doublings)
step := ladderStep{
attempt: attempt, doublings: doublings, maxTokens: maxTokens, cause: cause,
estimateUSD: r.callEstimateUSD(next, lc.model, lc.msgs, maxTokens),
}
if lc.afford == nil {
return step, true
}
paid, err := r.paidAfterBurns(next, lc.model, lc.snapID, lc.ch, attempt, maxTokens, lc.msgs)
if err != nil {
r.Log.WarnContext(ctx, "could not read whether the next attempt was already paid for; admitting it and letting the attempt itself meet the store error",
"stage", lc.stage.Name, "chunk", lc.ch.ChunkIdx, "attempt", attempt, "err", err)
return step, true
}
if paid {
return step, true // an answer the store already holds: no money moves, so no rule applies
}
if !lc.afford(step) {
r.Log.WarnContext(ctx, "the next attempt was NOT bought: the caller's own budget refused it, and the flagged answer stands",
"stage", lc.stage.Name, "role", lc.stage.Role, "chapter", lc.ch.Chapter, "chunk", lc.ch.ChunkIdx,
"attempt", attempt, "reason", string(cause), "next_max_tokens", maxTokens,
"estimate_usd", fmt.Sprintf("%.6f", step.estimateUSD))
return step, false
}
return step, true
}