P18's round 2 ended with two VALIDATED proposals whose affected_item codes were ordinary words from a road standard's prose -- impulsventilator (4 of 270 N500 documents) and bituminoest baerelag (4 of 1133 N200). Both are grounded in P7's sense and neither is inert in P18/B1's sense; they are simply not identifiers of a cost line, and the gate had no stage that could say so. Known positive MEASURED, not asserted: replayed offline against the bases those runs were given, both come back Rejection naming the denominator. IDENTIFIER_FORMS moved from generate.py to validator.py: they now drive both P8's report and this gate, and two copies of "what an identifier looks like" would let the two disagree about one run's own input. B1 -- two new forms, transcribed from measurement. R761's requirement numbers are bare dotted numbers and all six refs in kontrakt-sorasen's fasit are of that shape, which neither pre-P19 form matched: r761's whole offer was 3 identifiers over 6.5 MB, and is now 2332. The FIRST form was widened in the same pass because B2 made these forms decide prose vs identifier, and this repo's own ENERGI-TOTAL-EL matched none of them -- a gate may only be wrong in the direction that admits too much. Three things keep the gate from being a rule about shapes: the generality guard (it fires only where the input offers forms), the baseline exemption (stage 0 has already ruled that code real), and full-matching. Honesty limit, measured and given its OWN arm: a decimal and an R761 process number are typographically identical, so the form counts both. P8's existing "bare numbers" arm is narrowed to bare INTEGERS accordingly. Measured over all nine round-1+2 outboxes: 26 of 36 codes are prose. Load-bearing measured (22 arms), four mutations all red against the whole suite (16 / 10 / 1 / 2), green control 1734/5 and the golden byte-unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
314 lines
14 KiB
Python
314 lines
14 KiB
Python
"""S5.1 HITL — load-bearing detach seams (målbilde §3 / §7). Each test goes RED when the guarded
|
|
mechanism is removed; a bare happy-path pass would not catch the regression.
|
|
|
|
Seams pinned here:
|
|
- the outbox↔inbox **id-join** (a landed verdict must clear the queue);
|
|
- **causality control** (a pre-judged proposal is never listed);
|
|
- **inbox-predicate parity** with ``load_verdicts_from_dir`` (wrong decision + malformed features
|
|
are skipped, so ``pending`` never counts as judged a file the real loader would drop);
|
|
- (Step 3) **route classification** detach;
|
|
- (Step 5) **transitive import-graph** MAF-freedom probe.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
|
|
from portfolio_optimiser.outbox import write_outbox
|
|
from portfolio_optimiser.provenance import Citation, ProvenanceStamp
|
|
from portfolio_optimiser.retrieval import TextSpan
|
|
from portfolio_optimiser.validator import ValidatedProposal
|
|
from portfolio_optimiser.verdicts import ProposalFeatures, Verdict, write_verdict
|
|
|
|
from portfolio_optimiser import hitl
|
|
|
|
_PROVENANCE = ProvenanceStamp(
|
|
citations=[Citation(file="f.md", locator=TextSpan(start_index=0, end_index=5), snippet="hi")],
|
|
model="synthetic",
|
|
role="proposer",
|
|
validator_decision="validated",
|
|
token_usage=8,
|
|
# These fixtures stand in for an ordinary complete run; the road path is anchored by
|
|
# construction, so ``True`` is the honest value here. The un-anchored case has its own file.
|
|
cost_baseline_anchored=True,
|
|
bundle_id_source=None,
|
|
code_forms={},
|
|
)
|
|
|
|
|
|
def _write_proposal(
|
|
outbox: Path, run_id: str, *, verdict_id: str, codes: list[str] | None = None
|
|
) -> None:
|
|
items = [AffectedItem(code=c, quantity=1000.0, unit_cost=1.0) for c in (codes or ["05.2"])]
|
|
proposal = SavingsProposal(
|
|
project_id="P1", measure="LED-retrofit", affected_items=items, claimed_saving_nok=200.0
|
|
)
|
|
write_outbox(
|
|
str(outbox),
|
|
run_id,
|
|
outcome=ValidatedProposal(
|
|
proposal=proposal, p10=100.0, p50=150.0, p90=200.0, nominal_feasible=180.0
|
|
),
|
|
provenance=_PROVENANCE,
|
|
checker_verdict="approve",
|
|
verdict_id=verdict_id,
|
|
)
|
|
|
|
|
|
def _write_raw_verdict(inbox: Path, verdict_id: str, payload: dict) -> None:
|
|
"""Hand-write an inbox ``{id}.json`` — used to construct decisions/shapes ``write_verdict``
|
|
cannot emit (e.g. ``approved_with_adjustment``, or malformed features)."""
|
|
inbox.mkdir(parents=True, exist_ok=True)
|
|
(inbox / f"{verdict_id}.json").write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
|
|
# --- id-join (the flagship seam) ------------------------------------------------------------------
|
|
|
|
|
|
def test_verdict_landing_removes_from_pending(tmp_path: Path) -> None:
|
|
"""LOAD-BEARING: a proposal is pending until a verdict with the EXACT SAME id lands in the inbox.
|
|
The id is the shared literal passed to ``write_outbox(verdict_id=X)`` and ``Verdict(id=X)`` — not
|
|
a re-hash of features. Detach the ``verdict_id ∉ inbox id-set`` predicate (return all outbox
|
|
proposals) → the landed verdict never clears the queue → RED."""
|
|
outbox = tmp_path / "outbox"
|
|
inbox = tmp_path / "inbox"
|
|
_write_proposal(outbox, "run-1", verdict_id="SHARED-VID")
|
|
|
|
assert [p.run_id for p in hitl.pending(str(outbox), str(inbox))] == ["run-1"]
|
|
|
|
write_verdict(
|
|
str(inbox),
|
|
Verdict(
|
|
id="SHARED-VID",
|
|
proposal_features=ProposalFeatures(
|
|
affected_codes=frozenset({"05.2"}),
|
|
measure_type="scope_reduction",
|
|
claimed_saving_nok=200.0,
|
|
),
|
|
decision="approved",
|
|
rationale="expert reviewed (test)",
|
|
),
|
|
)
|
|
|
|
assert hitl.pending(str(outbox), str(inbox)) == []
|
|
|
|
|
|
def test_pending_ignores_prejudged_proposal(tmp_path: Path) -> None:
|
|
"""CAUSALITY CONTROL: a proposal whose verdict is ALREADY in the inbox at first read is never
|
|
listed — proving the removal above is caused by the id-join, not incidental."""
|
|
outbox = tmp_path / "outbox"
|
|
inbox = tmp_path / "inbox"
|
|
_write_proposal(outbox, "run-1", verdict_id="SHARED-VID")
|
|
write_verdict(
|
|
str(inbox),
|
|
Verdict(
|
|
id="SHARED-VID",
|
|
proposal_features=ProposalFeatures(
|
|
affected_codes=frozenset({"05.2"}),
|
|
measure_type="scope_reduction",
|
|
claimed_saving_nok=200.0,
|
|
),
|
|
decision="approved",
|
|
rationale="expert reviewed (test)",
|
|
),
|
|
)
|
|
|
|
assert hitl.pending(str(outbox), str(inbox)) == []
|
|
|
|
|
|
# --- inbox-predicate parity with load_verdicts_from_dir -------------------------------------------
|
|
|
|
|
|
def test_inbox_idset_skips_wrong_decision(tmp_path: Path) -> None:
|
|
"""An otherwise-fully-valid inbox verdict (all required keys, well-formed proposal_features) whose
|
|
ONLY defect is ``decision="approved_with_adjustment"`` (outside the binary run-path vocabulary)
|
|
does NOT clear pending — parity with ``load_verdicts_from_dir`` skipping it. Being otherwise
|
|
valid, dropping the decision filter is the ONLY reason it would skip → genuinely load-bearing."""
|
|
outbox = tmp_path / "outbox"
|
|
inbox = tmp_path / "inbox"
|
|
_write_proposal(outbox, "run-1", verdict_id="SHARED-VID")
|
|
_write_raw_verdict(
|
|
inbox,
|
|
"SHARED-VID",
|
|
{
|
|
"id": "SHARED-VID",
|
|
"decision": "approved_with_adjustment",
|
|
"rationale": "adjusted",
|
|
"proposal_features": {
|
|
"affected_codes": ["05.2"],
|
|
"measure_type": "scope_reduction",
|
|
"claimed_saving_nok": 200.0,
|
|
"description": "",
|
|
},
|
|
},
|
|
)
|
|
|
|
assert [p.run_id for p in hitl.pending(str(outbox), str(inbox))] == ["run-1"]
|
|
|
|
|
|
# --- route classification (Step 3) ----------------------------------------------------------------
|
|
|
|
|
|
def test_route_classification_is_load_bearing(tmp_path: Path) -> None:
|
|
"""LOAD-BEARING: ``_matches`` genuinely classifies. Detach it (route everything to ``entries[0]``)
|
|
→ both assertions flip RED: an unmatched proposal would stop being UNROUTABLE, and a two-entry
|
|
match would tie-break to ``entries[0]`` instead of the sorted-first id. ``entries[0]`` is
|
|
deliberately NOT the sorted-first id, so the tie-break assertion is sensitive to the detach."""
|
|
outbox = tmp_path / "outbox"
|
|
inbox = tmp_path / "inbox"
|
|
config = hitl.RoutingConfig(
|
|
entries=[
|
|
hitl.RoutingEntry(id="z-vei", allowed_code_prefixes=frozenset({"07"}), expert="Zeta"),
|
|
hitl.RoutingEntry(
|
|
id="a-energi", allowed_code_prefixes=frozenset({"05"}), expert="Alpha"
|
|
),
|
|
]
|
|
)
|
|
_write_proposal(outbox, "run-A", verdict_id="vA", codes=["99.9"]) # matches nothing
|
|
_write_proposal(outbox, "run-C", verdict_id="vC", codes=["05.1", "07.1"]) # matches BOTH
|
|
|
|
routed = {r.pending.run_id: r for r in hitl.route(str(outbox), str(inbox), config)}
|
|
assert routed["run-A"].expert is None # detach → routed to Zeta → RED
|
|
assert routed["run-C"].ambiguous is True
|
|
assert routed["run-C"].dimension_id == "a-energi" # sorted-first, NOT entries[0] z-vei
|
|
assert routed["run-C"].expert == "Alpha"
|
|
|
|
|
|
def test_inbox_idset_skips_malformed_features(tmp_path: Path) -> None:
|
|
"""An inbox verdict with all four top-level keys but ``proposal_features`` MISSING ``measure_type``
|
|
(which ``verdict_from_dict`` reads → would raise) is skipped, so it does NOT clear pending —
|
|
parity with the real loader dropping it."""
|
|
outbox = tmp_path / "outbox"
|
|
inbox = tmp_path / "inbox"
|
|
_write_proposal(outbox, "run-1", verdict_id="SHARED-VID")
|
|
_write_raw_verdict(
|
|
inbox,
|
|
"SHARED-VID",
|
|
{
|
|
"id": "SHARED-VID",
|
|
"decision": "approved",
|
|
"rationale": "ok",
|
|
"proposal_features": {"affected_codes": ["05.2"], "claimed_saving_nok": 200.0},
|
|
},
|
|
)
|
|
|
|
assert [p.run_id for p in hitl.pending(str(outbox), str(inbox))] == ["run-1"]
|
|
|
|
|
|
def test_inbox_idset_skips_non_iterable_affected_codes(tmp_path: Path) -> None:
|
|
"""PARITY (wrong-type): an inbox verdict with every required key present but ``affected_codes`` a
|
|
NON-ITERABLE (``null``) is skipped — the real loader's ``frozenset(pf['affected_codes'])``
|
|
(verdicts.py) raises ``TypeError`` and ``load_verdicts_from_dir`` drops it. So ``pending`` must NOT
|
|
count it as judged; otherwise ``hitl`` marks the proposal judged while the learning pipeline still
|
|
treats it pending — a silent false-negative in the operator queue, the exact failure the parity
|
|
docstring says it prevents. Detach the ``affected_codes`` iterability check (accept on key-presence
|
|
alone) → the file clears pending → RED. Ground-truth twin:
|
|
``test_verdicts.test_load_skips_non_iterable_affected_codes``."""
|
|
outbox = tmp_path / "outbox"
|
|
inbox = tmp_path / "inbox"
|
|
_write_proposal(outbox, "run-1", verdict_id="SHARED-VID")
|
|
_write_raw_verdict(
|
|
inbox,
|
|
"SHARED-VID",
|
|
{
|
|
"id": "SHARED-VID",
|
|
"decision": "approved",
|
|
"rationale": "ok",
|
|
"proposal_features": {
|
|
"affected_codes": None, # non-iterable → real loader's frozenset() raises → skips
|
|
"measure_type": "scope_reduction",
|
|
"claimed_saving_nok": 200.0,
|
|
},
|
|
},
|
|
)
|
|
|
|
assert [p.run_id for p in hitl.pending(str(outbox), str(inbox))] == ["run-1"]
|
|
|
|
|
|
# --- transitive import-graph MAF-freedom probe (Step 5) -------------------------------------------
|
|
|
|
_SRC_DIR = Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser"
|
|
_MAF_ROOTS = {"agent_framework", "mcp"}
|
|
|
|
|
|
def _first_party_submodule_edges(tree: ast.Module) -> set[str]:
|
|
"""Collect ``portfolio_optimiser.<name>`` submodule import edges from a parsed module —
|
|
``from portfolio_optimiser.<name> import ...`` and ``import portfolio_optimiser.<name>``.
|
|
|
|
DISCOUNTED by design (Assumption 3): the package top ``from portfolio_optimiser import X`` (X is
|
|
a re-exported symbol from the eager ``__init__`` ``__all__`` set, e.g. ``run_project``), and
|
|
``__init__`` itself — never traversed, else every walk would reach ``run`` → ``agent_framework``
|
|
and the probe would be unsatisfiable for a clean module. Known limitation: a hypothetical
|
|
``from portfolio_optimiser import verdicts`` (submodule via the package) is indistinguishable from
|
|
a symbol import here and would be discounted — optional future hardening; the specced detach
|
|
(``from portfolio_optimiser.verdicts import ...``) is a ``len>=2`` edge and IS caught."""
|
|
edges: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.ImportFrom):
|
|
parts = (node.module or "").split(".")
|
|
if len(parts) >= 2 and parts[0] == "portfolio_optimiser" and parts[1] != "__init__":
|
|
edges.add(parts[1])
|
|
elif isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
parts = alias.name.split(".")
|
|
if len(parts) >= 2 and parts[0] == "portfolio_optimiser" and parts[1] != "__init__":
|
|
edges.add(parts[1])
|
|
return edges
|
|
|
|
|
|
def _imports_maf(tree: ast.Module) -> bool:
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
if any(a.name.split(".")[0] in _MAF_ROOTS for a in node.names):
|
|
return True
|
|
elif isinstance(node, ast.ImportFrom):
|
|
if (node.module or "").split(".")[0] in _MAF_ROOTS:
|
|
return True
|
|
return False
|
|
|
|
|
|
def test_hitl_transitive_import_graph_is_maf_free() -> None:
|
|
"""LOAD-BEARING: no module reachable from ``hitl.py``'s OWN first-party import edges imports
|
|
``agent_framework``/``mcp``. A static AST BFS (never traversing ``__init__.py`` — see the discount
|
|
note) — NOT a ``sys.modules`` probe, which is unsatisfiable because ``__init__.py:11`` eagerly
|
|
loads ``run`` → MAF. hitl re-implements the outbox shape + admits-logic inline, so its steady
|
|
state is an EMPTY first-party edge set. Detach point (documented): add
|
|
``from portfolio_optimiser.verdicts import load_verdicts_from_dir`` to ``hitl.py`` → the walk
|
|
reaches ``verdicts.py`` (``agent_framework`` at line 29) → RED. A clean hitl → GREEN."""
|
|
seen: set[str] = set()
|
|
queue = list(_first_party_submodule_edges(ast.parse((_SRC_DIR / "hitl.py").read_text("utf-8"))))
|
|
maf_bearing: list[str] = []
|
|
while queue:
|
|
mod = queue.pop()
|
|
if mod in seen:
|
|
continue
|
|
seen.add(mod)
|
|
mod_file = _SRC_DIR / f"{mod}.py"
|
|
if not mod_file.is_file():
|
|
continue
|
|
tree = ast.parse(mod_file.read_text("utf-8"))
|
|
if _imports_maf(tree):
|
|
maf_bearing.append(mod)
|
|
queue.extend(_first_party_submodule_edges(tree) - seen)
|
|
assert maf_bearing == [], (
|
|
f"hitl's transitive first-party import graph reaches MAF-bearing module(s): {sorted(maf_bearing)}"
|
|
)
|
|
|
|
|
|
def test_hitl_source_has_no_network_import() -> None:
|
|
"""hitl.py imports no network library (``socket``/``urllib``/``http``/``requests``/``httpx``) —
|
|
the inspection tool reads local folders only, never egresses (målbilde §1 no-silent-egress)."""
|
|
forbidden = {"socket", "urllib", "http", "requests", "httpx"}
|
|
tree = ast.parse((_SRC_DIR / "hitl.py").read_text("utf-8"))
|
|
imported: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
imported |= {a.name.split(".")[0] for a in node.names}
|
|
elif isinstance(node, ast.ImportFrom):
|
|
imported.add((node.module or "").split(".")[0])
|
|
assert imported & forbidden == set(), (
|
|
f"hitl must not import network libs: {imported & forbidden}"
|
|
)
|