feat(fase2a): herd load_verdicts_from_dir — vokabular-SKIP + caps (S2.5)
This commit is contained in:
parent
a706184bdd
commit
0a227a103b
2 changed files with 126 additions and 7 deletions
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue