feat: run-budget policy and call accounting (closes #106) - #263
Conversation
Adds benchmaxxing.budget: a RunBudget config (max_cases/max_calls/seed), deterministic case subsampling where a smaller max_cases is always a prefix of a larger run, a CallAccountant that tallies calls/tokens per model for the run summary, and a BudgetedBackend that raises BudgetExceeded once max_calls is reached so a runner can stop cleanly and flush partial results instead of running unbounded.
|
Clean budget/accounting layer, and the deterministic-prefix subsampling (a pilot being a strict subset of the full run, not an independent sample) is the right property for reproducibility. Enforcing the cap by raising before the call rather than after is correct. Infra, so mock tests suffice under the real-data rule. Approving; it is independent of the run-CLI stack, so it can land whenever. |
|
Reviewed. I'd independently written the same feature (RunBudget/subsample/CallAccountant/BudgetedBackend, near-identical shape) before noticing this PR was already open -- sorry for the duplicate effort, withdrawing mine and reviewing this one instead since it landed first. Two correctness issues, both unexercised by the current tests: 1. if self.max_calls is not None and self.accountant.total_calls >= self.max_calls:
2. No lock around the check-then-record: a race under concurrent calls. The check and the Also worth double-checking (not a bug, just flagging): Happy to help fix these if useful, or feel free to take it from here since it's your branch. |
Address review feedback on #106: - Collapse the two disconnected max_calls (dead RunBudget field + per-backend param) into a single run-wide cap on CallAccountant, with from_budget() wiring RunBudget.max_calls to it. Models needing independent budgets use their own accountant. Resolves the cross-model starvation ambiguity. - Make the check-then-record atomic under a threading.Lock (try_record), so the ThreadPoolExecutor fan-out the experiment scripts use can never overspend the cap. Claim-before-spend: an errored call still counts (conservative). - Rank subsample_cases by a stable blake2b(seed, case_id) hash instead of a numpy index permutation, so the nested-subset property holds regardless of input order. - Tests: independent-budget non-starvation, concurrent-cap atomicity, subsample order-invariance, failed-call accounting, from_budget. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks @felipeocampoos, and no worries about the duplicate — all three addressed in 389b87b. 1. Per-model starvation. Root cause was two disconnected 2. Race. Check + increment are now one atomic 3. Order-dependent subsample. Switched from the numpy index permutation to ranking by a stable
|
|
The two fixes (per-model starvation, check-record race) are verified in the head. Before merge though, please run it for real once: a real budgeted run with |
maximinl
left a comment
There was a problem hiding this comment.
Peer review after the Felipe fixup (389b87b).
The three corrections look right:
- Run-wide
max_callsonCallAccountantmatches the issue’s one-scalar budget, and independent accountants are the documented escape hatch (pinned bytest_independent_budgets_do_not_starve_each_other). try_recordunder a lock + claim-before-spend is the right concurrent shape forThreadPoolExecutorfan-out; counting failed backend calls is the conservative direction for a spend cap.- blake2b(seed, case_id) ranking makes the nested-subset property order-invariant.
Remaining merge gate is Seb’s ask: one real budgeted run with max_calls below the natural count, showing the cap stops calls and the per-model tally matches. Happy to approve the code; that live check should still land before merge.
sebasmos
left a comment
There was a problem hiding this comment.
Formalizing the ask above: one real budgeted run (cap actually stops calls, per-model tally shown) before merge.
Agastya191
left a comment
There was a problem hiding this comment.
Nice work on budget.py, especially the atomic try_record cap and the ThreadPoolExecutor stress test. One thing on subsample_cases: the early return for max_cases None or >= len(cases) hands cases back in pool order while the subsampling path returns them in rank order, so a bounded pilot is a subset of an unbounded full run but not a prefix of it, and the "strict prefix of a larger run" from the summary only holds when the larger run is also bounded (the prefix test compares max_cases=5 against 20, both bounded). In practice that means diffing a pilot against the real, unbounded full run won't line up positionally even though the case sets nest correctly. You've got options; I'd lean toward ranking on the unbounded path too and slicing at len(cases) so the full run and every pilot share one order, or if subset is all you really need, softening the wording from prefix to subset.
…efix of the full run, not just a subset (addresses Agastya's #263 review); update test to the new contract + add prefix test
sebasmos
left a comment
There was a problem hiding this comment.
Ran the real budgeted check (real Gemini API): cap holds at 4 (per-model tally flash 2 / flash-lite 2), calls refused before spending, and 200 concurrent threads admit exactly 10 at a cap of 10, so the per-model-starvation and check-then-record fixes both hold on real hardware. Also pushed the fix for Agastya's point: the unbounded path now ranks too, so a bounded pilot is a strict prefix of the full run (test updated + prefix test added, 16/16 pass, ruff clean). Merge-ready. Thanks both for the careful review.
* feat: run-budget policy and call accounting (closes #106) Adds benchmaxxing.budget: a RunBudget config (max_cases/max_calls/seed), deterministic case subsampling where a smaller max_cases is always a prefix of a larger run, a CallAccountant that tallies calls/tokens per model for the run summary, and a BudgetedBackend that raises BudgetExceeded once max_calls is reached so a runner can stop cleanly and flush partial results instead of running unbounded. * fix: run-wide call cap, atomic accounting, order-independent subsample Address review feedback on #106: - Collapse the two disconnected max_calls (dead RunBudget field + per-backend param) into a single run-wide cap on CallAccountant, with from_budget() wiring RunBudget.max_calls to it. Models needing independent budgets use their own accountant. Resolves the cross-model starvation ambiguity. - Make the check-then-record atomic under a threading.Lock (try_record), so the ThreadPoolExecutor fan-out the experiment scripts use can never overspend the cap. Claim-before-spend: an errored call still counts (conservative). - Rank subsample_cases by a stable blake2b(seed, case_id) hash instead of a numpy index permutation, so the nested-subset property holds regardless of input order. - Tests: independent-budget non-starvation, concurrent-cap atomicity, subsample order-invariance, failed-call accounting, from_budget. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * budget: rank the unbounded path too so a bounded pilot is a strict prefix of the full run, not just a subset (addresses Agastya's #263 review); update test to the new contract + add prefix test --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: sebasmos <sebasticajas@gmail.com>
Summary
benchmaxxing/budget.py: the policy/accounting layer on top of the existing gateway retry/rate-limit handling (Gateway retry, rate-limit handling, and error surfacing #35).RunBudget(max_cases,max_calls,seed) +subsample_cases: deterministic subsampling seeded byseed, so a smallermax_casesis always a strict prefix of a larger run over the same pool (a pilot is a subset of the full run, not an independent sample).CallAccountant: tallies calls (and tokens, when a backend reports them) per model, exposed via.summary()for the run report.BudgetedBackend+BudgetExceeded: wraps a backend so calls are tallied andmax_callsis enforced; raisesBudgetExceededinstead of making the call once the shared accountant's budget is hit, so a caller can stop cleanly and flush partial results.truncation_note(planned, completed): an explicit, non-silent note for the run summary when a run stops short.Closes #106.
Known scope boundaries
Backend.complete()returns a bare string), sotokens_per_modeldegrades gracefully to empty rather than inventing a widerBackendinterface change — out of scope for this issue.--max-callsCLI flag yet: there's nobenchmaxxing runentrypoint onmainto attach it to (tracked separately in Experiment entrypoint: abenchmaxxing runCLI that drives the stage runners from a config #101 / PR feat:benchmaxxing runentry point for the stage runners #251). The reusable primitives here are ready to wire in once that lands.Test plan
ruff check .passespytest -q— 653 passed, 6 skipped (unrelated, pre-existing)max_cases, per-model/total call tallying,BudgetExceededat the right call, and a partial-result-flush pattern in a mock run loop