portfolio-optimiser/tests/test_budget.py
Kjell Tore Guttormsen 3748011d35 test(budget): defend the third field of the stop event — observed (kø-y)
BudgetExceeded carries kind/limit/observed as ONE structured stop event, but only
`observed` was undefended. Measured against the whole suite before writing anything:
four of five raise sites (TokenMeter.charge, tick_round, and BOTH arms of exhausted())
could report any value at all without a single one of 621 tests noticing. Only
PortfolioMeter.check was covered.

What hid it: spikes/_harness.py carries its OWN copy of BudgetExceeded/TokenMeter, so
the spike suite's `observed` assert never touched the shipped module — the production
tick_round had no direct test whatsoever.

exhausted() is the only site that CHOOSES a ledger (the S3.4 pre-call guard), so a
refusal naming portfolio_tokens while reporting the run's own spend would misdirect
every reader of it. Both arms are pinned with observed != limit on purpose: at
exactly-exhausted the two coincide, and a test written there would pass on an
implementation that echoed the cap back as the spend.

No defect in the values themselves (unlike kø-x and kø-p) — the triple was coherent at
all five sites; the gap was purely coverage.

Load-bearing MEASURED: nine mutations, all red — five observed mutations (including the
control) and four echo mutations. 621 -> 623 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LW749xcXQmVEgdipB6KNm4
2026-08-03 20:46:36 +02:00

231 lines
10 KiB
Python

