feat(s42): live_dry_run cut in run_project + DryRunReport

This commit is contained in:
Kjell Tore Guttormsen 2026-07-15 18:05:16 +02:00
commit 0e986fe6c4
3 changed files with 234 additions and 46 deletions

View file

@ -28,7 +28,7 @@ from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass, replace
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Literal
from typing import Any, Literal, cast
from agent_framework import BaseChatClient, SessionContext
@ -58,7 +58,7 @@ from portfolio_optimiser.verdicts import (
capture_verdict,
load_verdicts_from_dir,
)
from portfolio_optimiser.workflow import fresh_workflow
from portfolio_optimiser.workflow import _MAKER_CHECKER_ROLES, fresh_workflow
@dataclass(frozen=True)
@ -79,6 +79,20 @@ class RunResult:
checker_verdict: str = "absent"
@dataclass(frozen=True)
class DryRunReport:
"""S4.2 offline ``--live-dry-run`` outcome (comparison protocol §4 pkt 3): everything a real run
would use profile, the resolved model-id per BUILT role, and the round/token parameters
captured WITHOUT a model call. A DISTINCT type from ``RunResult``, whose post-generation fields
(outcome/provenance/verdict) do not exist yet on a run that stopped before the first model call."""
profile: str
resolved_models: dict[str, str]
max_rounds: int
max_tokens: int
top_k: int
@dataclass(frozen=True)
class GoalReached:
"""A savings-goal signal VALUE (Step 8, SC6) — NOT an exception. Structured like
@ -230,7 +244,8 @@ async def run_project(
enable_layer1_hitl: bool = False,
notify: Callable[[Verdict], None] | None = None,
meter: TokenMeter | None = None,
) -> RunResult:
live_dry_run: bool = False,
) -> RunResult | DryRunReport:
"""Run the vertical slice for ONE project. ``client_factory`` is the test-injection seam
(defaults to the real backend). ``verdict_input`` carries the expert decision/rationale
(Layer-2). ``bundle_dir`` (Fase 2a) makes the run OKF-bundle-driven: the project is derived
@ -247,7 +262,10 @@ async def run_project(
outbox into a folder later read as an inbox would re-ingest raw agent output and bypass the
Step-8 promotion gate (self-contamination) documented here, not enforced. Raises
``pydantic.ValidationError`` on a bad contract and ``BudgetExceeded`` when the token/round cap is
crossed, and ``ValueError`` when ``outbox_dir`` is set without a ``run_id``."""
crossed, and ``ValueError`` when ``outbox_dir`` is set without a ``run_id``. ``live_dry_run``
(S4.2, comparison protocol §4 pkt 2/3) is the offline drill: it walks the whole path up to the
EAGER client build, writes the run-config artefact (when ``outbox_dir`` is set), and returns a
``DryRunReport`` BEFORE the first model call (``debate.run``) zero chat calls."""
# 0. Fail-fast: an outbox write is byte-deterministic and keyed on run_id — no wall-clock default.
if outbox_dir is not None and run_id is None:
raise ValueError(
@ -310,6 +328,34 @@ async def run_project(
tools=debate_tools,
middleware=[budget_mw],
)
# S4.2 cut (comparison protocol §4 pkt 2/3): everything above is offline — contracts, budget, and
# the EAGER client build (fresh_workflow constructs the proposer+checker clients, workflow.py:64).
# Capture the run-config (resolved model per BUILT role, profile, params, token cap) and, for a
# ``--live-dry-run``, STOP HERE — before the first (paid) model call at ``debate.run`` below.
if outbox_dir is not None or live_dry_run:
# ``resolved_models`` reflects the configured MAP (the default factory's model-ids for the M2
# run). Under an injected ``client_factory`` the built clients may differ (e.g. "synthetic");
# ``provenance.model`` (below) stays the authority on the client actually built.
resolved_models = {role: resolve_model(profile, role) for role in _MAKER_CHECKER_ROLES}
if outbox_dir is not None:
assert run_id is not None # narrowed by the step-0 guard (no wall-clock default)
outbox.write_run_config(
outbox_dir,
run_id,
profile=Profile(profile).value,
resolved_models=resolved_models,
max_rounds=max_rounds,
max_tokens=max_tokens,
top_k=top_k,
)
if live_dry_run:
return DryRunReport(
profile=Profile(profile).value,
resolved_models=resolved_models,
max_rounds=max_rounds,
max_tokens=max_tokens,
top_k=top_k,
)
result = await debate.run(f"Find a cost-saving measure for {project.id}.\nContext:\n{context}")
# F1: the candidate must derive from the DEBATE. Feed the proposer's converged output into
# generation (retrieval context is the last-resort fallback only). The checker's verdict
@ -532,20 +578,25 @@ async def run_portfolio(
if per_project_goal.mode == "hard":
continue # skip THIS pid; the rest of the pass proceeds
result = await run_project(
pid,
profile,
docs_dir=project.docs_dir,
verdict_input=project.verdict_input,
bundle_dir=project.bundle_dir,
verdict_dir=project.verdict_dir,
dimension=dimension,
store=store,
client_factory=client_factory,
max_rounds=max_rounds,
max_tokens=max_tokens,
top_k=top_k,
meter=meter_factory() if meter_factory is not None else None,
# run_portfolio only drives full runs (never dry-run), so the return narrows to RunResult;
# the cast keeps the widened run_project signature honest without an @overload duplication.
result = cast(
RunResult,
await run_project(
pid,
profile,
docs_dir=project.docs_dir,
verdict_input=project.verdict_input,
bundle_dir=project.bundle_dir,
verdict_dir=project.verdict_dir,
dimension=dimension,
store=store,
client_factory=client_factory,
max_rounds=max_rounds,
max_tokens=max_tokens,
top_k=top_k,
meter=meter_factory() if meter_factory is not None else None,
),
)
runs.append(result)
@ -577,15 +628,20 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--rationale", default="reviewed by expert")
args = parser.parse_args(argv)
result = asyncio.run(
run_project(
args.project_id,
args.profile,
docs_dir=args.docs_dir,
bundle_dir=args.bundle_dir,
verdict_dir=args.verdict_dir,
verdict_input={"decision": args.decision, "rationale": args.rationale},
)
# S4.2: main() currently drives only full runs (no --live-dry-run flag yet — that is Step 3,
# which replaces this cast with an isinstance(DryRunReport) branch + AZURE-refusal try/except).
result = cast(
RunResult,
asyncio.run(
run_project(
args.project_id,
args.profile,
docs_dir=args.docs_dir,
bundle_dir=args.bundle_dir,
verdict_dir=args.verdict_dir,
verdict_input={"decision": args.decision, "rationale": args.rationale},
)
),
)
kind = type(result.outcome).__name__
print(f"{args.project_id}: {kind} (verdict id={result.verdict.id}, decision={args.decision})")