feat(budget): enforce a global portfolio token cap before the call, not after it (S3.4/F10)

PortfolioBudget + PortfolioMeter carry ONE token ledger over a whole portfolio
pass -- and, seeded from a persisted spend file, across passes -- while the
per-run Budget/TokenMeter pair is untouched. Three enforcement points, each
doing a different job:

- startup: a remainder that cannot fund one run raises BudgetRefused before
  anything loads (a pass that can afford zero projects is a caller mistake,
  not a result);
- wave assembly: an unfundable project is NEVER STARTED and the pass stops
  structurally (budget_stop + stopped_early, completed runs preserved).
  Because every member of a wave is funded against the SAME pre-wave
  remainder, admission RESERVES each member's requirement -- otherwise a wave
  of k over-commits the cap by up to k runs;
- pre-call: BudgetMiddleware refuses a call the remainder cannot pay for
  instead of making it. The post-charge check stays: real usage is only
  knowable after the response, so the guard stops the NEXT call, never the
  one in flight.

budget_stop is its own field rather than a widened stop_reason -- a goal-stop
is success, this is resource exhaustion, and fusing them would make "we
stopped" unreadable. PortfolioMeter splits record/check so tokens the provider
already billed reach the ledger even when the same charge breaks the run's own
cap. read_spend raises on corrupt content (our own accounting state, unlike
the tolerant RAW inbox layer); write_spend takes a REQUIRED stamp with no
wall-clock default, mirroring promote_verdict.

Load-bearing MEASURED, not asserted -- 6 mutations, all red: detach the wave
check; detach the pre-call guard; detach the wave reservation; check the run
cap before crediting the global ledger; detach the startup refusal; make
read_spend tolerant. Files restored from shasum-verified copies after each.

Two findings worth keeping: the pre-call guard MASKS a detached wave check if
the test asserts on overspend (spend stays under the cap either way), so the
load-bearing assertion had to become failures == () plus never-started; and
the token arithmetic is probed (32 tokens/run at tokens=8), not guessed.

537 -> 553 tests, ruff + mypy green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EaxFnaDAbMQkmTeX4u7sd
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 21:34:48 +02:00
commit a831aa1e3b
6 changed files with 733 additions and 11 deletions

View file

@ -149,6 +149,18 @@ when the seam is detached, so the loop cannot silently degrade into theater.
not CLI-enforced. Stop criteria and budget caps are required at startup. Try the offline
end-to-end proof (no model, no network): `uv run python -m portfolio_optimiser.simulation`.
- **A global token cap across the whole portfolio, enforced before the call.** Per-run caps alone
let N projects cost N times that with no ceiling over the pass. Pass a
`PortfolioMeter(PortfolioBudget(max_total_tokens=…, max_tokens_per_run=…))` to `run_portfolio`
and one ledger bounds the entire pass — and, seeded from `budget.read_spend`, a *series* of
passes. It bites in three places: a remainder that cannot fund one run refuses the pass at
startup (`BudgetRefused`); a project that cannot be funded is **never started**, stopping the
pass structurally (`budget_stop`, completed runs preserved); and a chat call the remainder
cannot pay for is **refused rather than made** (the post-charge check remains, since real usage
is only knowable after the response). Spend persists via `budget.write_spend`, which takes an
explicit stamp and no wall-clock default, so the file is byte-deterministic. Python API only —
not yet exposed on the CLI.
## What this enables
The reference case is portfolio cost review (the example bundle is a building-energy measure),