feat(portfolio): K8 — live-run drill, pre-call artifact capture (parity row 21) [skip-docs]

A future operator-gated live run (the M2-analog) is fully rigged and rehearsed
OFFLINE — without one model call, without a key (S4.2-analog, parity row 21;
buildable after K5 + K7). `--live-dry-run` builds everything a real run would
(contracts fail-fast §10 → compose §5 → SDK-client construction → preflight)
and captures the run-config + preflight artifacts, then STOPS before the first
model call. The stop IS the boundary: the loop is never entered, so nothing is
spent (strictly offline, no D6 gate).

- run.py --live-dry-run: requires --outbox + --run-id (the drill's artifacts are
  run_id-named), rejected fail-fast before any build. Writes a run_id-named PAIR
  to the outbox:
  * {run_id}-runconfig.json — comparison-protocol §4 pt 3: model-id per role the
    loop calls (proposer/checker, THROUGH resolve_model — the run's own path),
    profile, and every cap/parameter. Deliberately NO wall-clock date, so the
    bytes stay deterministic (the run's date is stamped at report time, §4 pt 3).
  * {run_id}-preflight.json — the captured preflight verdict (clear + refusals).
    The drill CAPTURES the preflight result rather than gating the build on it:
    exit 0 when clear (rig go-live-ready), non-zero when refused — artifacts
    captured and ZERO model calls in EITHER case.
- The client is constructed (the verified key-free SDK premise) but never called;
  a call-counting stand-in proves 0 calls. Bytes reuse the deterministic house
  JSON writer; run_s10.py/runs/ byte-untouched.

- test_dry_run_loadbearing.py: 7 tests. TWO seams detach-proven RED — the
  0-calls stop seam (neutralise the branch → falls to execute_run → the counting
  client fires → red) and the capture seam (drop the writes → outbox lacks the
  pair → red). Env monkeypatched so the preflight verdict is deterministic
  regardless of the operator's ambient shell.
- 514→521 green, golden byte-exact, full gate clean (ruff+format+mypy strict,
  24 src files). README: test-count sync ×2 + run.py drill note + load-bearing
  mention. IKKE-scope (held): the actual live run (M2-analog, operator) and any
  change to preflight/outbox.

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-24 06:54:58 +02:00
commit 08ffddbbb1
3 changed files with 397 additions and 8 deletions

View file

@ -14,6 +14,9 @@ executed by the suite — honesty rule §1); the navigated docs dir comes from
the validated startup contract, never straight from the raw argument (§10).
Run: uv run python -m portfolio_optimiser_claude.run --bundle <dir> [--inbox <dir>]
# K8 live-run drill (builds all, captures artifacts, STOPS before the first call):
uv run python -m portfolio_optimiser_claude.run --bundle <dir> \\
--outbox <dir> --run-id <id> --live-dry-run
"""
from __future__ import annotations
@ -21,16 +24,18 @@ from __future__ import annotations
import argparse
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from typing import Any, Callable
from portfolio_optimiser_claude.artifacts import (
_dump_json,
build_citations,
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.contracts import Contracts, load_contracts, resolve_model
from portfolio_optimiser_claude.preflight import Refusal, run_preflight
from portfolio_optimiser_claude.experience import (
CandidateFeatures,
VerdictStore,
@ -45,6 +50,9 @@ from portfolio_optimiser_claude.loop import ModelClient, run_project
from portfolio_optimiser_claude.validator import Rejection
_PROPOSER_ROLE = "proposer"
_CHECKER_ROLE = "checker"
# The one backend profile the run resolves against (mirrors SdkModelClient's default).
_DEFAULT_PROFILE = "anthropic"
# The injected client seam of the entrance: (contracts, max_budget_usd_per_call).
ClientFactory = Callable[[Contracts, float], ModelClient]
@ -195,6 +203,97 @@ def execute_run(
return 0
def build_dry_run_config(
contracts: Contracts,
*,
profile: str,
bundle_name: str,
run_id: str,
max_rounds: int,
max_tokens: int,
max_budget_usd_per_call: float,
max_debate_rounds: int,
max_attempts: int,
top_k: int,
) -> dict[str, Any]:
"""The run-config log (comparison protocol §4 pt 3): model-id, parameters, caps.
Records the model id each role the loop calls resolves to THROUGH
``resolve_model`` (the run's own resolution path), never a raw dict read — the
profile, and every cap/parameter a live run would carry. Deliberately carries
NO wall-clock date: the outbox promises byte-determinism (same input + run_id
identical file), and the run's date is stamped at report time (§4 pt 3),
never into the deterministic log.
"""
return {
"run_id": run_id,
"profile": profile,
"bundle": bundle_name,
"models": {
role: resolve_model(contracts.model_map, role, profile=profile)
for role in (_PROPOSER_ROLE, _CHECKER_ROLE)
},
"caps": {
"max_rounds": max_rounds,
"max_tokens": max_tokens,
"max_budget_usd_per_call": max_budget_usd_per_call,
"max_debate_rounds": max_debate_rounds,
"max_attempts": max_attempts,
"top_k": top_k,
},
}
def execute_dry_run(
*,
outbox_dir: Path,
run_id: str,
profile: str,
run_config: dict[str, Any],
refusals: list[Refusal],
) -> int:
"""Capture the run-config + preflight artifacts; STOP before any model call (K8).
Writes the run_id-named PAIR ``{run_id}-runconfig.json`` and
``{run_id}-preflight.json`` to the outbox as deterministic house JSON, then
returns WITHOUT ever driving the loop: the drill rehearses the whole build and
artifact capture offline, so a future operator-gated live run (the M2-analog)
is fully rigged. Exit 0 when the preflight is clear (rig go-live-ready); exit 1
when it refused the artifacts are captured EITHER way (the refusal is itself
one of them), and no model call is made in either case.
"""
outbox_dir.mkdir(parents=True, exist_ok=True)
paths = {
"runconfig": outbox_dir / f"{run_id}-runconfig.json",
"preflight": outbox_dir / f"{run_id}-preflight.json",
}
_dump_json(paths["runconfig"], run_config)
_dump_json(
paths["preflight"],
{
"run_id": run_id,
"profile": profile,
"clear": not refusals,
"refusals": [{"check": r.check, "detail": r.detail} for r in refusals],
},
)
for name, path in sorted(paths.items()):
print(f"artifact: {name} -> {path}")
if refusals:
print(
f"DRILL: preflight REFUSED ({len(refusals)}) — rig NOT clear to go live "
"(artifacts captured, no model call was made):"
)
for refusal in refusals:
print(f" [{refusal.check}] {refusal.detail}")
return 1
print(
"DRILL OK — built all, captured artifacts, stopped before the first model call "
"(0 model calls); rig clear to go live."
)
return 0
def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None = None) -> int:
"""The thin CLI: contracts fail-fast (§10) → compose (§5) → execute (§3, §8)."""
parser = argparse.ArgumentParser(
@ -211,12 +310,25 @@ def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None
parser.add_argument("--max-debate-rounds", type=int, default=3)
parser.add_argument("--max-attempts", type=int, default=3)
parser.add_argument("--top-k", type=int, default=3)
parser.add_argument(
"--live-dry-run",
action="store_true",
help="build all, capture run-config + preflight to the outbox, STOP before "
"the first model call (K8 live-run drill; no spend, no model call).",
)
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():
# on a run that cannot be filed (no wall-clock default fills the gap). The dry
# run's artifacts are ALSO run_id-named, so it requires both an outbox and an id.
if args.live_dry_run:
if args.outbox is None or not (args.run_id or "").strip():
parser.error(
"--live-dry-run requires --outbox and --run-id "
"(the drill's artifacts are run_id-named in the outbox)"
)
elif 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.
@ -237,6 +349,40 @@ def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None
)
factory = default_client_factory if client_factory is None else client_factory
client = factory(contracts, args.max_budget_usd_per_call)
# K8: the live-run drill builds the client (the key-free SDK construction
# premise) but never calls it — it captures the run-config + preflight
# artifacts and STOPS before the first model call. A future operator-gated
# live run is thus rigged and rehearsed offline, with zero spend.
if args.live_dry_run:
assert args.outbox is not None # narrowed by the fail-fast above
run_id = args.run_id or ""
refusals = run_preflight(
profile=_DEFAULT_PROFILE,
max_rounds=args.max_rounds,
max_tokens=args.max_tokens,
max_budget_usd_per_call=args.max_budget_usd_per_call,
)
run_config = build_dry_run_config(
contracts,
profile=_DEFAULT_PROFILE,
bundle_name=args.bundle.name,
run_id=run_id,
max_rounds=args.max_rounds,
max_tokens=args.max_tokens,
max_budget_usd_per_call=args.max_budget_usd_per_call,
max_debate_rounds=args.max_debate_rounds,
max_attempts=args.max_attempts,
top_k=args.top_k,
)
return execute_dry_run(
outbox_dir=args.outbox,
run_id=run_id,
profile=_DEFAULT_PROFILE,
run_config=run_config,
refusals=refusals,
)
return execute_run(
client,
composed,