feat(run): C2.0 — shippable step-7 run entrance + K2.9 seam bindings (closes C-N2, R-10, K2.9)
- run.py: compose_run_context (§5: merge inbox -> seed -> fold, read-only on the inbox) + execute_run (§8 meter, artifacts persisted on BOTH outcomes, structured exit 3 on budget stop) + thin CLI (python -m ..run). The model client is injected; only default_client_factory constructs the SDK client (wired, never executed by the suite). The navigated docs dir comes from the validated startup contract (resolves review OBS-2 on the shippable path; run_s10.py stays byte-frozen fasit -> won't-fix there). - test_run_entrance_loadbearing.py: inbox verdict reaches the composed context (detach-proven: merge dropped -> red), empty/missing-inbox controls, read-only inbox byte-proof, R-10 budget-stop binding via the NEW entrance (detach-proven: stop persistence dropped -> red), happy path through the CLI with the inbox signal surviving the chain, SDK-wiring test. - test_ingest_adoption.py (K2.9): the two library guarantees the consumer relies on, bound through the seam — empty CSV -> typed SourceError with NO partial bundle on disk; non-SELECT SQL -> SourceError 'returned no columns' (behavior verified empirically against pin dae0bd1a before binding). - README: inbox section now points at the shippable entrance; run.py added to the run layer; stale test count 265 -> 395. 386 -> 395 tests, full gate green (pytest, ruff check+format, mypy strict); goldens unchanged; runs/s10 and run_s10.py untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
5732d13369
commit
3587854074
4 changed files with 448 additions and 3 deletions
|
|
@ -99,3 +99,60 @@ class TestErrorContract:
|
|||
ingest.SourceError,
|
||||
):
|
||||
assert issubclass(exc, ingest.IngestError)
|
||||
|
||||
|
||||
class TestLibraryGuarantees:
|
||||
"""K2.9 (R-3): library-side guarantees this consumer relies on, bound at the seam.
|
||||
|
||||
Pre-adoption the local connector crashed mid-materialization on an empty
|
||||
CSV and left a PARTIAL bundle on disk (run-proven R-3). The library stages
|
||||
in memory, so the typed ``SourceError`` fires BEFORE the disk phase. Bound
|
||||
THROUGH the consumer entry point so a pin bump can never silently regress
|
||||
either guarantee.
|
||||
"""
|
||||
|
||||
def test_empty_csv_fails_typed_before_any_disk_write(self, tmp_path: Path) -> None:
|
||||
case = tmp_path / "case"
|
||||
fixture = case / "fixture"
|
||||
fixture.mkdir(parents=True)
|
||||
(fixture / "e.csv").write_text("", encoding="utf-8")
|
||||
manifest = {
|
||||
"manifest_version": 1,
|
||||
"source": {"type": "file", "id": "arkiv", "root": "fixture"},
|
||||
"bundle_summary": "s",
|
||||
"extractions": [
|
||||
{"id": "e", "title": "T", "query": "e.csv", "okf_type": "dataset", "max_rows": 5}
|
||||
],
|
||||
}
|
||||
manifest_path = case / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
bundle = tmp_path / "bundle"
|
||||
with pytest.raises(ingest.SourceError):
|
||||
ingest.materialize(manifest_path, bundle, INGESTED_AT)
|
||||
assert not bundle.exists() # never a partial bundle (in-memory staging)
|
||||
|
||||
def test_non_select_sql_fails_typed_with_no_partial_bundle(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import sqlite3
|
||||
|
||||
db = tmp_path / "src.sqlite"
|
||||
con = sqlite3.connect(db)
|
||||
con.execute("CREATE TABLE t (a INTEGER)")
|
||||
con.commit()
|
||||
con.close()
|
||||
monkeypatch.setenv("SRC_DSN", str(db))
|
||||
manifest = {
|
||||
"manifest_version": 1,
|
||||
"source": {"type": "sql", "id": "db", "connection_ref": "SRC_DSN"},
|
||||
"bundle_summary": "s",
|
||||
"extractions": [
|
||||
{"id": "e", "title": "T", "query": "BEGIN", "okf_type": "dataset", "max_rows": 5}
|
||||
],
|
||||
}
|
||||
manifest_path = tmp_path / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
bundle = tmp_path / "bundle"
|
||||
with pytest.raises(ingest.SourceError, match="returned no columns"):
|
||||
ingest.materialize(manifest_path, bundle, INGESTED_AT)
|
||||
assert not bundle.exists()
|
||||
|
|
|
|||
160
tests/test_run_entrance_loadbearing.py
Normal file
160
tests/test_run_entrance_loadbearing.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Run-entrance seams — LOAD-BEARING (C2.0; method-spec §3 Step 7, §5, §8, §11).
|
||||
|
||||
The seam this file keeps alive: the SHIPPABLE entrance (``run.py``) composes
|
||||
the §5 sequence (merge inbox → seed → fold) and drives the same orchestration
|
||||
the fasit run used — so the README's inbox claim is true of a deliverable
|
||||
path. ``run_s10.py`` stays byte-frozen and is still never imported here.
|
||||
|
||||
Detach proofs: drop the merge call from the composition → the inbox verdict
|
||||
never reaches the composed context → red. Drop the budget-stop persistence
|
||||
branch from the entrance → the structured stop leaves no artifacts → red
|
||||
(R-10: the orchestration branch the fasit suite could never bind, bound here
|
||||
on the new path with a scripted client and a low cap).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from _scripted import ScriptedClient, reply
|
||||
|
||||
from portfolio_optimiser_claude.contracts import Contracts, load_contracts
|
||||
from portfolio_optimiser_claude.experience import CandidateFeatures
|
||||
from portfolio_optimiser_claude.inbox import VerdictDocument, write_verdict
|
||||
from portfolio_optimiser_claude.ir import load_validator_input
|
||||
from portfolio_optimiser_claude.loop import ModelClient
|
||||
from portfolio_optimiser_claude.run import compose_run_context, default_client_factory, main
|
||||
|
||||
BUNDLE = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
||||
# Distinct from the bundle seed's own learning marker (realiseringsgrad=0.82),
|
||||
# so its presence proves the INBOX path, not the seeding path.
|
||||
MARKER = "INBOX-MARKER-79"
|
||||
RATIONALE = f"Justert: i drift realiseres ~79% ({MARKER})."
|
||||
|
||||
ClientFactory = Callable[[Contracts, float], ModelClient]
|
||||
|
||||
|
||||
def _inbox_document() -> VerdictDocument:
|
||||
# The expert judges the bundle's own candidate, so retrieval ranks the
|
||||
# verdict at similarity 1.0 and the fold must carry it.
|
||||
features = CandidateFeatures.from_proposal(load_validator_input(BUNDLE))
|
||||
return VerdictDocument.from_candidate(
|
||||
features,
|
||||
decision="approved_with_adjustment",
|
||||
rationale=RATIONALE,
|
||||
description="expert judgement of the bundle candidate",
|
||||
)
|
||||
|
||||
|
||||
def _scripted_factory(replies: list[object]) -> tuple[ClientFactory, list[ScriptedClient]]:
|
||||
created: list[ScriptedClient] = []
|
||||
|
||||
def factory(contracts: Contracts, max_budget_usd_per_call: float) -> ModelClient:
|
||||
client = ScriptedClient(replies=list(replies)) # type: ignore[arg-type]
|
||||
created.append(client)
|
||||
return client
|
||||
|
||||
return factory, created
|
||||
|
||||
|
||||
class TestComposeRunContext:
|
||||
"""§5 sequence, composed: merge inbox → seed → fold — read-only on the inbox."""
|
||||
|
||||
def test_a_dropped_verdict_reaches_the_composed_context(self, tmp_path: Path) -> None:
|
||||
inbox = tmp_path / "inbox"
|
||||
write_verdict(inbox, _inbox_document())
|
||||
composed = compose_run_context(BUNDLE, inbox, k=3)
|
||||
assert MARKER in composed.context
|
||||
assert composed.inbox_merged == 1
|
||||
assert composed.seeded == 1
|
||||
|
||||
def test_empty_inbox_control_keeps_the_base_composition(self, tmp_path: Path) -> None:
|
||||
inbox = tmp_path / "inbox"
|
||||
inbox.mkdir()
|
||||
with_empty = compose_run_context(BUNDLE, inbox, k=3)
|
||||
without = compose_run_context(BUNDLE, None, k=3)
|
||||
assert with_empty.context == without.context
|
||||
assert MARKER not in without.context
|
||||
assert with_empty.inbox_merged == 0
|
||||
|
||||
def test_missing_inbox_folder_behaves_like_empty(self, tmp_path: Path) -> None:
|
||||
composed = compose_run_context(BUNDLE, tmp_path / "never-created", k=3)
|
||||
assert composed.context == compose_run_context(BUNDLE, None, k=3).context
|
||||
|
||||
def test_composition_never_writes_to_the_inbox(self, tmp_path: Path) -> None:
|
||||
# Role split (§3 Step 7, unwaivable): the entrance READS the inbox.
|
||||
inbox = tmp_path / "inbox"
|
||||
write_verdict(inbox, _inbox_document())
|
||||
before = {p.name: p.read_bytes() for p in inbox.iterdir()}
|
||||
compose_run_context(BUNDLE, inbox, k=3)
|
||||
after = {p.name: p.read_bytes() for p in inbox.iterdir()}
|
||||
assert after == before
|
||||
|
||||
|
||||
class TestEntranceHappyPath:
|
||||
"""The thin CLI wires compose → run → persist; the inbox signal survives the chain."""
|
||||
|
||||
def test_exit_zero_persists_run_artifacts_and_threads_the_inbox(self, tmp_path: Path) -> None:
|
||||
inbox = tmp_path / "inbox"
|
||||
write_verdict(inbox, _inbox_document())
|
||||
out = tmp_path / "out"
|
||||
factory, created = _scripted_factory(
|
||||
[
|
||||
reply("debate reasoning"),
|
||||
reply("VERDICT: APPROVE"),
|
||||
reply(json.dumps(load_validator_input(BUNDLE).model_dump())),
|
||||
]
|
||||
)
|
||||
code = main(
|
||||
["--bundle", str(BUNDLE), "--inbox", str(inbox), "--out", str(out)],
|
||||
client_factory=factory,
|
||||
)
|
||||
assert code == 0
|
||||
(client,) = created
|
||||
assert any(MARKER in prompt for prompt in client.prompts("proposer"))
|
||||
run_result = json.loads((out / "run_result.json").read_text("utf-8"))
|
||||
assert run_result["validator_decision"] == "validated"
|
||||
for artifact in ("proposal.json", "provenance.json", "usage.json"):
|
||||
assert (out / artifact).is_file()
|
||||
|
||||
|
||||
class TestBudgetStopBinding:
|
||||
"""R-10: the budget-stop branch — structured exit + persisted stop artifacts."""
|
||||
|
||||
def test_budget_stop_persists_stop_artifacts_and_exits_structurally(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
out = tmp_path / "out"
|
||||
factory, _ = _scripted_factory([reply("debate reasoning", usage_tokens=10)])
|
||||
code = main(
|
||||
["--bundle", str(BUNDLE), "--out", str(out), "--max-tokens", "5"],
|
||||
client_factory=factory,
|
||||
)
|
||||
assert code == 3
|
||||
stop = json.loads((out / "stop.json").read_text("utf-8"))
|
||||
assert stop == {"kind": "tokens", "limit": 5, "observed": 10}
|
||||
usage = json.loads((out / "usage.json").read_text("utf-8"))
|
||||
assert usage["tokens_used"] == 10
|
||||
assert usage["max_tokens"] == 5
|
||||
# A client without cost accounting persists an honest null, never a 0.0.
|
||||
assert usage["cost_usd"] is None
|
||||
assert not (out / "proposal.json").exists()
|
||||
assert not (out / "run_result.json").exists()
|
||||
|
||||
|
||||
class TestDefaultClientFactory:
|
||||
"""The CLI's default factory constructs the REAL SDK client — wired, not executed."""
|
||||
|
||||
def test_default_factory_returns_the_sdk_client(self) -> None:
|
||||
# Construction needs no API key (verified premise); no query() is made.
|
||||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue