feat(fase2a): herd load_verdicts_from_dir — vokabular-SKIP + caps (S2.5)

This commit is contained in:
Kjell Tore Guttormsen 2026-07-15 07:26:22 +02:00
commit 0a227a103b
2 changed files with 126 additions and 7 deletions

View file

@ -20,6 +20,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import re
from dataclasses import dataclass
from pathlib import Path
@ -29,6 +30,8 @@ from agent_framework import ContextProvider, SessionContext
from portfolio_optimiser import okf
_log = logging.getLogger(__name__)
# Weights: the affected cost-code overlap dominates, then measure type, then magnitude.
_W_CODES, _W_MEASURE, _W_MAGNITUDE = 0.60, 0.25, 0.15
_MAGNITUDE_BUCKETS = [(0.0, 1e5), (1e5, 5e5), (5e5, 1e6), (1e6, float("inf"))]
@ -112,6 +115,10 @@ def capture_verdict(features: ProposalFeatures, decision: str, rationale: str) -
# band, and a separate later run pick them up (målbilde §3 long loop).
_REQUIRED_VERDICT_KEYS = {"id", "decision", "rationale", "proposal_features"}
# The binary run-path decision vocabulary (Verdict.decision domain + FeedbackContract). An inbox file
# with any other decision is SKIPPED by load_verdicts_from_dir — ``approved_with_adjustment`` is
# deliberately EXCLUDED here (it lives only in bundle-seed frontmatter + the promotion gate).
_INBOX_DECISION_VOCABULARY = frozenset({"approved", "rejected"})
def verdict_to_dict(verdict: Verdict) -> dict[str, Any]:
@ -171,24 +178,62 @@ def write_verdict(directory: str, verdict: Verdict) -> Path:
return target
def load_verdicts_from_dir(directory: str) -> list[Verdict]:
def load_verdicts_from_dir(
directory: str,
*,
max_rationale_len: int | None = None,
max_files: int | None = None,
) -> list[Verdict]:
"""Load every well-formed verdict JSON file from an async inbox folder, TOLERANTLY (OKF §4
spirit): a missing folder yields ``[]``; files that are not ``.json``, fail to parse, or lack a
required key are SKIPPED, never raised. The folder is written out of band by an external party,
so half-written or foreign files (e.g. a bundle's ``golden.json``) are realistic — this is the
raw layer, not required input (contrast ``okf.load_ir_projection``'s fail-fast). Order is
deterministic (sorted by filename)."""
spirit): a missing folder yields ``[]``; files that are not ``.json``, fail to parse, lack a
required key, carry a decision outside the binary vocabulary ``{approved, rejected}``, or exceed
``max_rationale_len`` are SKIPPED, never raised. The folder is written out of band by an external
party, so half-written or foreign files (e.g. a bundle's ``golden.json``) are realistic — this is
the raw layer, not required input (contrast ``okf.load_ir_projection``'s fail-fast). Order is
deterministic (sorted by filename).
Herding (S2.5), two distinct axes by design:
- ``max_rationale_len`` (per-file, TOLERANT): an over-long rationale is skipped + logged, never
raised matching the per-file skip contract the Step-7 loop relies on.
- ``max_files`` (aggregate, FAIL-FAST): more files than the cap RAISES ``ValueError`` before any
parsing an oversized inbox is an aggregate integrity signal, not a per-file anomaly.
Both default to ``None`` (no cap), so existing callers are byte-for-byte unaffected."""
path = Path(directory)
if not path.is_dir():
return []
files = sorted(path.glob("*.json"))
if max_files is not None and len(files) > max_files:
raise ValueError(
f"verdict inbox {directory!r} has {len(files)} files, over the max_files cap "
f"({max_files}) — an oversized inbox is a fail-fast aggregate guard (contrast the "
"per-file tolerant skips)"
)
verdicts: list[Verdict] = []
for file in sorted(path.glob("*.json")):
for file in files:
try:
data = json.loads(file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if not isinstance(data, dict) or not _REQUIRED_VERDICT_KEYS <= data.keys():
continue
if data.get("decision") not in _INBOX_DECISION_VOCABULARY:
_log.debug(
"skipping inbox verdict %s: decision %r outside vocabulary",
file.name,
data.get("decision"),
)
continue
if (
max_rationale_len is not None
and len(str(data.get("rationale", ""))) > max_rationale_len
):
_log.warning(
"skipping inbox verdict %s: rationale length %d exceeds max_rationale_len %d",
file.name,
len(str(data.get("rationale", ""))),
max_rationale_len,
)
continue
try:
verdicts.append(verdict_from_dict(data))
except (KeyError, TypeError):