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,277 @@
"""HITL verdict routing + pending tracking (method-spec §5; S5.1; paritetsrad 22; K9).
The operator's view of the long feedback loop: which proposals still AWAIT an
expert verdict, and who should judge each. A pure file-based id-join across the
three layers this module READS it never writes any of them (role split §3
Step 7: the expert writes the inbox, the system reads it; notification is K10's
job, never this).
* **outbox** (K5) each completed run persists a ``{run_id}`` PAIR; the
``{run_id}-outcome.json`` carries the join key ``verdict_id`` (minted the SAME
way the inbox mints a verdict id, ``mint_verdict_id`` over the candidate
features pinned in K5's test, reused here). Read verbatim, never re-minted.
* **inbox** (§4.2/§5) expert verdict files; a proposal is SETTLED once a
§4.2-valid verdict for its id is loaded (``load_inbox`` polices the decision
vocabulary a skipped/unknown decision never settles anything).
* **promoted** (§6) a promoted verdict in a bundle's context layer carries its
``verdict_id`` in frontmatter and settles the proposal too. Optional
(``bundle_dirs``) so the core join is exactly the two layers outboxinbox.
A proposal is *pending* when its ``verdict_id`` is in NONE of the settled sets.
Routing maps the proposal's ``measure`` (a config-string key NOW — K13 formalizes
the dimension catalog) to an expert; an unmatched measure falls to the optional
``default_expert``, else the proposal is UNROUTED (nobody configured to judge it).
The routing config is schema-validated fail-fast (§10) a malformed table never
reaches a routed report.
Run: uv run python -m portfolio_optimiser_claude.hitl pending --outbox <dir> --inbox <dir>
uv run python -m portfolio_optimiser_claude.hitl route --outbox <dir> --inbox <dir> \
--routing <file.json> [--bundle <dir> ...]
"""
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Any, Sequence
from pydantic import BaseModel, Field, ValidationError, model_validator
from portfolio_optimiser_claude.inbox import load_inbox
from portfolio_optimiser_claude.ir import SavingsProposal
from portfolio_optimiser_claude.okf import navigate_bundle
_OUTCOME_SUFFIX = "-outcome.json"
_PROPOSAL_SUFFIX = "-proposal.json"
_VERDICT_TYPE = "verdict"
# An expert id is a non-empty config string (K13 gives it a formal catalog).
_ExpertId = Annotated[str, Field(min_length=1)]
@dataclass(frozen=True)
class PendingProposal:
"""One outbox proposal awaiting a verdict — the join key plus routing fields.
``measure`` is the routing dimension read as a config string today (K13
formalizes the dimension catalog); ``verdict_id`` is the persisted K5 join
key, read verbatim from the outcome file (never re-minted here).
"""
run_id: str
verdict_id: str
project_id: str
measure: str
claimed_saving_nok: float
@dataclass(frozen=True)
class RoutedProposal:
"""A pending proposal plus its assigned expert (``None`` = UNROUTED)."""
proposal: PendingProposal
expert: str | None
class RoutingContract(BaseModel):
"""nøkkel→ekspert routing table (§10, fail-fast before any routed report).
Keys are opaque config strings NOW (K13 formalizes the dimension catalog);
values are non-empty expert ids. The table must be non-empty (an empty table
could never route anyone). ``default_expert`` is the optional fall-through for
an unmatched key; absent means an unmatched proposal is UNROUTED.
"""
routes: dict[str, _ExpertId] = Field(min_length=1)
default_expert: _ExpertId | None = None
@model_validator(mode="after")
def _keys_non_empty(self) -> RoutingContract:
for key in self.routes:
if not key.strip():
raise ValueError("routing key must be a non-empty string")
return self
def load_routing(raw: dict[str, Any]) -> RoutingContract:
"""Validate the routing config at startup (§10) — ``ValidationError`` fail-fast."""
return RoutingContract(**raw)
def load_outbox_proposals(outbox_dir: Path) -> list[PendingProposal]:
"""Read every persisted outbox pair into a ``PendingProposal`` (sorted by run_id).
Each ``{run_id}-outcome.json`` supplies the join key ``verdict_id`` and the
``run_id``; its sibling ``{run_id}-proposal.json`` supplies the routing/display
fields. A pair that cannot be read as a well-formed unit (bad JSON, missing
field, absent sibling) is SKIPPED a defensive read boundary over the
system's own deterministic output, never a crash. Nothing is written.
"""
if not outbox_dir.is_dir():
return []
proposals: list[PendingProposal] = []
for outcome_path in sorted(outbox_dir.glob(f"*{_OUTCOME_SUFFIX}")):
stem = outcome_path.name[: -len(_OUTCOME_SUFFIX)]
proposal_path = outcome_path.with_name(f"{stem}{_PROPOSAL_SUFFIX}")
try:
record = json.loads(outcome_path.read_text("utf-8"))
run_id = str(record["run_id"])
verdict_id = str(record["verdict_id"])
proposal = SavingsProposal.model_validate(json.loads(proposal_path.read_text("utf-8")))
except (OSError, KeyError, ValueError, ValidationError):
continue # a broken pair is skipped — read boundary, never a raise
proposals.append(
PendingProposal(
run_id=run_id,
verdict_id=verdict_id,
project_id=proposal.project_id,
measure=proposal.measure,
claimed_saving_nok=proposal.claimed_saving_nok,
)
)
return sorted(proposals, key=lambda p: p.run_id)
def _promoted_verdict_ids(bundle_dir: Path) -> set[str]:
"""The verdict ids of PROMOTED verdicts in a bundle (§6) — read-only.
A promoted file carries ``type: verdict`` plus a ``verdict_id`` in its
frontmatter; a hand-authored seed verdict has no ``verdict_id`` and is not a
judgment of an outbox proposal, so it is ignored.
"""
return {
vid
for concept in navigate_bundle(bundle_dir)
if concept.type == _VERDICT_TYPE and (vid := concept.frontmatter.get("verdict_id"))
}
def settled_verdict_ids(inbox_dir: Path, *, bundle_dirs: Sequence[Path] = ()) -> set[str]:
"""Every verdict id already settled: §4.2-valid inbox verdicts promoted verdicts.
Inbox ids come THROUGH ``load_inbox`` (decision-vocabulary policed a skipped
unknown decision never settles a proposal; a capacity breach still fails fast).
Promoted ids come from each bundle's context layer. Read-only.
"""
ids = {document.id for document in load_inbox(inbox_dir)}
for bundle_dir in bundle_dirs:
ids |= _promoted_verdict_ids(bundle_dir)
return ids
def pending_proposals(
outbox_dir: Path, inbox_dir: Path, *, bundle_dirs: Sequence[Path] = ()
) -> list[PendingProposal]:
"""Outbox proposals whose ``verdict_id`` is in NONE of the settled sets (the id-join).
The load-bearing seam: an outbox proposal survives ONLY if it is not settled.
Drop the ``not in settled`` filter and a judged proposal is still listed
the loop's whole point (surface only the outstanding) collapses.
"""
settled = settled_verdict_ids(inbox_dir, bundle_dirs=bundle_dirs)
return [
proposal
for proposal in load_outbox_proposals(outbox_dir)
if proposal.verdict_id not in settled
]
def route_pending(
pending: Sequence[PendingProposal], routing: RoutingContract
) -> list[RoutedProposal]:
"""Assign an expert to each pending proposal by its ``measure`` (config-string key).
An unmatched measure falls to ``default_expert`` (``None`` when absent
UNROUTED). No I/O a pure mapping over the already-loaded pending set.
"""
return [
RoutedProposal(
proposal=proposal,
expert=routing.routes.get(proposal.measure, routing.default_expert),
)
for proposal in pending
]
def _bundle_dirs(values: list[str] | None) -> list[Path]:
return [Path(value) for value in (values or [])]
def _cmd_pending(args: argparse.Namespace) -> int:
pending = pending_proposals(
Path(args.outbox), Path(args.inbox), bundle_dirs=_bundle_dirs(args.bundle)
)
print(f"PENDING — {len(pending)} proposal(s) awaiting a verdict:")
for proposal in pending:
print(
f" {proposal.run_id} verdict_id={proposal.verdict_id} "
f"measure={proposal.measure!r} project={proposal.project_id}"
)
return 0
def _cmd_route(args: argparse.Namespace) -> int:
try:
routing = load_routing(json.loads(Path(args.routing).read_text("utf-8")))
except (OSError, ValueError, ValidationError) as exc:
print(f"ROUTE FAILED — invalid routing config (fail-fast, §10): {exc}")
return 1
pending = pending_proposals(
Path(args.outbox), Path(args.inbox), bundle_dirs=_bundle_dirs(args.bundle)
)
routed = route_pending(pending, routing)
print(f"ROUTE — {len(routed)} pending proposal(s), assigned expert per measure:")
for item in routed:
expert = item.expert if item.expert is not None else "UNROUTED (no route, no default)"
print(
f" {item.proposal.run_id} measure={item.proposal.measure!r} "
f"verdict_id={item.proposal.verdict_id} -> {expert}"
)
return 0
def main(argv: list[str] | None = None) -> int:
"""The thin CLI: ``pending`` lists outstanding proposals; ``route`` adds experts.
``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 (§10). Neither subcommand writes anything.
"""
parser = argparse.ArgumentParser(
description=(
"Show which proposals still await an expert verdict (pending) and who "
"should judge each (route) — a read-only file-based id-join across the "
"outbox, the inbox, and promoted verdicts. This never writes any layer."
)
)
subparsers = parser.add_subparsers(dest="command", required=True)
def _add_common(sub: argparse.ArgumentParser) -> None:
sub.add_argument("--outbox", required=True, help="outbox dir (run_id-named pairs, K5)")
sub.add_argument("--inbox", required=True, help="inbox dir (expert verdict files, §5)")
sub.add_argument(
"--bundle",
action="append",
help="bundle dir whose promoted verdicts also settle a proposal (repeatable)",
)
pending_parser = subparsers.add_parser("pending", help="list proposals awaiting a verdict")
_add_common(pending_parser)
pending_parser.set_defaults(func=_cmd_pending)
route_parser = subparsers.add_parser("route", help="assign an expert to each pending proposal")
_add_common(route_parser)
route_parser.add_argument(
"--routing", required=True, help="routing config JSON (nøkkel→ekspert, schema-validated)"
)
route_parser.set_defaults(func=_cmd_route)
args = parser.parse_args(argv)
result: int = args.func(args)
return result
if __name__ == "__main__":
raise SystemExit(main())