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:
parent
ce5b1151c8
commit
b50e3fdff2
4 changed files with 448 additions and 1 deletions
145
src/portfolio_optimiser/hitl.py
Normal file
145
src/portfolio_optimiser/hitl.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue