portfolio-optimiser/src/portfolio_optimiser/outbox.py
Kjell Tore Guttormsen cec9b8fe50
fix(outbox,toolbox): the class was every caller-supplied name, and containment was not unreachable
Session 146 fixed `--run-id` across the seven doors and called it a class. It was not: the class
is every caller-supplied value that reaches a file name, and `write_outbox` composes
`{run_id}-{approach_id}`. Measured 20.09 -- `--approach-id a/../../../ESCAPE` answered 0 and put
the artefacts three levels above the directory the caller named. Counted rather than assumed: 11
path compositions in `outbox.py`, 2 such values, both now through one `_checked_name`.

The ledger sentence that said containment was UNREACHABLE after the string rule was untrue, and
the approach-id escape is the disproof -- the removed check would have caught it. It is back, but
in `outbox._artefact_path`, where the composition is, not in the door. That is the difference
that makes it reachable: the string rule lives in the door, while `run.py` hands its own
`--run-id` straight to the writers and goes past it. Checked before the directory is created, so
a refusal leaves nothing behind, and it covers the next flag someone interpolates into a name.

The judge's exact call now answers 3 with 0 files outside. Suite 2291/0/5/5 (746 s), ruff clean,
mypy 0. Both gates re-run after `git add`: v1 exit 1 (0/3, 0/3, 3/8, no report, 3/8, NOT
MEASURED, 1/20), B exit 1 (15/17, 0/2, 15/15 over 516 files, 0/3, 4/5, NOT MEASURED) -- no row
moved, and row 3's denominator held because the probe grew in place rather than as a new file.

Also: the presentation deck said 1.1.0 was the current version in two places. 1.2.0 now stands in
every tracked place that claims the repo's version. No bump, no tag, no new capability.

[skip-docs]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 13:12:15 +02:00

