feat(portfolio): C3.5 — pre-call run-total USD budget belt (parity row 16/31) [skip-docs]
Add a pre-call USD belt on top of the post-charge token/round meter (§8), so no future live run can loop past its run budget. Belt-and-braces above the SDK's per-call max_budget_usd cap. - budget.py: optional run-total `max_cost_usd` on BudgetMeter (fail-fast on non-positive, §10) + `guard_before_call(spent_usd)` raising the same structured stop event (BudgetKind widened with "cost_usd"; limit/observed → float). Reaching the cap exactly does not stop; crossing it does (mirrors the token cap). - loop.py: `_guarded_complete` helper reads the client's accumulated total_cost_usd (0.0 for scripted clients) and guards BEFORE every client.complete; all three call sites routed through it — one detach point. - sdk_client.py: total_cost_usd already exposed/accumulated — untouched. - tests/test_budget.py: meter-level cap tests + load-bearing loop-wiring test (counting client; detach the guard → unguarded loop runs to the round cap → kind "rounds" not "cost_usd" → red). 457→462 green, golden byte-exact, full gate clean (ruff+format+mypy strict, 22 src files), run_s10.py/runs/ byte-untouched. README test-count sync ×2 + budget.py belt note. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiTwaKLesgcwXx2mDviqpt
This commit is contained in:
parent
a926e4ad46
commit
111b320b75
4 changed files with 145 additions and 14 deletions
|
|
@ -60,6 +60,22 @@ class ModelClient(Protocol):
|
|||
def complete(self, prompt: str, *, role: str) -> ModelReply: ...
|
||||
|
||||
|
||||
def _guarded_complete(
|
||||
client: ModelClient, prompt: str, *, role: str, meter: BudgetMeter
|
||||
) -> ModelReply:
|
||||
"""One model call, pre-guarded by the run-total USD belt (C3.5, §8).
|
||||
|
||||
Every model call in the loop goes through here. The pre-call guard reads the
|
||||
client's accumulated ``total_cost_usd`` (0.0 for scripted clients that carry
|
||||
no cost) and refuses to make the call once the run-total USD cap is crossed
|
||||
— the belt that stops the loop from spending past its run budget, on TOP of
|
||||
the per-call SDK cap and the post-charge token/round meter.
|
||||
"""
|
||||
spent_usd: float = getattr(client, "total_cost_usd", 0.0)
|
||||
meter.guard_before_call(spent_usd)
|
||||
return client.complete(prompt, role=role)
|
||||
|
||||
|
||||
# --- Step 2: hypothesise (structured candidate generation) ---------------------------------
|
||||
|
||||
|
||||
|
|
@ -87,7 +103,7 @@ def generate_candidate(
|
|||
bounded by the budget meter: a round tick is charged between attempts (§8).
|
||||
"""
|
||||
while True:
|
||||
model_reply = client.complete(prompt, role=_PROPOSER_ROLE)
|
||||
model_reply = _guarded_complete(client, prompt, role=_PROPOSER_ROLE, meter=meter)
|
||||
meter.charge_tokens(model_reply.usage_tokens)
|
||||
try:
|
||||
# JSONDecodeError and pydantic's ValidationError are ValueErrors.
|
||||
|
|
@ -157,15 +173,17 @@ def run_debate(
|
|||
for _ in range(max_rounds):
|
||||
turns += 1
|
||||
check_turn_safety_net(turns, max_rounds)
|
||||
proposer_reply = client.complete(
|
||||
_proposer_debate_prompt(context, critique), role=_PROPOSER_ROLE
|
||||
proposer_reply = _guarded_complete(
|
||||
client, _proposer_debate_prompt(context, critique), role=_PROPOSER_ROLE, meter=meter
|
||||
)
|
||||
meter.charge_tokens(proposer_reply.usage_tokens)
|
||||
proposer_output = proposer_reply.text
|
||||
|
||||
turns += 1
|
||||
check_turn_safety_net(turns, max_rounds)
|
||||
checker_reply = client.complete(_checker_prompt(proposer_output), role=_CHECKER_ROLE)
|
||||
checker_reply = _guarded_complete(
|
||||
client, _checker_prompt(proposer_output), role=_CHECKER_ROLE, meter=meter
|
||||
)
|
||||
meter.charge_tokens(checker_reply.usage_tokens)
|
||||
checker_last = checker_reply.text
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue