"""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 def _meter(max_rounds: int = 5, max_tokens: int = 100) -> BudgetMeter: return BudgetMeter(TerminationContract(max_rounds=max_rounds, max_tokens=max_tokens)) 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)