464 lines
22 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, Unsupported, 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 _artefact_path(directory: Path, name: str) -> Path:
"""Compose ONE artefact path, and refuse one that leaves the directory — the last link.
Every writer below builds ``<directory>/<name>``, where ``name`` interpolates caller-supplied
values. The doors (``toolbox._checked_name``) reject a value that carries a path before it
ever gets here, and that string rule is the first link. This is the last one, and it is not
the same check twice: it looks at the FINISHED path, so it holds for the NEXT value someone
interpolates into a file name without remembering the door — and for the callers that never
pass a door at all. ``run.py`` hands its ``--run-id`` straight to these writers, so the
property is reachable here in a way it was not inside the door, where mutating it away in
session 146 left all 36 arms green and it was removed as dead code.
Checked BEFORE the directory is created, so a refusal leaves nothing behind — not the
artefact, and not a folder the caller would have to clean up."""
path = directory / name
root = directory.resolve()
if root not in path.resolve().parents:
raise ValueError(
f"{name!r} resolves outside {directory}: an artefact belongs in the output directory "
"the caller named — utkatalogen — and this path leaves it."
)
directory.mkdir(parents=True, exist_ok=True)
return path
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)
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
# Both paths BEFORE either write: a pair where the second escapes must not leave the first
# on disk, which is what composing them one at a time would do.
proposal_path = _artefact_path(directory, f"{stem}-proposal.json")
outcome_path = _artefact_path(directory, f"{stem}-outcome.json")
proposal_path.write_text(
_dump(
{
**keys,
"proposal": outcome.proposal.model_dump(),
"provenance": provenance.model_dump(),
}
),
encoding="utf-8",
)
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.
``unsupported`` (row 6) is checked FIRST because it subclasses ``Rejection``: it carries the
validator's percentiles (the numbers held) AND the reason, so a reader sees both halves."""
if isinstance(outcome, Unsupported):
return {
"outcome_type": "unsupported",
"reason": outcome.reason,
"p10": outcome.validated.p10,
"p50": outcome.validated.p50,
"p90": outcome.validated.p90,
"nominal_feasible": outcome.validated.nominal_feasible,
"checker_verdict": checker_verdict,
"verdict_id": verdict_id,
}
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)
path = _artefact_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)
path = _artefact_path(directory, f"{run_id}-exploration.json")
path.write_text(_dump({"run_id": run_id, **dict(payload)}), encoding="utf-8")
return path
def write_debate_tools(
outbox_dir: str,
run_id: str,
*,
tool_calls: Sequence[Mapping[str, Any]],
requirements: Sequence[Mapping[str, Any]] = (),
) -> Path:
"""Write ``{run_id}-debate.json`` — WHICH documents the debate opened, in call order (S2c), and
WHICH requirement it declared as binding (P19 DEL A).
The sibling of ``write_exploration`` one phase over. Since S2c the debate navigates the
knowledge base instead of being handed it whole, so "what did this run actually read" is a
question about the debate too, and over a 630-concept corpus it is not answerable from a
prompt log: the whole point is that the prompts no longer carry the base.
Written on EVERY run that has an outbox, including one whose agents opened nothing — unlike
``write_parse_failures``, whose presence IS its signal. Here the empty case is the S2c
regression itself (a debate that navigates nothing looks exactly like a cheap one), so it must
be readable off the artefact rather than inferred from a file that is not there.
Takes already-rendered plain mappings (``explore.tool_call_payload``) so the RAW output layer
stays MAF-free — ``write_exploration``'s own rule, same reason."""
directory = Path(outbox_dir)
path = _artefact_path(directory, f"{run_id}-debate.json")
path.write_text(
_dump(
{
"run_id": run_id,
"tool_calls": [dict(call) for call in tool_calls],
# DEFAULTS to empty for the same reason ``Bundle.skipped`` does: "this debate
# declared nothing" is an honest positive statement, and it is the one every run
# written before today makes.
"requirements": [dict(r) for r in requirements],
}
),
encoding="utf-8",
)
return path
def write_multibase(
outbox_dir: str,
run_id: str,
*,
runs: Sequence[Mapping[str, Any]],
completed: bool,
unreached: Sequence[Mapping[str, Any]],
collisions: Sequence[Mapping[str, Any]],
stopped_early: bool,
budget_stop: Mapping[str, Any] | None,
) -> Path:
"""Write ``{run_id}-multibase.json`` — what ONE commission did across SEVERAL bases (P17b).
The question no per-base artefact can answer. Each base writes its own full set under its own
minted ``run_id``, but nothing in that set says in which ORDER the bases were spent, which id
each one was given, which approaches were never reached, or which candidates two bases both
described — and a reader who has to reconstruct the ``<run-id>-<bundle_id>`` convention to
pair the files back to the pass has been handed a naming rule instead of a record.
Written IFF the pass was given an outbox, exactly like its neighbours, and the per-base
``stop_reason`` rows are READ BACK from each base's own ``{run_id}-coverage.json`` by the
caller rather than recomputed here: P19 D2 put that fact in that file, and a second derivation
of it would be free to disagree with the one the judge reads.
Plain data only, so the RAW output layer stays MAF-free (``write_debate_tools``' own rule)."""
directory = Path(outbox_dir)
path = _artefact_path(directory, f"{run_id}-multibase.json")
path.write_text(
_dump(
{
"run_id": run_id,
# REQUIRED, never inferred from an empty ``unreached``: a pass a cap or a provider
# cut short never got to say what it did not reach, and "nothing was left
# unreached" must not be the value that means "we never found out"
# (``ExplorationTrace.completed``'s own reason).
"completed": completed,
"runs": [dict(row) for row in runs],
"unreached": [dict(row) for row in unreached],
"collisions": [dict(row) for row in collisions],
"stopped_early": stopped_early,
"budget_stop": dict(budget_stop) if budget_stop is not None else None,
}
),
encoding="utf-8",
)
return path
def write_prepass(
outbox_dir: str,
run_id: str,
*,
declaration: Mapping[str, Any],
) -> Path:
"""Write ``{run_id}-prepass.json`` — the CUT this run was given (order 20260907T080223Z).
Written IFF a payload was supplied, including when the cut withheld nothing —
``write_proposal_reviews``' rule, and here it does a second job. Since a payload WITHDRAWS the
navigator tools, ``{run_id}-debate.json``'s ``tool_calls`` is empty by construction on this
path; and that file is written unconditionally precisely because an empty trace IS the S2c
regression (a debate that navigated nothing looks exactly like a cheap one). Without this
artefact beside it, "withdrawn by design" and "regressed" would read identically. Its PRESENCE
is what tells them apart.
Takes an already-rendered plain mapping (``prepass.declaration_payload``) so the RAW output
layer stays framework-free — ``write_exploration``'s rule, same reason."""
directory = Path(outbox_dir)
path = _artefact_path(directory, f"{run_id}-prepass.json")
path.write_text(_dump({"run_id": run_id, "prepass": dict(declaration)}), 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)
path = _artefact_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_proposal_reviews(
outbox_dir: str,
run_id: str,
*,
payload: Mapping[str, Any],
) -> Path:
"""Write ``{run_id}-proposal-reviews.json`` — what a human answered about the proposals this
run put on the table (MAJOR-2) — and return its path.
Takes an already-rendered plain mapping (``proposal_review.proposal_reviews_payload``) for the
reason ``write_plan_review`` does: the renderer lives in the module that owns the type, and
this layer stays MAF-free. The writer prepends ``run_id`` exactly as its sibling does, so the
on-disk object is ``{"reviews": [...], "run_id": ...}`` under ``_dump``'s sorted keys.
**The write rule is: iff a reviewer was given, INCLUDING when the list is empty** (D4).
Both halves are load-bearing and they pull in opposite directions. ``write_debate_tools``
writes unconditionally because THERE the empty case is the regression; here a reviewer-LESS
run must leave the outbox byte-identical, and two existing tests pin an exact four-name
listing on such a run. But a reviewer that was offered and never consulted — no candidate ever
validated — is a fact this artefact must be able to STATE, not something an operator has to
infer from a missing file. "Iff a reviewer was given" is the only rule that keeps both."""
directory = Path(outbox_dir)
path = _artefact_path(directory, f"{run_id}-proposal-reviews.json")
path.write_text(_dump({"run_id": run_id, **dict(payload)}), encoding="utf-8")
return path
def write_coverage(
outbox_dir: str,
run_id: str,
*,
rows: Sequence[Mapping[str, Any]],
stop_reason: str,
) -> Path:
"""Write ``{run_id}-coverage.json`` — WHY each commissioned approach ended as it did (P19 D2).
``settle`` prints the coverage report and ``ApproachOutcome`` has carried ``not_evaluated``
since Trekk A3, but neither ever reached a FILE: measured 14.09, a judge reading an outbox could
see that an approach had no artefact and could not tell a budget stop from an approach nobody
ordered. That is the very silence ``ApproachOutcome`` exists to remove, one layer out.
``stop_reason`` is ``BudgetExceeded.kind`` when a cap cut the run short (``tokens`` /
``rounds`` / the portfolio's own kinds) and ``""`` when nothing did. A REQUIRED argument rather
than an inferred one, for ``cost_baseline_anchored``'s reason: "the run finished" and "we never
found out" must not be the same value.
Byte-deterministic and wall-clock-free, mirroring ``write_run_config``; plain data only, so the
RAW output layer stays MAF-free."""
directory = Path(outbox_dir)
path = _artefact_path(directory, f"{run_id}-coverage.json")
path.write_text(
_dump(
{
"run_id": run_id,
"stop_reason": stop_reason,
"rows": [dict(row) for row in rows],
}
),
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)
path = _artefact_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