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
332 lines
16 KiB
Python
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())
|