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
75 lines
2.7 KiB
Python
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)
|