feat(s51): pending registry — outbox↔inbox id-join (MAF-clean)
Gate: pytest tests/test_hitl.py tests/test_hitl_loadbearing.py tests/test_okf.py → 26 passed.
This commit is contained in:
parent
ce5b1151c8
commit
b50e3fdff2
4 changed files with 448 additions and 1 deletions
140
tests/test_hitl.py
Normal file
140
tests/test_hitl.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""S5.1 HITL pending registry + dimension→expert routing (roadmap E, målbilde §3).
|
||||
|
||||
Behaviour + fail-fast tests for the operator inspection tool ``python -m
|
||||
portfolio_optimiser.hitl pending|route``. The load-bearing detach seams (id-join,
|
||||
route classification, transitive-MAF probe, inbox-predicate parity) live in
|
||||
``test_hitl_loadbearing.py``.
|
||||
|
||||
Outbox artefacts are built via the REAL ``write_outbox`` and inbox verdicts via the REAL
|
||||
``write_verdict`` (mirrors ``test_outbox_loadbearing.py`` / ``test_step7_async_loop_loadbearing.py``),
|
||||
so a shape drift in either writer breaks these tests rather than passing green-but-dead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _make_validated(measure: str, codes: list[str], claimed: float = 200.0) -> ValidatedProposal:
|
||||
items = [AffectedItem(code=c, quantity=1000.0, unit_cost=1.0) for c in codes]
|
||||
proposal = SavingsProposal(
|
||||
project_id="P1", measure=measure, affected_items=items, claimed_saving_nok=claimed
|
||||
)
|
||||
return ValidatedProposal(proposal=proposal, p10=100.0, p50=150.0, p90=200.0, nominal_feasible=180.0)
|
||||
|
||||
|
||||
def _write_proposal(
|
||||
outbox: Path, run_id: str, *, verdict_id: str, measure: str = "LED-retrofit", codes: list[str] | None = None
|
||||
) -> None:
|
||||
"""Persist one run's outbox pair via the real writer, keyed on ``verdict_id``."""
|
||||
write_outbox(
|
||||
str(outbox),
|
||||
run_id,
|
||||
outcome=_make_validated(measure, codes or ["05.2"]),
|
||||
provenance=_PROVENANCE,
|
||||
checker_verdict="approve",
|
||||
verdict_id=verdict_id,
|
||||
)
|
||||
|
||||
|
||||
def _drop_verdict(inbox: Path, verdict_id: str, *, decision: str = "approved") -> None:
|
||||
write_verdict(
|
||||
str(inbox),
|
||||
Verdict(
|
||||
id=verdict_id,
|
||||
proposal_features=ProposalFeatures(
|
||||
affected_codes=frozenset({"05.2"}),
|
||||
measure_type="scope_reduction",
|
||||
claimed_saving_nok=200.0,
|
||||
),
|
||||
decision=decision,
|
||||
rationale="expert reviewed (test)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# --- pending() behaviour --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pending_lists_unjudged_proposal(tmp_path: Path) -> None:
|
||||
"""A proposal in the outbox with an EMPTY inbox is listed pending (run_id + verdict_id +
|
||||
outcome_type surfaced, codes/measure captured)."""
|
||||
outbox = tmp_path / "outbox"
|
||||
inbox = tmp_path / "inbox"
|
||||
_write_proposal(outbox, "run-1", verdict_id="vid-1", measure="LED-retrofit", codes=["05.2"])
|
||||
|
||||
result = hitl.pending(str(outbox), str(inbox))
|
||||
|
||||
assert len(result) == 1
|
||||
p = result[0]
|
||||
assert p.run_id == "run-1"
|
||||
assert p.verdict_id == "vid-1"
|
||||
assert p.outcome_type == "validated"
|
||||
assert p.measure == "LED-retrofit"
|
||||
assert p.codes == frozenset({"05.2"})
|
||||
|
||||
|
||||
def test_pending_tolerant_to_missing_dir_and_foreign_files(tmp_path: Path) -> None:
|
||||
"""Missing outbox → []; foreign / half-written files are skipped, never raised."""
|
||||
inbox = tmp_path / "inbox"
|
||||
assert hitl.pending(str(tmp_path / "does-not-exist"), str(inbox)) == []
|
||||
|
||||
outbox = tmp_path / "outbox"
|
||||
_write_proposal(outbox, "run-1", verdict_id="vid-1")
|
||||
(outbox / "notes.txt").write_text("not json", encoding="utf-8")
|
||||
(outbox / "broken-proposal.json").write_text("{ half written", encoding="utf-8")
|
||||
# a foreign file in the inbox must not blow up the id-set read either
|
||||
inbox.mkdir()
|
||||
(inbox / "golden.json").write_text("{ not a verdict", encoding="utf-8")
|
||||
|
||||
result = hitl.pending(str(outbox), str(inbox))
|
||||
assert [p.run_id for p in result] == ["run-1"]
|
||||
|
||||
|
||||
def test_pending_skips_orphan_proposal_without_outcome(tmp_path: Path) -> None:
|
||||
"""A proposal file with no matching outcome (orphan from a half-written live run) is skipped."""
|
||||
outbox = tmp_path / "outbox"
|
||||
inbox = tmp_path / "inbox"
|
||||
_write_proposal(outbox, "run-1", verdict_id="vid-1")
|
||||
(outbox / "run-1-outcome.json").unlink() # orphan the proposal
|
||||
|
||||
assert hitl.pending(str(outbox), str(inbox)) == []
|
||||
|
||||
|
||||
def test_pending_is_deterministic(tmp_path: Path) -> None:
|
||||
"""Output order is stable across calls, sorted by (run_id, verdict_id)."""
|
||||
outbox = tmp_path / "outbox"
|
||||
inbox = tmp_path / "inbox"
|
||||
_write_proposal(outbox, "run-c", verdict_id="vid-3")
|
||||
_write_proposal(outbox, "run-a", verdict_id="vid-1")
|
||||
_write_proposal(outbox, "run-b", verdict_id="vid-2")
|
||||
|
||||
first = [(p.run_id, p.verdict_id) for p in hitl.pending(str(outbox), str(inbox))]
|
||||
second = [(p.run_id, p.verdict_id) for p in hitl.pending(str(outbox), str(inbox))]
|
||||
assert first == second == [("run-a", "vid-1"), ("run-b", "vid-2"), ("run-c", "vid-3")]
|
||||
|
||||
|
||||
def test_hitl_registered_maf_free() -> None:
|
||||
"""Meta: hitl.py is registered in the MAF-free guard list, so ``test_okf_is_maf_free`` actually
|
||||
scans it — otherwise the MAF-free source claim would be green-but-dead."""
|
||||
from tests.test_okf import _MAF_FREE_MODULES
|
||||
|
||||
assert "hitl.py" in _MAF_FREE_MODULES
|
||||
Loading…
Add table
Add a link
Reference in a new issue