F4 gjorde "be om svar, BRUKE svarene" naabar, men bare SYNKRONT: terminal_plan_reviewer
blokkerer loekka paa et menneske ved en terminal, saa svaret maa komme mens prosessen lever.
Maalbilde §3s tidsskala er den andre - eksperten svarer dager senere, i en prosess som aldri
saa kjoeringen.
--checkpoint-dir PARKERER reviewen (FileCheckpointStorage + {run_id}-plan-review.json) og
avslutter; --resume <run_id> leser svaret fra --review-inbox i en fersk interpreter. Det
eneste som krysser prosessgrensen er disk.
MAALT FELLE (Verifiseringsloven ansikt 4): list_checkpoints (_checkpoint.py:386-388) svelger
en blokkert deserialisering til en logger.warning og returnerer TOM liste. Uten BEGGE
MagenticPlanReviewRequest/Response i allowed_checkpoint_types feiler en resume som et FRAVAER,
ikke som en feil. _ALLOWED_CHECKPOINT_TYPES har derfor EN kopi, checkpoint_storage er eneste
konstruksjonssted, og en tom listing ved park raiser CheckpointUnreadable i stedet for aa
skrive et spoersmaal ingen kan besvare.
Budsjettet og revisjons-capen spenner over suspensjonen (meter.charge(parked.tokens_spent) +
trace.ledger.extend), ellers faar hver park et helt budsjett paa nytt. Fail-closed paa
ekspertens egen fil: request_id-mismatch, ord utenfor vokabularet og revise uten innhold
refuseres alle ved navn. hitl.pending_plan_reviews er registeret over hvem som venter.
Load-bearing MAALT: 17 tester, TRETTEN mutasjoner alle roede mot HELE suiten, groenn kontroll
1059 passed / 5 skipped, golden demo-transcript.stdout byte-uendret
(ea8c534773acdbe41ae68f2c55724d69aaf8be4f).
EN MUTASJON FALSIFISERTE SUITEN (vakuoes-gate-klassen, tiende gang): detach av
trace.plan_reviews.extend(parked.plan_reviews) lot HELE suiten staa groenn - capen leser
parked.plan_reviews DIREKTE, saa den binder uansett, og de to foerste legene er identiske
under begge implementasjoner. Gaten maatte bli det TREDJE leget, der artefaktet ellers taper
dag 1s revisjon og to ulike planer deler indeks 1. Ny test skrevet mot mutasjonen foerst.
Aerlighets-grenser: hostet flate NEKTER fortsatt (synkron review ville blokkert baade
requesten og event-loekka som svarer /readiness); en park midt i loepet etter en stall har
ingen naabar sti under det skriptede manuset, saa carry-overen som betjener den drives gjennom
en CRAFTED parkert tilstand.
Ordre 20260825T114645Z-6622513622-from-portfolio-optimiser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
253 lines
11 KiB
Python
253 lines
11 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 collections.abc import Mapping, Sequence
|
|
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_parse_failures(
|
|
outbox_dir: str,
|
|
run_id: str,
|
|
*,
|
|
failures: Sequence[Mapping[str, str]],
|
|
) -> Path:
|
|
"""Write ``{run_id}-parse-failures.json`` — the raw model replies that did NOT parse into the
|
|
typed IR (Fase 1b, funn 1) — and return its path.
|
|
|
|
**This is the only outbox artefact written from a ``finally``**, because it is the only one whose
|
|
subject is a run that may never finish: the measured 1b failure exhausted the round ledger inside
|
|
the generation loop and left ``run_project`` as a ``BudgetExceeded``, so the proposal/outcome
|
|
writers below were never reached. An artefact that recorded parse failures only for runs that
|
|
survived them would be silent for exactly the runs that need it.
|
|
|
|
Takes plain mappings (the caller flattens ``generate.ParseFailure``), so this module stays
|
|
MAF-free — ``generate`` imports ``agent_framework``, and importing it here would drag MAF into
|
|
the RAW output layer.
|
|
|
|
Byte-determinism is NOT claimed for this file, unlike its two neighbours: its content is a live
|
|
model's prose, which is not reproducible by construction. It uses the same ``_dump`` form for
|
|
consistency of reading, not to pin bytes. The caller writes it only when there is at least one
|
|
failure, so the file's PRESENCE is itself the signal that something did not parse."""
|
|
directory = Path(outbox_dir)
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
path = directory / f"{run_id}-parse-failures.json"
|
|
path.write_text(
|
|
_dump({"run_id": run_id, "parse_failures": [dict(f) for f in failures]}),
|
|
encoding="utf-8",
|
|
)
|
|
return path
|
|
|
|
|
|
def write_exploration(
|
|
outbox_dir: str,
|
|
run_id: str,
|
|
*,
|
|
payload: Mapping[str, Any],
|
|
) -> Path:
|
|
"""Write ``{run_id}-exploration.json`` — what the U4 exploration did before the pipeline ran
|
|
(§ C.2) — and return its path.
|
|
|
|
Takes an already-rendered plain mapping (``explore.trace_payload``) for the reason
|
|
``write_parse_failures`` takes plain mappings: ``explore`` imports ``agent_framework``, and
|
|
importing it here would drag MAF into the RAW output layer. The ONE renderer lives beside the
|
|
dataclasses it renders; this writer only decides bytes and a filename.
|
|
|
|
Byte-deterministic like its neighbours (the caller supplies ``run_id``; no wall-clock), and
|
|
written even when the exploration RAISED — the caller writes it from a ``finally``, because a
|
|
capped exploration is precisely the one whose per-round ledger a reader needs."""
|
|
directory = Path(outbox_dir)
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
path = directory / f"{run_id}-exploration.json"
|
|
path.write_text(_dump({"run_id": run_id, **dict(payload)}), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def write_plan_review(
|
|
outbox_dir: str,
|
|
run_id: str,
|
|
*,
|
|
payload: Mapping[str, Any],
|
|
) -> Path:
|
|
"""Write ``{run_id}-plan-review.json`` — the open question of a PARKED exploration (U12) — and
|
|
return its path.
|
|
|
|
This is the outbox half of the asynchronous HITL door: the run writes the question, the expert
|
|
writes the answer into a separate review INBOX, days later. The two folders are never the same
|
|
one, for the reason the verdict inbox is never the outbox — a run that read its own output as
|
|
input would be answering itself.
|
|
|
|
Takes an already-rendered plain mapping (``explore.parked_payload``) for the reason
|
|
``write_exploration`` does: ``explore`` imports ``agent_framework`` and this layer stays
|
|
MAF-free. Byte-deterministic like its neighbours.
|
|
|
|
**Last write wins**, exactly one open question per run: a revision produces a NEW review of a
|
|
REPLANNED plan, and leaving the superseded one on disk would let an expert answer a question
|
|
the loop has already moved past. Staleness is caught anyway — the answer names the
|
|
``request_id`` it answers — but the file should not invite it."""
|
|
directory = Path(outbox_dir)
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
path = directory / f"{run_id}-plan-review.json"
|
|
path.write_text(_dump({"run_id": run_id, **dict(payload)}), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
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
|