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:
Kjell Tore Guttormsen 2026-07-03 07:21:02 +02:00
commit 9a4caeb419
7 changed files with 1003 additions and 0 deletions

53
tests/_scripted.py Normal file
View 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)

75
tests/test_budget.py Normal file
View file

@ -0,0 +1,75 @@
"""The budget meter (method-spec §8) — never an unbounded loop, anywhere.
Normative behaviours proved here: token accounting comes from provider-reported
usage only (a missing usage fails CLOSED, never silently stops counting);
crossing a cap raises a STRUCTURED stop event carrying the breached kind, the
limit, and the observed value never a silent hang.
"""
from __future__ import annotations
import pytest
from portfolio_optimiser_claude.budget import BudgetExceeded, BudgetMeter, UsageAccountingError
from portfolio_optimiser_claude.contracts import TerminationContract
def _meter(max_rounds: int = 5, max_tokens: int = 100) -> BudgetMeter:
return BudgetMeter(TerminationContract(max_rounds=max_rounds, max_tokens=max_tokens))
class TestTokenAccounting:
def test_accumulates_provider_reported_usage(self) -> None:
meter = _meter(max_tokens=100)
meter.charge_tokens(30)
meter.charge_tokens(20)
assert meter.tokens_used == 50
def test_missing_usage_fails_closed(self) -> None:
# §8: on counting paths, a response missing usage MUST fail closed —
# an error, not a silently-uncounted call.
meter = _meter()
with pytest.raises(UsageAccountingError):
meter.charge_tokens(None)
assert meter.tokens_used == 0
def test_reaching_the_cap_exactly_does_not_stop(self) -> None:
meter = _meter(max_tokens=100)
meter.charge_tokens(100)
assert meter.tokens_used == 100
def test_crossing_the_token_cap_raises_structured_stop(self) -> None:
meter = _meter(max_tokens=100)
meter.charge_tokens(90)
with pytest.raises(BudgetExceeded) as exc_info:
meter.charge_tokens(20)
stop = exc_info.value
assert stop.kind == "tokens"
assert stop.limit == 100
assert stop.observed == 110
class TestRoundAccounting:
def test_round_ticks_accumulate(self) -> None:
meter = _meter(max_rounds=5)
meter.charge_round()
meter.charge_round()
assert meter.rounds_used == 2
def test_crossing_the_round_cap_raises_structured_stop(self) -> None:
meter = _meter(max_rounds=2)
meter.charge_round()
meter.charge_round()
with pytest.raises(BudgetExceeded) as exc_info:
meter.charge_round()
stop = exc_info.value
assert stop.kind == "rounds"
assert stop.limit == 2
assert stop.observed == 3
def test_caps_come_from_the_startup_contract() -> None:
# §8/§10: the termination contract already refuses non-positive caps at
# construction — the meter builds on that, never on loose ints.
with pytest.raises(Exception):
TerminationContract(max_rounds=0, max_tokens=100)

View file

@ -0,0 +1,162 @@
"""Load-bearing: the checker gate (method-spec §3 Step 4, §11).
Either the checker actually gates, or the debate must not be called
maker-checker. RED-conditions proved here: the checker's surfaced output is
detached from the gate, OR its explicit REJECT no longer overrides an
otherwise-validated outcome. The gate is opt-in-reject (fail-open): APPROVE or
a missing marker never blocks; a validator rejection stands regardless of the
checker; the two falsifiers are recorded SEPARATELY (§9) the provenance
field mirrors only the validator, the checker's decision is its own field.
"""
from __future__ import annotations
import json
from _scripted import ScriptedClient, reply
from portfolio_optimiser_claude.budget import BudgetMeter
from portfolio_optimiser_claude.contracts import TerminationContract
from portfolio_optimiser_claude.loop import (
CheckerVerdict,
apply_checker_gate,
run_project,
)
from portfolio_optimiser_claude.validator import Rejection, ValidatedProposal
# 100 × 1000 = 100_000 NOK total; nominal feasible 30_000 == p90 (no band).
VALID_PROPOSAL = {
"project_id": "p1",
"measure": "led-retrofit",
"affected_items": [{"code": "E01", "quantity": 100, "unit_cost": 1000}],
"claimed_saving_nok": 25000,
}
# IR-valid (≤ total) but above p90 → the validator rejects it.
OVERCLAIMED_PROPOSAL = dict(VALID_PROPOSAL, claimed_saving_nok=90000)
VALIDATED = ValidatedProposal(
validates=True,
claimed_saving_nok=25000,
nominal_feasible=30000,
p10=30000,
p50=30000,
p90=30000,
)
def _meter() -> BudgetMeter:
return BudgetMeter(TerminationContract(max_rounds=50, max_tokens=10_000))
class TestApplyCheckerGate:
def test_explicit_reject_overrides_a_validated_outcome(self) -> None:
# THE load-bearing seam: red if the override is detached.
verdict = CheckerVerdict(decision="reject", reason="double-counted savings")
outcome = apply_checker_gate(VALIDATED, verdict)
assert isinstance(outcome, Rejection)
assert outcome.reason.startswith("double-counted savings")
def test_approve_never_blocks(self) -> None:
verdict = CheckerVerdict(decision="approve", reason=None)
assert apply_checker_gate(VALIDATED, verdict) is VALIDATED
def test_absent_marker_never_blocks(self) -> None:
# Fail-open: the validator remains the sole gate on such runs.
verdict = CheckerVerdict(decision="absent", reason=None)
assert apply_checker_gate(VALIDATED, verdict) is VALIDATED
def test_a_validator_rejection_stands_regardless_of_the_checker(self) -> None:
validator_rejection = Rejection(reason="claimed saving exceeds the feasible bound")
verdict = CheckerVerdict(decision="approve", reason=None)
assert apply_checker_gate(validator_rejection, verdict) is validator_rejection
class TestRunProjectGating:
def test_checker_reject_flips_an_otherwise_validated_run(self) -> None:
# Speiltest (sesjonsplan S8): numbers pass the validator, the checker's
# surfaced REJECT flips the run outcome — red without the override.
client = ScriptedClient(
replies=[
reply("reasoning"),
reply("VERDICT: REJECT - assumptions unsupported"),
reply(json.dumps(VALID_PROPOSAL)),
]
)
result = run_project(client, "context", meter=_meter(), max_debate_rounds=1)
assert isinstance(result.outcome, Rejection)
assert result.outcome.reason.startswith("assumptions unsupported")
# §9: the two falsifiers are never conflated — the provenance mirror
# says the NUMBERS passed; the checker's decision is its own field.
assert result.validator_decision == "validated"
assert result.checker_decision == "reject"
def test_the_gate_consumes_the_checkers_last_surfaced_output(self) -> None:
# Two debate rounds, two distinct REJECT reasons: the gate must carry
# the LAST one — red if the surfaced-output seam is detached.
client = ScriptedClient(
replies=[
reply("reasoning v1"),
reply("VERDICT: REJECT - EARLY-REASON"),
reply("reasoning v2"),
reply("VERDICT: REJECT - FINAL-REASON"),
reply(json.dumps(VALID_PROPOSAL)),
]
)
result = run_project(client, "context", meter=_meter(), max_debate_rounds=2)
assert isinstance(result.outcome, Rejection)
assert result.outcome.reason.startswith("FINAL-REASON")
assert "EARLY-REASON" not in result.outcome.reason
def test_an_approved_run_passes_through_ungated(self) -> None:
client = ScriptedClient(
replies=[
reply("reasoning"),
reply("sound. VERDICT: APPROVE"),
reply(json.dumps(VALID_PROPOSAL)),
]
)
result = run_project(client, "context", meter=_meter(), max_debate_rounds=1)
assert isinstance(result.outcome, ValidatedProposal)
assert result.validator_decision == "validated"
assert result.checker_decision == "approve"
def test_a_missing_marker_is_fail_open(self) -> None:
client = ScriptedClient(
replies=[
reply("reasoning"),
reply("no explicit verdict line here"),
reply(json.dumps(VALID_PROPOSAL)),
]
)
result = run_project(client, "context", meter=_meter(), max_debate_rounds=1)
assert isinstance(result.outcome, ValidatedProposal)
assert result.checker_decision == "absent"
def test_a_validator_rejection_stands_even_when_the_checker_approves(self) -> None:
client = ScriptedClient(
replies=[
reply("reasoning"),
reply("VERDICT: APPROVE"),
reply(json.dumps(OVERCLAIMED_PROPOSAL)),
reply(json.dumps(OVERCLAIMED_PROPOSAL)),
reply(json.dumps(OVERCLAIMED_PROPOSAL)),
]
)
result = run_project(client, "context", meter=_meter(), max_debate_rounds=1, max_attempts=3)
assert isinstance(result.outcome, Rejection)
assert result.validator_decision == "rejected"
assert result.checker_decision == "approve"
def test_the_debates_converged_output_feeds_generation(self) -> None:
# §3 Step 3: the debate's converged proposer output feeds Step 2's
# generation context.
client = ScriptedClient(
replies=[
reply("CONVERGED-REASONING-MARKER"),
reply("VERDICT: APPROVE"),
reply(json.dumps(VALID_PROPOSAL)),
]
)
run_project(client, "context", meter=_meter(), max_debate_rounds=1)
generation_prompt = client.prompts("proposer")[1]
assert "CONVERGED-REASONING-MARKER" in generation_prompt

