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 hashlib
import json import json
import logging
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -29,6 +30,8 @@ from agent_framework import ContextProvider, SessionContext
from portfolio_optimiser import okf from portfolio_optimiser import okf
_log = logging.getLogger(__name__)
# Weights: the affected cost-code overlap dominates, then measure type, then magnitude. # Weights: the affected cost-code overlap dominates, then measure type, then magnitude.
_W_CODES, _W_MEASURE, _W_MAGNITUDE = 0.60, 0.25, 0.15 _W_CODES, _W_MEASURE, _W_MAGNITUDE = 0.60, 0.25, 0.15
_MAGNITUDE_BUCKETS = [(0.0, 1e5), (1e5, 5e5), (5e5, 1e6), (1e6, float("inf"))] _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). # band, and a separate later run pick them up (målbilde §3 long loop).
_REQUIRED_VERDICT_KEYS = {"id", "decision", "rationale", "proposal_features"} _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]: def verdict_to_dict(verdict: Verdict) -> dict[str, Any]:
@ -171,24 +178,62 @@ def write_verdict(directory: str, verdict: Verdict) -> Path:
return target 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 """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 spirit): a missing folder yields ``[]``; files that are not ``.json``, fail to parse, lack a
required key are SKIPPED, never raised. The folder is written out of band by an external party, required key, carry a decision outside the binary vocabulary ``{approved, rejected}``, or exceed
so half-written or foreign files (e.g. a bundle's ``golden.json``) are realistic — this is the ``max_rationale_len`` are SKIPPED, never raised. The folder is written out of band by an external
raw layer, not required input (contrast ``okf.load_ir_projection``'s fail-fast). Order is party, so half-written or foreign files (e.g. a bundle's ``golden.json``) are realistic — this is
deterministic (sorted by filename).""" 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) path = Path(directory)
if not path.is_dir(): if not path.is_dir():
return [] 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] = [] verdicts: list[Verdict] = []
for file in sorted(path.glob("*.json")): for file in files:
try: try:
data = json.loads(file.read_text(encoding="utf-8")) data = json.loads(file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
continue continue
if not isinstance(data, dict) or not _REQUIRED_VERDICT_KEYS <= data.keys(): if not isinstance(data, dict) or not _REQUIRED_VERDICT_KEYS <= data.keys():
continue 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: try:
verdicts.append(verdict_from_dict(data)) verdicts.append(verdict_from_dict(data))
except (KeyError, TypeError): except (KeyError, TypeError):

View file

@ -7,6 +7,7 @@ genuine two-arg ``extend_instructions(source_id, instructions)`` GA signature
Critical Fase 1 risk. Pattern: tests/spikes/test_d_verdictstore.py + real SessionContext. Critical Fase 1 risk. Pattern: tests/spikes/test_d_verdictstore.py + real SessionContext.
""" """
import logging
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -19,7 +20,9 @@ from portfolio_optimiser.verdicts import (
VerdictStore, VerdictStore,
bundle_candidate_features, bundle_candidate_features,
capture_verdict, capture_verdict,
load_verdicts_from_dir,
seed_store_from_bundle, seed_store_from_bundle,
write_verdict,
) )
_BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro" _BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
@ -145,3 +148,74 @@ def test_seed_store_retrieval_matches_the_candidate() -> None:
hits = store.retrieve(query, k=3) hits = store.retrieve(query, k=3)
assert len(hits) == 1 assert len(hits) == 1
assert "0.82" in hits[0].rationale assert "0.82" in hits[0].rationale
# --- S2.5 (Step 7): inbox herding — vocabulary SKIP + rationale-cap (skip) + file-count (fail-fast) ---
def _feats(code: str, magnitude: float = 1.0) -> ProposalFeatures:
return ProposalFeatures(
affected_codes=frozenset({code}), measure_type="m", claimed_saving_nok=magnitude
)
def test_load_skips_unknown_decision_vocabulary(tmp_path) -> None:
"""T-2.5a: an inbox verdict whose decision is outside the binary run-path vocabulary
{approved, rejected} is SKIPPED (never enters the store) tolerant load, never raises. Detach
the vocabulary check the ``banana`` verdict enters the store RED."""
write_verdict(str(tmp_path), capture_verdict(_feats("X"), "banana", "weird decision"))
write_verdict(str(tmp_path), capture_verdict(_feats("Y", 2.0), "approved", "fine"))
decisions = [v.decision for v in load_verdicts_from_dir(str(tmp_path))]
assert "banana" not in decisions
assert "approved" in decisions
def test_load_skips_oversized_rationale_with_log(tmp_path, caplog) -> None:
"""T-2.5b: a rationale over ``max_rationale_len`` is SKIPPED and logged (caplog-observable) — a
per-file tolerant skip, NOT a raise (contrast the file-count cap)."""
write_verdict(str(tmp_path), capture_verdict(_feats("X"), "approved", "x" * 500))
with caplog.at_level(logging.WARNING):
loaded = load_verdicts_from_dir(str(tmp_path), max_rationale_len=100)
assert loaded == []
assert any("rationale" in r.message.lower() for r in caplog.records)
def test_load_fails_fast_over_max_files(tmp_path) -> None:
"""T-2.5c: a file count over ``max_files`` per merge is a fail-fast RAISE (aggregate guard,
contrast the per-file tolerant skips)."""
for i in range(3):
write_verdict(
str(tmp_path), capture_verdict(_feats(f"C{i}", float(i + 1)), "approved", "r")
)
with pytest.raises(ValueError):
load_verdicts_from_dir(str(tmp_path), max_files=2)
def test_load_within_caps_is_unchanged(tmp_path) -> None:
"""Control: with vocabulary-valid decisions and no cap breach, the loader behaves exactly as
before the tolerant contract (which the Step-7 loop relies on) is intact."""
write_verdict(str(tmp_path), capture_verdict(_feats("X"), "approved", "ok"))
write_verdict(str(tmp_path), capture_verdict(_feats("Y", 2.0), "rejected", "no"))
loaded = load_verdicts_from_dir(str(tmp_path), max_rationale_len=100, max_files=10)
assert {v.decision for v in loaded} == {"approved", "rejected"}
def test_no_inbox_json_uses_approved_with_adjustment() -> None:
"""Assumption 3 (TDD guard): no shipped/test ``.json`` uses the promotion-only decision
``approved_with_adjustment`` the vocabulary SKIP would now silently drop it. It lives only in
bundle-seed frontmatter + the promotion gate ``_APPROVED_DECISIONS``, never a binary run-path
inbox file."""
root = Path(__file__).resolve().parents[1]
offenders = [
p
for p in root.rglob("*.json")
if ".venv" not in p.parts
and ".git" not in p.parts
and "approved_with_adjustment" in p.read_text(encoding="utf-8", errors="ignore")
]
assert offenders == [], f"inbox JSON with promotion-only decision: {offenders}"