"""Step 4 tests — token meter + budget middleware off REAL UsageDetails (no LLM).
Usage is hand-built (synthetic ``UsageDetails``), so these run in CI without an endpoint.
The strict-usage guard hard-fails on a missing usage; the meta-guard proves the meter is
fed from ``UsageDetails`` and never a word-count proxy. Pattern: tests/spikes/test_harness.py.
"""
import re
from pathlib import Path
import pytest
from agent_framework import ChatResponse, Message, UsageDetails
from portfolio_optimiser.budget import (
Budget,
BudgetExceeded,
BudgetMiddleware,
PortfolioBudget,
PortfolioMeter,
TokenMeter,
UsageUnavailable,
)
class _Ctx:
"""Minimal ChatContext stand-in carrying the post-call ``result``."""
def __init__(self, result: object) -> None:
self.result = result
async def _noop() -> None:
return None
def _resp(total: int | None) -> ChatResponse:
usage = UsageDetails(total_token_count=total) if total is not None else None
return ChatResponse(messages=[Message(role="assistant", contents=["x"])], usage_details=usage)
async def test_meter_reads_total_token_count_and_accumulates() -> None:
meter = TokenMeter(Budget(max_tokens=1000, max_rounds=10))
mw = BudgetMiddleware(meter)
await mw.process(_Ctx(_resp(40)), _noop) # type: ignore[arg-type]
await mw.process(_Ctx(_resp(50)), _noop) # type: ignore[arg-type]
assert meter.tokens == 90 # read from UsageDetails, accumulated across calls
async def test_cap_crossed_raises_budget_exceeded() -> None:
meter = TokenMeter(Budget(max_tokens=50, max_rounds=10))
mw = BudgetMiddleware(meter)
with pytest.raises(BudgetExceeded) as exc:
await mw.process(_Ctx(_resp(60)), _noop) # type: ignore[arg-type]
assert exc.value.kind == "tokens"
assert exc.value.limit == 50
assert exc.value.observed == 60 # the spend that crossed it, not merely that something did
def test_round_cap_crossed_carries_the_round_ledger() -> None:
"""The rounds arm of the same stop event (kø-(y)).
The shipped ``tick_round`` had NO direct test at all — only ``spikes/_harness.py``'s separate
copy of ``TokenMeter`` did, and that file is not the one that runs. Its entire error contract
was riding on a module the framework never imports."""
meter = TokenMeter(Budget(max_tokens=1000, max_rounds=2))
assert meter.tick_round() == 1
assert meter.tick_round() == 2 # exactly at the cap is still within it
with pytest.raises(BudgetExceeded) as exc:
meter.tick_round()
assert (exc.value.kind, exc.value.limit, exc.value.observed) == ("rounds", 2, 3)
def test_non_positive_cap_rejected() -> None:
with pytest.raises(ValueError):
Budget(max_tokens=0, max_rounds=5)
with pytest.raises(ValueError):
Budget(max_tokens=5, max_rounds=0)
async def test_strict_usage_none_hard_fails() -> None:
meter = TokenMeter(Budget(max_tokens=100, max_rounds=10))
mw = BudgetMiddleware(meter, strict_usage=True)
with pytest.raises(UsageUnavailable):
await mw.process(_Ctx(_resp(None)), _noop) # type: ignore[arg-type]
async def test_budget_middleware_fires_on_real_agent_chat(make_client_factory) -> None:
"""F8 (real-client half): registering BudgetMiddleware on a real Agent (layered chat client
that carries ChatMiddlewareLayer) and running an actual chat call short-circuits with
BudgetExceeded — the middleware<->client integration the prior suite never exercised (the
minimal-base stand-in silently no-ops the middleware)."""
from agent_framework import Agent
client = make_client_factory("ok", tokens=8)("proposer") # 8 tokens/reply > cap 5
meter = TokenMeter(Budget(max_tokens=5, max_rounds=10))
agent = Agent(
client, "propose a measure", name="proposer", middleware=[BudgetMiddleware(meter)]
)
with pytest.raises(BudgetExceeded) as exc:
await agent.run("hi")
assert exc.value.kind == "tokens"
assert meter.tokens == 8 # charged from the synthetic UsageDetails via the middleware
async def test_pre_call_guard_does_not_await_call_next_when_exhausted() -> None:
"""S3.4 unit arm of the pre-call guard: the middleware refuses BEFORE ``call_next``. Counting
the awaits is the whole measurement — the post-charge arm raises either way."""
calls = {"n": 0}
async def _counted() -> None:
calls["n"] += 1
meter = TokenMeter(Budget(max_tokens=10, max_rounds=10))
meter.charge(10) # exactly at the cap -> nothing left to fund a call with
mw = BudgetMiddleware(meter)
with pytest.raises(BudgetExceeded):
await mw.process(_Ctx(_resp(5)), _counted) # type: ignore[arg-type]
assert calls["n"] == 0
async def test_below_the_cap_still_calls_through() -> None:
"""Control for the guard: with budget left, the call goes through exactly as before — the
guard must gate exhaustion, not traffic."""
calls = {"n": 0}
async def _counted() -> None:
calls["n"] += 1
meter = TokenMeter(Budget(max_tokens=10, max_rounds=10))
mw = BudgetMiddleware(meter)
await mw.process(_Ctx(_resp(4)), _counted) # type: ignore[arg-type]
assert calls["n"] == 1 and meter.tokens == 4 and meter.remaining() == 6
def test_portfolio_budget_rejects_unusable_configs() -> None:
"""Fail-fast at construction (mirroring ``Budget``): non-positive caps, a per-run cap larger
than the global one (one run could then cross the pass's own ceiling), and a reserve larger
than a run can ever spend (which would refuse every pass forever)."""
with pytest.raises(ValueError):
PortfolioBudget(max_total_tokens=0, max_tokens_per_run=10)
with pytest.raises(ValueError):
PortfolioBudget(max_total_tokens=100, max_tokens_per_run=0)
with pytest.raises(ValueError):
PortfolioBudget(max_total_tokens=100, max_tokens_per_run=200)
with pytest.raises(ValueError):
PortfolioBudget(max_total_tokens=100, max_tokens_per_run=50, min_run_reserve=60)
with pytest.raises(ValueError):
PortfolioMeter(PortfolioBudget(max_total_tokens=100, max_tokens_per_run=50), spent=-1)
def test_portfolio_meter_accumulates_and_crosses() -> None:
budget = PortfolioBudget(max_total_tokens=100, max_tokens_per_run=50)
meter = PortfolioMeter(budget, spent=40)
assert meter.remaining() == 60 and meter.required_per_run == 50
meter.record(60)
meter.check() # exactly at the cap is still within it (mirrors TokenMeter's `>` boundary)
meter.record(1)
with pytest.raises(BudgetExceeded) as exc:
meter.check()
assert exc.value.kind == "portfolio_tokens"
assert exc.value.limit == 100 and exc.value.observed == 101
async def test_run_meter_bound_to_portfolio_charges_both_ledgers() -> None:
"""A bound run meter charges the global ledger on every call, and ``remaining`` reads whichever
cap binds — that is what lets one project's spend refuse another's next call."""
portfolio = PortfolioMeter(PortfolioBudget(max_total_tokens=100, max_tokens_per_run=80))
meter = TokenMeter(Budget(max_tokens=80, max_rounds=10), portfolio=portfolio)
mw = BudgetMiddleware(meter)
await mw.process(_Ctx(_resp(30)), _noop) # type: ignore[arg-type]
assert meter.tokens == 30 and portfolio.spent == 30
assert meter.remaining() == 50 # run has 50 left, portfolio 70 -> the RUN binds
await mw.process(_Ctx(_resp(45)), _noop) # type: ignore[arg-type]
assert meter.remaining() == 5 # run 5, portfolio 25 -> still the run
async def test_spend_is_ledgered_even_when_the_per_run_cap_raises() -> None:
"""Order matters: tokens the provider already billed must reach the global ledger even though
the RUN's own cap raises on the same charge. Checking the run cap first and returning early
would lose that spend and hand the next project a budget that was never really there."""
portfolio = PortfolioMeter(PortfolioBudget(max_total_tokens=1000, max_tokens_per_run=50))
meter = TokenMeter(Budget(max_tokens=50, max_rounds=10), portfolio=portfolio)
with pytest.raises(BudgetExceeded) as exc:
meter.charge(60)
assert exc.value.kind == "tokens" # the RUN's cap is the one that broke
assert portfolio.spent == 60 # ...and the global ledger still saw the spend
def test_exhausted_names_one_ledger_in_all_three_fields() -> None:
"""kø-(y): the pre-call guard's refusal must describe ONE ledger consistently.
``exhausted`` is where the S3.4 guard decides what to refuse a call with, and it is the only
raise site that CHOOSES between two ledgers. Naming the binding cap in ``kind`` is already
tested; this pins the other two fields to that same choice — a refusal that says
``portfolio_tokens`` while reporting the run's own spend would misdirect every reader of it.
Both arms are built with ``observed != limit`` on purpose. At exactly-exhausted the two
coincide, so a test written at that point cannot tell them apart and would pass on an
implementation that echoed the cap back as the spend. Overrunning first is also the honest
case: a run that crossed its cap and had the error caught is precisely when the next call
must be refused."""
meter = TokenMeter(Budget(max_tokens=10, max_rounds=10))
with pytest.raises(BudgetExceeded):
meter.charge(15) # the overrun is real: the meter now stands at 15 against a cap of 10
own = meter.exhausted()
assert own is not None
assert (own.kind, own.limit, own.observed) == ("tokens", 10, 15)
# The global arm: this run has spent NOTHING — a sibling drained the pass.
portfolio = PortfolioMeter(
PortfolioBudget(max_total_tokens=100, max_tokens_per_run=100), spent=150
)
bound = TokenMeter(Budget(max_tokens=100, max_rounds=10), portfolio=portfolio)
assert bound.tokens == 0
shared = bound.exhausted()
assert shared is not None
assert (shared.kind, shared.limit, shared.observed) == ("portfolio_tokens", 100, 150)
def test_no_word_count_token_proxy_in_src() -> None:
# The meter is fed from real UsageDetails, never a len(text.split()) word-count proxy
# (research 03 Rec 3 — the Fase 1 _word_tokens anti-pattern is retired). NOTE: a bare
# `.split()` grep is wrong — retrieval.py legitimately uses .split() for KEYWORD scoring
# (a [0,1] overlap ratio, not a token count). So guard the specific proxy signature.
proxy = re.compile(r"len\(\s*[^)]*\.split\(\)\s*\)")
offenders = [
py.name
for py in Path("src/portfolio_optimiser").rglob("*.py")
if proxy.search(py.read_text(encoding="utf-8"))
]
assert offenders == [], f"word-count token proxy found in: {offenders}"