portfolio-optimiser/tests/test_checker_gate_loadbearing.py
Kjell Tore Guttormsen 4ec778c855 feat(fase3): make the maker-checker checker actually gate the reasoning
Closes gap #3 (maalbilde §5): the GroupChat checker critiqued into the void —
output_from=[proposer] surfaced only the proposer, so an explicit checker
rejection was ignored and the deterministic validator was the sole gate. Two
falsifiers now act on the same candidate: the validator gates the NUMBERS
(blocking, unchanged), the checker gates the REASONING (maalbilde §2/§6).

- workflow.py: output_from=agents surfaces both participants; the checker
  instruction ends with a VERDICT: APPROVE / VERDICT: REJECT - <reason> line.
- run.py: _authored_texts() reads author_name through out.messages (MAF 1.9.0
  puts it there, not on the AgentResponse); _debate_text() now selects the
  PROPOSER-authored output (fixes a latent texts[-1] regression that would feed
  the checker's verdict to generation at even round counts); _checker_verdict()
  parses the gate decision. An explicit REJECT overrides an otherwise-validated
  outcome to a checker-sourced Rejection. Opt-in-reject (fail-open on a missing
  marker). RunResult gains checker_verdict; provenance.validator_decision is
  stamped from the validator outcome BEFORE the override, so it never conflates
  the two falsifiers (provenance honesty).

Load-bearing (maalbilde §7): tests/test_checker_gate_loadbearing.py is a PAIR —
an explicit checker REJECT on a VALIDATOR-VALID proposal yields a Rejection whose
reason carries the checker's reason while validator_decision stays "validated";
the causality control (checker APPROVE, same proposer) validates normally. Proven
RED on BOTH detach points (revert output_from, or drop the override).

Suite 134->136 passed, 4 skipped; mypy + ruff check clean. Pre-existing
ruff-format drift (backends/budget/verdicts/test_contracts) left untouched for a
surgical diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MHR8iKxJRxDiDfNw8HZmWE
2026-06-30 07:24:30 +02:00

102 lines
4.9 KiB
Python

"""Step 3/4 load-bearing seam (målbilde §2/§6/§7): an explicit checker REJECT MUST gate.
The gap (fot-i-bakken, verified in code): the GroupChat checker critiqued into the void —
``output_from=[proposer]`` surfaced only the proposer, so an explicit checker rejection was
ignored and the ONLY blocking gate was the deterministic validator (on the *numbers*). Fase 3
surfaces the checker verdict and makes an explicit ``VERDICT: REJECT`` block an otherwise-
validated proposal — qualitative falsification on the *reasoning*, distinct from the validator
on the *numbers* (målbilde §2 step 4).
These two tests are a load-bearing PAIR, not a smoke test (per the Fase-2 green-but-dead trap):
- the positive test uses a VALIDATOR-VALID proposer, so the ONLY possible rejecter is the
checker; it asserts the checker's reason propagates into ``outcome.reason`` AND that provenance
stays honest (``validator_decision == "validated"``). It goes RED on EITHER detach: revert
``output_from=agents`` to proposer-only, OR drop the run.py checker override.
- the approve test is the causality control: the SAME valid proposer with a checker APPROVE
validates normally — proving the rejection above is CAUSED by the checker verdict, not the
fixture.
"""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from agent_framework import BaseChatClient
from conftest import SyntheticUsageChatClient
from portfolio_optimiser.run import run_project
from portfolio_optimiser.validator import Rejection, ValidatedProposal
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
# A VALIDATOR-VALID BYGG-KONTOR-NORD proposal (same shape as the Step-1 test): affected total
# 300000 x 1.0; no assumptions -> degenerate Monte Carlo P90 = 0.30 x 300000 = 90000 >= claimed
# 30000 -> validates on the first attempt. So in the reject test the ONLY possible rejecter is
# the checker, never the validator (closes the green-but-dead trap).
_VALID_PROPOSER_REPLY = (
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],"claimed_saving_nok":30000}'
)
_VERDICT_INPUT = {"decision": "approved", "rationale": "expert reviewed (sim)"}
# A unique reason substring: it can only reach outcome.reason if the checker's output actually
# reached the gate (the "third assertion" that makes the pair load-bearing, not green-but-dead).
_REJECT_REASON = "payback exceeds horizon (sim-unique a1b2c3)"
def _role_factory(proposer_reply: str, checker_reply: str) -> Callable[[str], BaseChatClient]:
"""A role-aware synthetic factory: the checker speaks its verdict, the proposer its proposal.
The existing make_*_client_factory fixtures are role-agnostic, so the gate needs this."""
def factory(role: str) -> BaseChatClient:
return SyntheticUsageChatClient(
default_reply=checker_reply if role == "checker" else proposer_reply
)
return factory
async def test_explicit_checker_reject_gates_a_validated_proposal() -> None:
"""LOAD-BEARING: an explicit checker REJECT blocks an otherwise-validated proposal. Goes RED
if ``output_from`` drops the checker OR the run.py checker override is removed."""
factory = _role_factory(_VALID_PROPOSER_REPLY, f"VERDICT: REJECT - {_REJECT_REASON}")
result = await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
client_factory=factory,
)
assert isinstance(result.outcome, Rejection), (
"checker said REJECT but the proposal was not gated — the checker verdict is ignored"
)
assert _REJECT_REASON in result.outcome.reason, (
"the checker's reason did not propagate into the outcome — its output never reached the gate"
)
# Provenance honesty: the VALIDATOR passed (the numbers are feasible); only the checker
# rejected. validator_decision must reflect the numbers, never conflate the two falsifiers.
assert result.provenance.validator_decision == "validated", (
"provenance conflates the two falsifiers — validator_decision must reflect the numbers only"
)
assert result.checker_verdict == "reject"
async def test_checker_approve_lets_a_validated_proposal_through() -> None:
"""CAUSALITY CONTROL: the SAME valid proposer with a checker APPROVE validates normally —
proving the rejection above is caused by the checker verdict, not the fixture's mere shape."""
factory = _role_factory(_VALID_PROPOSER_REPLY, "VERDICT: APPROVE")
result = await run_project(
"BYGG-KONTOR-NORD",
"local",
docs_dir=str(BUNDLE_DIR),
bundle_dir=str(BUNDLE_DIR),
verdict_input=_VERDICT_INPUT,
client_factory=factory,
)
assert isinstance(result.outcome, ValidatedProposal)
assert result.checker_verdict == "approve"