210
tests/test_loop.py Normal file
View file

@ -0,0 +1,210 @@
"""Steps 2 and 3 of the loop (method-spec §3): generation + maker-checker debate.
Step 2 a reply that fails to parse into the typed IR is retried BLIND (same
prompt), never silently accepted or repaired downstream, bounded by the budget
meter (§8; round ticks between attempts). Step 3 the debate is round-capped
with a turn-count safety net above it, state is fresh per run, and the checker
is INSTRUCTED to end with exactly one verdict line. Verdict parsing is
case-insensitive, the reject marker takes precedence, trailing text is the
reason, and a missing marker parses as absent (fail-open input to the gate).
"""
from __future__ import annotations
import json
import pytest
from _scripted import ScriptedClient, reply
from portfolio_optimiser_claude.budget import BudgetExceeded, BudgetMeter
from portfolio_optimiser_claude.contracts import TerminationContract
from portfolio_optimiser_claude.loop import (
generate_candidate,
parse_checker_verdict,
run_debate,
)
# One affected item: 100 × 1000 = 100_000 NOK total; nominal feasible 30_000;
# no assumptions band, so every Monte Carlo sample is 30_000 and p90 == 30_000.
VALID_PROPOSAL = {
"project_id": "p1",
"measure": "led-retrofit",
"affected_items": [{"code": "E01", "quantity": 100, "unit_cost": 1000}],
"claimed_saving_nok": 25000,
}
def _meter(max_rounds: int = 50, max_tokens: int = 10_000) -> BudgetMeter:
return BudgetMeter(TerminationContract(max_rounds=max_rounds, max_tokens=max_tokens))
class TestGenerateCandidate:
def test_valid_reply_parses_into_the_typed_ir(self) -> None:
client = ScriptedClient(replies=[reply(json.dumps(VALID_PROPOSAL))])
meter = _meter()
proposal = generate_candidate(client, "base prompt", meter=meter)
assert proposal.claimed_saving_nok == 25000
assert meter.tokens_used == 10
def test_malformed_reply_is_retried_blind_with_the_same_prompt(self) -> None:
client = ScriptedClient(
replies=[reply("not json at all"), reply(json.dumps(VALID_PROPOSAL))]
)
proposal = generate_candidate(client, "base prompt", meter=_meter())
assert proposal.measure == "led-retrofit"
# Blind retry: the SAME prompt, unchanged — never a repair instruction.
assert client.prompts("proposer") == ["base prompt", "base prompt"]
def test_schema_invalid_json_is_retried_never_repaired(self) -> None:
# Claim above the items' own total is a schema error (§7.1) — the value
# must never exist; the loop retries, it does not clamp or repair.
overclaim = dict(VALID_PROPOSAL, claimed_saving_nok=999_999)
client = ScriptedClient(
replies=[reply(json.dumps(overclaim)), reply(json.dumps(VALID_PROPOSAL))]
)
proposal = generate_candidate(client, "base prompt", meter=_meter())
assert proposal.claimed_saving_nok == 25000
def test_parse_retries_charge_round_ticks(self) -> None:
# §8: round ticks are charged between attempts so the meter also
# bounds parse-retries.
client = ScriptedClient(
replies=[reply("garbage"), reply("garbage"), reply(json.dumps(VALID_PROPOSAL))]
)
meter = _meter()
generate_candidate(client, "base prompt", meter=meter)
assert meter.rounds_used == 2
def test_endless_garbage_is_stopped_by_the_meter(self) -> None:
client = ScriptedClient(script=lambda role, prompt: reply("garbage"))
with pytest.raises(BudgetExceeded) as exc_info:
generate_candidate(client, "base prompt", meter=_meter(max_rounds=3))
assert exc_info.value.kind == "rounds"
def test_project_id_may_be_defaulted_from_the_project(self) -> None:
omitted = {k: v for k, v in VALID_PROPOSAL.items() if k != "project_id"}
client = ScriptedClient(replies=[reply(json.dumps(omitted))])
proposal = generate_candidate(
client, "base prompt", meter=_meter(), default_project_id="p-default"
)
assert proposal.project_id == "p-default"
def test_present_project_id_is_never_overwritten_by_the_default(self) -> None:
client = ScriptedClient(replies=[reply(json.dumps(VALID_PROPOSAL))])
proposal = generate_candidate(
client, "base prompt", meter=_meter(), default_project_id="p-default"
)
assert proposal.project_id == "p1"
def test_missing_usage_on_the_counting_path_fails_closed(self) -> None:
client = ScriptedClient(replies=[reply(json.dumps(VALID_PROPOSAL), usage_tokens=None)])
with pytest.raises(Exception, match="usage"):
generate_candidate(client, "base prompt", meter=_meter())
class TestRunDebate:
def test_converges_when_the_checker_approves(self) -> None:
client = ScriptedClient(replies=[reply("reasoning v1"), reply("holds. VERDICT: APPROVE")])
debate = run_debate(client, "context", max_rounds=3, meter=_meter())
assert debate.rounds == 1
assert debate.proposer_output == "reasoning v1"
assert "VERDICT: APPROVE" in debate.checker_last
def test_round_cap_bounds_a_never_approving_debate(self) -> None:
client = ScriptedClient(
script=lambda role, prompt: reply(
"VERDICT: REJECT - weak numbers" if role == "checker" else "reasoning"
)
)
debate = run_debate(client, "context", max_rounds=2, meter=_meter())
assert debate.rounds == 2
assert len(client.prompts("proposer")) == 2
assert len(client.prompts("checker")) == 2
assert "REJECT" in debate.checker_last
def test_checker_is_instructed_to_end_with_the_verdict_line(self) -> None:
# §3 Step 3: the checker MUST be instructed to end its reply with
# exactly one verdict line, both marker forms spelled out.
client = ScriptedClient(replies=[reply("reasoning"), reply("VERDICT: APPROVE")])
run_debate(client, "context", max_rounds=1, meter=_meter())
checker_prompt = client.prompts("checker")[0]
assert "VERDICT: APPROVE" in checker_prompt
assert "VERDICT: REJECT - <short reason>" in checker_prompt
def test_checker_critique_reaches_the_next_proposer_turn(self) -> None:
client = ScriptedClient(
replies=[
reply("reasoning v1"),
reply("VERDICT: REJECT - unit costs are stale"),
reply("reasoning v2"),
reply("VERDICT: APPROVE"),
]
)
debate = run_debate(client, "context", max_rounds=3, meter=_meter())
assert debate.rounds == 2
assert "unit costs are stale" in client.prompts("proposer")[1]
assert debate.proposer_output == "reasoning v2"
def test_debate_state_is_fresh_per_run(self) -> None:
# §3 Step 3: no conversation state may survive from one run into the
# next — the second run's opening proposer prompt carries nothing from
# the first run's transcript.
client = ScriptedClient(
replies=[
reply("FIRST-RUN-MARKER reasoning"),
reply("VERDICT: REJECT - FIRST-RUN-CRITIQUE"),
reply("more reasoning"),
reply("VERDICT: APPROVE"),
reply("second-run reasoning"),
reply("VERDICT: APPROVE"),
]
)
run_debate(client, "context A", max_rounds=2, meter=_meter())
run_debate(client, "context B", max_rounds=2, meter=_meter())
second_run_opening = client.prompts("proposer")[2]
assert "FIRST-RUN-MARKER" not in second_run_opening
assert "FIRST-RUN-CRITIQUE" not in second_run_opening
def test_every_turn_is_charged_on_the_meter(self) -> None:
client = ScriptedClient(
replies=[reply("reasoning", usage_tokens=7), reply("VERDICT: APPROVE", usage_tokens=5)]
)
meter = _meter()
run_debate(client, "context", max_rounds=1, meter=meter)
assert meter.tokens_used == 12
assert meter.rounds_used == 1
def test_the_turn_safety_net_sits_above_the_round_cap(self) -> None:
# §3 Step 3 / §8: an additional turn-count termination safety net
# ABOVE the round cap — it must never fire within a round-capped run,
# and must refuse a turn count beyond it.
from portfolio_optimiser_claude.loop import check_turn_safety_net
check_turn_safety_net(turns=2 * 3, max_rounds=3) # within: no raise
with pytest.raises(RuntimeError):
check_turn_safety_net(turns=2 * 3 + 3, max_rounds=3)
class TestParseCheckerVerdict:
def test_approve_is_parsed_case_insensitively(self) -> None:
verdict = parse_checker_verdict("the numbers hold.\nverdict: approve")
assert verdict.decision == "approve"
def test_reject_carries_the_trailing_text_as_reason(self) -> None:
verdict = parse_checker_verdict("VERDICT: REJECT - savings claim is double-counted")
assert verdict.decision == "reject"
assert verdict.reason == "savings claim is double-counted"
def test_the_reject_marker_takes_precedence(self) -> None:
verdict = parse_checker_verdict(
"VERDICT: APPROVE was my first instinct, but no.\nVERDICT: REJECT - stale baseline"
)
assert verdict.decision == "reject"
assert verdict.reason == "stale baseline"
def test_missing_marker_parses_as_absent(self) -> None:
verdict = parse_checker_verdict("looks fine to me")
assert verdict.decision == "absent"
def test_empty_text_parses_as_absent(self) -> None:
assert parse_checker_verdict("").decision == "absent"

