portfolio-optimiser-claude/tests/test_budget.py
Kjell Tore Guttormsen 9a4caeb419 feat(loop): S8 — D7 agentic loop: budget meter, maker-checker gate, informed refinement
Spec §3 steps 2–5 + §8, TDD-ed offline (scripted, honesty-marked stand-in):
- budget.py: BudgetMeter over TerminationContract — provider-reported usage
  only (missing usage fails closed), structured BudgetExceeded stop event.
- loop.py: ModelClient protocol; blind parse-retry generation (never silent
  repair); round-capped debate with turn safety net and mandated VERDICT
  line; opt-in-reject checker gate (explicit REJECT overrides a validated
  outcome, validator rejection stands); most-recent-reason-verbatim informed
  refinement under max_attempts; validator_decision stamped BEFORE override,
  checker_decision as its own result field (§9, never conflated).
- 45 new tests (121 total, no API key); four detach proofs run RED and
  reverted green: checker override, informed block, surfaced checker output,
  stamp-before-override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdSfQdND84oeq2mbjueLTS
2026-07-03 07:21:02 +02:00

75 lines
2.7 KiB
Python

"""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)