feat(portfolio): K9 — HITL verdict routing + pending tracking (parity row 22) [skip-docs]

The operator's view of the long feedback loop (S5.1-analog, parity row 22;
buildable after K5): which proposals still AWAIT an expert verdict, and who
should judge each — a pure file-based id-join across the three layers hitl
READS and NEVER writes (role split §3 Step 7: the expert writes the inbox, the
system reads it; notification is K10's job, never this).

- hitl.py:
  * pending_proposals — the id-join. An outbox proposal (K5) is pending unless
    its persisted verdict_id (read verbatim from {run_id}-outcome.json, minted
    the SAME way the inbox mints a verdict id — the K5 assumption) is in the
    settled set. settled = §4.2-valid inbox verdicts (THROUGH load_inbox, so a
    skipped/unknown decision never settles anything) ∪ promoted verdicts (§6,
    optional bundle_dirs, so the core join is exactly outbox↔inbox).
  * RoutingContract — nøkkel→ekspert, schema-validated fail-fast (§10): non-empty
    table, non-empty keys/expert ids, optional default_expert. route_pending maps
    a proposal's measure (a config-string key NOW; K13 formalizes the dimension
    catalog) to an expert; an unmatched measure → default, else UNROUTED.
  * CLI python -m …hitl pending|route — pending is a pure report (exit 0); route
    loads the routing config fail-fast (a malformed/missing config exits non-zero
    WITHOUT touching any layer). Neither subcommand writes anything.

- test_hitl_loadbearing.py: 23 tests. TWO seams detach-proven RED — the id-join
  seam (drop the `not in settled` filter → a judged proposal is STILL listed →
  red) and the read-only seam (any read path that writes a byte → the before/
  after outbox+inbox snapshot diverges → red). Covers: undecided → pending,
  inbox/promoted verdict settles, exact-id join (no coincidental match), skipped
  decision does not settle, deterministic order, malformed routing fail-fast,
  measure→expert / default / UNROUTED, and the CLI subcommands.

- 521→544 green, golden byte-exact, full gate clean (ruff+format+mypy strict,
  25 src files). README: test-count sync ×2 + hitl module note + load-bearing
  mention. IKKE-scope (held): notification (K10), web-UI, writing the inbox.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RiTwaKLesgcwXx2mDviqpt
This commit is contained in:
Kjell Tore Guttormsen 2026-07-24 19:58:29 +02:00
commit b9dd479865
3 changed files with 676 additions and 3 deletions

View file

@ -0,0 +1,385 @@
"""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]