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
53
tests/_scripted.py
Normal file
53
tests/_scripted.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"""SCRIPTED model-client stand-in for the offline test suite.
|
||||
|
||||
Honesty rule (method-spec §1): this is a scripted stand-in, NOT a model. It
|
||||
replays canned replies (or computes them via a prompt-sensitive script
|
||||
function) deterministically, records every call, and never touches a network
|
||||
or an API key. The ONE genuine model run in the programme is S10.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from portfolio_optimiser_claude.loop import ModelReply
|
||||
|
||||
ScriptFn = Callable[[str, str], ModelReply]
|
||||
|
||||
|
||||
class ScriptedClient:
|
||||
"""Deterministic ``ModelClient`` stand-in — canned replies, recorded calls.
|
||||
|
||||
Either ``replies`` (a FIFO of ``ModelReply``) or ``script`` (a function of
|
||||
``(role, prompt)`` — prompt-sensitive, for load-bearing flip proofs) must
|
||||
be given. Every call is recorded as ``(role, prompt)`` in ``calls``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
replies: list[ModelReply] | None = None,
|
||||
script: ScriptFn | None = None,
|
||||
) -> None:
|
||||
if (replies is None) == (script is None):
|
||||
raise ValueError("give exactly one of 'replies' or 'script'")
|
||||
self._replies = list(replies) if replies is not None else None
|
||||
self._script = script
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
def complete(self, prompt: str, *, role: str) -> ModelReply:
|
||||
self.calls.append((role, prompt))
|
||||
if self._script is not None:
|
||||
return self._script(role, prompt)
|
||||
assert self._replies is not None
|
||||
if not self._replies:
|
||||
raise AssertionError("scripted client exhausted: no reply left for this call")
|
||||
return self._replies.pop(0)
|
||||
|
||||
def prompts(self, role: str) -> list[str]:
|
||||
"""The recorded prompts sent to ``role``, in call order."""
|
||||
return [prompt for r, prompt in self.calls if r == role]
|
||||
|
||||
|
||||
def reply(text: str, usage_tokens: int | None = 10) -> ModelReply:
|
||||
"""Shorthand for a scripted ``ModelReply`` with a small default usage."""
|
||||
return ModelReply(text=text, usage_tokens=usage_tokens)
|
||||
Loading…
Add table
Add a link
Reference in a new issue