"""RAW output layer (Fase 2a, S2.1 · målbilde §3, R2): byte-deterministic outbox writers. Two writers live here. ``write_run_config`` (S4.2, comparison protocol §4 pkt 3) persists the run-config artefact ``{run_id}-runconfig.json`` — the resolved model-id per built role, profile, params and token cap — describing a run WITHOUT a model call (the S4.2 dry-run drill + the M2 live run). ``write_outbox`` persists the post-run proposal/outcome artefacts. 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, approach_id: str | None = None, ) -> 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). ``approach_id`` (A5) names WHICH commissioned approach an artefact belongs to, so a run that evaluated several can have each of them judged rather than only the one it selected. It widens the file key to ``{run_id}-{approach_id}-*.json`` AND is written into the payload, because the reader (``hitl._read_outbox_proposals``) joins proposal to outcome on file CONTENT, never on the filename — a widened filename alone would still collapse three approaches onto one ``run_id`` key. When it is ``None`` the key and the payload are exactly as before: the field is OMITTED rather than written as null, since these artefacts are byte-deterministic by contract and a run nobody commissioned has no approach to name.""" directory = Path(outbox_dir) directory.mkdir(parents=True, exist_ok=True) stem = run_id if approach_id is None else f"{run_id}-{approach_id}" keys: dict[str, Any] = {"run_id": run_id} if approach_id is not None: keys["approach_id"] = approach_id proposal_path = directory / f"{stem}-proposal.json" proposal_path.write_text( _dump( { **keys, "proposal": outcome.proposal.model_dump(), "provenance": provenance.model_dump(), } ), encoding="utf-8", ) outcome_path = directory / f"{stem}-outcome.json" outcome_path.write_text( _dump( { **keys, **outcome_payload(outcome, checker_verdict=checker_verdict, verdict_id=verdict_id), } ), encoding="utf-8", ) return proposal_path, outcome_path def outcome_payload( outcome: ValidatedProposal | Rejection, *, checker_verdict: str | None, verdict_id: str, ) -> dict[str, Any]: """The outcome artefact's payload minus the file keys — the ONE copy of the validated/rejected branching, shared by ``write_outbox`` and the hosted invocations response (``hosting._response_payload``). Two copies of the branch would drift, and a drifted copy would let the HTTP surface describe an outcome the outbox never wrote — the ``to_ore`` single-source rule (kø-(p)) applied to a payload shape.""" if isinstance(outcome, ValidatedProposal): return { "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, } return { "outcome_type": "rejected", "reason": outcome.reason, "checker_verdict": checker_verdict, "verdict_id": verdict_id, } def write_run_config( config_dir: str, run_id: str, *, profile: str, resolved_models: dict[str, str], max_rounds: int, max_tokens: int, top_k: int, ) -> Path: """Write the byte-deterministic run-config artefact ``{run_id}-runconfig.json`` (S4.2, comparison protocol §4 pkt 3): the resolved model-id per *built* role, the profile, the round/token parameters and the token cap — everything that describes a run WITHOUT a model call. Takes plain data only (the caller resolves the models via ``resolve_model``), so this module stays MAF-free. NO wall-clock / date (that lives in the S11 report envelope, not the deterministic artefact), so two runs with identical config produce byte-identical files (mirrors ``write_outbox``).""" directory = Path(config_dir) directory.mkdir(parents=True, exist_ok=True) path = directory / f"{run_id}-runconfig.json" path.write_text( _dump( { "run_id": run_id, "profile": profile, "resolved_models": resolved_models, "max_rounds": max_rounds, "max_tokens": max_tokens, "top_k": top_k, } ), encoding="utf-8", ) return path