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

@ -12,12 +12,22 @@ import pytest
from portfolio_optimiser_claude.budget import BudgetExceeded, BudgetMeter, UsageAccountingError
from portfolio_optimiser_claude.contracts import TerminationContract
from portfolio_optimiser_claude.loop import ModelReply, generate_candidate
def _meter(max_rounds: int = 5, max_tokens: int = 100) -> BudgetMeter:
return BudgetMeter(TerminationContract(max_rounds=max_rounds, max_tokens=max_tokens))
def _usd_meter(
max_cost_usd: float, *, max_rounds: int = 1000, max_tokens: int = 10_000
) -> BudgetMeter:
return BudgetMeter(
TerminationContract(max_rounds=max_rounds, max_tokens=max_tokens),
max_cost_usd=max_cost_usd,
)
class TestTokenAccounting:
def test_accumulates_provider_reported_usage(self) -> None:
meter = _meter(max_tokens=100)
@ -73,3 +83,72 @@ def test_caps_come_from_the_startup_contract() -> None:
# construction — the meter builds on that, never on loose ints.
with pytest.raises(Exception):
TerminationContract(max_rounds=0, max_tokens=100)
class _CountingClient:
"""A ``ModelClient`` stand-in that counts calls and accumulates a fixed USD
cost per call, exposing it as ``total_cost_usd`` exactly like the SDK client
the seam the pre-call guard reads (C3.5)."""
def __init__(self, *, cost_per_call: float, text: str) -> None:
self._cost_per_call = cost_per_call
self._text = text
self.calls = 0
self.total_cost_usd = 0.0
def complete(self, prompt: str, *, role: str) -> ModelReply:
self.calls += 1
self.total_cost_usd += self._cost_per_call
return ModelReply(text=self._text, usage_tokens=1, model="counting")
class TestRunTotalUsdCap:
def test_non_positive_usd_cap_is_refused_at_construction(self) -> None:
# §10 fail-fast discipline: a USD cap, when set, must be positive.
with pytest.raises(ValueError):
BudgetMeter(TerminationContract(max_rounds=5, max_tokens=100), max_cost_usd=0.0)
def test_no_cap_makes_the_guard_a_noop(self) -> None:
# No USD cap configured (the default) → the guard never fires, so the
# offline suite and scripted clients that carry no cost are untouched.
meter = _meter()
meter.guard_before_call(9_999.0) # no raise
def test_reaching_the_usd_cap_exactly_does_not_stop(self) -> None:
# Mirrors the token cap (test_reaching_the_cap_exactly_does_not_stop):
# equal-to-cap is allowed; only crossing it stops.
meter = _usd_meter(0.25)
meter.guard_before_call(0.25) # no raise
def test_crossing_the_usd_cap_raises_structured_stop(self) -> None:
# §8: the run-total USD belt raises the SAME structured stop event form
# as the token/round caps — breached kind + limit + observed value.
meter = _usd_meter(0.25)
with pytest.raises(BudgetExceeded) as exc_info:
meter.guard_before_call(0.5)
stop = exc_info.value
assert stop.kind == "cost_usd"
assert stop.limit == 0.25
assert stop.observed == 0.5
class TestPreCallGuardWiredIntoLoop:
"""Load-bearing (§11): the guard runs BEFORE each model call via
``loop._guarded_complete``. Detach point: delete the
``meter.guard_before_call`` line in ``_guarded_complete`` the overspending
call is made anyway the call-count and kind assertions below go RED (the
unguarded loop instead runs to the round cap, raising ``kind == "rounds"``)."""
def test_guard_stops_before_the_call_that_would_overspend(self) -> None:
# +0.125 USD/call, cap 0.25 (all exact binary fractions): calls 1..3
# spend 0.125 / 0.25 / 0.375 (call 3's pre-guard sees exactly 0.25 == cap
# → allowed). BEFORE call 4 the guard sees 0.375 > 0.25 and refuses, so
# call 4 is NEVER made. Unparseable text forces generate_candidate's
# retry loop, so every iteration is a fresh guarded call.
client = _CountingClient(cost_per_call=0.125, text="not json — force the retry loop")
meter = _usd_meter(0.25)
with pytest.raises(BudgetExceeded) as exc_info:
generate_candidate(client, "prompt", meter=meter)
assert exc_info.value.kind == "cost_usd"
assert exc_info.value.observed == 0.375
assert client.calls == 3