portfolio-optimiser-claude/tests/test_loop.py
Kjell Tore Guttormsen 7637c6feae fix(run): S10 del 2 — post-mortem: stopp-artefakt, SDK-isolasjon, raw-JSON-direktiv
Transkript-analyse av den stoppede live-kjøringen (10 kall, 162 250 tokens,
$0.331506): konfig-lekkasjen (setting_sources=None laster ALLE filsystem-
settings) injiserte operatørens Claude-konfig i hvert kall — ~10-15k uncachede
tokens, en påtvunget bekreftelses-preamble som gjorde ren-JSON-svar umulige,
og en checker kapret av lekkede instrukser (debatt konvergerte aldri).

- persist_stop_artifacts: stopp-event verbatim + usage/kost persisteres ALLTID
  ved BudgetExceeded (delt usage-shape med fullført-run-stien)
- build_call_options: setting_sources=[] (SDK isolation mode, verifisert mot
  installert 0.2.110-kilde), system_prompt=None → tom system-prompt; detach-
  bevis via monkeypatchet query
- _generation_prompt: krever ONLY the raw JSON object (fence-innpakning ga
  4 fullpris parse-retries)

187/187 uten nøkkel · ruff + mypy --strict rene · tre detach-bevis RØDE → grønn

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdSfQdND84oeq2mbjueLTS
2026-07-03 10:49:51 +02:00

229 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
run_project,
)
# 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"
class TestGenerationPrompt:
def test_generation_demands_the_raw_json_object_only(self) -> None:
# S10 post-mortem: 3 of 4 generation replies carried plausible JSON
# wrapped in a markdown fence + commentary — each one a full-price
# blind parse-retry (§3 Step 2). The prompt must forbid the wrapping.
client = ScriptedClient(
replies=[
reply("reasoning"),
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 "ONLY the raw JSON object" in generation_prompt
assert "no markdown fences" in generation_prompt