portfolio-optimiser/src/portfolio_optimiser/hitl.py
Kjell Tore Guttormsen c08ae91809 feat(explore): plan-reviewen kan besvares over DAGER (U12 + asynkron U13, rad 3)
F4 gjorde "be om svar, BRUKE svarene" naabar, men bare SYNKRONT: terminal_plan_reviewer
blokkerer loekka paa et menneske ved en terminal, saa svaret maa komme mens prosessen lever.
Maalbilde §3s tidsskala er den andre - eksperten svarer dager senere, i en prosess som aldri
saa kjoeringen.

--checkpoint-dir PARKERER reviewen (FileCheckpointStorage + {run_id}-plan-review.json) og
avslutter; --resume <run_id> leser svaret fra --review-inbox i en fersk interpreter. Det
eneste som krysser prosessgrensen er disk.

MAALT FELLE (Verifiseringsloven ansikt 4): list_checkpoints (_checkpoint.py:386-388) svelger
en blokkert deserialisering til en logger.warning og returnerer TOM liste. Uten BEGGE
MagenticPlanReviewRequest/Response i allowed_checkpoint_types feiler en resume som et FRAVAER,
ikke som en feil. _ALLOWED_CHECKPOINT_TYPES har derfor EN kopi, checkpoint_storage er eneste
konstruksjonssted, og en tom listing ved park raiser CheckpointUnreadable i stedet for aa
skrive et spoersmaal ingen kan besvare.

Budsjettet og revisjons-capen spenner over suspensjonen (meter.charge(parked.tokens_spent) +
trace.ledger.extend), ellers faar hver park et helt budsjett paa nytt. Fail-closed paa
ekspertens egen fil: request_id-mismatch, ord utenfor vokabularet og revise uten innhold
refuseres alle ved navn. hitl.pending_plan_reviews er registeret over hvem som venter.

Load-bearing MAALT: 17 tester, TRETTEN mutasjoner alle roede mot HELE suiten, groenn kontroll
1059 passed / 5 skipped, golden demo-transcript.stdout byte-uendret
(ea8c534773acdbe41ae68f2c55724d69aaf8be4f).

EN MUTASJON FALSIFISERTE SUITEN (vakuoes-gate-klassen, tiende gang): detach av
trace.plan_reviews.extend(parked.plan_reviews) lot HELE suiten staa groenn - capen leser
parked.plan_reviews DIREKTE, saa den binder uansett, og de to foerste legene er identiske
under begge implementasjoner. Gaten maatte bli det TREDJE leget, der artefaktet ellers taper
dag 1s revisjon og to ulike planer deler indeks 1. Ny test skrevet mot mutasjonen foerst.

Aerlighets-grenser: hostet flate NEKTER fortsatt (synkron review ville blokkert baade
requesten og event-loekka som svarer /readiness); en park midt i loepet etter en stall har
ingen naabar sti under det skriptede manuset, saa carry-overen som betjener den drives gjennom
en CRAFTED parkert tilstand.

Ordre 20260825T114645Z-6622513622-from-portfolio-optimiser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:26:04 +02:00

