feat(s51): pending registry — outbox↔inbox id-join (MAF-clean)

Gate: pytest tests/test_hitl.py tests/test_hitl_loadbearing.py tests/test_okf.py → 26 passed.
This commit is contained in:
Kjell Tore Guttormsen 2026-07-15 19:33:59 +02:00
commit b50e3fdff2
4 changed files with 448 additions and 1 deletions

View file

@ -0,0 +1,145 @@
"""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
from dataclasses import dataclass
from pathlib import Path
from typing import Any
# 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."""
run_id: str
verdict_id: str
outcome_type: str
measure: str
codes: frozenset[str]
def _read_outbox_proposals(outbox_dir: str) -> list[PendingProposal]:
"""Read the outbox, joining ``{run_id}-proposal.json`` and ``{run_id}-outcome.json`` on the
``run_id`` FIELD 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[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[str(data["run_id"])] = data
outcomes: dict[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[str(data["run_id"])] = data
result: list[PendingProposal] = []
for run_id in proposals.keys() & outcomes.keys(): # inner join — orphans on either side dropped
proposal = proposals[run_id]["proposal"]
outcome = outcomes[run_id]
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,
)
)
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. 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
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, verdict_id)``."""
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.verdict_id))
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, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None