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
222
src/portfolio_optimiser_claude/run.py
Normal file
222
src/portfolio_optimiser_claude/run.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"""The shippable run entrance (C2.0): merge inbox → seed → fold → run (§3, §5).
|
||||
|
||||
Where ``run_s10.py`` is the byte-frozen fasit of the programme's ONE live run
|
||||
(never imported by the suite), this module is the generic, deliverable
|
||||
entrance the README's inbox claim points at. The composition
|
||||
(``compose_run_context``) is pure config/file logic and offline-testable: it
|
||||
ingests the inbox READ-only (role split §3 Step 7), seeds from the bundle, and
|
||||
folds the retrieved verdicts into the generation context (§3 Step 1). The
|
||||
orchestration (``execute_run``) drives the loop under the §8 meter and
|
||||
persists artifacts on BOTH outcomes — a budget stop is a run outcome, not an
|
||||
absence of one. The model client is injected: the real SDK client is
|
||||
constructed only by ``default_client_factory`` on the CLI path (wired, never
|
||||
executed by the suite — honesty rule §1); the navigated docs dir comes from
|
||||
the validated startup contract, never straight from the raw argument (§10).
|
||||
|
||||
Run: uv run python -m portfolio_optimiser_claude.run --bundle <dir> [--inbox <dir>]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
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 Contracts, load_contracts
|
||||
from portfolio_optimiser_claude.experience import (
|
||||
CandidateFeatures,
|
||||
VerdictStore,
|
||||
fold_experience,
|
||||
seed_store_from_bundle,
|
||||
)
|
||||
from portfolio_optimiser_claude.inbox import merge_inbox_into_store
|
||||
from portfolio_optimiser_claude.ir import SavingsProposal, load_validator_input
|
||||
from portfolio_optimiser_claude.okf import bundle_context, navigate_bundle
|
||||
from portfolio_optimiser_claude.provenance import Citation, Provenance
|
||||
from portfolio_optimiser_claude.loop import ModelClient, run_project
|
||||
from portfolio_optimiser_claude.validator import Rejection
|
||||
|
||||
_PROPOSER_ROLE = "proposer"
|
||||
|
||||
# The injected client seam of the entrance: (contracts, max_budget_usd_per_call).
|
||||
ClientFactory = Callable[[Contracts, float], ModelClient]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComposedRunContext:
|
||||
"""The §5 sequence's output: the folded context + what fed it (§9-traceable)."""
|
||||
|
||||
context: str
|
||||
citations: list[Citation]
|
||||
ir_projection: SavingsProposal
|
||||
inbox_merged: int
|
||||
seeded: int
|
||||
|
||||
|
||||
def compose_run_context(
|
||||
bundle_dir: Path, inbox_dir: Path | None = None, *, k: int
|
||||
) -> ComposedRunContext:
|
||||
"""Compose the run context per §5: merge inbox → seed → fold — read-only.
|
||||
|
||||
Citations are built BEFORE anything else so an uncitable context fails
|
||||
fast ahead of any spend (§9). A missing/empty ``inbox_dir`` (or ``None``)
|
||||
leaves the composition identical to the no-inbox base. Nothing is ever
|
||||
written — the system reads the inbox, the expert writes it (§3 Step 7).
|
||||
"""
|
||||
citations = build_citations(navigate_bundle(bundle_dir))
|
||||
ir_projection = load_validator_input(bundle_dir)
|
||||
store = VerdictStore()
|
||||
inbox_merged = merge_inbox_into_store(store, inbox_dir) if inbox_dir is not None else 0
|
||||
seeded = seed_store_from_bundle(store, bundle_dir)
|
||||
context = fold_experience(
|
||||
store,
|
||||
CandidateFeatures.from_proposal(ir_projection),
|
||||
bundle_context(bundle_dir),
|
||||
k,
|
||||
)
|
||||
return ComposedRunContext(
|
||||
context=context,
|
||||
citations=citations,
|
||||
ir_projection=ir_projection,
|
||||
inbox_merged=inbox_merged,
|
||||
seeded=seeded,
|
||||
)
|
||||
|
||||
|
||||
def default_client_factory(contracts: Contracts, max_budget_usd_per_call: float) -> ModelClient:
|
||||
"""The CLI's default: the real SDK client (run-path only, §1).
|
||||
|
||||
Imported lazily so composing/executing with an injected client never
|
||||
touches the SDK module — the suite drives the same orchestration with the
|
||||
scripted stand-in.
|
||||
"""
|
||||
from portfolio_optimiser_claude.sdk_client import SdkModelClient
|
||||
|
||||
return SdkModelClient(contracts.model_map, max_budget_usd_per_call=max_budget_usd_per_call)
|
||||
|
||||
|
||||
def _client_cost_usd(client: ModelClient) -> float | None:
|
||||
# Only the SDK client accounts USD; a client without the attribute
|
||||
# persists an honest null (the usage artifact allows it), never a 0.0.
|
||||
cost = getattr(client, "total_cost_usd", None)
|
||||
return None if cost is None else round(float(cost), 6)
|
||||
|
||||
|
||||
def execute_run(
|
||||
client: ModelClient,
|
||||
composed: ComposedRunContext,
|
||||
*,
|
||||
contracts: Contracts,
|
||||
out_dir: Path,
|
||||
max_debate_rounds: int,
|
||||
max_attempts: int,
|
||||
) -> int:
|
||||
"""Drive the loop under the §8 meter; persist artifacts on BOTH outcomes.
|
||||
|
||||
Exit 0: the run completed (validated or typed rejection) and its artifacts
|
||||
are on disk. Exit 3: a structured budget stop (§8) — the stop event and
|
||||
the usage-vs-caps artifact are persisted; a stop is never a silent hang.
|
||||
"""
|
||||
meter = BudgetMeter(contracts.termination)
|
||||
try:
|
||||
result = run_project(
|
||||
client,
|
||||
composed.context,
|
||||
meter=meter,
|
||||
max_debate_rounds=max_debate_rounds,
|
||||
max_attempts=max_attempts,
|
||||
default_project_id=composed.ir_projection.project_id,
|
||||
)
|
||||
except BudgetExceeded as stop:
|
||||
print(f"STOPPED by budget: {stop.kind} observed {stop.observed} > limit {stop.limit}")
|
||||
stop_paths = persist_stop_artifacts(
|
||||
out_dir,
|
||||
stop=stop,
|
||||
termination=contracts.termination,
|
||||
tokens_used=meter.tokens_used,
|
||||
rounds_used=meter.rounds_used,
|
||||
cost_usd=_client_cost_usd(client),
|
||||
)
|
||||
for name, path in sorted(stop_paths.items()):
|
||||
print(f"artifact: {name} -> {path}")
|
||||
return 3
|
||||
|
||||
provenance = Provenance(
|
||||
citations=composed.citations,
|
||||
model=getattr(client, "last_model", None) or "unknown", # §9: real id or neutral
|
||||
role=_PROPOSER_ROLE,
|
||||
validator_decision=result.validator_decision,
|
||||
tokens_used=meter.tokens_used,
|
||||
)
|
||||
paths = persist_run_artifacts(
|
||||
out_dir,
|
||||
run=result,
|
||||
provenance=provenance,
|
||||
termination=contracts.termination,
|
||||
tokens_used=meter.tokens_used,
|
||||
rounds_used=meter.rounds_used,
|
||||
cost_usd=_client_cost_usd(client),
|
||||
)
|
||||
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}"
|
||||
)
|
||||
for name, path in sorted(paths.items()):
|
||||
print(f"artifact: {name} -> {path}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, *, client_factory: ClientFactory | None = None) -> int:
|
||||
"""The thin CLI: contracts fail-fast (§10) → compose (§5) → execute (§3, §8)."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run one project through the loop (merge inbox → seed → fold → run)."
|
||||
)
|
||||
parser.add_argument("--bundle", type=Path, required=True)
|
||||
parser.add_argument("--inbox", type=Path, default=None)
|
||||
parser.add_argument("--out", type=Path, default=Path("runs") / "run")
|
||||
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)
|
||||
|
||||
# §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)"},
|
||||
)
|
||||
# The navigated dir is the CONTRACT's, so the validated config is load-bearing.
|
||||
composed = compose_run_context(
|
||||
Path(contracts.data_source.docs_dir), args.inbox, k=contracts.data_source.top_k
|
||||
)
|
||||
print(
|
||||
f"run: bundle={args.bundle.name} inbox_merged={composed.inbox_merged} "
|
||||
f"seeded={composed.seeded} caps: max_rounds={args.max_rounds} "
|
||||
f"max_tokens={args.max_tokens} "
|
||||
f"max_budget_usd_per_call={args.max_budget_usd_per_call}"
|
||||
)
|
||||
factory = default_client_factory if client_factory is None else client_factory
|
||||
client = factory(contracts, args.max_budget_usd_per_call)
|
||||
return execute_run(
|
||||
client,
|
||||
composed,
|
||||
contracts=contracts,
|
||||
out_dir=args.out,
|
||||
max_debate_rounds=args.max_debate_rounds,
|
||||
max_attempts=args.max_attempts,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue