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:
Kjell Tore Guttormsen 2026-09-05 07:06:22 +02:00
commit fedf9897c4
2 changed files with 437 additions and 3 deletions

View file

@ -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