feat(loop): S8 — D7 agentic loop: budget meter, maker-checker gate, informed refinement
Spec §3 steps 2–5 + §8, TDD-ed offline (scripted, honesty-marked stand-in): - budget.py: BudgetMeter over TerminationContract — provider-reported usage only (missing usage fails closed), structured BudgetExceeded stop event. - loop.py: ModelClient protocol; blind parse-retry generation (never silent repair); round-capped debate with turn safety net and mandated VERDICT line; opt-in-reject checker gate (explicit REJECT overrides a validated outcome, validator rejection stands); most-recent-reason-verbatim informed refinement under max_attempts; validator_decision stamped BEFORE override, checker_decision as its own result field (§9, never conflated). - 45 new tests (121 total, no API key); four detach proofs run RED and reverted green: checker override, informed block, surfaced checker output, stamp-before-override. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QdSfQdND84oeq2mbjueLTS
This commit is contained in:
parent
46f2f521f7
commit
9a4caeb419
7 changed files with 1003 additions and 0 deletions
57
src/portfolio_optimiser_claude/budget.py
Normal file
57
src/portfolio_optimiser_claude/budget.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""The budget meter (method-spec §8) — never an unbounded loop, anywhere.
|
||||
|
||||
Token accounting comes from the PROVIDER-REPORTED usage after each model call —
|
||||
never a word-count or character proxy; on counting paths a response missing
|
||||
usage fails CLOSED (``UsageAccountingError``), not silently uncounted. Crossing
|
||||
a cap raises ``BudgetExceeded``, a STRUCTURED stop event carrying the breached
|
||||
kind, the limit, and the observed value — never a silent hang. The caps come
|
||||
from the fail-fast startup ``TerminationContract`` (§10), which already refuses
|
||||
non-positive values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from portfolio_optimiser_claude.contracts import TerminationContract
|
||||
|
||||
BudgetKind = Literal["tokens", "rounds"]
|
||||
|
||||
|
||||
class UsageAccountingError(Exception):
|
||||
"""A response missing provider-reported usage on a counting path (§8)."""
|
||||
|
||||
|
||||
class BudgetExceeded(Exception):
|
||||
"""The structured stop event: breached kind + limit + observed value (§8)."""
|
||||
|
||||
def __init__(self, kind: BudgetKind, limit: int, observed: int) -> None:
|
||||
super().__init__(f"budget exceeded: {kind} observed {observed} > limit {limit}")
|
||||
self.kind: BudgetKind = kind
|
||||
self.limit = limit
|
||||
self.observed = observed
|
||||
|
||||
|
||||
class BudgetMeter:
|
||||
"""Run-scoped usage meter over the startup termination contract (§8)."""
|
||||
|
||||
def __init__(self, termination: TerminationContract) -> None:
|
||||
self._termination = termination
|
||||
self.tokens_used = 0
|
||||
self.rounds_used = 0
|
||||
|
||||
def charge_tokens(self, usage_tokens: int | None) -> None:
|
||||
"""Charge provider-reported usage; a missing usage fails closed (§8)."""
|
||||
if usage_tokens is None:
|
||||
raise UsageAccountingError(
|
||||
"response carries no usage — token accounting must fail closed (§8)"
|
||||
)
|
||||
self.tokens_used += usage_tokens
|
||||
if self.tokens_used > self._termination.max_tokens:
|
||||
raise BudgetExceeded("tokens", self._termination.max_tokens, self.tokens_used)
|
||||
|
||||
def charge_round(self) -> None:
|
||||
"""Charge one round tick (debate rounds and between-attempt ticks, §8)."""
|
||||
self.rounds_used += 1
|
||||
if self.rounds_used > self._termination.max_rounds:
|
||||
raise BudgetExceeded("rounds", self._termination.max_rounds, self.rounds_used)
|
||||
Loading…
Add table
Add a link
Reference in a new issue