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
102 lines
4.9 KiB
Python
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"
|