264 lines
16 KiB
Go
264 lines
16 KiB
Go
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
|
||
}
|