View file

@ -0,0 +1,119 @@
"""Load-bearing: informed, bounded refinement (method-spec §3 Step 5, §11).
RED-conditions proved here: the prior rejection reason no longer appears
VERBATIM in the next prompt, or the outcome never flips. The flip proof uses a
prompt-SENSITIVE scripted stand-in (honesty rule §1): it returns a feasible
proposal ONLY when the full rejection reason is present in the prompt so the
test goes red the moment the informed block is detached. Bounds proved: only
the MOST RECENT reason is carried (never accumulated history), never the prior
proposal JSON, attempt 1 uses the unchanged base prompt, and the loop stops at
``max_attempts``.
"""
from __future__ import annotations
import json
import pytest
from _scripted import ScriptedClient, reply
from portfolio_optimiser_claude.budget import BudgetMeter
from portfolio_optimiser_claude.contracts import TerminationContract
from portfolio_optimiser_claude.ir import SavingsProposal
from portfolio_optimiser_claude.loop import ModelReply, run_candidate_loop
from portfolio_optimiser_claude.validator import Rejection, ValidatedProposal, validate_proposal
# 100 × 1000 = 100_000 NOK total; nominal feasible 30_000 == p90 (no band).
GOOD = {
"project_id": "p1",
"measure": "led-retrofit",
"affected_items": [{"code": "E01", "quantity": 100, "unit_cost": 1000}],
"claimed_saving_nok": 25000,
}
BAD_90K = dict(GOOD, claimed_saving_nok=90000) # IR-valid, above p90 → rejected
BAD_80K = dict(GOOD, claimed_saving_nok=80000) # a SECOND distinct rejection reason
def _rejection_reason(raw: dict[str, object]) -> str:
outcome = validate_proposal(SavingsProposal.model_validate(raw))
assert isinstance(outcome, Rejection)
return outcome.reason
def _meter() -> BudgetMeter:
return BudgetMeter(TerminationContract(max_rounds=50, max_tokens=10_000))
class TestInformedRefinement:
def test_the_outcome_flips_because_the_reason_reaches_the_prompt(self) -> None:
# THE load-bearing flip: the stand-in addresses the falsification ONLY
# if the full rejection reason appears verbatim — red at detach.
reason_90k = _rejection_reason(BAD_90K)
def script(role: str, prompt: str) -> ModelReply:
if reason_90k in prompt:
return reply(json.dumps(GOOD))
return reply(json.dumps(BAD_90K))
client = ScriptedClient(script=script)
result = run_candidate_loop(client, "base prompt", meter=_meter(), max_attempts=3)
assert isinstance(result.outcome, ValidatedProposal)
assert result.attempts == 2
assert reason_90k in client.prompts("proposer")[1]
def test_attempt_one_uses_the_unchanged_base_prompt(self) -> None:
client = ScriptedClient(replies=[reply(json.dumps(GOOD))])
run_candidate_loop(client, "base prompt", meter=_meter(), max_attempts=3)
assert client.prompts("proposer")[0] == "base prompt"
def test_only_the_most_recent_reason_is_carried(self) -> None:
# §3 Step 5: never an accumulated history — bounded prompt growth.
reason_90k = _rejection_reason(BAD_90K)
reason_80k = _rejection_reason(BAD_80K)
client = ScriptedClient(
replies=[
reply(json.dumps(BAD_90K)),
reply(json.dumps(BAD_80K)),
reply(json.dumps(GOOD)),
]
)
result = run_candidate_loop(client, "base prompt", meter=_meter(), max_attempts=3)
assert isinstance(result.outcome, ValidatedProposal)
third_prompt = client.prompts("proposer")[2]
assert reason_80k in third_prompt
assert reason_90k not in third_prompt
def test_the_prior_proposal_json_is_never_carried(self) -> None:
# The model must address the falsification, not parrot the rejected
# candidate — only the REASON crosses attempts.
client = ScriptedClient(replies=[reply(json.dumps(BAD_90K)), reply(json.dumps(GOOD))])
run_candidate_loop(client, "base prompt", meter=_meter(), max_attempts=3)
second_prompt = client.prompts("proposer")[1]
assert json.dumps(BAD_90K) not in second_prompt
assert '"affected_items"' not in second_prompt
def test_the_loop_stops_at_max_attempts(self) -> None:
client = ScriptedClient(script=lambda role, prompt: reply(json.dumps(BAD_90K)))
result = run_candidate_loop(client, "base prompt", meter=_meter(), max_attempts=3)
assert isinstance(result.outcome, Rejection)
assert result.attempts == 3
assert len(client.prompts("proposer")) == 3
def test_max_attempts_must_be_positive(self) -> None:
client = ScriptedClient(replies=[reply(json.dumps(GOOD))])
with pytest.raises(ValueError):
run_candidate_loop(client, "base prompt", meter=_meter(), max_attempts=0)
def test_round_ticks_are_charged_between_attempts(self) -> None:
client = ScriptedClient(replies=[reply(json.dumps(BAD_90K)), reply(json.dumps(GOOD))])
meter = _meter()
run_candidate_loop(client, "base prompt", meter=meter, max_attempts=3)
assert meter.rounds_used == 1
def test_a_never_validating_run_yields_a_typed_rejection_not_a_bare_failure(self) -> None:
# §3 Step 6: the outcome is either the validated proposal or a TYPED
# rejection with its reason — never a bare failure.
client = ScriptedClient(script=lambda role, prompt: reply(json.dumps(BAD_90K)))
result = run_candidate_loop(client, "base prompt", meter=_meter(), max_attempts=2)
assert isinstance(result.outcome, Rejection)
assert "exceeds the optimistic feasible bound" in result.outcome.reason