portfolio-optimiser-claude/src/portfolio_optimiser_claude/run_s10.py
Kjell Tore Guttormsen f92b04bf62
fix(credential): a subscription paid for the run, and one print line decided it
ANTHROPIC_API_KEY is now the ONLY accepted credential. Through v0.1.0 the
preflight cleared on CLAUDE_CODE_OAUTH_TOKEN, and run_s10 went further: an
unset key printed "note: relying on the CLI's own credentials" and carried
on. That note was not a warning, it was a decision - made silently, on the
operator's behalf, about who pays. Both paths are gone; a run with no key
refuses with exit 2 before anything is opened.

Red first, both halves: _check_credentials refuses an OAuth-only env, and
the run entrance is driven as a real subprocess with a deliberately missing
bundle, so the credential refusal must win the race against the bundle
error. Detach it and the process reaches navigate_bundle instead - a
different exit code, no refusal line, the fallback back in the output. The
positive control (key set) gets past the gate and fails on the bundle, so
the gate is a gate and not a wall. 997 -> 1002, offline, no key in env.

The SDK exception is now stated where a reader meets it, not implied: this
framework runs on the Claude Agent SDK, which starts the Claude Code CLI it
bundles as a subprocess. That is the SDK's intended use WITH an API key,
and it is a deliberate, stated exception to the owner's rule that his own
code never starts Claude Code. Rewriting to direct HTTP calls was weighed
and declined - measuring what the Agent SDK offers is the point of D7. The
repo is closed as a worked example.

Two prose claims were corrected rather than left standing: run_s10.py is no
longer byte-frozen (it carries exactly one change, and runs/s10/ is still
the v0.1.0 run), and its two round() call sites moved 110->118, 130->138.

The credential paragraph is prose under an existing heading, not a new
section: test_readme_anchors_loadbearing.py pins 14 heading ids MEASURED on
the published page and forbids re-deriving them. This order forbids push, so
a new heading could not have been honestly re-measured.

Version 0.1.1: pyproject.toml, uv.lock self-entry, CHANGELOG - 3 of 3. No
version badge in README, no constant in src. v0.1.0 stands as released.

Order 20260920T131502Z-7496226791-from-.claude. The older D7 mirroring order
20260913T053840Z-9473220509 is retired unexecuted: po closes at v1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:24:55 +02:00

156 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
persist_stop_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", "").strip():
# REFUSAL, never a fallback: an own API key is the only credential this
# run accepts. Through v0.1.0 an unset key merely printed a note and the
# run carried on, letting the bundled CLI resolve its own login — which
# would have charged a consumer subscription. The gate fires FIRST, so
# nothing is opened, built or spent before it.
print(
"REFUSED: ANTHROPIC_API_KEY is not set. It is the only accepted credential; "
"a Claude Code subscription token (CLAUDE_CODE_OAUTH_TOKEN) is NOT accepted "
"and must not be used to pay for a run. No model call was made."
)
return 2
# §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, and
# persist the spend (the first live run stopped here with NO record).
print(f"STOPPED by budget: {stop.kind} observed {stop.observed} > limit {stop.limit}")
print(f"cost so far: {client.total_cost_usd:.6f} USD")
stop_paths = persist_stop_artifacts(
args.out,
stop=stop,
termination=contracts.termination,
tokens_used=meter.tokens_used,
rounds_used=meter.rounds_used,
cost_usd=round(client.total_cost_usd, 6),
)
for name, path in sorted(stop_paths.items()):
print(f"artifact: {name} -> {path}")
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())