feat(fase2a): MAF-fri outbox.py — byte-deterministisk proposal/outcome-writer (S2.1)

This commit is contained in:
Kjell Tore Guttormsen 2026-07-15 07:18:21 +02:00
commit 79c6e44f92
3 changed files with 206 additions and 1 deletions

View 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