feat(s42): live_dry_run cut in run_project + DryRunReport
This commit is contained in:
parent
ebae4639dd
commit
0e986fe6c4
3 changed files with 234 additions and 46 deletions
|
|
@ -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})")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue