feat(portfolio): C3.5 — pre-call run-total USD budget belt (parity row 16/31) [skip-docs]

Add a pre-call USD belt on top of the post-charge token/round meter (§8),
so no future live run can loop past its run budget. Belt-and-braces above
the SDK's per-call max_budget_usd cap.

- budget.py: optional run-total `max_cost_usd` on BudgetMeter (fail-fast on
  non-positive, §10) + `guard_before_call(spent_usd)` raising the same
  structured stop event (BudgetKind widened with "cost_usd"; limit/observed
  → float). Reaching the cap exactly does not stop; crossing it does
  (mirrors the token cap).
- loop.py: `_guarded_complete` helper reads the client's accumulated
  total_cost_usd (0.0 for scripted clients) and guards BEFORE every
  client.complete; all three call sites routed through it — one detach point.
- sdk_client.py: total_cost_usd already exposed/accumulated — untouched.
- tests/test_budget.py: meter-level cap tests + load-bearing loop-wiring
  test (counting client; detach the guard → unguarded loop runs to the round
  cap → kind "rounds" not "cost_usd" → red).

457→462 green, golden byte-exact, full gate clean (ruff+format+mypy strict,
22 src files), run_s10.py/runs/ byte-untouched. README test-count sync ×2 +
budget.py belt note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RiTwaKLesgcwXx2mDviqpt
This commit is contained in:
Kjell Tore Guttormsen 2026-07-23 22:45:37 +02:00
commit 111b320b75
4 changed files with 145 additions and 14 deletions

View file

@ -4,9 +4,16 @@ Token accounting comes from the PROVIDER-REPORTED usage after each model call
never a word-count or character proxy; on counting paths a response missing
usage fails CLOSED (``UsageAccountingError``), not silently uncounted. Crossing
a cap raises ``BudgetExceeded``, a STRUCTURED stop event carrying the breached
kind, the limit, and the observed value never a silent hang. The caps come
from the fail-fast startup ``TerminationContract`` (§10), which already refuses
non-positive values.
kind, the limit, and the observed value never a silent hang. The token/round
caps come from the fail-fast startup ``TerminationContract`` (§10), which
already refuses non-positive values.
On TOP of the post-charge token/round meter sits an optional PRE-call USD belt
(C3.5): a run-total ``max_cost_usd`` cap the loop checks BEFORE every model
call against the SDK client's accumulated ``total_cost_usd``. Once the
run-total cost has crossed the cap the next call is refused with the same
structured stop event (``kind="cost_usd"``) belt-and-braces above the SDK's
per-call USD cap, so no future live run can loop past its run budget.
"""
from __future__ import annotations
@ -15,7 +22,7 @@ from typing import Literal
from portfolio_optimiser_claude.contracts import TerminationContract
BudgetKind = Literal["tokens", "rounds"]
BudgetKind = Literal["tokens", "rounds", "cost_usd"]
class UsageAccountingError(Exception):
@ -25,7 +32,7 @@ class UsageAccountingError(Exception):
class BudgetExceeded(Exception):
"""The structured stop event: breached kind + limit + observed value (§8)."""
def __init__(self, kind: BudgetKind, limit: int, observed: int) -> None:
def __init__(self, kind: BudgetKind, limit: float, observed: float) -> None:
super().__init__(f"budget exceeded: {kind} observed {observed} > limit {limit}")
self.kind: BudgetKind = kind
self.limit = limit
@ -33,10 +40,20 @@ class BudgetExceeded(Exception):
class BudgetMeter:
"""Run-scoped usage meter over the startup termination contract (§8)."""
"""Run-scoped usage meter over the startup termination contract (§8).
def __init__(self, termination: TerminationContract) -> None:
``max_cost_usd`` is an OPTIONAL run-total USD cap for the pre-call belt
(C3.5); left unset the belt is a no-op and only the token/round caps apply.
When set it must be positive (§10 fail-fast discipline).
"""
def __init__(
self, termination: TerminationContract, *, max_cost_usd: float | None = None
) -> None:
if max_cost_usd is not None and max_cost_usd <= 0:
raise ValueError(f"max_cost_usd must be positive when set, got {max_cost_usd}")
self._termination = termination
self._max_cost_usd = max_cost_usd
self.tokens_used = 0
self.rounds_used = 0
@ -55,3 +72,18 @@ class BudgetMeter:
self.rounds_used += 1
if self.rounds_used > self._termination.max_rounds:
raise BudgetExceeded("rounds", self._termination.max_rounds, self.rounds_used)
def guard_before_call(self, spent_usd: float) -> None:
"""Pre-call USD belt (C3.5): refuse the NEXT model call once the
accumulated run-total cost has crossed the cap.
This is the ONLY cap read from OUTSIDE the meter the SDK client
accumulates ``total_cost_usd`` from each ``ResultMessage`` and the loop
passes it here BEFORE every ``client.complete``. No cap configured (or a
scripted client reporting no spend) makes this a no-op, so the offline
suite is untouched. Reaching the cap exactly does not stop (mirrors the
token cap); crossing it raises the structured stop event (§8)."""
if self._max_cost_usd is None:
return
if spent_usd > self._max_cost_usd:
raise BudgetExceeded("cost_usd", self._max_cost_usd, spent_usd)

View file

@ -60,6 +60,22 @@ class ModelClient(Protocol):
def complete(self, prompt: str, *, role: str) -> ModelReply: ...
def _guarded_complete(
client: ModelClient, prompt: str, *, role: str, meter: BudgetMeter
) -> ModelReply:
"""One model call, pre-guarded by the run-total USD belt (C3.5, §8).
Every model call in the loop goes through here. The pre-call guard reads the
client's accumulated ``total_cost_usd`` (0.0 for scripted clients that carry
no cost) and refuses to make the call once the run-total USD cap is crossed
the belt that stops the loop from spending past its run budget, on TOP of
the per-call SDK cap and the post-charge token/round meter.
"""
spent_usd: float = getattr(client, "total_cost_usd", 0.0)
meter.guard_before_call(spent_usd)
return client.complete(prompt, role=role)
# --- Step 2: hypothesise (structured candidate generation) ---------------------------------
@ -87,7 +103,7 @@ def generate_candidate(
bounded by the budget meter: a round tick is charged between attempts (§8).
"""
while True:
model_reply = client.complete(prompt, role=_PROPOSER_ROLE)
model_reply = _guarded_complete(client, prompt, role=_PROPOSER_ROLE, meter=meter)
meter.charge_tokens(model_reply.usage_tokens)
try:
# JSONDecodeError and pydantic's ValidationError are ValueErrors.
@ -157,15 +173,17 @@ def run_debate(
for _ in range(max_rounds):
turns += 1
check_turn_safety_net(turns, max_rounds)
proposer_reply = client.complete(
_proposer_debate_prompt(context, critique), role=_PROPOSER_ROLE
proposer_reply = _guarded_complete(
client, _proposer_debate_prompt(context, critique), role=_PROPOSER_ROLE, meter=meter
)
meter.charge_tokens(proposer_reply.usage_tokens)
proposer_output = proposer_reply.text
turns += 1
check_turn_safety_net(turns, max_rounds)
checker_reply = client.complete(_checker_prompt(proposer_output), role=_CHECKER_ROLE)
checker_reply = _guarded_complete(
client, _checker_prompt(proposer_output), role=_CHECKER_ROLE, meter=meter
)
meter.charge_tokens(checker_reply.usage_tokens)
checker_last = checker_reply.text