"""HITL verdict routing + pending tracking — LOAD-BEARING (S5.1-analog; §5, §11; K9). The seam this file keeps alive: the operator sees which proposals still AWAIT a verdict, and who should judge each — a pure file-based id-join across the three layers hitl READS (never writes). A proposal is *pending* when its persisted ``verdict_id`` (minted by K5 into ``{run_id}-outcome.json``) has NO matching verdict in the inbox and NO matching promoted verdict in a bundle. Routing maps the proposal's measure (a config-string key NOW; K13 formalizes the dimension catalog) to an expert. Role split (§3 Step 7, unwaivable): hitl READS both settled layers and the outbox; writing the inbox is the authoring primitive's job (K10 does notification, never this). RED if hitl ever writes. Two detached seams proven RED here: * Detach proof (the id-join seam): drop the ``not in settled`` filter in ``pending_proposals`` so it returns every outbox proposal → a judged proposal is still listed as pending → ``test_inbox_verdict_settles_the_proposal`` red. * Detach proof (the read-only seam): make any read path write a byte → the before/after snapshot of outbox+inbox diverges → ``test_hitl_never_writes`` red. Key assumption (pinned in K5's ``test_outbox_loadbearing`` and reused here): the outcome's ``verdict_id`` is minted the SAME way the inbox mints a verdict id (``mint_verdict_id`` over the candidate features), so an inbox verdict about the same candidate joins by id. """ from __future__ import annotations import json from pathlib import Path import pytest from pydantic import ValidationError from portfolio_optimiser_claude.experience import CandidateFeatures, mint_verdict_id from portfolio_optimiser_claude.hitl import ( PendingProposal, RoutingContract, load_routing, main, pending_proposals, route_pending, settled_verdict_ids, ) from portfolio_optimiser_claude.inbox import VerdictDocument, write_verdict from portfolio_optimiser_claude.ir import AffectedItem, SavingsProposal from portfolio_optimiser_claude.loop import RunResult from portfolio_optimiser_claude.outbox import persist_outbox from portfolio_optimiser_claude.promotion import promote from portfolio_optimiser_claude.provenance import Citation, Provenance from portfolio_optimiser_claude.validator import ValidatedProposal # --- fixtures: outbox pairs, inbox verdicts, promoted verdicts ------------------------------- def _proposal(measure: str = "LED-retrofit", code: str = "EL-01") -> SavingsProposal: return SavingsProposal( project_id="bygg-kontor-nord", measure=measure, affected_items=[AffectedItem(code=code, quantity=100, unit_cost=250.0)], claimed_saving_nok=20000.0, ) def _run(proposal: SavingsProposal) -> 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 _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, ) def _persist(outbox: Path, proposal: SavingsProposal, run_id: str) -> str: """Persist an outbox pair for ``proposal`` and return its join key (verdict_id).""" persist_outbox(outbox, run=_run(proposal), provenance=_provenance(), run_id=run_id) return mint_verdict_id(CandidateFeatures.from_proposal(proposal)) def _drop_inbox_verdict( inbox: Path, proposal: SavingsProposal, *, decision: str = "approved" ) -> str: """Author an inbox verdict for ``proposal`` (id minted the join way) and write it.""" document = VerdictDocument.from_candidate( CandidateFeatures.from_proposal(proposal), decision=decision, rationale="expert judged this candidate", description="LED retrofit for the north office", ) write_verdict(inbox, document) return document.id def _snapshot(root: Path) -> dict[str, bytes]: """Every file byte under ``root`` keyed by relative path (missing dir → empty).""" if not root.exists(): return {} return { str(path.relative_to(root)): path.read_bytes() for path in sorted(root.rglob("*")) if path.is_file() } # --- the pending registry (the id-join) ------------------------------------------------------ class TestPendingRegistry: """pending_proposals: outbox proposals minus settled, joined on verdict_id.""" def test_undecided_proposal_is_listed_pending(self, tmp_path: Path) -> None: # Forslag uten dom → listes utestående (no inbox verdict, no promotion). outbox, inbox = tmp_path / "outbox", tmp_path / "inbox" vid = _persist(outbox, _proposal(), run_id="r-001") pending = pending_proposals(outbox, inbox) assert [p.verdict_id for p in pending] == [vid] assert pending[0].run_id == "r-001" assert pending[0].measure == "LED-retrofit" assert pending[0].project_id == "bygg-kontor-nord" def test_inbox_verdict_settles_the_proposal(self, tmp_path: Path) -> None: # LOAD-BEARING (the id-join seam). Dom i inbox → forsvinner fra pending. # Detach point: drop the ``not in settled`` filter in pending_proposals # so it returns every outbox proposal → this judged proposal is STILL # listed → RED. (Restore from the implemented copy, never git checkout.) outbox, inbox = tmp_path / "outbox", tmp_path / "inbox" proposal = _proposal() vid = _persist(outbox, proposal, run_id="r-001") inbox_id = _drop_inbox_verdict(inbox, proposal) assert inbox_id == vid # the join key is shared (K5 assumption) assert pending_proposals(outbox, inbox) == [] def test_join_is_exact_a_different_candidate_stays_pending(self, tmp_path: Path) -> None: # A verdict about a DIFFERENT candidate must not settle this one — the # join is by exact id, never a coincidental match. outbox, inbox = tmp_path / "outbox", tmp_path / "inbox" vid = _persist(outbox, _proposal(), run_id="r-001") _drop_inbox_verdict(inbox, _proposal(measure="HVAC-upgrade", code="EL-99")) pending = pending_proposals(outbox, inbox) assert [p.verdict_id for p in pending] == [vid] def test_skipped_inbox_decision_does_not_settle(self, tmp_path: Path) -> None: # §4.2 vocabulary: an inbox verdict with an unknown decision is SKIPPED by # load_inbox → never reaches the store → must NOT settle the proposal. outbox, inbox = tmp_path / "outbox", tmp_path / "inbox" proposal = _proposal() vid = _persist(outbox, proposal, run_id="r-001") inbox.mkdir(parents=True) bogus_id = mint_verdict_id(CandidateFeatures.from_proposal(proposal)) (inbox / f"{bogus_id}.json").write_text( json.dumps( { "id": bogus_id, "decision": "maybe-later", # outside §4.2 → skipped "rationale": "not a real decision", "proposal_features": { "affected_codes": ["EL-01"], "measure_type": "LED-retrofit", "claimed_saving_nok": 20000.0, "description": "x", }, } ), encoding="utf-8", ) assert [p.verdict_id for p in pending_proposals(outbox, inbox)] == [vid] def test_multiple_proposals_deterministic_order(self, tmp_path: Path) -> None: outbox, inbox = tmp_path / "outbox", tmp_path / "inbox" _persist(outbox, _proposal(measure="m-b", code="EL-02"), run_id="r-002") _persist(outbox, _proposal(measure="m-a", code="EL-01"), run_id="r-001") pending = pending_proposals(outbox, inbox) assert [p.run_id for p in pending] == ["r-001", "r-002"] # sorted by run_id def test_missing_outbox_dir_is_empty(self, tmp_path: Path) -> None: assert pending_proposals(tmp_path / "nope", tmp_path / "inbox") == [] class TestPromotedSettles: """A PROMOTED verdict (§6) settles a proposal too — inbox-/promotert dom.""" def test_promoted_verdict_settles_the_proposal(self, tmp_path: Path) -> None: outbox, inbox, bundle = tmp_path / "outbox", tmp_path / "inbox", tmp_path / "bundle" proposal = _proposal() _persist(outbox, proposal, run_id="r-001") # A promoted verdict lives in the bundle (context layer), carrying the # same verdict_id in frontmatter. Promote requires an accepted decision. bundle.mkdir(parents=True) (bundle / "index.md").write_text("# Bundle\n", encoding="utf-8") document = VerdictDocument.from_candidate( CandidateFeatures.from_proposal(proposal), decision="approved", rationale="promoted after approval", description="d", ) promote( document, bundle, approved_by="expert", experiment="exp-1", timestamp="2026-07-24", ) # Without the bundle it is pending; WITH the bundle the promotion settles it. assert len(pending_proposals(outbox, inbox)) == 1 assert pending_proposals(outbox, inbox, bundle_dirs=[bundle]) == [] def test_settled_ids_union_inbox_and_promoted(self, tmp_path: Path) -> None: inbox, bundle = tmp_path / "inbox", tmp_path / "bundle" a = _drop_inbox_verdict(inbox, _proposal()) bundle.mkdir(parents=True) (bundle / "index.md").write_text("# Bundle\n", encoding="utf-8") other = _proposal(measure="HVAC-upgrade", code="EL-99") document = VerdictDocument.from_candidate( CandidateFeatures.from_proposal(other), decision="approved", rationale="promoted", description="d", ) promote(document, bundle, approved_by="e", experiment="x", timestamp="2026-07-24") ids = settled_verdict_ids(inbox, bundle_dirs=[bundle]) assert a in ids assert document.id in ids # --- the routing config (nøkkel→ekspert, schema-validated fail-fast) -------------------------- class TestRoutingContract: """load_routing: fail-fast on a malformed routing config (§10).""" def test_valid_config_loads(self) -> None: routing = load_routing( {"routes": {"LED-retrofit": "energy-expert"}, "default_expert": "triage"} ) assert routing.routes["LED-retrofit"] == "energy-expert" assert routing.default_expert == "triage" def test_default_expert_optional(self) -> None: routing = load_routing({"routes": {"LED-retrofit": "energy-expert"}}) assert routing.default_expert is None @pytest.mark.parametrize( "bad", [ {"routes": {}}, # empty table — nobody can ever be routed {"routes": {"LED-retrofit": ""}}, # empty expert id {"routes": {"": "energy-expert"}}, # empty routing key {"default_expert": "triage"}, # routes missing {"routes": {"LED-retrofit": "energy-expert"}, "default_expert": ""}, # empty default ], ) def test_malformed_config_fails_fast(self, bad: dict[str, object]) -> None: with pytest.raises(ValidationError): load_routing(bad) class TestRoutePending: """route_pending: assign an expert per proposal by measure (config-string key).""" def test_routes_by_measure(self, tmp_path: Path) -> None: outbox, inbox = tmp_path / "outbox", tmp_path / "inbox" _persist(outbox, _proposal(measure="LED-retrofit"), run_id="r-001") pending = pending_proposals(outbox, inbox) routing = RoutingContract(routes={"LED-retrofit": "energy-expert"}) routed = route_pending(pending, routing) assert [(r.proposal.run_id, r.expert) for r in routed] == [("r-001", "energy-expert")] def test_unmatched_measure_uses_default(self, tmp_path: Path) -> None: outbox, inbox = tmp_path / "outbox", tmp_path / "inbox" _persist(outbox, _proposal(measure="HVAC-upgrade"), run_id="r-001") pending = pending_proposals(outbox, inbox) routing = RoutingContract(routes={"LED-retrofit": "energy-expert"}, default_expert="triage") routed = route_pending(pending, routing) assert routed[0].expert == "triage" def test_unmatched_measure_no_default_is_unrouted(self, tmp_path: Path) -> None: outbox, inbox = tmp_path / "outbox", tmp_path / "inbox" _persist(outbox, _proposal(measure="HVAC-upgrade"), run_id="r-001") pending = pending_proposals(outbox, inbox) routing = RoutingContract(routes={"LED-retrofit": "energy-expert"}) routed = route_pending(pending, routing) assert routed[0].expert is None # UNROUTED — nobody configured to judge it # --- the read-only invariant (LOAD-BEARING: hitl never writes) ------------------------------- class TestReadOnly: """LOAD-BEARING (§3 Step 7): hitl READS three layers, writes NONE of them.""" def test_hitl_never_writes(self, tmp_path: Path) -> None: # Byte-snapshot outbox + inbox before and after every read path. Detach # point: make any read (pending/settled/route) write a byte → a snapshot # diverges → RED. The role split forbids hitl writing the inbox (K10 does # notification, never this). outbox, inbox = tmp_path / "outbox", tmp_path / "inbox" proposal = _proposal() _persist(outbox, proposal, run_id="r-001") _persist(outbox, _proposal(measure="HVAC-upgrade", code="EL-99"), run_id="r-002") _drop_inbox_verdict(inbox, proposal) before_out, before_in = _snapshot(outbox), _snapshot(inbox) pending = pending_proposals(outbox, inbox) route_pending(pending, RoutingContract(routes={"LED-retrofit": "energy-expert"})) settled_verdict_ids(inbox) assert _snapshot(outbox) == before_out assert _snapshot(inbox) == before_in # --- the CLI (python -m ...hitl pending|route) ----------------------------------------------- def _write_routing(path: Path) -> Path: path.write_text( json.dumps({"routes": {"LED-retrofit": "energy-expert"}, "default_expert": "triage"}), encoding="utf-8", ) return path class TestCli: """The thin CLI: pending|route subcommands, fail-fast on a bad routing file.""" def test_pending_lists_and_exits_zero( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: outbox, inbox = tmp_path / "outbox", tmp_path / "inbox" _persist(outbox, _proposal(), run_id="r-001") code = main(["pending", "--outbox", str(outbox), "--inbox", str(inbox)]) assert code == 0 assert "r-001" in capsys.readouterr().out def test_route_lists_expert_and_exits_zero( self, tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: outbox, inbox = tmp_path / "outbox", tmp_path / "inbox" _persist(outbox, _proposal(), run_id="r-001") routing = _write_routing(tmp_path / "routing.json") code = main( ["route", "--outbox", str(outbox), "--inbox", str(inbox), "--routing", str(routing)] ) assert code == 0 assert "energy-expert" in capsys.readouterr().out def test_route_with_malformed_config_fails_fast(self, tmp_path: Path) -> None: outbox, inbox = tmp_path / "outbox", tmp_path / "inbox" _persist(outbox, _proposal(), run_id="r-001") bad = tmp_path / "bad.json" bad.write_text(json.dumps({"routes": {}}), encoding="utf-8") code = main( ["route", "--outbox", str(outbox), "--inbox", str(inbox), "--routing", str(bad)] ) assert code != 0 def test_pending_type_is_frozen(self) -> None: # PendingProposal is an immutable value (no accidental mutation on the # read path — reinforces the read-only invariant at the type level). p = PendingProposal( run_id="r", verdict_id="v", project_id="p", measure="m", claimed_saving_nok=1.0 ) with pytest.raises((AttributeError, TypeError)): p.run_id = "other" # type: ignore[misc]