"""S5.1 HITL pending registry + dimension→expert routing (roadmap E, målbilde §3). Behaviour + fail-fast tests for the operator inspection tool ``python -m portfolio_optimiser.hitl pending|route``. The load-bearing detach seams (id-join, route classification, transitive-MAF probe, inbox-predicate parity) live in ``test_hitl_loadbearing.py``. Outbox artefacts are built via the REAL ``write_outbox`` and inbox verdicts via the REAL ``write_verdict`` (mirrors ``test_outbox_loadbearing.py`` / ``test_step7_async_loop_loadbearing.py``), so a shape drift in either writer breaks these tests rather than passing green-but-dead. """ from __future__ import annotations import json from pathlib import Path 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.validator import ValidatedProposal from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, write_verdict from portfolio_optimiser import hitl _PROVENANCE = ProvenanceStamp( citations=[Citation(file="f.md", locator=TextSpan(start_index=0, end_index=5), snippet="hi")], model="synthetic", role="proposer", validator_decision="validated", token_usage=8, ) def _make_validated(measure: str, codes: list[str], claimed: float = 200.0) -> ValidatedProposal: items = [AffectedItem(code=c, quantity=1000.0, unit_cost=1.0) for c in codes] proposal = SavingsProposal( project_id="P1", measure=measure, affected_items=items, claimed_saving_nok=claimed ) return ValidatedProposal(proposal=proposal, p10=100.0, p50=150.0, p90=200.0, nominal_feasible=180.0) def _write_proposal( outbox: Path, run_id: str, *, verdict_id: str, measure: str = "LED-retrofit", codes: list[str] | None = None ) -> None: """Persist one run's outbox pair via the real writer, keyed on ``verdict_id``.""" write_outbox( str(outbox), run_id, outcome=_make_validated(measure, codes or ["05.2"]), provenance=_PROVENANCE, checker_verdict="approve", verdict_id=verdict_id, ) def _drop_verdict(inbox: Path, verdict_id: str, *, decision: str = "approved") -> None: write_verdict( str(inbox), Verdict( id=verdict_id, proposal_features=ProposalFeatures( affected_codes=frozenset({"05.2"}), measure_type="scope_reduction", claimed_saving_nok=200.0, ), decision=decision, rationale="expert reviewed (test)", ), ) # --- pending() behaviour -------------------------------------------------------------------------- def test_pending_lists_unjudged_proposal(tmp_path: Path) -> None: """A proposal in the outbox with an EMPTY inbox is listed pending (run_id + verdict_id + outcome_type surfaced, codes/measure captured).""" outbox = tmp_path / "outbox" inbox = tmp_path / "inbox" _write_proposal(outbox, "run-1", verdict_id="vid-1", measure="LED-retrofit", codes=["05.2"]) result = hitl.pending(str(outbox), str(inbox)) assert len(result) == 1 p = result[0] assert p.run_id == "run-1" assert p.verdict_id == "vid-1" assert p.outcome_type == "validated" assert p.measure == "LED-retrofit" assert p.codes == frozenset({"05.2"}) def test_pending_tolerant_to_missing_dir_and_foreign_files(tmp_path: Path) -> None: """Missing outbox → []; foreign / half-written files are skipped, never raised.""" inbox = tmp_path / "inbox" assert hitl.pending(str(tmp_path / "does-not-exist"), str(inbox)) == [] outbox = tmp_path / "outbox" _write_proposal(outbox, "run-1", verdict_id="vid-1") (outbox / "notes.txt").write_text("not json", encoding="utf-8") (outbox / "broken-proposal.json").write_text("{ half written", encoding="utf-8") # a foreign file in the inbox must not blow up the id-set read either inbox.mkdir() (inbox / "golden.json").write_text("{ not a verdict", encoding="utf-8") result = hitl.pending(str(outbox), str(inbox)) assert [p.run_id for p in result] == ["run-1"] def test_pending_skips_orphan_proposal_without_outcome(tmp_path: Path) -> None: """A proposal file with no matching outcome (orphan from a half-written live run) is skipped.""" outbox = tmp_path / "outbox" inbox = tmp_path / "inbox" _write_proposal(outbox, "run-1", verdict_id="vid-1") (outbox / "run-1-outcome.json").unlink() # orphan the proposal assert hitl.pending(str(outbox), str(inbox)) == [] def test_pending_is_deterministic(tmp_path: Path) -> None: """Output order is stable across calls, sorted by (run_id, verdict_id).""" outbox = tmp_path / "outbox" inbox = tmp_path / "inbox" _write_proposal(outbox, "run-c", verdict_id="vid-3") _write_proposal(outbox, "run-a", verdict_id="vid-1") _write_proposal(outbox, "run-b", verdict_id="vid-2") first = [(p.run_id, p.verdict_id) for p in hitl.pending(str(outbox), str(inbox))] second = [(p.run_id, p.verdict_id) for p in hitl.pending(str(outbox), str(inbox))] assert first == second == [("run-a", "vid-1"), ("run-b", "vid-2"), ("run-c", "vid-3")] def test_hitl_registered_maf_free() -> None: """Meta: hitl.py is registered in the MAF-free guard list, so ``test_okf_is_maf_free`` actually scans it — otherwise the MAF-free source claim would be green-but-dead.""" from tests.test_okf import _MAF_FREE_MODULES assert "hitl.py" in _MAF_FREE_MODULES