feat(run): a CLI door onto the offline whole-loop run (--scripted-replies)
An adopter without an API budget had two half-doors and no whole one. `--live-dry-run` takes their own bundle but stops before the first model call (`run_project` returns a DryRunReport), while `portfolio_optimiser.simulation` runs the complete loop but only over ITS bundle with ITS scripted answers. The seam for the missing third case -- the whole loop over your OWN data, offline -- already existed as `run_project(client_factory=...)` and had zero CLI exposure. This is the door onto that one seam, not a second implementation of it (`scripted_factory` is imported lazily; `simulation` imports `run`, so a module-level import would be circular). The honesty banner is part of the feature, not decoration (maalbilde §1): a scripted run that reads like a model run is worse than having no offline mode, so every scripted invocation prints what is real (context navigation, debate plumbing, deterministic validator, verdict) and what is not (the answers). The two offline modes are mutually exclusive rather than one silently winning, `--report` mode refuses the new flag by allowlist, and a replies file that cannot serve the run is refused at the door rather than surfacing as a KeyError mid-run. Load-bearing MEASURED against the whole suite (645 -> 652), six mutations all red: detach the wiring · detach the banner · detach the dry-run exclusivity · drop the flag from the --report allowlist · make the loader tolerant · control (print the banner unconditionally). The --report blade was measured GREEN first: with a non-existent ledger path the load failure refused before the gate and masked it entirely. Rewritten against a valid saved ledger, so rc 1 can only come from mode-exclusivity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWsexbQjPo9rsV3aUE54ZS
This commit is contained in:
parent
00830b3b85
commit
3abc61bac3
2 changed files with 247 additions and 0 deletions
|
|
@ -26,8 +26,10 @@ durable learned verdict captured out-of-band in the VerdictStore (D7-portable).
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
from collections.abc import Callable, Iterable, Sequence
|
from collections.abc import Callable, Iterable, Sequence
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Literal, cast
|
from typing import Any, Literal, cast
|
||||||
|
|
||||||
from agent_framework import BaseChatClient, SessionContext
|
from agent_framework import BaseChatClient, SessionContext
|
||||||
|
|
@ -994,6 +996,45 @@ async def run_portfolio(
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
# The roles ``debate``/``generate`` ask the factory for. Fixed here so a malformed replies file is
|
||||||
|
# caught at the door instead of mid-run.
|
||||||
|
_SCRIPTED_ROLES = ("proposer", "checker")
|
||||||
|
|
||||||
|
# The honesty banner for the scripted door. It is a REQUIREMENT, not decoration (målbilde §1):
|
||||||
|
# a scripted run that reads like a model run is worse than having no offline mode at all, so this
|
||||||
|
# prints on every scripted invocation and mirrors ``simulation.main``'s banner.
|
||||||
|
_SCRIPTED_BANNER = (
|
||||||
|
"=" * 78
|
||||||
|
+ "\nSCRIPTED OFFLINE RUN — every agent reply is read from your --scripted-replies file."
|
||||||
|
+ "\nNO MODEL WAS CALLED (ingen modellkall gjort). The context navigation, the debate"
|
||||||
|
+ "\nplumbing, the deterministic validator and the verdict are real; the agents' answers"
|
||||||
|
+ "\nare yours, not a model's. This proves the loop closes — not that an LLM would say this."
|
||||||
|
+ "\n"
|
||||||
|
+ "=" * 78
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_scripted_replies(path: str) -> dict[str, str]:
|
||||||
|
"""Load the caller's scripted answers, fail-fast. Every role the debate can ask for must be
|
||||||
|
present AND a string: a missing role would otherwise surface as a ``KeyError`` deep inside
|
||||||
|
``scripted_factory``'s lookup, mid-run, long after the run appeared to start cleanly."""
|
||||||
|
try:
|
||||||
|
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ValueError(f"--scripted-replies file not found: {path}") from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(f"--scripted-replies is not valid JSON ({path}): {exc}") from exc
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError(f"--scripted-replies must be a JSON object of role -> reply ({path})")
|
||||||
|
missing = [r for r in _SCRIPTED_ROLES if not isinstance(raw.get(r), str)]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"--scripted-replies needs a string reply for each of {', '.join(_SCRIPTED_ROLES)}; "
|
||||||
|
f"missing or non-string: {', '.join(missing)} ({path})"
|
||||||
|
)
|
||||||
|
return {role: raw[role] for role in _SCRIPTED_ROLES}
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
"""Single-command console entry: run the slice for one project against a docs folder."""
|
"""Single-command console entry: run the slice for one project against a docs folder."""
|
||||||
import argparse
|
import argparse
|
||||||
|
|
@ -1080,6 +1121,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="offline drill: build contracts/clients/budget, STOP before the first model call",
|
help="offline drill: build contracts/clients/budget, STOP before the first model call",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--scripted-replies",
|
||||||
|
default=None,
|
||||||
|
metavar="FILE",
|
||||||
|
help="offline WHOLE-LOOP run over your own bundle with ZERO model calls: FILE is JSON "
|
||||||
|
'{"proposer": "<reply>", "checker": "<reply>"} and those fixed strings stand in for every '
|
||||||
|
"model answer. Unlike --live-dry-run (which stops before the first call) the complete loop "
|
||||||
|
"runs — hypothesis, debate, deterministic validator, verdict. The answers are yours, not a "
|
||||||
|
"model's, and the run says so on every invocation",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--report",
|
"--report",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -1123,6 +1174,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
"--dimension-config": args.dimension_config is not None,
|
"--dimension-config": args.dimension_config is not None,
|
||||||
"--semantic-retrieval": args.semantic_retrieval,
|
"--semantic-retrieval": args.semantic_retrieval,
|
||||||
"--embedder-config": args.embedder_config is not None,
|
"--embedder-config": args.embedder_config is not None,
|
||||||
|
"--scripted-replies": args.scripted_replies is not None,
|
||||||
}
|
}
|
||||||
if any(report_forbidden.values()):
|
if any(report_forbidden.values()):
|
||||||
print(
|
print(
|
||||||
|
|
@ -1254,6 +1306,34 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
# The scripted door (offline WHOLE-loop run over the caller's own bundle). Resolved BEFORE the
|
||||||
|
# dry-run branch so the two offline modes cannot both be honoured.
|
||||||
|
scripted_client_factory: Callable[[str], BaseChatClient] | None = None
|
||||||
|
if args.scripted_replies is not None:
|
||||||
|
if args.live_dry_run:
|
||||||
|
# Both are offline, and they contradict: --live-dry-run stops before the first model
|
||||||
|
# call while --scripted-replies answers every one of them. Refuse rather than let one
|
||||||
|
# silently win (S5.3's "refused, never ignored" partition).
|
||||||
|
print(
|
||||||
|
"run refused: --scripted-replies and --live-dry-run are both offline modes and "
|
||||||
|
"contradict each other (dry-run stops before the first model call; scripted "
|
||||||
|
"answers all of them) — pick one",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
replies = _load_scripted_replies(args.scripted_replies)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
print(f"run refused: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
# Imported HERE rather than at module scope: ``simulation`` imports ``run``, so a top-level
|
||||||
|
# import would be circular. The scripted client already exists as MAF-side scaffolding —
|
||||||
|
# this flag is a DOOR onto that one seam, never a second implementation of it.
|
||||||
|
from portfolio_optimiser.simulation import scripted_factory
|
||||||
|
|
||||||
|
scripted_client_factory = scripted_factory(replies, [])
|
||||||
|
print(_SCRIPTED_BANNER)
|
||||||
|
|
||||||
if args.live_dry_run:
|
if args.live_dry_run:
|
||||||
# S4.2 drill (comparison protocol §4 pkt 2/3): walk the offline path, STOP before the first
|
# S4.2 drill (comparison protocol §4 pkt 2/3): walk the offline path, STOP before the first
|
||||||
# model call, print the run-config. A misconfigured profile (e.g. AZURE with a
|
# model call, print the run-config. A misconfigured profile (e.g. AZURE with a
|
||||||
|
|
@ -1325,6 +1405,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
run_id=args.run_id,
|
run_id=args.run_id,
|
||||||
verdict_input={"decision": args.decision, "rationale": args.rationale},
|
verdict_input={"decision": args.decision, "rationale": args.rationale},
|
||||||
semantic_retrieval=args.semantic_retrieval,
|
semantic_retrieval=args.semantic_retrieval,
|
||||||
|
client_factory=scripted_client_factory,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
166
tests/test_scripted_cli_door_loadbearing.py
Normal file
166
tests/test_scripted_cli_door_loadbearing.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
"""The offline CLI door: run the WHOLE loop over your OWN bundle with zero model calls.
|
||||||
|
|
||||||
|
Before this, an adopter without an API budget had two half-doors and no whole one:
|
||||||
|
``--live-dry-run`` takes their own bundle but STOPS before the first model call
|
||||||
|
(``run.py`` returns a ``DryRunReport``), while ``portfolio_optimiser.simulation`` runs the
|
||||||
|
complete loop but only over ITS bundle with ITS scripted answers. The seam for the missing
|
||||||
|
third case already existed — ``run_project(client_factory=...)``, the test-injection seam —
|
||||||
|
with no CLI exposure at all. ``--scripted-replies <file.json>`` is that door.
|
||||||
|
|
||||||
|
**The honesty condition is part of the feature, not decoration** (målbilde §1): a scripted run
|
||||||
|
that reads like a model run is worse than no offline mode, so the banner asserted here is
|
||||||
|
load-bearing in the same sense the dataflow is. It mirrors ``simulation.py``'s honesty banner.
|
||||||
|
|
||||||
|
Load-bearing (each blade measured by detaching exactly one thing):
|
||||||
|
1. the door drives a FULL run offline over a caller-supplied bundle (RED without the flag);
|
||||||
|
2. the run is genuinely model-free (RED if a real factory is built);
|
||||||
|
3. the banner is emitted and unmistakable (RED when detached);
|
||||||
|
4. control: no banner without the flag — so blade 3 cannot pass on a constant;
|
||||||
|
5. the two offline modes are mutually exclusive rather than one silently winning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from portfolio_optimiser import run
|
||||||
|
from portfolio_optimiser.ledger import SavingsLedger
|
||||||
|
|
||||||
|
BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro"
|
||||||
|
|
||||||
|
# The same scripted answers the simulation uses, re-declared here as CALLER-supplied input —
|
||||||
|
# which is the whole point of the door: these arrive as a file the adopter writes, not as a
|
||||||
|
# module constant only the shipped simulation can reach.
|
||||||
|
_VALID_PROPOSAL = (
|
||||||
|
'{"measure":"LED-retrofit av kontorbelysning","affected_items":'
|
||||||
|
'[{"code":"ENERGI-TOTAL-EL","quantity":300000,"unit_cost":1.0}],"claimed_saving_nok":30000}'
|
||||||
|
)
|
||||||
|
_CHECKER_APPROVE = "Tallene er innenfor feasibelt område og resonnementet holder. VERDICT: APPROVE"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_model_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Hermetic env (mirrors ``test_live_dry_run.py``): the operator's Foundry overrides must not
|
||||||
|
reach these assertions."""
|
||||||
|
monkeypatch.delenv("PORTFOLIO_MODEL_MAP", raising=False)
|
||||||
|
monkeypatch.delenv("PORTFOLIO_FOUNDRY_PROJECT_ENDPOINT", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def bundle(tmp_path: Path) -> Path:
|
||||||
|
"""A throwaway COPY — the shared fixture is commons-owned and is never mutated by a test."""
|
||||||
|
dst = tmp_path / "bundle"
|
||||||
|
shutil.copytree(BUNDLE_DIR, dst)
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def replies_file(tmp_path: Path) -> Path:
|
||||||
|
path = tmp_path / "replies.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"proposer": _VALID_PROPOSAL, "checker": _CHECKER_APPROVE}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _argv(bundle: Path, replies_file: Path) -> list[str]:
|
||||||
|
return [
|
||||||
|
"BYGG-KONTOR-NORD",
|
||||||
|
"--docs-dir",
|
||||||
|
str(bundle),
|
||||||
|
"--bundle-dir",
|
||||||
|
str(bundle),
|
||||||
|
"--scripted-replies",
|
||||||
|
str(replies_file),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_scripted_door_runs_the_whole_loop_offline(bundle, replies_file, capsys) -> None:
|
||||||
|
"""Blade 1 — the door exists and completes a FULL run (not a dry-run report) over the
|
||||||
|
caller's own bundle. RED before the flag: argparse exits 2 on an unrecognized argument."""
|
||||||
|
rc = run.main(_argv(bundle, replies_file))
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert rc == 0, out
|
||||||
|
# A full run reports its outcome type + the minted verdict id; a dry-run never gets this far.
|
||||||
|
assert "BYGG-KONTOR-NORD:" in out
|
||||||
|
assert "verdict id=" in out
|
||||||
|
assert "LIVE-DRY-RUN" not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_scripted_door_makes_no_real_client(bundle, replies_file, monkeypatch, capsys) -> None:
|
||||||
|
"""Blade 2 — genuinely model-free: the production factory must never be built. Detonates if
|
||||||
|
the scripted factory is ignored and the run falls back to ``_default_factory``."""
|
||||||
|
|
||||||
|
def _boom(_profile: str): # pragma: no cover - the point is that it never runs
|
||||||
|
raise AssertionError("a real client factory was built on the scripted path")
|
||||||
|
|
||||||
|
monkeypatch.setattr(run, "_default_factory", _boom)
|
||||||
|
rc = run.main(_argv(bundle, replies_file))
|
||||||
|
assert rc == 0, capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_scripted_door_says_so_unmistakably(bundle, replies_file, capsys) -> None:
|
||||||
|
"""Blade 3 — the honesty banner. A scripted run that looks like a model run is the failure
|
||||||
|
mode this asserts against; RED the moment the banner is detached."""
|
||||||
|
run.main(_argv(bundle, replies_file))
|
||||||
|
out = capsys.readouterr().out.upper()
|
||||||
|
assert "SCRIPTED" in out
|
||||||
|
assert "NO MODEL" in out or "INGEN MODELLKALL" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_banner_without_the_flag(bundle, capsys) -> None:
|
||||||
|
"""Blade 4 (control) — the banner must be CAUSED by the flag, not printed unconditionally.
|
||||||
|
Uses --live-dry-run so the control needs no model call of its own."""
|
||||||
|
run.main(
|
||||||
|
[
|
||||||
|
"BYGG-KONTOR-NORD",
|
||||||
|
"--docs-dir",
|
||||||
|
str(bundle),
|
||||||
|
"--bundle-dir",
|
||||||
|
str(bundle),
|
||||||
|
"--live-dry-run",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert "SCRIPTED" not in capsys.readouterr().out.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_two_offline_modes_are_exclusive(bundle, replies_file, capsys) -> None:
|
||||||
|
"""Blade 5 — ``--live-dry-run`` stops before the first call and ``--scripted-replies`` runs
|
||||||
|
every call; together they are a contradiction. Refuse, never let one silently win (mirrors
|
||||||
|
S5.3's 'refused, never ignored' partition)."""
|
||||||
|
rc = run.main([*_argv(bundle, replies_file), "--live-dry-run"])
|
||||||
|
assert rc == 1
|
||||||
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_mode_refuses_the_scripted_flag(tmp_path, replies_file, capsys) -> None:
|
||||||
|
"""Blade 5b — ``--report`` is mode-exclusive by ALLOWLIST; a new flag must join the refusal
|
||||||
|
set rather than be silently dropped.
|
||||||
|
|
||||||
|
The ledger here is VALID and saved on purpose. Measured: with a non-existent ledger path this
|
||||||
|
test passed even after the allowlist entry was removed — the load failure refused first and
|
||||||
|
masked the gate entirely. With a loadable ledger the report would otherwise print and return
|
||||||
|
0, so rc 1 can only come from the mode-exclusivity check itself.
|
||||||
|
"""
|
||||||
|
ledger_path = tmp_path / "ledger.json"
|
||||||
|
SavingsLedger().save(str(ledger_path))
|
||||||
|
rc = run.main(
|
||||||
|
["--report", "--ledger", str(ledger_path), "--scripted-replies", str(replies_file)]
|
||||||
|
)
|
||||||
|
assert rc == 1
|
||||||
|
assert "mode-exclusive" in capsys.readouterr().err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_replies_file_is_refused(bundle, tmp_path, capsys) -> None:
|
||||||
|
"""A replies file that exists but cannot serve the run is refused with rc 1, never a
|
||||||
|
traceback and never a partial run — the same fail-fast posture the loaders take."""
|
||||||
|
bad = tmp_path / "bad.json"
|
||||||
|
bad.write_text("{not json", encoding="utf-8")
|
||||||
|
rc = run.main(_argv(bundle, bad))
|
||||||
|
assert rc == 1
|
||||||
|
assert "refused" in capsys.readouterr().err.lower()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue