"""Outbox persistence seams — LOAD-BEARING (S2.1-analog; method-spec §2, §11). The seam this file keeps alive: the system OWNS its output layer. Each completed run persists a ``run_id``-named ``proposal``/``outcome`` PAIR to the outbox, so outstanding verdicts can be tracked (K9, id-join outbox ↔ inbox) and live artifacts captured (K8). The role split (§3 Step 7) governs the inbox and the wiki — the expert writes, the system reads — NOT the system's own output layer, which the system writes freely. Key assumption pinned here (reused by K9): the outcome's ``verdict_id`` is minted the SAME way the inbox mints an expert verdict's id (``mint_verdict_id`` over the proposal's candidate features), so a later inbox verdict about the same candidate joins by id. RED if the outbox invents a different id grammar. Detach proof: drop the ``persist_outbox`` call from ``execute_run`` → the run-path pair is absent → red. The bytes are deterministic (the house JSON convention: sorted keys, 2-space indent, trailing LF) — same input + same run_id ⇒ identical files — and the S10 ``artifacts.py`` fasit formats are never touched. """ from __future__ import annotations import json from pathlib import Path from typing import Callable import pytest from _scripted import ScriptedClient, reply from portfolio_optimiser_claude.contracts import Contracts from portfolio_optimiser_claude.experience import CandidateFeatures, mint_verdict_id from portfolio_optimiser_claude.ir import AffectedItem, SavingsProposal, load_validator_input from portfolio_optimiser_claude.loop import ModelClient, ModelReply, RunResult from portfolio_optimiser_claude.outbox import persist_outbox from portfolio_optimiser_claude.provenance import Citation, Provenance from portfolio_optimiser_claude.run import main from portfolio_optimiser_claude.validator import Rejection, ValidatedProposal BUNDLE = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro" # --- unit fixtures: a run + its stamp, both outcome types ------------------------------------ def _proposal() -> SavingsProposal: return SavingsProposal( project_id="bygg-kontor-nord", measure="LED-retrofit", affected_items=[AffectedItem(code="EL-01", quantity=100, unit_cost=250.0)], claimed_saving_nok=20000.0, ) def _validated_run() -> RunResult: return RunResult( outcome=ValidatedProposal( validates=True, claimed_saving_nok=20000.0, nominal_feasible=25000.0, p10=18000.0, p50=22000.0, p90=27000.0, ), validator_decision="validated", checker_decision="approve", attempts=1, proposal=_proposal(), ) def _rejected_run() -> RunResult: # §9 non-conflation: the validator PASSED the numbers, the checker overrode. return RunResult( outcome=Rejection(reason="unit cost unsupported (checker REJECT overrode)"), validator_decision="validated", checker_decision="reject", attempts=2, proposal=_proposal(), ) def _provenance() -> Provenance: return Provenance( citations=[Citation(file="index.md", span="chars 0-5", snippet="Bygg-")], model="claude-haiku-4-5-20251001", role="proposer", validator_decision="validated", tokens_used=1234, ) class TestOutboxUnit: """persist_outbox: the run_id-named pair, verbatim outcome, deterministic bytes.""" def test_writes_the_run_id_named_pair(self, tmp_path: Path) -> None: paths = persist_outbox( tmp_path / "outbox", run=_validated_run(), provenance=_provenance(), run_id="r-001" ) assert set(paths) == {"proposal", "outcome"} assert (tmp_path / "outbox" / "r-001-proposal.json").is_file() assert (tmp_path / "outbox" / "r-001-outcome.json").is_file() @pytest.mark.parametrize("run", [_validated_run(), _rejected_run()]) def test_outcome_carries_the_inbox_join_key(self, tmp_path: Path, run: RunResult) -> None: # KEY ASSUMPTION (K9): verdict_id is minted the SAME way the inbox mints an # expert verdict's id (mint_verdict_id over the proposal's candidate # features) — so a later inbox verdict about this candidate joins by id. # RED if the outbox invents a different id grammar. paths = persist_outbox(tmp_path / "ob", run=run, provenance=_provenance(), run_id="r-1") record = json.loads(paths["outcome"].read_text("utf-8")) assert record["verdict_id"] == mint_verdict_id( CandidateFeatures.from_proposal(run.proposal) ) assert record["run_id"] == "r-1" def test_validated_outcome_carries_percentiles(self, tmp_path: Path) -> None: paths = persist_outbox( tmp_path / "ob", run=_validated_run(), provenance=_provenance(), run_id="r" ) outcome = json.loads(paths["outcome"].read_text("utf-8"))["outcome"] assert outcome["type"] == "validated" assert outcome["p50"] == 22000.0 def test_rejected_outcome_carries_reason_and_no_percentiles(self, tmp_path: Path) -> None: paths = persist_outbox( tmp_path / "ob", run=_rejected_run(), provenance=_provenance(), run_id="r" ) outcome = json.loads(paths["outcome"].read_text("utf-8"))["outcome"] assert outcome == { "type": "rejected", "reason": "unit cost unsupported (checker REJECT overrode)", } def test_mirrors_the_two_falsifiers_verbatim(self, tmp_path: Path) -> None: # §9: validator_decision and checker_decision are the RunResult's own, # never recomputed from the (checker-overridden) outcome. paths = persist_outbox( tmp_path / "ob", run=_rejected_run(), provenance=_provenance(), run_id="r" ) record = json.loads(paths["outcome"].read_text("utf-8")) assert record["validator_decision"] == "validated" assert record["checker_decision"] == "reject" assert record["attempts"] == 2 def test_outcome_embeds_the_provenance_stamp(self, tmp_path: Path) -> None: paths = persist_outbox( tmp_path / "ob", run=_validated_run(), provenance=_provenance(), run_id="r" ) record = json.loads(paths["outcome"].read_text("utf-8")) assert record["provenance"] == _provenance().model_dump() def test_proposal_round_trips_through_the_ir(self, tmp_path: Path) -> None: paths = persist_outbox( tmp_path / "ob", run=_validated_run(), provenance=_provenance(), run_id="r" ) loaded = SavingsProposal.model_validate(json.loads(paths["proposal"].read_text("utf-8"))) assert loaded == _proposal() def test_bytes_are_deterministic(self, tmp_path: Path) -> None: # Two writes, same input + same run_id ⇒ byte-identical files (K8/K9 read # a stable pair). House JSON convention: sorted keys, 2-space indent, LF. a = persist_outbox( tmp_path / "a", run=_validated_run(), provenance=_provenance(), run_id="r-1" ) b = persist_outbox( tmp_path / "b", run=_validated_run(), provenance=_provenance(), run_id="r-1" ) assert a["proposal"].read_bytes() == b["proposal"].read_bytes() assert a["outcome"].read_bytes() == b["outcome"].read_bytes() assert a["outcome"].read_text("utf-8").endswith("\n") @pytest.mark.parametrize("bad", ["", " "]) def test_requires_a_run_id_no_wall_clock_default(self, tmp_path: Path, bad: str) -> None: # run_id is REQUIRED when the outbox is set — no wall-clock default (a # timestamp default would break the byte-determinism above). Fail fast. with pytest.raises(ValueError, match="run_id"): persist_outbox( tmp_path / "ob", run=_validated_run(), provenance=_provenance(), run_id=bad ) # --- wiring through the shippable entrance --------------------------------------------------- ClientFactory = Callable[[Contracts, float], ModelClient] def _validated_replies() -> list[ModelReply]: # The scripted three-turn sequence that drives the loop to a VALIDATED # outcome (mirrors the entrance happy-path): one debate turn, an APPROVE # checker verdict, then a proposal echoing the bundle's own IR projection. return [ reply("debate reasoning"), reply("VERDICT: APPROVE"), reply(json.dumps(load_validator_input(BUNDLE).model_dump())), ] def _scripted_factory() -> tuple[ClientFactory, list[ScriptedClient]]: created: list[ScriptedClient] = [] def factory(contracts: Contracts, max_budget_usd_per_call: float) -> ModelClient: client = ScriptedClient(replies=_validated_replies()) created.append(client) return client return factory, created class TestOutboxWiring: """LOAD-BEARING (§11): the entrance writes the pair on the completed-run path.""" def test_run_with_outbox_persists_the_run_id_named_pair(self, tmp_path: Path) -> None: # Detach point: drop the persist_outbox call in execute_run → RED. out = tmp_path / "out" outbox = tmp_path / "outbox" factory, created = _scripted_factory() code = main( [ "--bundle", str(BUNDLE), "--out", str(out), "--outbox", str(outbox), "--run-id", "run-042", ], client_factory=factory, ) assert code == 0 assert (outbox / "run-042-proposal.json").is_file() record = json.loads((outbox / "run-042-outcome.json").read_text("utf-8")) assert record["run_id"] == "run-042" assert record["validator_decision"] == "validated" # The join key is minted over the composed proposal on the REAL path. assert record["verdict_id"] == mint_verdict_id( CandidateFeatures.from_proposal(load_validator_input(BUNDLE)) ) def test_run_without_outbox_writes_no_outbox(self, tmp_path: Path) -> None: # Control: no --outbox ⇒ the outbox dir is never created; the run # artifacts (out_dir) are still written exactly as before. out = tmp_path / "out" outbox = tmp_path / "outbox" factory, _ = _scripted_factory() code = main(["--bundle", str(BUNDLE), "--out", str(out)], client_factory=factory) assert code == 0 assert not outbox.exists() assert (out / "proposal.json").is_file() def test_outbox_without_run_id_fails_fast_before_any_spend(self, tmp_path: Path) -> None: # fail-fast (§10 spirit): --outbox without --run-id errors BEFORE a client # is ever constructed — no spend on a run that cannot be filed. factory, created = _scripted_factory() with pytest.raises(SystemExit): main( [ "--bundle", str(BUNDLE), "--out", str(tmp_path / "out"), "--outbox", str(tmp_path / "outbox"), ], client_factory=factory, ) assert created == []