Files
6krrt/iteration.py
adlee-was-taken e1fd65f3e4 feat: tier is an iteration budget, spent on evidence rather than on a hunch
A tier used to mean only a capability floor. It now also buys corrective
attempts after a verification failure: tier 1 gets one shot, tier 2 one retry,
tier 3 two. Interactive requests are capped below their tier regardless,
because every retry doubles time-to-answer and in interactive use latency IS a
quality loss.

escalation.preemptive_on_low_confidence now defaults to FALSE. Bumping the
tier because the classifier was unsure of its own call pays frontier prices
before anything has gone wrong. Spending after a check has actually failed is
better on both mandates: the cheap attempt usually succeeds and costs nothing
extra, and when it fails there is evidence rather than a hunch.

Retries are matched to the failure, because the causes differ. Truncation
raises the token budget on the same model -- a different one would run out
too. Malformed output escalates to the next-ranked candidate, since more
tokens will not make unparseable output parse. 'ok' and 'unverifiable' buy
nothing; retrying unverifiable would burn quota across the majority of prose
traffic for no signal at all.

Testing it live exposed that the truncation branch was UNREACHABLE as first
written. Through /v1 the token cap is either the client's, which is not ours
to override, or absent -- and when absent the model hit its own ceiling, so
doubling changes nothing. It now escalates to a candidate with a larger output
ceiling, which is the actionable move, and declines when no such candidate
exists rather than wasting an attempt against a fixed kWh quota.

Known limit, recorded in CLAUDE.md: retry does not reach the streaming path.
Once bytes have gone to the client there is nothing to take back, and
buffering to allow correction would cost streaming itself. opencode streams,
so the main workflow gets verification and feedback but not correction.

Tests 211 -> 227.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018xTPER7K8fNyKiuqNvTCTa
2026-08-17 22:25:54 -04:00

143 lines
5.0 KiB
Python

"""Pure logic for spending a tier's iteration budget.
Reframes what a tier means. It used to be only a capability floor —
"don't route below this". It is now also a budget for getting the answer
right: tier 1 buys one attempt, higher tiers buy corrective attempts after a
verification failure.
Why this is better than what it replaces. `apply_escalation` bumps the tier
*preemptively* when the classifier is unsure of its own call, so an uncertain
guess costs frontier prices before anything has gone wrong. Spending after a
check has actually failed is strictly better on both mandates: the cheap
attempt usually succeeds and costs nothing extra, and when it fails you have
evidence rather than a hunch.
The retry strategy depends on the failure, because the failures have different
causes:
- **truncated** — the answer ran out of budget. If there is a cap to raise,
raise it on the same model. If there is not, the model's own output ceiling
is the wall, so escalate to a candidate that can emit more. (Without that
second case the branch was unreachable in practice: through /v1 the cap is
either the client's, which is not ours to override, or absent.)
- **malformed** — the model produced something that would not parse. More
tokens will not help, so this escalates to the next-best candidate.
Latency is treated as a cost. Every retry doubles time-to-answer, which in
interactive use *is* a quality loss, so interactive work gets a lower cap than
batch work regardless of tier.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, Optional
INTERACTIVE = "interactive"
BATCH = "batch"
# Multiplier applied to the token budget when retrying a truncated answer.
TRUNCATION_BUDGET_MULTIPLIER = 2
@dataclass
class RetryPlan:
"""How to spend one attempt from the budget."""
reason: Literal["truncated", "malformed"]
model_id: str
max_tokens: Optional[int]
detail: str
def attempts_allowed(
tier: int,
latency_tolerance: str,
attempts_by_tier: dict[int, int],
max_attempts_interactive: int,
) -> int:
"""How many corrective attempts this request has earned.
Interactive requests are capped below their tier's budget: a user waiting
on an answer pays for every retry in latency, and a slow correct answer can
be worth less than a fast one they can judge themselves.
"""
budget = attempts_by_tier.get(tier, 0)
if latency_tolerance == INTERACTIVE:
return min(budget, max_attempts_interactive)
return budget
def plan_retry(
verdict: str,
current_model: str,
current_max_tokens: Optional[int],
model_ceiling: Optional[int],
runners_up: list[tuple[str, Optional[int]]],
client_capped: bool,
) -> Optional[RetryPlan]:
"""Decide how to retry a failed response, or decline to.
``runners_up`` is (model_id, output_ceiling) in ranked order, because a
truncation retry needs to know which alternatives can actually hold a
longer answer.
Returns None when retrying cannot help, which is as important as returning
a plan — a wasted attempt costs energy against a fixed quota.
"""
if verdict == "truncated":
# A client that set its own max_tokens chose this outcome. Overriding
# it would ignore an explicit instruction, and the caller may well want
# a short answer.
if client_capped:
return None
if current_max_tokens is not None:
bigger = current_max_tokens * TRUNCATION_BUDGET_MULTIPLIER
if model_ceiling:
bigger = min(bigger, model_ceiling)
if bigger > current_max_tokens:
return RetryPlan(
"truncated",
current_model,
bigger,
f"retrying {current_model} with {bigger} tokens",
)
# No cap to raise, or already at this model's limit: the model's own
# output ceiling is the wall. Retrying it changes nothing, but a
# candidate that can emit MORE might finish the answer.
roomier = next(
(
(m, ceiling)
for m, ceiling in runners_up
if ceiling and (not model_ceiling or ceiling > model_ceiling)
),
None,
)
if roomier is None:
return None
return RetryPlan(
"truncated",
roomier[0],
current_max_tokens,
f"escalating to {roomier[0]} ({roomier[1]} output tokens)",
)
if verdict == "malformed":
# More tokens will not make unparseable output parse. Try the next
# candidate the ranking preferred.
if not runners_up:
return None
return RetryPlan(
"malformed",
runners_up[0][0],
current_max_tokens,
f"escalating to {runners_up[0][0]}",
)
# 'ok' and 'unverifiable' are not failures and buy no retry. Spending an
# attempt on 'unverifiable' would burn quota on the majority of prose
# traffic for no signal at all.
return None