"""The budget meter (method-spec §8) — never an unbounded loop, anywhere. Normative behaviours proved here: token accounting comes from provider-reported usage only (a missing usage fails CLOSED, never silently stops counting); crossing a cap raises a STRUCTURED stop event carrying the breached kind, the limit, and the observed value — never a silent hang. """ from __future__ import annotations 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) meter.charge_tokens(30) meter.charge_tokens(20) assert meter.tokens_used == 50 def test_missing_usage_fails_closed(self) -> None: # §8: on counting paths, a response missing usage MUST fail closed — # an error, not a silently-uncounted call. meter = _meter() with pytest.raises(UsageAccountingError): meter.charge_tokens(None) assert meter.tokens_used == 0 def test_reaching_the_cap_exactly_does_not_stop(self) -> None: meter = _meter(max_tokens=100) meter.charge_tokens(100) assert meter.tokens_used == 100 def test_crossing_the_token_cap_raises_structured_stop(self) -> None: meter = _meter(max_tokens=100) meter.charge_tokens(90) with pytest.raises(BudgetExceeded) as exc_info: meter.charge_tokens(20) stop = exc_info.value assert stop.kind == "tokens" assert stop.limit == 100 assert stop.observed == 110 class TestRoundAccounting: def test_round_ticks_accumulate(self) -> None: meter = _meter(max_rounds=5) meter.charge_round() meter.charge_round() assert meter.rounds_used == 2 def test_crossing_the_round_cap_raises_structured_stop(self) -> None: meter = _meter(max_rounds=2) meter.charge_round() meter.charge_round() with pytest.raises(BudgetExceeded) as exc_info: meter.charge_round() stop = exc_info.value assert stop.kind == "rounds" assert stop.limit == 2 assert stop.observed == 3 def test_caps_come_from_the_startup_contract() -> None: # §8/§10: the termination contract already refuses non-positive caps at # 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