feat(major2): --proposal-review answers the review at the terminal; four refusals by name, EOF stops the run [skip-docs]
Ordre 20260904T173146Z-8102814273-from-portfolio-optimiser, steg 6 av 10.
(a) argparse --proposal-review. (b) rader i BEGGE partisjonene (report_forbidden og
single_only) - report-modus og portefoelje returnerer over dispatchen, saa en utelatelse
er et STILLE DROPP, ikke en nekt (F4-gapet). (c) TRE navngitte nekter i EN topp-nivaa-blokk
if args.proposal_review: - plasseringen er MAALT, ikke plassert paa oeyemaal: naboen
--scripted-replies/--live-dry-run er nostet under if args.scripted_replies, og
--explore/--live-dry-run under if args.explore, saa under noen av dem ville et bart
--live-dry-run --proposal-review falt rett gjennom til dry-run-dispatchen og droppet flagget.
(d) reviewer bygget paa KALLSTEDET + except ProposalReviewInputError -> "run stopped:" rc 1,
en DISTINKT kanal fra "run refused:". (e) proposal_review_notice printes fra kjoeringens EGEN
post. (f) _load_scripted_replies' aerlighetsgrense navngir review-stien.
--resume KOMPONERER (A3 verifisert av en arm, ikke utsatt): resume-blokka gir mandatet og
faller gjennom til SAMME full-run-dispatch.
RODT foer impl: 9 armer. T13 og T16 kjoerer i et BARN (P4). Nekt-armene kjoerer in-process
med _default_factory som REISER - ved exit-koden ser en nekt etter forbruket identisk ut med
en foer (oekt 57).
TO ARMER BLE FALSIFISERT AV MAALINGEN FOER de kunne gate noe:
(1) T18s rc-0-kontroll avslorte at F4-testens ledger-fixtur ({"entries": []}) faar rc 1 av
SavingsLedger.load ("must be a JSON array"), ikke av partisjonsraden - armen ville vaert
groenn mot en fjernet rad. Fixturen er naa en JSON-array, og kontrollen beviser at argv-en
ellers ville blitt AKSEPTERT.
(2) notice-null-armen ga BudgetExceeded i stedet for en avvist kjoering: et to-stegs
proposer-manus mot max_attempts=3 faller til default-svaret, som aldri parser, og rundeboka
fyrer - noeyaktig aerlighetsgrensen _load_scripted_replies uttaler, reprodusert ved uhell.
Manuset har naa like mange steg som forsoek.
Planens T19 er foldet inn i T13 og uttalt: "et bart, uskriptet flaggparse" ville kalt en
levende modell, saa argparse-vitnet er barnets egen unrecognized-arguments-assert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c391fb5d67
commit
fedf9897c4
2 changed files with 437 additions and 3 deletions
|
|
@ -104,7 +104,10 @@ from portfolio_optimiser.mcp_tools import (
|
|||
from portfolio_optimiser.proposal_review import (
|
||||
ProposalReview,
|
||||
ProposalReviewer,
|
||||
ProposalReviewInputError,
|
||||
proposal_review_notice,
|
||||
proposal_reviews_payload,
|
||||
terminal_proposal_reviewer,
|
||||
)
|
||||
from portfolio_optimiser.provenance import ProvenanceStamp
|
||||
from portfolio_optimiser.reference_domain import Project, load_reference_projects
|
||||
|
|
@ -2099,7 +2102,12 @@ def _load_scripted_replies(
|
|||
start, by the fresh client ``generate_via_llm`` builds. A script whose first step is a tool
|
||||
call therefore answers the generation call with a ``function_call`` too, which will not parse.
|
||||
That is the operator's to write correctly: guessing which steps were "meant for" which call
|
||||
site would be repair, and this loader validates."""
|
||||
site would be repair, and this loader validates.
|
||||
|
||||
The same limit binds ``--proposal-review`` (MAJOR-2), where it is easiest to trip: each
|
||||
``revise`` the piped answers request buys ONE more generation attempt, so a proposer script
|
||||
one entry short of them degrades to the selector's default reply, which never parses — and the
|
||||
round ledger fires on the parse-retry. Write the script long enough."""
|
||||
try:
|
||||
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except FileNotFoundError as exc:
|
||||
|
|
@ -2190,6 +2198,20 @@ def main(argv: list[str] | None = None) -> int:
|
|||
"round trip is recorded in {run_id}-exploration.json, feedback verbatim. Input that ends "
|
||||
"without an answer is an error, NEVER a sign-off",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--proposal-review",
|
||||
action="store_true",
|
||||
help="MAJOR-2 synchronous HITL door: after the deterministic validator ACCEPTS a "
|
||||
'candidate, show it AT THIS TERMINAL and read "approve" or "revise <what to change>". A '
|
||||
"revision buys ONE more attempt under the existing attempt/round budget — no new loop and "
|
||||
"no new cap — and the words go into the next hypothesis prompt verbatim. Every answer is "
|
||||
"recorded in {run_id}-proposal-reviews.json. Input that ends without an answer STOPS the "
|
||||
"run, NEVER a sign-off. An approve is not an expert verdict (that still arrives via "
|
||||
"--decision/--rationale or the verdict inbox). Refused with --portfolio, --report, "
|
||||
"--live-dry-run, --proposals-from-mandate and --checkpoint-dir (pass it at --resume "
|
||||
"instead); it composes with --explore --plan-review, which reads two doors from one stdin "
|
||||
"in sequence",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--checkpoint-dir",
|
||||
default=None,
|
||||
|
|
@ -2429,6 +2451,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||
# this list is silently dropped rather than refused — which is the whole reason the
|
||||
# list enumerates every distinguishable flag instead of the ones that would misbehave.
|
||||
"--plan-review": args.plan_review,
|
||||
# MAJOR-2, and for the identical reason: report mode returns above the run dispatch, so
|
||||
# an omission here is a SILENT DROP — the door would be accepted and never asked.
|
||||
"--proposal-review": args.proposal_review,
|
||||
# The three U12 flags, listed for exactly that reason: report mode returns before the
|
||||
# resume dispatch, so an omission here is a silent drop, not a refusal.
|
||||
"--checkpoint-dir": args.checkpoint_dir is not None,
|
||||
|
|
@ -2507,6 +2532,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||
# --explore" would tell an operator who wrote --portfolio --plan-review to add the one
|
||||
# flag this mode also refuses.
|
||||
"--plan-review": args.plan_review,
|
||||
# MAJOR-2: one terminal, and a portfolio pass interleaves its waves as coroutines —
|
||||
# several projects' candidates would arrive at the same prompt with nothing to tell
|
||||
# them apart. BY NAME, like its neighbours: falling through to a --bundle-dir
|
||||
# requirement would tell the operator to add a flag this mode also refuses.
|
||||
"--proposal-review": args.proposal_review,
|
||||
# And the asynchronous half of the same door, on the same side of the partition and by
|
||||
# NAME for the same reason.
|
||||
"--checkpoint-dir": args.checkpoint_dir,
|
||||
|
|
@ -2674,6 +2704,38 @@ def main(argv: list[str] | None = None) -> int:
|
|||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
# --- MAJOR-2. THREE modes return ABOVE generation, so each would accept the door and then
|
||||
# never ask it — the silent-drop class ``report_forbidden`` exists for. The block is at
|
||||
# FUNCTION scope on purpose, measured rather than placed by eye: the neighbouring
|
||||
# ``--scripted-replies``/``--live-dry-run`` refusal is nested under ``if args.scripted_replies
|
||||
# is not None`` and the ``--explore``/``--live-dry-run`` one under ``if args.explore is not
|
||||
# None``, so under either guard a bare ``--live-dry-run --proposal-review`` would fall straight
|
||||
# through to the dry-run dispatch and drop the flag. Every message carries prose the others do
|
||||
# not share, and each fires before the first model call.
|
||||
if args.proposal_review:
|
||||
if args.live_dry_run:
|
||||
print(
|
||||
"run refused: --proposal-review and --live-dry-run contradict (the dry run stops "
|
||||
"before the first model call, so no candidate ever reaches a reviewer) — drop one",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if args.proposals_from_mandate:
|
||||
print(
|
||||
"run refused: --proposal-review has nothing to answer under "
|
||||
"--proposals-from-mandate (that mode settles the commission deterministically and "
|
||||
"never generates a candidate) — drop one",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if args.checkpoint_dir is not None:
|
||||
print(
|
||||
"run refused: --proposal-review and --checkpoint-dir: a parked exploration "
|
||||
"returns before any candidate exists, so the review would never be asked — pass "
|
||||
"--proposal-review at --resume instead",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
if args.resume is not None:
|
||||
if args.explore is not None:
|
||||
print(
|
||||
|
|
@ -3323,9 +3385,23 @@ def main(argv: list[str] | None = None) -> int:
|
|||
client_factory=scripted_client_factory,
|
||||
mandate=mandate,
|
||||
mcp_servers=mcp_servers,
|
||||
# Built at the CALL SITE, never inside the library (the ``--plan-review``
|
||||
# precedent): the terminal is the operator's, and ``run_project`` must stay
|
||||
# answerable by a persona, a test double or nobody at all.
|
||||
proposal_reviewer=(
|
||||
terminal_proposal_reviewer() if args.proposal_review else None
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
except ProposalReviewInputError as exc:
|
||||
# A DISTINCT channel from ``run refused:`` below, and the class is what routes it here.
|
||||
# The argv was fine and the run had already spent tokens, so "refused" would mislabel it;
|
||||
# and a ``ValueError``-shaped error would sit one frame from ``_fetch_parsed``'s
|
||||
# ``except (ValidationError, ValueError, TypeError)`` and be captured as a parse failure
|
||||
# instead. ``run_project``'s ``finally`` has already written the review record.
|
||||
print(f"run stopped: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except (ValueError, FileNotFoundError, ValidationError, BudgetExceeded) as exc:
|
||||
# Structured refusal (rc 1, no traceback) for the full-run path: run_project's fail-fast
|
||||
# loaders (contracts, load_dimension, outbox run_id guard) surface here as one clean line.
|
||||
|
|
@ -3361,6 +3437,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||
fold_notice = unkeyed_verdicts_notice(result.unkeyed_verdicts)
|
||||
if fold_notice is not None:
|
||||
print(fold_notice)
|
||||
# MAJOR-2: what the human answered, read off the run's OWN record rather than off argv — so
|
||||
# stdout and ``{run_id}-proposal-reviews.json`` cannot disagree. ``offered`` IS argv, because
|
||||
# "a reviewer was offered and never consulted" is a fact only the flag can supply.
|
||||
review_notice = proposal_review_notice(result.expert_revisions, offered=args.proposal_review)
|
||||
if review_notice is not None:
|
||||
print(review_notice)
|
||||
# The settlement against the commission (Trekk A4). Empty without a mandate, so an
|
||||
# un-commissioned run prints exactly what it printed before.
|
||||
settlement = settle(result.coverage)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from __future__ import annotations
|
|||
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
|
@ -41,6 +42,7 @@ from portfolio_optimiser.generate import ParseFailure, _build_messages, generate
|
|||
from portfolio_optimiser.ir import AffectedItem, SavingsProposal
|
||||
from portfolio_optimiser.mandate import OWN_PROPOSAL_ID, Approach, Mandate
|
||||
from portfolio_optimiser.reference_domain import load_reference_projects
|
||||
from portfolio_optimiser import run
|
||||
from portfolio_optimiser.run import RunResult, run_project
|
||||
from portfolio_optimiser.simulation import ScriptedChatClient
|
||||
from portfolio_optimiser.validator import Rejection, ValidatedProposal, proposal_for
|
||||
|
|
@ -810,8 +812,8 @@ async def test_t5run_a_revise_on_one_approach_leaves_the_next_untouched(tmp_path
|
|||
sink=control_sink,
|
||||
)
|
||||
assert isinstance(treated, RunResult) and isinstance(control, RunResult)
|
||||
for run in (treated, control):
|
||||
assert [row.status for row in run.coverage].count("not_evaluated") == 0
|
||||
for finished in (treated, control):
|
||||
assert [row.status for row in finished.coverage].count("not_evaluated") == 0
|
||||
|
||||
def _gen_prompts(sink: list[str], label: str) -> list[str]:
|
||||
return [b for b in sink if _GENERATION_MARK in b and label in b]
|
||||
|
|
@ -1043,3 +1045,353 @@ def test_t20_the_streams_are_resolved_at_call_time(monkeypatch: pytest.MonkeyPat
|
|||
monkeypatch.setattr(sys, "stdout", out)
|
||||
assert door(_request()) == pr.ProposalReviewDecision.revise("later")
|
||||
assert "PROPOSAL REVIEW" in out.getvalue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Group C (Step 6) — the CLI door: the flag, the four refusals, the ``run stopped:`` channel and
|
||||
# the notice. The two door arms run in a CHILD (P4: stdin/stdout/rc are answered in a subprocess,
|
||||
# never with ``capsys``); the refusal arms run in-process with the model factory RAISING, because
|
||||
# at the exit code a refusal after the spend looks exactly like a refusal before it.
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
|
||||
_CLI_FEEDBACK = f"Bruk 40000, ikke 30000. {_FEEDBACK_SENTINEL}"
|
||||
_CLI_MEASURE = "Behovsstyrt belysning i fellesarealer"
|
||||
|
||||
|
||||
def _cli_replies_file(
|
||||
tmp_path: Path, *, claims: tuple[int, ...] = (30_000, 40_000), name: str = "replies.json"
|
||||
) -> str:
|
||||
"""A TWO-ENTRY proposer step list. A single constant string would make attempt 2 byte-identical
|
||||
to attempt 1 — the door would go green while proving nothing about the answer being used."""
|
||||
path = tmp_path / name
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"proposer": [_run_reply(_CLI_MEASURE, claim) for claim in claims],
|
||||
"checker": "VERDICT: APPROVE",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return str(path)
|
||||
|
||||
|
||||
def _cli_argv(tmp_path: Path, *, outbox: str, replies: str | None = None) -> list[str]:
|
||||
return [
|
||||
_RUN_PID,
|
||||
"--docs-dir",
|
||||
str(_BUNDLE_DIR),
|
||||
"--bundle-dir",
|
||||
str(_BUNDLE_DIR),
|
||||
"--scripted-replies",
|
||||
replies or _cli_replies_file(tmp_path),
|
||||
"--proposal-review",
|
||||
"--outbox-dir",
|
||||
str(tmp_path / outbox),
|
||||
"--run-id",
|
||||
_RUN_ID,
|
||||
]
|
||||
|
||||
|
||||
def _child(argv: list[str], *, stdin: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "portfolio_optimiser.run", *argv],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(_REPO),
|
||||
)
|
||||
|
||||
|
||||
def _refuse_model(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The CLI's ONE injection point (``test_run_cli_loadbearing``'s seam). A refusal that fires
|
||||
after the spend is indistinguishable from one that fires before it at the exit code — økt 57's
|
||||
rule — so every refusal arm asserts on ZERO model clients, not only on rc 1."""
|
||||
|
||||
def _raise(_profile: Any) -> Any:
|
||||
raise AssertionError("a model client was built on a path that must make no model calls")
|
||||
|
||||
monkeypatch.setattr(run, "_default_factory", _raise)
|
||||
|
||||
|
||||
def test_t13_the_flag_answers_the_review_from_a_real_argv_and_the_answer_is_used(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""T13 — the CLI door, end to end in a CHILD. Detach points: the argparse flag (M20), the
|
||||
reviewer wiring at the dispatch, the sink, the artefact.
|
||||
|
||||
The child settles three things no in-process arm can: that the flag exists on the parsed
|
||||
command line, that a real pipe reaches the reviewer, and that stdout renders the candidate as
|
||||
TEXT. The proposer's second step carries a DIFFERENT amount, so "the answer was used" is the
|
||||
outcome, not the presence of a record.
|
||||
|
||||
(The plan's separate T19 — "a bare, unscripted flag parse" — is folded in here: without a
|
||||
script that argv would call a live model, so the argparse witness is this child's own
|
||||
``unrecognized arguments`` assertion.)"""
|
||||
proc = _child(_cli_argv(tmp_path, outbox="outbox"), stdin=f"revise {_CLI_FEEDBACK}\napprove\n")
|
||||
|
||||
assert "unrecognized arguments" not in proc.stderr, proc.stderr
|
||||
assert "Traceback" not in proc.stderr, proc.stderr
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
|
||||
payload = _reviews_on_disk(tmp_path / "outbox")
|
||||
assert [(r["decision"], r["honoured"]) for r in payload["reviews"]] == [
|
||||
("revise", True),
|
||||
("approve", True),
|
||||
]
|
||||
assert payload["reviews"][0]["feedback"] == _CLI_FEEDBACK # VERBATIM
|
||||
assert [r["attempt"] for r in payload["reviews"]] == [0, 1] # exactly two generation attempts
|
||||
|
||||
outcome = json.loads(
|
||||
(tmp_path / "outbox" / f"{_RUN_ID}-proposal.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert outcome["proposal"]["claimed_saving_nok"] == 40_000 # entry 2 — the answer was USED
|
||||
|
||||
assert _CLI_MEASURE in proc.stdout # the candidate was rendered as TEXT
|
||||
assert _REPR_LEAK not in proc.stdout
|
||||
assert "proposal review: 2 answer(s)" in proc.stdout
|
||||
|
||||
|
||||
def test_t16_input_that_ends_without_an_answer_stops_the_run_on_its_own_channel(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""T16 — EOF at the CLI. Detach points: reading EOF as approve (M15), not catching
|
||||
``ProposalReviewInputError`` by name (M16).
|
||||
|
||||
``run stopped:`` is a DISTINCT channel from ``run refused:``: the argv was fine and the run
|
||||
had already spent tokens, so "refused" would mislabel it — and a ``ValueError``-shaped error
|
||||
would sit one frame from ``_fetch_parsed``'s catch-all and be captured as a *parse failure*
|
||||
instead. Both are asserted: no traceback, and no ``{run_id}-parse-failures.json``.
|
||||
|
||||
T13's run (same fixture, same flags, a SEPARATE outbox) is the positive control that
|
||||
``-outcome.json`` CAN appear when the door is answered."""
|
||||
control = _child(
|
||||
_cli_argv(tmp_path, outbox="answered"), stdin=f"revise {_CLI_FEEDBACK}\napprove\n"
|
||||
)
|
||||
assert control.returncode == 0, control.stderr
|
||||
assert (tmp_path / "answered" / f"{_RUN_ID}-outcome.json").exists()
|
||||
|
||||
proc = _child(_cli_argv(tmp_path, outbox="silent"), stdin="")
|
||||
|
||||
assert proc.returncode == 1
|
||||
assert "Traceback" not in proc.stderr, proc.stderr
|
||||
assert "run refused:" not in proc.stderr
|
||||
assert "run stopped:" in proc.stderr
|
||||
assert "--proposal-review" in proc.stderr
|
||||
assert "end of input" in proc.stderr
|
||||
|
||||
silent = tmp_path / "silent"
|
||||
assert not (silent / f"{_RUN_ID}-outcome.json").exists()
|
||||
assert not (silent / f"{_RUN_ID}-parse-failures.json").exists()
|
||||
# The ``finally`` still wrote the record, and it is empty of decisions.
|
||||
assert _reviews_on_disk(silent) == {"reviews": [], "run_id": _RUN_ID}
|
||||
|
||||
|
||||
def test_t17_the_door_belongs_to_single_project_mode(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""T17 — the documented partition. Detach point: dropping the ``single_only`` row (M18).
|
||||
|
||||
Concurrent portfolio waves would share ONE terminal and interleave prompts from several
|
||||
projects. The assert names ``--portfolio``, never the shared ``--proposal-review``: a dropped
|
||||
row falls through to a neighbouring refusal that names the flag too, and asserting on the
|
||||
shared token would stay green against no partition entry at all (økt 57's own mutation)."""
|
||||
_refuse_model(monkeypatch)
|
||||
assert run.main(["--portfolio", "--proposal-review"]) == 1
|
||||
assert "--portfolio" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_t18_the_door_is_refused_in_report_mode(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""T18 — report mode returns ABOVE every dispatch, so a flag missing from
|
||||
``report_forbidden`` is a silent DROP, not a refusal (the F4 gap). Detach point: dropping the
|
||||
row (M19).
|
||||
|
||||
Run against an argv report mode would otherwise ACCEPT, with the rc-0 control proving it —
|
||||
otherwise rc 1 could come from the missing ``--ledger`` rather than from this row."""
|
||||
_refuse_model(monkeypatch)
|
||||
ledger = tmp_path / "ledger.json"
|
||||
# A JSON ARRAY: ``SavingsLedger.load`` refuses a dict, and an rc-1 from THAT would make the
|
||||
# arm green for the wrong reason — which is precisely what the rc-0 control below catches.
|
||||
ledger.write_text(json.dumps([]), encoding="utf-8")
|
||||
|
||||
assert run.main(["--report", "--ledger", str(ledger)]) == 0
|
||||
capsys.readouterr()
|
||||
assert run.main(["--report", "--ledger", str(ledger), "--proposal-review"]) == 1
|
||||
assert "mode-exclusive" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_the_door_and_a_dry_run_contradict(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Detach point: dropping the ``--live-dry-run`` refusal (M30).
|
||||
|
||||
The dry-run cut returns ABOVE generation, so no candidate ever reaches a reviewer — the flag
|
||||
would be accepted and then silently inert. Asserts on the PARTNER token."""
|
||||
_refuse_model(monkeypatch)
|
||||
rc = run.main(
|
||||
[
|
||||
_RUN_PID,
|
||||
"--docs-dir",
|
||||
str(_BUNDLE_DIR),
|
||||
"--bundle-dir",
|
||||
str(_BUNDLE_DIR),
|
||||
"--live-dry-run",
|
||||
"--proposal-review",
|
||||
]
|
||||
)
|
||||
assert rc == 1
|
||||
assert "--live-dry-run" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_the_door_has_nothing_to_answer_under_proposals_from_mandate(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Detach point: dropping the ``--proposals-from-mandate`` refusal (M31).
|
||||
|
||||
That mode is SYNC by construction — it settles the commission against the derived baseline and
|
||||
returns at the terminal dispatch, above any generation — so the flag would exit 0 having asked
|
||||
nobody anything. The rc-0 control (same argv without the flag) proves the refusal is this row
|
||||
and not some other precondition."""
|
||||
_refuse_model(monkeypatch)
|
||||
mandate_path = tmp_path / "mandate.json"
|
||||
mandate_path.write_text(
|
||||
Mandate(
|
||||
objective="Finn kostnadsbesparelser i K2",
|
||||
approaches=(
|
||||
Approach(
|
||||
id="a1",
|
||||
label="Redusert sprengningsvolum i sone A",
|
||||
affected_codes=("21.1",),
|
||||
claimed_saving_nok=200_000.0,
|
||||
),
|
||||
),
|
||||
allow_own_proposals=False,
|
||||
).model_dump_json(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
priced = str(_REPO / "tests" / "fixtures" / "k2-prisskjema-SYNTETISK")
|
||||
argv = [
|
||||
"K2",
|
||||
"--docs-dir",
|
||||
priced,
|
||||
"--bundle-dir",
|
||||
priced,
|
||||
"--derive-cost-baseline",
|
||||
"--proposals-from-mandate",
|
||||
"--mandate",
|
||||
str(mandate_path),
|
||||
]
|
||||
assert run.main(argv) == 0
|
||||
capsys.readouterr()
|
||||
assert run.main([*argv, "--proposal-review"]) == 1
|
||||
assert "--proposals-from-mandate" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_the_door_and_a_parked_exploration_contradict(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""The FOURTH return above generation. Detach point: dropping the ``--checkpoint-dir``
|
||||
refusal (M39).
|
||||
|
||||
A parked exploration returns on the park leg before any candidate exists, so the flag would be
|
||||
accepted and never used. The refusal names ``--resume``, which is where the door DOES compose
|
||||
— a refusal that only forbids leaves the operator without the door that works."""
|
||||
_refuse_model(monkeypatch)
|
||||
rc = run.main(
|
||||
[
|
||||
_RUN_PID,
|
||||
"--docs-dir",
|
||||
str(_BUNDLE_DIR),
|
||||
"--bundle-dir",
|
||||
str(_BUNDLE_DIR),
|
||||
"--explore",
|
||||
"Find the cheapest saving.",
|
||||
"--checkpoint-dir",
|
||||
str(tmp_path / "checkpoints"),
|
||||
"--outbox-dir",
|
||||
str(tmp_path / "outbox"),
|
||||
"--run-id",
|
||||
_RUN_ID,
|
||||
"--proposal-review",
|
||||
]
|
||||
)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert "--checkpoint-dir" in err
|
||||
assert "--resume" in err
|
||||
|
||||
|
||||
def test_the_door_composes_with_resume(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A3, VERIFIED rather than deferred: the resume block yields the parked exploration's mandate
|
||||
and falls through to the SAME full-run dispatch, so the reviewer built at the CLI is reached.
|
||||
Detach point: refusing ``--resume`` together with the door, or forgetting to pass the reviewer
|
||||
on that path.
|
||||
|
||||
``run_project`` is replaced by a recorder, so the arm measures the WIRING rather than a whole
|
||||
resumed exploration."""
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
async def _recorder(*args: Any, **kwargs: Any) -> Any:
|
||||
calls.append(kwargs)
|
||||
raise ValueError("recorded")
|
||||
|
||||
monkeypatch.setattr(run, "run_project", _recorder)
|
||||
_refuse_model(monkeypatch)
|
||||
run.main(
|
||||
[
|
||||
_RUN_PID,
|
||||
"--docs-dir",
|
||||
str(_BUNDLE_DIR),
|
||||
"--bundle-dir",
|
||||
str(_BUNDLE_DIR),
|
||||
"--scripted-replies",
|
||||
_cli_replies_file(tmp_path),
|
||||
"--outbox-dir",
|
||||
str(tmp_path / "outbox"),
|
||||
"--run-id",
|
||||
_RUN_ID,
|
||||
"--proposal-review",
|
||||
]
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["proposal_reviewer"] is not None
|
||||
|
||||
|
||||
def test_a_reviewer_nobody_could_consult_says_so_on_stdout(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""The notice's zero case, at the CLI. Detach points: the renderer returning ``None`` on zero
|
||||
with a reviewer present (M33), and the print itself (M37).
|
||||
|
||||
An operator who passed ``--proposal-review`` and sees nothing cannot tell "no candidate was
|
||||
ever validated, so nobody was asked" from "the door hung"."""
|
||||
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
|
||||
rc = run.main(
|
||||
_cli_argv(
|
||||
tmp_path,
|
||||
outbox="outbox",
|
||||
# BOTH attempts claim above the P90 cap (90 000), so the validator rejects each one
|
||||
# and the reviewer is never reached — offered, never consulted.
|
||||
# As many entries as ``max_attempts`` (3), so every attempt PARSES and is rejected
|
||||
# by the deterministic gate. A shorter list would fall through to the selector's
|
||||
# default reply, which never parses, and the round ledger would fire instead — the
|
||||
# honesty limit ``_load_scripted_replies`` states, reproduced here by accident once.
|
||||
replies=_cli_replies_file(
|
||||
tmp_path, claims=(200_000, 200_000, 200_000), name="rejected.json"
|
||||
),
|
||||
)
|
||||
)
|
||||
assert rc == 0
|
||||
assert "proposal review offered, never consulted" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_a_run_with_no_reviewer_prints_no_review_line(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""CONTROL for the notice (M32): the renderer must return ``None`` when no reviewer was
|
||||
offered, so a run that answered nobody says nothing."""
|
||||
argv = [a for a in _cli_argv(tmp_path, outbox="outbox") if a != "--proposal-review"]
|
||||
assert run.main(argv) == 0
|
||||
assert "proposal review" not in capsys.readouterr().out
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue