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:
parent
22bfc80dda
commit
0238507df4
5 changed files with 583 additions and 0 deletions
114
src/portfolio_optimiser_claude/artifacts.py
Normal file
114
src/portfolio_optimiser_claude/artifacts.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue