112 lines
4.5 KiB
Python
112 lines
4.5 KiB
Python
"""S2.1 outbox output-layer (målbilde §3, R2 RAW layer): ``write_outbox`` persists two
|
|
byte-deterministic JSON artefacts per run — ``{run_id}-proposal.json`` (the candidate + provenance)
|
|
and ``{run_id}-outcome.json`` (the validated percentiles OR the rejection reason, plus the checker
|
|
verdict + verdict id). This is the traceable output layer Fase 5 (S5.1/S5.4) builds on.
|
|
|
|
The writer is MAF-free (pure stdlib + the MAF-free ``validator`` leaf) — proven load-bearing by
|
|
``test_okf_is_maf_free`` covering ``outbox.py`` (T-2.1d), plus the byte-determinism (T-2.1b) and
|
|
both-arms (T-2.1a) checks below.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
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 Rejection, ValidatedProposal
|
|
|
|
_PROPOSAL = SavingsProposal(
|
|
project_id="P1",
|
|
measure="LED-retrofit av kontorbelysning",
|
|
affected_items=[AffectedItem(code="ENERGI-TOTAL-EL", quantity=1000.0, unit_cost=1.0)],
|
|
claimed_saving_nok=200.0,
|
|
assumptions={"ENERGI-TOTAL-EL": (0.8, 1.2)},
|
|
)
|
|
_PROVENANCE = ProvenanceStamp(
|
|
citations=[
|
|
Citation(file="f.md", locator=TextSpan(start_index=0, end_index=5), snippet="hello")
|
|
],
|
|
model="synthetic",
|
|
role="proposer",
|
|
validator_decision="validated",
|
|
token_usage=8,
|
|
)
|
|
_VALIDATED = ValidatedProposal(
|
|
proposal=_PROPOSAL, p10=100.0, p50=150.0, p90=200.0, nominal_feasible=180.0
|
|
)
|
|
_REJECTION = Rejection(proposal=_PROPOSAL, reason="checker rejected: unsupported reasoning")
|
|
|
|
|
|
def _read(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_outbox_writes_validated_arm(tmp_path) -> None:
|
|
"""T-2.1a (validated arm): both files are written with the proposal fields + provenance in
|
|
proposal.json, and outcome_type=validated + percentiles + checker verdict + verdict id in
|
|
outcome.json."""
|
|
prop_path, out_path = write_outbox(
|
|
str(tmp_path),
|
|
"run-1",
|
|
outcome=_VALIDATED,
|
|
provenance=_PROVENANCE,
|
|
checker_verdict="approve",
|
|
verdict_id="vid-abc",
|
|
)
|
|
assert prop_path == tmp_path / "run-1-proposal.json"
|
|
assert out_path == tmp_path / "run-1-outcome.json"
|
|
|
|
proposal_text = _read(prop_path)
|
|
assert "LED-retrofit av kontorbelysning" in proposal_text
|
|
assert "ENERGI-TOTAL-EL" in proposal_text
|
|
assert '"model": "synthetic"' in proposal_text # provenance embedded
|
|
|
|
outcome_text = _read(out_path)
|
|
assert '"outcome_type": "validated"' in outcome_text
|
|
assert '"p90": 200.0' in outcome_text
|
|
assert '"checker_verdict": "approve"' in outcome_text
|
|
assert '"verdict_id": "vid-abc"' in outcome_text
|
|
assert "reason" not in outcome_text # the validated arm carries no rejection reason
|
|
|
|
|
|
def test_outbox_writes_rejected_arm(tmp_path) -> None:
|
|
"""T-2.1a (rejected arm): a Rejection outcome writes outcome_type=rejected + the reason, and NO
|
|
percentiles (the Rejection type carries none — it can never masquerade as validated)."""
|
|
_prop_path, out_path = write_outbox(
|
|
str(tmp_path),
|
|
"run-2",
|
|
outcome=_REJECTION,
|
|
provenance=_PROVENANCE,
|
|
checker_verdict="reject",
|
|
verdict_id="vid-def",
|
|
)
|
|
outcome_text = _read(out_path)
|
|
assert '"outcome_type": "rejected"' in outcome_text
|
|
assert "unsupported reasoning" in outcome_text
|
|
assert '"verdict_id": "vid-def"' in outcome_text
|
|
assert "p90" not in outcome_text # no percentiles on the rejected arm
|
|
|
|
|
|
def test_outbox_is_byte_deterministic(tmp_path) -> None:
|
|
"""T-2.1b: two writes with identical input + the same run_id produce byte-identical files
|
|
(sort_keys + indent=2 + explicit LF, no wall-clock/uuid) — the artefacts are diff-stable."""
|
|
a = tmp_path / "a"
|
|
b = tmp_path / "b"
|
|
args = dict(
|
|
outcome=_VALIDATED, provenance=_PROVENANCE, checker_verdict="approve", verdict_id="vid-abc"
|
|
)
|
|
pa, oa = write_outbox(str(a), "run-1", **args)
|
|
pb, ob = write_outbox(str(b), "run-1", **args)
|
|
assert pa.read_bytes() == pb.read_bytes()
|
|
assert oa.read_bytes() == ob.read_bytes()
|
|
|
|
|
|
def test_outbox_registered_maf_free() -> None:
|
|
"""T-2.1d meta: outbox.py is registered in the MAF-free guard list, so test_okf_is_maf_free
|
|
actually scans it — otherwise the MAF-free claim would be green-but-dead (never checked)."""
|
|
from tests.test_okf import _MAF_FREE_MODULES
|
|
|
|
assert "outbox.py" in _MAF_FREE_MODULES
|