feat(fase2a): MAF-fri outbox.py — byte-deterministisk proposal/outcome-writer (S2.1)
This commit is contained in:
parent
9311813080
commit
79c6e44f92
3 changed files with 206 additions and 1 deletions
93
src/portfolio_optimiser/outbox.py
Normal file
93
src/portfolio_optimiser/outbox.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""RAW output layer (Fase 2a, S2.1 · målbilde §3, R2): byte-deterministic outbox writer.
|
||||
|
||||
After a run, ``write_outbox`` persists two JSON artefacts — ``{run_id}-proposal.json`` (the candidate
|
||||
IR + its provenance stamp) and ``{run_id}-outcome.json`` (the validated Monte-Carlo percentiles OR
|
||||
the rejection reason, plus the checker verdict + the captured verdict id). This is the traceable
|
||||
output layer Fase 5 (S5.1/S5.4) consumes; it is the OUTBOX, distinct from the async verdict INBOX
|
||||
(``verdict_dir``) — a run must never write its outbox into a folder it also reads as an inbox
|
||||
(that would bypass the Step-8 promotion gate; see ``run_project``'s docstring).
|
||||
|
||||
**MAF-free** (D7-portable): pure stdlib plus the MAF-free ``validator`` leaf for the outcome
|
||||
``isinstance`` branch. ``ProvenanceStamp`` is imported ONLY under ``TYPE_CHECKING`` and serialized
|
||||
duck-typed via ``.model_dump()`` — so importing this module never pulls in ``agent_framework``.
|
||||
Registered in ``tests/test_okf.py``'s ``_MAF_FREE_MODULES`` and enforced by ``test_okf_is_maf_free``
|
||||
(which proves no DIRECT ``agent_framework``/``mcp`` import — MAF-freedom otherwise holds by
|
||||
construction, since only the MAF-free ``validator`` is imported at runtime).
|
||||
|
||||
Byte-determinism: ``json.dumps(payload, sort_keys=True, indent=2)`` + explicit trailing ``\\n`` +
|
||||
UTF-8, and the caller supplies ``run_id`` (no wall-clock / uuid default) — so two runs with identical
|
||||
input produce byte-identical files (diff-stable, mirrors ``verdicts.write_verdict`` /
|
||||
``ledger.SavingsLedger.save``).
|
||||
|
||||
Deliberately NOT exported in ``portfolio_optimiser.__all__``: this is an internal wiring primitive
|
||||
called by ``run_project``, not a public authoring API (contrast ``verdicts.write_verdict``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from portfolio_optimiser.validator import Rejection, ValidatedProposal
|
||||
|
||||
if TYPE_CHECKING: # provenance imports agent_framework — keep it out of the runtime import graph
|
||||
from portfolio_optimiser.provenance import ProvenanceStamp
|
||||
|
||||
|
||||
def _dump(payload: dict[str, Any]) -> str:
|
||||
"""Byte-deterministic on-disk form: sorted keys, 2-space indent, explicit trailing newline."""
|
||||
return json.dumps(payload, sort_keys=True, indent=2) + "\n"
|
||||
|
||||
|
||||
def write_outbox(
|
||||
outbox_dir: str,
|
||||
run_id: str,
|
||||
*,
|
||||
outcome: ValidatedProposal | Rejection,
|
||||
provenance: ProvenanceStamp,
|
||||
checker_verdict: str | None,
|
||||
verdict_id: str,
|
||||
) -> tuple[Path, Path]:
|
||||
"""Write ``{run_id}-proposal.json`` + ``{run_id}-outcome.json`` into ``outbox_dir`` (created if
|
||||
needed) and return their paths. The proposal file carries the candidate IR + provenance; the
|
||||
outcome file branches on the outcome type — a ``ValidatedProposal`` writes its percentiles, a
|
||||
``Rejection`` writes its reason (and NO percentiles, mirroring the type distinction)."""
|
||||
directory = Path(outbox_dir)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
proposal_path = directory / f"{run_id}-proposal.json"
|
||||
proposal_path.write_text(
|
||||
_dump(
|
||||
{
|
||||
"run_id": run_id,
|
||||
"proposal": outcome.proposal.model_dump(),
|
||||
"provenance": provenance.model_dump(),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
if isinstance(outcome, ValidatedProposal):
|
||||
outcome_payload: dict[str, Any] = {
|
||||
"run_id": run_id,
|
||||
"outcome_type": "validated",
|
||||
"p10": outcome.p10,
|
||||
"p50": outcome.p50,
|
||||
"p90": outcome.p90,
|
||||
"nominal_feasible": outcome.nominal_feasible,
|
||||
"checker_verdict": checker_verdict,
|
||||
"verdict_id": verdict_id,
|
||||
}
|
||||
else:
|
||||
outcome_payload = {
|
||||
"run_id": run_id,
|
||||
"outcome_type": "rejected",
|
||||
"reason": outcome.reason,
|
||||
"checker_verdict": checker_verdict,
|
||||
"verdict_id": verdict_id,
|
||||
}
|
||||
outcome_path = directory / f"{run_id}-outcome.json"
|
||||
outcome_path.write_text(_dump(outcome_payload), encoding="utf-8")
|
||||
|
||||
return proposal_path, outcome_path
|
||||
|
|
@ -18,7 +18,7 @@ from portfolio_optimiser import okf
|
|||
|
||||
# Framework-neutral, D7-portable modules that must never import MAF/mcp (C2:
|
||||
# the guard previously scanned only okf.py; dimension.py is now covered too).
|
||||
_MAF_FREE_MODULES = ["okf.py", "dimension.py"]
|
||||
_MAF_FREE_MODULES = ["okf.py", "dimension.py", "outbox.py"]
|
||||
|
||||
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
||||
|
||||
|
|
|
|||
112
tests/test_outbox_loadbearing.py
Normal file
112
tests/test_outbox_loadbearing.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue