feat(portfolio): K5 — outbox persistence, run_id-named pairs (parity row 7) [skip-docs]

S2.1-analog: each completed run persists a run_id-named proposal/outcome pair
to the outbox — the system's OWN output layer (the role split §3 Step 7 governs
the inbox and wiki, not this). The outcome carries outcome type + figures, the
two §9 falsifiers mirrored verbatim from the RunResult, the provenance stamp,
and verdict_id — minted the SAME way inbox.py mints an expert verdict's id
(mint_verdict_id over the proposal's candidate features), so a later inbox
verdict about the same candidate joins by id (the K9 key assumption, pinned
here in test and reused there).

New outbox.py reuses artifacts' deterministic house JSON writer (sorted keys /
indent 2 / LF) — same input + same run_id => byte-identical files — and never
touches the S10 artifacts.py fasit formats. run.py grows optional
--outbox/--run-id; run_id is REQUIRED when the outbox is set (no wall-clock
default — a timestamp would break determinism) and is fail-fasted at the CLI
BEFORE any client/spend. A budget stop has no proposal, so it writes no pair.

New test_outbox_loadbearing.py (14): unit (pair, verdict_id join key on both
outcome types, percentiles-vs-reason, verbatim falsifiers, provenance,
round-trip, byte-determinism, run_id fail-fast) + wiring (entrance writes the
pair; no-outbox control; --outbox-without-run-id fails fast before spend).
Detach-proved: drop the persist_outbox call in execute_run -> wiring test RED.

443->457 green, golden byte-exact (13/13), run_s10.py/runs/ untouched, full
gate clean (ruff+format+mypy strict). README synced (count + module + seam).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RiTwaKLesgcwXx2mDviqpt
This commit is contained in:
Kjell Tore Guttormsen 2026-07-23 22:31:18 +02:00
commit a926e4ad46
4 changed files with 378 additions and 3 deletions

View file

