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