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:
parent
0d50ab89d3
commit
a831aa1e3b
6 changed files with 733 additions and 11 deletions
12
README.md
12
README.md
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue