portfolio-optimiser-claude/tests/test_dry_run_loadbearing.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

236 lines
9 KiB
Python

"""Live-run drill — LOAD-BEARING (K8; method-spec §8; comparison protocol §4 pt 3).
The seam this file keeps alive: ``--live-dry-run`` BUILDS everything a real
live run would (contracts fail-fast → compose → client construction → preflight)
and CAPTURES the run-config + preflight artifacts to the outbox, then STOPS
before the first model call. A future operator-gated live run (the M2-analog) is
thus fully rigged and rehearsed offline — without one model call, without a key.
Detach proof (the 0-calls seam): remove the dry-run branch from ``main`` so it
falls through to ``execute_run`` → the injected call-counting client's
``complete`` fires → ``calls`` is non-empty (and the empty-reply stand-in raises)
→ red. Detach proof (the capture seam): drop the artifact write → the outbox
lacks the run_id-named pair → red.
No credential and no network are needed: the drill constructs the client (the
verified key-free SDK premise) and the call-counting stand-in guarantees the
boundary. The env is monkeypatched so the preflight verdict is deterministic
regardless of the operator's ambient shell.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Callable
import pytest
from _scripted import ScriptedClient
from portfolio_optimiser_claude.contracts import Contracts, load_contracts
from portfolio_optimiser_claude.loop import ModelClient
from portfolio_optimiser_claude.run import main
BUNDLE = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
RUN_ID = "dryrun-001"
ClientFactory = Callable[[Contracts, float], ModelClient]
def _counting_factory() -> tuple[ClientFactory, list[ScriptedClient]]:
"""A factory whose clients record every call and carry NO replies.
An empty reply list means any ``complete`` both records the call and raises —
so a detached dry-run (one that reaches the loop) fails loudly, and a correct
dry-run leaves ``calls`` empty.
"""
created: list[ScriptedClient] = []
def factory(contracts: Contracts, max_budget_usd_per_call: float) -> ModelClient:
client = ScriptedClient(replies=[])
created.append(client)
return client
return factory, created
def _clear_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
# ANTHROPIC_API_KEY is the only accepted credential; the subscription token
# is cleared as env hygiene, so an ambient one cannot colour any result.
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_CODE_OAUTH_TOKEN", raising=False)
def _set_credential(monkeypatch: pytest.MonkeyPatch) -> None:
# A non-placeholder form; the preflight never validates it online, so this is
# not a real key and never leaves the process (the counting client blocks any
# call). It only exercises the clear-preflight branch.
_clear_credentials(monkeypatch)
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-drill-not-a-real-key")
class TestDryRunStopsBeforeFirstCall:
"""The boundary: the drill builds everything but never calls the model."""
def test_zero_model_calls(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_clear_credentials(monkeypatch)
factory, created = _counting_factory()
main(
[
"--bundle",
str(BUNDLE),
"--outbox",
str(tmp_path / "outbox"),
"--run-id",
RUN_ID,
"--live-dry-run",
],
client_factory=factory,
)
# The client was constructed (the drill builds the client), but never called.
(client,) = created
assert client.calls == []
def test_requires_outbox_and_run_id(self, tmp_path: Path) -> None:
factory, _ = _counting_factory()
# No --outbox / --run-id: the run_id-named artifacts have nowhere to go.
with pytest.raises(SystemExit):
main(
["--bundle", str(BUNDLE), "--live-dry-run"],
client_factory=factory,
)
class TestDryRunArtifactCapture:
"""The captured set (run-config + preflight) is complete and deterministic."""
def test_captures_runconfig_and_preflight(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_credentials(monkeypatch)
outbox = tmp_path / "outbox"
factory, _ = _counting_factory()
main(
[
"--bundle",
str(BUNDLE),
"--outbox",
str(outbox),
"--run-id",
RUN_ID,
"--live-dry-run",
],
client_factory=factory,
)
runconfig = json.loads((outbox / f"{RUN_ID}-runconfig.json").read_text("utf-8"))
preflight = json.loads((outbox / f"{RUN_ID}-preflight.json").read_text("utf-8"))
# §4 pt 3: model-id per role the loop calls, parameters, caps — no wall-clock.
assert runconfig["run_id"] == RUN_ID
assert runconfig["profile"] == "anthropic"
assert runconfig["models"]["proposer"] == "claude-haiku-4-5-20251001"
assert runconfig["models"]["checker"] == "claude-haiku-4-5-20251001"
assert runconfig["caps"]["max_rounds"] == 12
assert runconfig["caps"]["max_tokens"] == 150_000
assert runconfig["caps"]["max_budget_usd_per_call"] == 0.25
assert "date" not in runconfig # determinism: date is stamped at report time
# Preflight result captured (no credential here → credential refusal recorded).
assert preflight["run_id"] == RUN_ID
assert preflight["clear"] is False
assert any(r["check"] == "credential" for r in preflight["refusals"])
def test_artifacts_are_byte_deterministic(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_credentials(monkeypatch)
first = tmp_path / "a"
second = tmp_path / "b"
for outbox in (first, second):
factory, _ = _counting_factory()
main(
[
"--bundle",
str(BUNDLE),
"--outbox",
str(outbox),
"--run-id",
RUN_ID,
"--live-dry-run",
],
client_factory=factory,
)
for name in (f"{RUN_ID}-runconfig.json", f"{RUN_ID}-preflight.json"):
assert (first / name).read_bytes() == (second / name).read_bytes()
class TestDryRunPreflightGate:
"""Exit code reflects go-live readiness; capture happens either way."""
def test_clear_preflight_exits_zero(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_set_credential(monkeypatch)
outbox = tmp_path / "outbox"
factory, created = _counting_factory()
code = main(
[
"--bundle",
str(BUNDLE),
"--outbox",
str(outbox),
"--run-id",
RUN_ID,
"--live-dry-run",
],
client_factory=factory,
)
assert code == 0
preflight = json.loads((outbox / f"{RUN_ID}-preflight.json").read_text("utf-8"))
assert preflight["clear"] is True
assert preflight["refusals"] == []
(client,) = created
assert client.calls == [] # still zero calls
def test_refused_preflight_captures_but_exits_nonzero(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_credentials(monkeypatch)
outbox = tmp_path / "outbox"
factory, created = _counting_factory()
code = main(
[
"--bundle",
str(BUNDLE),
"--outbox",
str(outbox),
"--run-id",
RUN_ID,
"--live-dry-run",
],
client_factory=factory,
)
assert code != 0 # refused: the rig is not clear to go live
# ...yet the artifacts are captured and no model call was made.
assert (outbox / f"{RUN_ID}-runconfig.json").is_file()
assert (outbox / f"{RUN_ID}-preflight.json").is_file()
(client,) = created
assert client.calls == []
class TestKeyFreeConstruction:
"""K8 key premise: the SDK client constructs with no credential (no call)."""
def test_default_factory_constructs_without_credential(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
_clear_credentials(monkeypatch)
from portfolio_optimiser_claude.run import default_client_factory
from portfolio_optimiser_claude.sdk_client import SdkModelClient
contracts = load_contracts(
data_source={"docs_dir": str(BUNDLE), "top_k": 3},
termination={"max_rounds": 1, "max_tokens": 1},
feedback={"decision": "approved", "rationale": "startup shape check (§10)"},
)
client = default_client_factory(contracts, 0.25)
assert isinstance(client, SdkModelClient)