portfolio-optimiser/src/portfolio_optimiser/hitl.py
Kjell Tore Guttormsen 455d93d33e feat(outbox): every evaluated approach becomes something an expert can judge
A run commissioned to evaluate three approaches wrote ONE proposal artefact, so
only the approach it selected could ever receive a verdict. The other two were
evaluated, reported in the settlement, and then taught the learning loop nothing.

The defect class is a key collapse, and it had two halves — fixing either alone
leaves it intact:

* the WRITER wrote one pair per run, so the non-selected approaches never existed
  on disk;
* the READER (hitl._read_outbox_proposals) joins proposal to outcome on the
  run_id FIELD read from file CONTENT, never the filename. Three files sharing
  one run_id collapse onto one dict key, last write wins — so widening only the
  filename would have produced three artefacts and still one pending row. This is
  the S3.2 collision class: two rows under one key silently become one.

Artefacts are now keyed {run_id}-{approach_id}-*.json AND carry approach_id in the
payload; the join key is (run_id, approach_id). Two properties make them genuinely
judgeable rather than merely present:

* verdict_id is minted per approach (verdicts.verdict_key, the S3.2 content hash)
  — reusing the run's single id would let one delivered verdict clear all three
  from the queue;
* provenance.validator_decision follows ITS OWN approach — the run's stamp would
  report a rejected candidate as validated, and nothing downstream could correct it.

verdicts.verdict_key is public so a run can stamp the key a verdict WILL arrive
under without capturing a decision nobody has made; it delegates to _mint_id
rather than restating the hash (the (p) rule: one keying rule, one copy).

The per-approach set REPLACES the run-level pair rather than joining it — the
selected approach is already among them, and writing both would count it twice in
hitl pending. The selected one carries the run's final outcome, so the outbox can
never disagree with the RunResult; the others carry the validator's verdict, the
only falsifier that ran on them.

mandate.py is deliberately untouched: hanging a ValidatedProposal off a coverage
row would drag validator — and pulp — into a module kept to pydantic+stdlib for
D7 portability, so _evaluate_mandate returns the evaluated outcomes alongside.

Ran it, not just tested it: a real CLI run wrote six artefacts and hitl pending
listed three rows. It also showed the honest edge — three approaches that produce
an identical candidate share one content-hash key, so one verdict settles all
three. That is correct (they were one candidate), and it is now documented.

Load-bearing MEASURED (tests/test_a5_per_approach_artifacts_loadbearing.py) against
the whole 750-test suite, five mutations all red: detach the per-approach writer ·
drop approach_id from the join key · reuse the run's verdict id · reuse the run's
provenance stamp · widen the filename but not the payload. Control: on a full
detach exactly the 5 new tests fail and 745 pre-existing ones stay green — the
no-mandate path is inert, and writes neither the filename segment nor the field.

Docs: bestille-en-kjoring.md (what the commissioner gets) + ekspert-svar.md (what
the expert's queue looks like, and that "rejected" is the validator's verdict on
the numbers, never a professional judgement of the idea).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtRd8y1PDPGwkrRXFhubqr
2026-08-05 21:12:09 +02:00

332 lines
16 KiB
Python

"""S5.1 — HITL pending registry + dimension→expert routing (roadmap E, målbilde §3).
An operator inspection tool (mirrors ``preflight.py`` / ``costsim.py``) that makes the async
verdict queue visible. After every run the OUTBOX accumulates ``{run_id}-proposal.json`` /
``{run_id}-outcome.json`` (``outbox.write_outbox``); each outcome carries a ``verdict_id`` (the
``_mint_id`` content-hash). A matching expert verdict lands in the INBOX as ``{id}.json``
(``verdicts.write_verdict``). This module derives, from those two folders:
- a file-derived **pending registry** — outbox ``verdict_id`` MINUS inbox ``id`` (an id-join), i.e.
the proposals still awaiting an expert dom; and
- a declarative **``dimension → expert`` routing** — which expert should supply each pending dom,
classified by **cost-code prefix** (see routing notes in Step 3 + the plan's Risk #1).
Exposed via ``python -m portfolio_optimiser.hitl pending|route``. It is an inspection tool, NOT wired
into ``run_project`` — the system READS these folders, the expert/persona WRITES them (målbilde §3
role split).
**MAF-free source** (D7-portable): pure stdlib + pydantic. The inbox id-set predicate mirrors
``verdicts.load_verdicts_from_dir`` INLINE rather than importing it — ``verdicts.py`` imports
``agent_framework`` at module top, so importing its reader would pull MAF into this D7-portable
logic. Registered in ``tests/test_okf.py``'s ``_MAF_FREE_MODULES`` (direct AST guard) and pinned by a
transitive import-graph probe (``test_hitl_loadbearing.py``).
**Honesty limitation (CLI needs MAF at runtime):** this module's SOURCE is MAF-clean, but invoking
it via the package (``python -m portfolio_optimiser.hitl``) first runs ``__init__.py`` → ``run`` →
``agent_framework`` before hitl's body. The "no agent runtime" framing is therefore source-level
(the logic stays portable); a lazy ``__init__`` that would make the CLI itself MAF-free is out of
S5.1 scope. The static import-graph probe guards hitl's OWN logic against a MAF-bearing edge.
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field, ValidationError, model_validator
# Mirrored INLINE from verdicts.py (NOT imported — verdicts.py:29 pulls agent_framework). The inbox
# predicate must match load_verdicts_from_dir EXACTLY, else pending would count as judged a file the
# real loader drops. Kept in lockstep with verdicts._REQUIRED_VERDICT_KEYS / _INBOX_DECISION_VOCABULARY
# / the inner keys verdict_from_dict reads; pinned by the parity tests in test_hitl_loadbearing.py.
_REQUIRED_VERDICT_KEYS = {"id", "decision", "rationale", "proposal_features"}
_INBOX_DECISION_VOCABULARY = frozenset({"approved", "rejected"})
_REQUIRED_FEATURE_KEYS = {"affected_codes", "measure_type", "claimed_saving_nok"}
@dataclass(frozen=True)
class PendingProposal:
"""One outbox proposal still awaiting an expert verdict. ``codes``/``measure`` carry the routing
keys (Step 3); ``verdict_id`` is the id-join key against the inbox. ``approach_id`` names the
commissioned approach the artefact belongs to (A5), and is ``""`` for an artefact written
without a mandate — a run nobody commissioned has no approach to name."""
run_id: str
verdict_id: str
outcome_type: str
measure: str
codes: frozenset[str]
approach_id: str = ""
def _join_key(data: dict[str, Any]) -> tuple[str, str]:
"""The key one artefact is filed under: ``(run_id, approach_id)``, read from file CONTENT.
``run_id`` alone was the key until A5 gave a run several judgeable approaches. Once it does, a
``run_id``-only key collapses every approach of one run onto a single dict entry (last write
wins) and the expert's queue silently reports one candidate where three were evaluated — the
S3.2 key-collision class. An artefact with no ``approach_id`` keys on ``""``, which is exactly
the pre-A5 behaviour for pre-A5 files."""
approach_id = data.get("approach_id")
return str(data["run_id"]), str(approach_id) if isinstance(approach_id, str) else ""
def _read_outbox_proposals(outbox_dir: str) -> list[PendingProposal]:
"""Read the outbox, joining ``{run_id}[-{approach_id}]-proposal.json`` and its ``-outcome.json``
on the ``run_id``/``approach_id`` FIELDS read from file content (never the filename). TOLERANT
(RAW layer, contrast ``okf.load_ir_projection``'s fail-fast): a missing dir yields ``[]``;
unparseable files, and orphans (a proposal without its outcome or vice-versa), are SKIPPED,
never raised — a live run writes the pair non-atomically, so half-written state is realistic."""
directory = Path(outbox_dir)
if not directory.is_dir():
return []
proposals: dict[tuple[str, str], dict[str, Any]] = {}
for file in sorted(directory.glob("*-proposal.json")):
data = _load_json_dict(file)
if data is None or "run_id" not in data or not isinstance(data.get("proposal"), dict):
continue
proposals[_join_key(data)] = data
outcomes: dict[tuple[str, str], dict[str, Any]] = {}
for file in sorted(directory.glob("*-outcome.json")):
data = _load_json_dict(file)
if data is None or "run_id" not in data:
continue
outcomes[_join_key(data)] = data
result: list[PendingProposal] = []
for key in proposals.keys() & outcomes.keys(): # inner join — orphans on either side dropped
run_id, approach_id = key
proposal = proposals[key]["proposal"]
outcome = outcomes[key]
verdict_id = outcome.get("verdict_id")
outcome_type = outcome.get("outcome_type")
if not isinstance(verdict_id, str) or not isinstance(outcome_type, str):
continue
try:
codes = frozenset(item["code"] for item in proposal.get("affected_items", []))
except (KeyError, TypeError):
continue
result.append(
PendingProposal(
run_id=run_id,
verdict_id=verdict_id,
outcome_type=outcome_type,
measure=str(proposal.get("measure", "")),
codes=codes,
approach_id=approach_id,
)
)
return result
def _inbox_verdict_ids(verdict_dir: str) -> set[str]:
"""Collect the ``id`` of every inbox verdict file that ``load_verdicts_from_dir`` WOULD accept —
the same tolerant predicate mirrored inline: a dict carrying all ``_REQUIRED_VERDICT_KEYS``, a
``decision`` in the binary vocabulary ``{approved, rejected}``, and a ``proposal_features`` dict
carrying the inner keys ``verdict_from_dict`` reads AND an ``affected_codes`` that
``frozenset(...)`` accepts (a non-iterable — ``null``/int/float/bool — makes the real loader's
``frozenset(pf['affected_codes'])`` raise ``TypeError``, so it is skipped here too). A file that
would be skipped or raise in the real loader is skipped here too, so ``pending`` never treats it as
a delivered dom."""
directory = Path(verdict_dir)
if not directory.is_dir():
return set()
ids: set[str] = set()
for file in sorted(directory.glob("*.json")):
data = _load_json_dict(file)
if data is None or not _REQUIRED_VERDICT_KEYS <= data.keys():
continue
if data.get("decision") not in _INBOX_DECISION_VOCABULARY:
continue
features = data.get("proposal_features")
if not isinstance(features, dict) or not _REQUIRED_FEATURE_KEYS <= features.keys():
continue
# Mirror verdict_from_dict EXACTLY: frozenset(affected_codes) raises TypeError on a
# non-iterable (null/int/float/bool), which the real loader skips — so skip it here too.
try:
frozenset(features["affected_codes"])
except TypeError:
continue
ids.add(data["id"])
return ids
def pending(outbox_dir: str, verdict_dir: str) -> list[PendingProposal]:
"""The pending registry: outbox proposals whose ``verdict_id`` is NOT yet in the inbox id-set,
sorted deterministically by ``(run_id, approach_id, verdict_id)`` — one row per evaluated
approach, since each is judged on its own key."""
judged = _inbox_verdict_ids(verdict_dir)
unjudged = [p for p in _read_outbox_proposals(outbox_dir) if p.verdict_id not in judged]
return sorted(unjudged, key=lambda p: (p.run_id, p.approach_id, p.verdict_id))
# --- Routing config: self-contained dimension→expert table (fail-fast) ----------------------------
# A minimal MVP stand-in for the S3.5 dimension catalog (kept DISTINCT — see the plan's Non-Goals).
# Field names mirror ``dimension.Dimension`` so the two reconcile cleanly when S3.5 lands. No ``label``
# field: ``_matches`` never builds a ``Dimension``, so a label would be dead single-use surface.
class RoutingEntry(BaseModel):
"""One ``dimension → expert`` routing rule. ``allowed_measure_types`` EMPTY means "any measure"
(route by code prefix alone — see ``_matches`` + the plan's Risk #1); a non-empty set restores a
measure gate for deployments that want one."""
id: str = Field(min_length=1)
allowed_measure_types: frozenset[str] = frozenset()
allowed_code_prefixes: frozenset[str] = frozenset()
expert: str = Field(min_length=1)
class RoutingConfig(BaseModel):
"""The routing table. Entry ``id``s must be unique — a duplicate would make the sorted-first
tie-break ambiguous."""
entries: list[RoutingEntry]
@model_validator(mode="after")
def _unique_entry_ids(self) -> RoutingConfig:
ids = [e.id for e in self.entries]
if len(ids) != len(set(ids)):
raise ValueError("routing config has duplicate entry ids")
return self
def load_routing_config(path: str | Path) -> RoutingConfig:
"""Load + validate the routing config, fail-fast (mirrors ``costsim.load_pricing``): a missing
file raises ``FileNotFoundError``; malformed data raises ``pydantic.ValidationError``; a duplicate
entry id raises ``ValueError`` (via the after-validator). Top-level ``_``-prefixed keys are
ignored (doc/comment convention)."""
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"routing config not found: {str(p)!r}")
raw = json.loads(p.read_text(encoding="utf-8"))
data = {k: v for k, v in raw.items() if not k.startswith("_")}
return RoutingConfig(**data)
# --- Routing: classify each pending proposal to an expert (Step 3) --------------------------------
def _matches(entry: RoutingEntry, *, measure: str, codes: frozenset[str]) -> bool:
"""Mirror ``dimension.admits`` BUT with an OPTIONAL measure gate: an empty
``allowed_measure_types`` means "any measure" (route by code prefix alone). ``measure`` is
open-vocabulary prose (verified non-discriminating — plan Risk #1), so code prefixes are the
reliable domain key; the measure gate is a strict opt-in filter for deployments that set one."""
if entry.allowed_measure_types and measure not in entry.allowed_measure_types:
return False
if not entry.allowed_code_prefixes:
return True
return any(code.startswith(prefix) for code in codes for prefix in entry.allowed_code_prefixes)
@dataclass(frozen=True)
class RoutedProposal:
"""A pending proposal classified to an expert. ``expert``/``dimension_id`` are ``None`` when no
entry admits it (unroutable, still emitted); ``ambiguous`` flags a >1-entry match resolved by the
sorted-first ``entry.id`` tie-break."""
pending: PendingProposal
expert: str | None
dimension_id: str | None
ambiguous: bool
def route(outbox_dir: str, verdict_dir: str, config: RoutingConfig) -> list[RoutedProposal]:
"""Classify each pending proposal to the expert who owns its dimension. 0 matching entries →
unroutable (emitted with ``expert=None``); 1 → that entry; >1 → the sorted-first ``entry.id``
(deterministic) flagged ``ambiguous``. Order follows ``pending`` (sorted, deterministic)."""
routed: list[RoutedProposal] = []
for proposal in pending(outbox_dir, verdict_dir):
matches = [
entry
for entry in config.entries
if _matches(entry, measure=proposal.measure, codes=proposal.codes)
]
if not matches:
routed.append(RoutedProposal(proposal, expert=None, dimension_id=None, ambiguous=False))
continue
winner = min(matches, key=lambda entry: entry.id)
routed.append(
RoutedProposal(
proposal, expert=winner.expert, dimension_id=winner.id, ambiguous=len(matches) > 1
)
)
return routed
def _load_json_dict(file: Path) -> dict[str, Any] | None:
"""Tolerant read: parse ``file`` as JSON and return it only if it is a dict, else ``None`` (an
unreadable / non-JSON / non-object file is skipped by every reader here)."""
try:
data = json.loads(file.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
# UnicodeDecodeError: a *.json file hand-saved in Latin-1 (Norwegian æ/ø/å) is invalid UTF-8 —
# skip it, not raise (a ValueError subclass, so neither OSError nor JSONDecodeError catches it).
return None
return data if isinstance(data, dict) else None
# --- CLI: python -m portfolio_optimiser.hitl pending|route (Step 4) --------------------------------
def main(argv: list[str] | None = None) -> int:
"""CLI entry: ``python -m portfolio_optimiser.hitl pending|route`` — the operator inspection tool.
``pending`` prints one ``run_id verdict_id outcome_type`` line per un-judged proposal; ``route``
prints ``run_id verdict_id → <expert|UNROUTABLE> [dim:<id>][ AMBIGUOUS]``. Output is sorted /
deterministic. A config-load error → structured ``hitl: <reason>`` on stderr + rc 1 (never a
traceback). rc 0 on success."""
import argparse
parser = argparse.ArgumentParser(
prog="portfolio_optimiser.hitl",
description="HITL-inspeksjon (S5.1): vis ventende forslag (outbox uten inbox-dom) og rut dem "
"til fagekspert etter kostnadskode-prefiks. Leser mapper; ingen modellkall, ingen skriving.",
)
sub = parser.add_subparsers(dest="command", required=True)
p_pending = sub.add_parser("pending", help="list proposals still awaiting an expert verdict")
p_pending.add_argument(
"--outbox-dir", required=True, help="run outbox (proposal/outcome files)"
)
p_pending.add_argument("--verdict-dir", required=True, help="expert verdict inbox")
p_route = sub.add_parser("route", help="route pending proposals to experts by cost-code prefix")
p_route.add_argument("--outbox-dir", required=True, help="run outbox (proposal/outcome files)")
p_route.add_argument("--verdict-dir", required=True, help="expert verdict inbox")
p_route.add_argument("--routing-config", required=True, help="dimension→expert routing config")
args = parser.parse_args(argv)
if args.command == "pending":
for proposal in pending(args.outbox_dir, args.verdict_dir):
# The approach is appended only when there is one: an artefact written without a
# mandate has no approach, and printing an empty column would suggest a missing value
# rather than a run nobody commissioned by approach.
approach = f" [{proposal.approach_id}]" if proposal.approach_id else ""
print(f"{proposal.run_id} {proposal.verdict_id} {proposal.outcome_type}{approach}")
return 0
try:
config = load_routing_config(args.routing_config)
except (FileNotFoundError, ValidationError, ValueError) as exc:
print(f"hitl: {exc}", file=sys.stderr)
return 1
for routed in route(args.outbox_dir, args.verdict_dir, config):
p = routed.pending
if routed.expert is None:
print(f"{p.run_id} {p.verdict_id} → UNROUTABLE")
else:
suffix = " AMBIGUOUS" if routed.ambiguous else ""
print(
f"{p.run_id} {p.verdict_id}{routed.expert} [dim:{routed.dimension_id}]{suffix}"
)
return 0
if __name__ == "__main__": # pragma: no cover - console entry
raise SystemExit(main())