464 lines
22 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))
# --- U12: the pending PLAN REVIEWS of parked explorations, and the expert's answer ---------------
# The same registry shape as ``pending`` above, one time-scale earlier: there the outbox holds a
# PROPOSAL awaiting a verdict, here it holds a QUESTION awaiting a decision. Both live in this
# MAF-free module because both are read by an operator tool that must not drag the framework in.
#: The closed answer vocabulary, identical to the terminal door's (``explore.terminal_plan_reviewer``).
#: Two words, matched structurally — a file cannot be re-asked, so anything else is a refusal.
_PLAN_REVIEW_ANSWERS = frozenset({"approve", "revise"})
@dataclass(frozen=True)
class PendingPlanReview:
"""One parked exploration still waiting on a human. ``plan`` is carried because a registry that
only counted questions could not be used to answer one."""
run_id: str
request_id: str
index: int
plan: str
is_stalled: bool
@dataclass(frozen=True)
class PlanReviewAnswer:
"""The expert's decision, as read off a file. ``feedback`` is empty exactly when approving —
the same encoding ``explore.PlanReviewDecision`` uses, kept plain so this module stays
MAF-free and the adapter between them lives at ONE call site."""
run_id: str
request_id: str
decision: str
feedback: str
class PlanReviewAnswerError(ValueError):
"""An answer file that cannot be read as a decision.
Fail-closed, and deliberately NOT the tolerant rule the verdict inbox uses. A dropped verdict
that will not parse is one opinion missing from a fold; an unreadable plan-review answer is the
one thing standing between a suspended run and a plan nobody signed. A ``ValueError`` so the
CLI's existing structured-refusal arm surfaces it as ``rc 1`` rather than a traceback."""
def _answer_path(review_dir: str, run_id: str) -> Path:
return Path(review_dir) / f"{run_id}-plan-review-answer.json"
def read_plan_review_question(outbox_dir: str, run_id: str) -> dict[str, Any] | None:
"""The open question of ``run_id``, or ``None`` when there is none. Tolerant: an outbox with no
such file simply has no parked review."""
return _load_json_dict(Path(outbox_dir) / f"{run_id}-plan-review.json")
def load_plan_review_answer(review_dir: str, run_id: str, *, request_id: str) -> PlanReviewAnswer:
"""Read the expert's answer to ONE named review, fail-closed at every step.
``request_id`` is a required argument rather than something read off the file and trusted: two
reviews of one run share a file name, so an answer left over from the previous round would
otherwise be applied to a plan the expert never saw. A mismatch is REFUSED by name — it is a
stale answer, not an absent one, and the two need different words.
A missing file raises rather than returning ``None``: "not answered yet" is the normal state of
this door, and the caller asking to resume has already said it believes otherwise."""
path = _answer_path(review_dir, run_id)
data = _load_json_dict(path)
if data is None:
raise PlanReviewAnswerError(
f"no answer for plan review {request_id} of run {run_id!r} in {review_dir!r} "
f"(expected {path.name}): the review is still waiting on a human"
)
found = str(data.get("request_id", ""))
if found != request_id:
raise PlanReviewAnswerError(
f"the answer in {path.name} answers plan review {found!r}, but the open review of run "
f"{run_id!r} is {request_id!r}. Refused: an answer to another question is not an "
f"answer to this one"
)
decision = str(data.get("decision", ""))
if decision not in _PLAN_REVIEW_ANSWERS:
raise PlanReviewAnswerError(
f"{path.name} answers {decision!r}, which is outside the vocabulary "
f"{sorted(_PLAN_REVIEW_ANSWERS)}. Refused, never read as a sign-off"
)
feedback = str(data.get("feedback", ""))
if decision == "revise" and not feedback.strip():
raise PlanReviewAnswerError(
f"{path.name} answers 'revise' with nothing to revise: the manager would be asked to "
f"replan against an empty instruction. Say what to change, or answer 'approve'"
)
return PlanReviewAnswer(
run_id=run_id, request_id=request_id, decision=decision, feedback=feedback
)
def pending_plan_reviews(outbox_dir: str, review_dir: str) -> list[PendingPlanReview]:
"""Every parked plan review whose OWN answer has not landed, sorted by ``run_id``.
Mirrors ``pending``: an outbox artefact joined against an inbox, with the join on the key each
side names. The key here is ``request_id`` — an answer to a different review leaves this one
pending rather than quietly clearing it, which is the same fail-closed rule the resume path
applies and for the same reason.
Tolerant on the READ side (an unreadable file in either folder is not a question and not an
answer), fail-closed on the DECIDE side (``load_plan_review_answer``). The registry says who is
waiting; it never decides what they said."""
waiting: list[PendingPlanReview] = []
directory = Path(outbox_dir)
if not directory.is_dir():
return waiting
for file in sorted(directory.glob("*-plan-review.json")):
data = _load_json_dict(file)
if data is None:
continue
run_id = str(data.get("run_id", ""))
request_id = str(data.get("request_id", ""))
if not run_id or not request_id:
continue
answer = _load_json_dict(_answer_path(review_dir, run_id))
if answer is not None and str(answer.get("request_id", "")) == request_id:
continue
waiting.append(
PendingPlanReview(
run_id=run_id,
request_id=request_id,
index=int(data.get("index", 0)),
plan=str(data.get("plan", "")),
is_stalled=bool(data.get("is_stalled", False)),
)
)
return sorted(waiting, key=lambda p: (p.run_id, p.index))
# --- 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())