"""S2.1 outbox output-layer (målbilde §3, R2 RAW layer): ``write_outbox`` persists two byte-deterministic JSON artefacts per run — ``{run_id}-proposal.json`` (the candidate + provenance) and ``{run_id}-outcome.json`` (the validated percentiles OR the rejection reason, plus the checker verdict + verdict id). This is the traceable output layer Fase 5 (S5.1/S5.4) builds on. The writer is MAF-free (pure stdlib + the MAF-free ``validator`` leaf) — proven load-bearing by ``test_okf_is_maf_free`` covering ``outbox.py`` (T-2.1d), plus the byte-determinism (T-2.1b) and both-arms (T-2.1a) checks below. """ from __future__ import annotations from collections.abc import Callable from pathlib import Path import pytest from agent_framework import BaseChatClient from conftest import SyntheticUsageChatClient from portfolio_optimiser.ir import AffectedItem, SavingsProposal from portfolio_optimiser.outbox import write_outbox from portfolio_optimiser.provenance import Citation, ProvenanceStamp from portfolio_optimiser.retrieval import TextSpan from portfolio_optimiser.run import run_project from portfolio_optimiser.validator import Rejection, ValidatedProposal # --- Step 6 (run_project wiring) fixtures: the shared bundle + a role-aware scripted factory --- BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro" # A VALIDATOR-VALID BYGG-KONTOR-NORD proposal (degenerate Monte Carlo P90 = 0.30 x 300000 = 90000 # >= claimed 30000 -> validates), so the only possible rejecter is the checker. _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)"} 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.""" def factory(role: str) -> BaseChatClient: return SyntheticUsageChatClient( default_reply=checker_reply if role == "checker" else proposer_reply ) return factory _PROPOSAL = SavingsProposal( project_id="P1", measure="LED-retrofit av kontorbelysning", affected_items=[AffectedItem(code="ENERGI-TOTAL-EL", quantity=1000.0, unit_cost=1.0)], claimed_saving_nok=200.0, assumptions={"ENERGI-TOTAL-EL": (0.8, 1.2)}, ) _PROVENANCE = ProvenanceStamp( citations=[ Citation(file="f.md", locator=TextSpan(start_index=0, end_index=5), snippet="hello") ], model="synthetic", role="proposer", validator_decision="validated", token_usage=8, ) _VALIDATED = ValidatedProposal( proposal=_PROPOSAL, p10=100.0, p50=150.0, p90=200.0, nominal_feasible=180.0 ) _REJECTION = Rejection(proposal=_PROPOSAL, reason="checker rejected: unsupported reasoning") def _read(path: Path) -> str: return path.read_text(encoding="utf-8") def test_outbox_writes_validated_arm(tmp_path) -> None: """T-2.1a (validated arm): both files are written with the proposal fields + provenance in proposal.json, and outcome_type=validated + percentiles + checker verdict + verdict id in outcome.json.""" prop_path, out_path = write_outbox( str(tmp_path), "run-1", outcome=_VALIDATED, provenance=_PROVENANCE, checker_verdict="approve", verdict_id="vid-abc", ) assert prop_path == tmp_path / "run-1-proposal.json" assert out_path == tmp_path / "run-1-outcome.json" proposal_text = _read(prop_path) assert "LED-retrofit av kontorbelysning" in proposal_text assert "ENERGI-TOTAL-EL" in proposal_text assert '"model": "synthetic"' in proposal_text # provenance embedded outcome_text = _read(out_path) assert '"outcome_type": "validated"' in outcome_text assert '"p90": 200.0' in outcome_text assert '"checker_verdict": "approve"' in outcome_text assert '"verdict_id": "vid-abc"' in outcome_text assert "reason" not in outcome_text # the validated arm carries no rejection reason def test_outbox_writes_rejected_arm(tmp_path) -> None: """T-2.1a (rejected arm): a Rejection outcome writes outcome_type=rejected + the reason, and NO percentiles (the Rejection type carries none — it can never masquerade as validated).""" _prop_path, out_path = write_outbox( str(tmp_path), "run-2", outcome=_REJECTION, provenance=_PROVENANCE, checker_verdict="reject", verdict_id="vid-def", ) outcome_text = _read(out_path) assert '"outcome_type": "rejected"' in outcome_text assert "unsupported reasoning" in outcome_text assert '"verdict_id": "vid-def"' in outcome_text assert "p90" not in outcome_text # no percentiles on the rejected arm def test_outbox_is_byte_deterministic(tmp_path) -> None: """T-2.1b: two writes with identical input + the same run_id produce byte-identical files (sort_keys + indent=2 + explicit LF, no wall-clock/uuid) — the artefacts are diff-stable.""" a = tmp_path / "a" b = tmp_path / "b" args = dict( outcome=_VALIDATED, provenance=_PROVENANCE, checker_verdict="approve", verdict_id="vid-abc" ) pa, oa = write_outbox(str(a), "run-1", **args) pb, ob = write_outbox(str(b), "run-1", **args) assert pa.read_bytes() == pb.read_bytes() assert oa.read_bytes() == ob.read_bytes() def test_outbox_registered_maf_free() -> None: """T-2.1d meta: outbox.py is registered in the MAF-free guard list, so test_okf_is_maf_free actually scans it — otherwise the MAF-free claim would be green-but-dead (never checked).""" from tests.test_okf import _MAF_FREE_MODULES assert "outbox.py" in _MAF_FREE_MODULES async def test_run_project_writes_outbox_validated_arm(tmp_path) -> None: """T-2.1a (via run_project, validated): a run with an ``outbox_dir`` + ``run_id`` writes both artefacts; the outcome file records ``validated``. Detach the ``write_outbox`` call in ``run_project`` → the files are absent → RED.""" 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, outbox_dir=str(tmp_path), run_id="run-validated", ) assert isinstance(result.outcome, ValidatedProposal) proposal_file = tmp_path / "run-validated-proposal.json" outcome_file = tmp_path / "run-validated-outcome.json" assert proposal_file.is_file() assert outcome_file.is_file() assert '"outcome_type": "validated"' in outcome_file.read_text(encoding="utf-8") async def test_run_project_writes_outbox_rejected_arm(tmp_path) -> None: """T-2.1a (via run_project, rejected): a checker ``VERDICT: REJECT`` flips an otherwise-validated proposal to a Rejection, and the outbox outcome file records ``rejected``.""" factory = _role_factory( _VALID_PROPOSER_REPLY, "VERDICT: REJECT - payback exceeds horizon (sim)" ) 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, outbox_dir=str(tmp_path), run_id="run-rejected", ) assert isinstance(result.outcome, Rejection) outcome_file = tmp_path / "run-rejected-outcome.json" assert '"outcome_type": "rejected"' in outcome_file.read_text(encoding="utf-8") async def test_run_project_without_outbox_dir_writes_nothing(tmp_path) -> None: """T-2.1c (control): a run WITHOUT ``outbox_dir`` writes no artefacts — proving the writes above are caused by the seam, not incidental.""" factory = _role_factory(_VALID_PROPOSER_REPLY, "VERDICT: APPROVE") 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 list(tmp_path.iterdir()) == [] async def test_run_project_outbox_dir_requires_run_id(tmp_path) -> None: """T-2.1e: an ``outbox_dir`` with ``run_id=None`` raises (no wall-clock/uuid default — the artefacts are byte-deterministic and keyed on run_id).""" factory = _role_factory(_VALID_PROPOSER_REPLY, "VERDICT: APPROVE") with pytest.raises(ValueError): await run_project( "BYGG-KONTOR-NORD", "local", docs_dir=str(BUNDLE_DIR), bundle_dir=str(BUNDLE_DIR), verdict_input=_VERDICT_INPUT, client_factory=factory, outbox_dir=str(tmp_path), run_id=None, )