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,114 @@
"""The S10 output layer: §9 citations + deterministic run-artifact persistence.
``build_citations`` cites the navigated read-context (never ``type: verdict``
files the verdict layer must not leak, §3 Step 1) with EXACT character spans
into the source files; a context that yields no citable content fails fast (§9).
``persist_run_artifacts`` writes the captured run for S11: the decisions are
mirrored VERBATIM from the ``RunResult`` (§9 non-conflation never recomputed
from a checker-overridden outcome), and the bytes are deterministic (sorted
keys, 2-space indent the house JSON convention).
Pure file layer by design imports no agent toolkit; the SDK client stays
run-path-only (§11: the suite runs without a key).
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from portfolio_optimiser_claude.contracts import TerminationContract
from portfolio_optimiser_claude.loop import RunResult
from portfolio_optimiser_claude.okf import ConceptFile
from portfolio_optimiser_claude.provenance import Citation, Provenance
from portfolio_optimiser_claude.validator import Rejection
_VERDICT_TYPE = "verdict"
def build_citations(concepts: list[ConceptFile]) -> list[Citation]:
"""One citation per citable concept: file + exact char span + snippet (§9).
The snippet is the concept body's first non-empty line; the span locates it
verbatim in the SOURCE file (frontmatter included), so every citation is
independently verifiable. Verdict files and empty bodies are skipped; a
context with no citable content raises (fail fast, §9).
"""
citations: list[Citation] = []
for concept in concepts:
if concept.type == _VERDICT_TYPE or not concept.body:
continue
snippet = next((line for line in concept.body.splitlines() if line.strip()), "")
if not snippet:
continue
source = concept.path.read_text(encoding="utf-8")
start = source.find(snippet)
if start < 0:
continue
citations.append(
Citation(
file=concept.path.name,
span=f"chars {start}-{start + len(snippet)}",
snippet=snippet,
)
)
if not citations:
raise ValueError("context yields no citable content — a run must fail fast (§9)")
return citations
def _dump_json(path: Path, payload: dict[str, Any]) -> None:
path.write_text(json.dumps(payload, sort_keys=True, indent=2) + "\n", encoding="utf-8")
def persist_run_artifacts(
out_dir: Path,
*,
run: RunResult,
provenance: Provenance,
termination: TerminationContract,
tokens_used: int,
rounds_used: int,
cost_usd: float | None,
) -> dict[str, Path]:
"""Persist the run for S11: proposal, result, provenance stamp, usage-vs-caps.
``validator_decision`` and ``checker_decision`` are copied from the
``RunResult`` fields the two falsifiers were recorded separately there
(§9) and recomputing either from the outcome would conflate them.
"""
out_dir.mkdir(parents=True, exist_ok=True)
outcome: dict[str, Any] = (
{"type": "rejected", "reason": run.outcome.reason}
if isinstance(run.outcome, Rejection)
else {"type": "validated", **run.outcome.model_dump()}
)
paths = {
"proposal": out_dir / "proposal.json",
"run_result": out_dir / "run_result.json",
"provenance": out_dir / "provenance.json",
"usage": out_dir / "usage.json",
}
_dump_json(paths["proposal"], run.proposal.model_dump())
_dump_json(
paths["run_result"],
{
"validator_decision": run.validator_decision,
"checker_decision": run.checker_decision,
"attempts": run.attempts,
"outcome": outcome,
},
)
_dump_json(paths["provenance"], provenance.model_dump())
_dump_json(
paths["usage"],
{
"tokens_used": tokens_used,
"rounds_used": rounds_used,
"max_tokens": termination.max_tokens,
"max_rounds": termination.max_rounds,
"cost_usd": cost_usd,
},
)
return paths