@ -0,0 +1,75 @@
"""Outbox persistence (method-spec §2, S2.1-analog): run_id-named proposal/outcome pairs.
The system OWNS its output layer the role split (§3 Step 7) governs the inbox
and the wiki (the expert writes, the system reads), NOT the system's own output,
which the system writes freely. Each completed run persists a ``run_id``-named
PAIR ``{run_id}-proposal.json`` and ``{run_id}-outcome.json`` so outstanding
verdicts can be tracked (K9, id-join outbox inbox on the verdict id) and live
artifacts captured (K8). The outcome's ``verdict_id`` is minted the SAME way the
inbox mints an expert verdict's id (``mint_verdict_id`` over the proposal's
candidate features), so a later inbox verdict about the same candidate joins by id.
Reuses the ``artifacts`` house JSON writer (sorted keys, 2-space indent, trailing
LF) bytes are deterministic (same input + same run_id identical files) and
never touches the S10 ``artifacts.py`` fasit formats. ``run_id`` is REQUIRED when
the outbox is set: a blank id has no wall-clock fallback (a timestamp default
would break the determinism the outbox promises K8/K9).
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from portfolio_optimiser_claude.artifacts import _dump_json
from portfolio_optimiser_claude.experience import CandidateFeatures, mint_verdict_id
from portfolio_optimiser_claude.loop import RunResult
from portfolio_optimiser_claude.provenance import Provenance
from portfolio_optimiser_claude.validator import Rejection
def _outcome_payload(run: RunResult) -> dict[str, Any]:
# The SAME validated/rejected mapping the S10 run_result artifact uses: a
# rejection carries only its reason (no percentiles); a validated outcome
# carries the feasibility band verbatim.
if isinstance(run.outcome, Rejection):
return {"type": "rejected", "reason": run.outcome.reason}
return {"type": "validated", **run.outcome.model_dump()}
def persist_outbox(
outbox_dir: Path,
*,
run: RunResult,
provenance: Provenance,
run_id: str,
) -> dict[str, Path]:
"""Persist the run's proposal/outcome pair, named by ``run_id`` (§2, S2.1).
Writes ``{run_id}-proposal.json`` (the proposal verbatim) and
``{run_id}-outcome.json`` (outcome type + figures, the two §9 falsifiers
mirrored from the ``RunResult``, the inbox join key ``verdict_id``, and the
provenance stamp). ``run_id`` is required a blank id has no wall-clock
fallback (that would break the byte-determinism the outbox promises K8/K9).
"""
if not run_id.strip():
raise ValueError("outbox requires a non-empty run_id (no wall-clock default)")
outbox_dir.mkdir(parents=True, exist_ok=True)
paths = {
"proposal": outbox_dir / f"{run_id}-proposal.json",
"outcome": outbox_dir / f"{run_id}-outcome.json",
}
_dump_json(paths["proposal"], run.proposal.model_dump())
_dump_json(
paths["outcome"],
{
"run_id": run_id,
"verdict_id": mint_verdict_id(CandidateFeatures.from_proposal(run.proposal)),
"validator_decision": run.validator_decision,
"checker_decision": run.checker_decision,
"attempts": run.attempts,
"outcome": _outcome_payload(run),
"provenance": provenance.model_dump(),
},
)
return paths

View file

@ -28,6 +28,7 @@ from portfolio_optimiser_claude.artifacts import (
persist_run_artifacts,
persist_stop_artifacts,
)
from portfolio_optimiser_claude.outbox import persist_outbox
from portfolio_optimiser_claude.budget import BudgetExceeded, BudgetMeter
from portfolio_optimiser_claude.contracts import Contracts, load_contracts
from portfolio_optimiser_claude.experience import (
@ -124,12 +125,19 @@ def execute_run(
out_dir: Path,
max_debate_rounds: int,
max_attempts: int,
outbox_dir: Path | None = None,
run_id: str | None = None,
) -> int:
"""Drive the loop under the §8 meter; persist artifacts on BOTH outcomes.
Exit 0: the run completed (validated or typed rejection) and its artifacts
are on disk. Exit 3: a structured budget stop (§8) the stop event and
the usage-vs-caps artifact are persisted; a stop is never a silent hang.
When ``outbox_dir`` is set, the completed run also persists a ``run_id``-named
proposal/outcome pair to the outbox (S2.1) the system's own output layer,
read by K8 (live capture) and K9 (pending tracking). A budget stop has no
proposal, so it writes no outbox pair.
"""
meter = BudgetMeter(contracts.termination)
try:
@ -178,6 +186,12 @@ def execute_run(
)
for name, path in sorted(paths.items()):
print(f"artifact: {name} -> {path}")
if outbox_dir is not None:
outbox_paths = persist_outbox(
outbox_dir, run=result, provenance=provenance, run_id=run_id or ""
)
for name, path in sorted(outbox_paths.items()):
print(f"outbox: {name} -> {path}")
return 0
@ -189,6 +203,8 @@ def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None
parser.add_argument("--bundle", type=Path, required=True)
parser.add_argument("--inbox", type=Path, default=None)
parser.add_argument("--out", type=Path, default=Path("runs") / "run")
parser.add_argument("--outbox", type=Path, default=None)
parser.add_argument("--run-id", type=str, default=None)
parser.add_argument("--max-rounds", type=int, default=12)
parser.add_argument("--max-tokens", type=int, default=150_000)
parser.add_argument("--max-budget-usd-per-call", type=float, default=0.25)
@ -197,6 +213,12 @@ def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None
parser.add_argument("--top-k", type=int, default=3)
args = parser.parse_args(argv)
# fail-fast (§10 spirit): a run persisted to the outbox MUST carry an explicit
# run_id — reject BEFORE composing or constructing a client, so no spend rides
# on a run that cannot be filed (no wall-clock default fills the gap).
if args.outbox is not None and not (args.run_id or "").strip():
parser.error("--outbox requires --run-id (no wall-clock default)")
# §10: ALL startup contracts schema-validated BEFORE any model client exists.
contracts = load_contracts(
data_source={"docs_dir": str(args.bundle), "top_k": args.top_k},
@ -222,6 +244,8 @@ def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None
out_dir=args.out,
max_debate_rounds=args.max_debate_rounds,
max_attempts=args.max_attempts,
outbox_dir=args.outbox,
run_id=args.run_id,
)