portfolio-optimiser-claude/tests/_scripted.py
Kjell Tore Guttormsen 9a4caeb419 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
2026-07-03 07:21:02 +02:00

53 lines
2 KiB
Python

"""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)