To målinger avgjorde formen FØR koden: (1) hosting-pakkas InvocationsHostServer
finnes kun i bygg som krever agent-framework-core>=1.13.0 (treet låser 1.9.0;
eneste 1.9-kompatible bygg er en forlatt alfa som importerer mcp udeklarert),
(2) et gjenbrukt bygget workflow er single-use på 1.9.0 (kall-serie [2,0,0] —
rundetaket persisterer; ferskt objekt per kall er ren kontroll). Derfor spikens
§5-fallback: hosting.py serverer kontrakten (8088/PORT, /readiness,
/invocations, SIGTERM→0) selv, stdlib asyncio på ÉN løkke — aldri as_agent()
(gatene ligger utenfor grafen), aldri tråder (NG1-guarden fanget første utkast
med ThreadingHTTPServer; asyncio-formen består den by construction).
Payload whitelistes på run_projects signatur — ukjente felt nektes ved navn
(400), aldri stille droppet; profile defaulter til azure kun her. ValueError →
400, alt annet → 500 {error_type, error}; Rejection er vellykket kjøring → 200.
outbox.outcome_payload ekstrahert som den ENE kopien av validated/rejected-
forgreningen (kø-(p)-regelen). azure.yaml validert GRØNN mot begge autoritative
skjemaer (jsonschema, hentet ferskt); ingen env:, ingen startupCommand (imagets
CMD er den ene kopien). Dockerfile: 3.12-slim-bookworm + git + uv==0.9.8 +
uv sync --frozen --no-dev; git archive <indeks-tre> | docker build
--platform linux/amd64 grønn på nøyaktig de stagede bytene.
Iron Law fulgt: testfila rød ved collection FØR modulen fantes. 835 passed /
4 skipped (fra 821), ruff+format+mypy rene. Seks mutasjoner mot HELE suiten,
alle røde på riktig test: detach felt-mappingen · dropp ukjente felt stille ·
flipp 400/500 · detach azure-defaulten · detach SIGTERM-handleren · detach
main.py-shimen (de to siste kun fanget av subprosess-testen, P4-presedensen).
Deploy IKKE utført — azd-steget er operatørens.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEiiSGRShizKc771ZBa1iq
166 lines
7.2 KiB
Python
166 lines
7.2 KiB
Python
"""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
|