feat(run): S10 del 1 — run-lag: §9-citations, artefakt-persistens, SDK-klient

TDD offline (RØD bekreftet før implementasjon): resolve_model (rolle->modell-id,
ukjent profil feiler fail-fast), build_citations (eksakte char-spans, verdict-
ekskludering, uncitable kontekst -> raise FØR spend), persist_run_artifacts
(deterministiske bytes; validator/checker-avgjørelser speilet VERBATIM fra
RunResult — §9 non-konflatering). Run-path-only, aldri importert av tester:
SdkModelClient (claude-agent-sdk 0.2.110 verifisert mot installert pakke;
max_turns=1, tools=[], max_budget_usd per kall; manglende usage -> None så
§8-meteret feiler lukket) + run_s10 (kontrakter FØR klient §10, BudgetExceeded
som strukturert stopp). 178/178 uten nøkkel; ruff+mypy --strict rene; fire
detach-bevis røde -> revertert grønne. Live-kjøringen gjenstår (credential-gated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdSfQdND84oeq2mbjueLTS
This commit is contained in:
Kjell Tore Guttormsen 2026-07-03 07:51:05 +02:00
commit 0238507df4
5 changed files with 583 additions and 0 deletions

View file

@ -0,0 +1,133 @@
"""S10 — the ONE live model run of the programme (D6). RUN-PATH ONLY.
Wires the offline-proven pipeline (S6S9) to the real Claude Agent SDK client
for a single controlled run on the micro-bundle: startup contracts validated
fail-fast BEFORE the client is built (§10), citations proven citable BEFORE any
spend (§9), the read-context navigated + experience-folded exactly as the
offline suite proves (§3 Step 1), the loop bounded by the §8 meter AND the
per-call USD cap, and the captured artifacts persisted for S11 with the cost
logged. Never imported by the test suite.
Run: uv run python -m portfolio_optimiser_claude.run_s10
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from portfolio_optimiser_claude.artifacts import build_citations, persist_run_artifacts
from portfolio_optimiser_claude.budget import BudgetExceeded, BudgetMeter
from portfolio_optimiser_claude.contracts import load_contracts
from portfolio_optimiser_claude.experience import (
CandidateFeatures,
VerdictStore,
fold_experience,
seed_store_from_bundle,
)
from portfolio_optimiser_claude.ir import load_validator_input
from portfolio_optimiser_claude.loop import run_project
from portfolio_optimiser_claude.okf import bundle_context, navigate_bundle
from portfolio_optimiser_claude.provenance import Provenance
from portfolio_optimiser_claude.sdk_client import SdkModelClient
from portfolio_optimiser_claude.validator import Rejection
_DEFAULT_BUNDLE = Path(__file__).resolve().parents[2] / "shared" / "examples" / "bygg-energi-mikro"
_PROPOSER_ROLE = "proposer"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="S10: the single live run (hard caps, D6).")
parser.add_argument("--bundle", type=Path, default=_DEFAULT_BUNDLE)
parser.add_argument("--out", type=Path, default=Path("runs") / "s10")
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)
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)
args = parser.parse_args(argv)
if not os.environ.get("ANTHROPIC_API_KEY"):
# The bundled CLI resolves its own credentials (Claude Code login) when
# no key is exported; an unauthenticated run fails on the FIRST call.
print("note: ANTHROPIC_API_KEY not set — relying on the CLI's own credentials.")
# §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},
termination={"max_rounds": args.max_rounds, "max_tokens": args.max_tokens},
feedback={"decision": "approved", "rationale": "startup shape check (§10)"},
)
# §9: prove the context citable BEFORE any spend; §3 Step 1: navigate + fold.
concepts = navigate_bundle(args.bundle)
citations = build_citations(concepts)
ir_projection = load_validator_input(args.bundle)
store = VerdictStore()
seeded = seed_store_from_bundle(store, args.bundle)
context = fold_experience(
store,
CandidateFeatures.from_proposal(ir_projection),
bundle_context(args.bundle),
contracts.data_source.top_k,
)
meter = BudgetMeter(contracts.termination)
client = SdkModelClient(
contracts.model_map, max_budget_usd_per_call=args.max_budget_usd_per_call
)
print(
f"S10 live run: bundle={args.bundle.name} seeded_verdicts={seeded} "
f"caps: max_rounds={args.max_rounds} max_tokens={args.max_tokens} "
f"max_budget_usd_per_call={args.max_budget_usd_per_call}"
)
try:
result = run_project(
client,
context,
meter=meter,
max_debate_rounds=args.max_debate_rounds,
max_attempts=args.max_attempts,
default_project_id=ir_projection.project_id,
)
except BudgetExceeded as stop:
# §8: the structured stop event — report it, never a silent hang.
print(f"STOPPED by budget: {stop.kind} observed {stop.observed} > limit {stop.limit}")
print(f"cost so far: {client.total_cost_usd:.6f} USD")
return 3
provenance = Provenance(
citations=citations,
model=client.last_model or "unknown", # §9: the REAL id, neutral fallback
role=_PROPOSER_ROLE,
validator_decision=result.validator_decision,
tokens_used=meter.tokens_used,
)
paths = persist_run_artifacts(
args.out,
run=result,
provenance=provenance,
termination=contracts.termination,
tokens_used=meter.tokens_used,
rounds_used=meter.rounds_used,
cost_usd=round(client.total_cost_usd, 6),
)
outcome_kind = "rejected" if isinstance(result.outcome, Rejection) else "validated"
print(
f"result: validator={result.validator_decision} checker={result.checker_decision} "
f"attempts={result.attempts} outcome={outcome_kind}"
)
print(
f"usage: tokens={meter.tokens_used}/{contracts.termination.max_tokens} "
f"rounds={meter.rounds_used}/{contracts.termination.max_rounds}"
)
print(f"model: {client.last_model} cost: {client.total_cost_usd:.6f} USD")
for name, path in sorted(paths.items()):
print(f"artifact: {name} -> {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())