B's premise applied to the three steps that DECIDE a proposal: a proposal authored outside po -- by a human, or by an agent that is not po -- now meets the blocking deterministic gate, mints the learning key, and is captured as a Verdict, all without a chat client on the way. Three thin adapters, no second implementation. The reason is the one the first four doors were built on, but it bites harder here: the refusal SENTENCE is fed back verbatim into the next attempt by step 5, so a door that reworded it would break the repair loop while still looking correct. The probes assert the sentence, not a substring two stages share. One measurement decided a design detail. The IR writes whole magnitudes as JSON integers (30000), the run path carries the pydantic float, and verdicts._mint_id hashes the raw value -- so minting from the undeclared JSON would hand out a DIFFERENT verdict id than the debate does for the same proposal. The door therefore reads the proposal through SavingsProposal and feeds model_dump() to the public features_from_ir; the probe pins both forms and asserts they differ, so the shortcut cannot come back silently. A blocked proposal exits 3, carrying the verdict rather than an exception envelope. "You asked right and the answer is no" is the same fact whether a file was missing or a claim was infeasible, and a caller that only reads the exit code must not see a blocked proposal as a cleared one. Fasit outside the door in every arm: the base's own checked-in golden suite (written before the toolbox existed, so it cannot have been fitted to it), a cost baseline authored in the test, a method cap computed by hand from the fixture, and the public minting rule. Each refusal arm has an rc-0 control on an argv that would otherwise be accepted. STATED LIMIT: the input-grounding stage (P7, stage 0b) has no flag here. It falsifies a proposal against the rendered prompt the model received, and an outside caller has no such prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
417 lines
18 KiB
Python
417 lines
18 KiB
Python
"""B-gatens rad 1, steg 2: de fire verktøykasse-dørene, drevet UTENFRA som en kommando.
|
||
|
||
Hver arm her er en **atferdsprobe** i B-gatens forstand: den starter `portfolio_optimiser.toolbox`
|
||
som en subprosess med underkommandoens navn i argv, og asserterer på det kommandoen SKREV. Ingen
|
||
av dem importerer kjernefunksjonen og kaller den in-prosess — det er nettopp den forskjellen
|
||
gaten finnes for å måle, og en probe som gjør begge deler beviser ingenting om døren.
|
||
|
||
Nevneren for «hva kommandoen skal ha skrevet» er hentet UTENFOR døren i hver arm: filsystemet
|
||
(navigate-bundle), en tabell transkribert fra prisskjema-fixturen (cost-baseline), det in-prosess
|
||
API-et den deler søm med (retrieve-chunks), og produsentens egen innsjekkede nyttelast
|
||
(prepass-admit). En arm som sammenlignet døren med seg selv ville vært grønn uansett hva døren
|
||
gjorde.
|
||
|
||
INGEN modellkall, intet nett: hele poenget med verktøykassen er at disse stegene ikke har en
|
||
chatklient på veien, og gaten feller døren om de får en.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import ast
|
||
import json
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
from portfolio_optimiser import datasource
|
||
from portfolio_optimiser.verdicts import (
|
||
ProposalFeatures,
|
||
capture_verdict,
|
||
verdict_key,
|
||
verdict_to_dict,
|
||
)
|
||
|
||
_REPO = Path(__file__).resolve().parents[1]
|
||
_MIKRO = _REPO / "shared" / "examples" / "bygg-energi-mikro"
|
||
_PRISSKJEMA = _REPO / "tests" / "fixtures" / "k2-prisskjema-SYNTETISK"
|
||
_PREPASS_PAYLOAD = (
|
||
_REPO / "tests" / "fixtures" / "prepass" / "bygg-energi-mikro-fixture.payload.json"
|
||
)
|
||
_DECLARED_ID = "bygg-energi-mikro-fixture"
|
||
|
||
#: Prisskjema-fixturens tabell, transkribert fra fixturen — ikke fra derivasjonen som leser den.
|
||
_PRICED_ROWS = {
|
||
"21.1": (1250.0, 850.0),
|
||
"24.2": (48000.0, 42.5),
|
||
"36.1": (4.0, 187500.0),
|
||
}
|
||
|
||
|
||
def _toolbox(*args: str) -> subprocess.CompletedProcess[str]:
|
||
"""Døren, som en subprosess. ``-m``-formen med vilje: den virker i en ren klone uten sync."""
|
||
return subprocess.run(
|
||
[sys.executable, "-m", "portfolio_optimiser.toolbox", *args],
|
||
cwd=_REPO,
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
|
||
|
||
# --- navigate-bundle --------------------------------------------------------------------------
|
||
|
||
|
||
def test_navigate_bundle_from_outside_reports_every_file_the_base_holds() -> None:
|
||
"""Fasiten er FILSYSTEMET: mikro-basens `.md`-filer, talt her. Basen er bygget så hver av dem
|
||
nås fra indeksen, så «navigert» og «finnes» skal falle sammen — og gjør de det ikke, er det
|
||
dét armen skal si."""
|
||
proc = _toolbox("navigate-bundle", "--bundle-dir", str(_MIKRO))
|
||
assert proc.returncode == 0, proc.stderr
|
||
payload = json.loads(proc.stdout)
|
||
paa_disk = sorted(p.name for p in _MIKRO.glob("*.md"))
|
||
assert sorted(f["name"] for f in payload["files"]) == paa_disk
|
||
assert payload["counts"]["files"] == len(paa_disk)
|
||
assert payload["bundle_dir"] == str(_MIKRO)
|
||
# Sporet bæres UT av døren, ikke summert bort: mikro-basens indeks lenker med vilje til
|
||
# `shared/README.md`, som ligger utenfor basen. En dør som bare ga et filtall ville latt
|
||
# «dokumentet finnes ikke» og «lenken var feil» se like ut for den som skal rette det.
|
||
assert payload["skipped"] == [
|
||
{"from_file": "index.md", "target": "../../README.md", "reason": "outside-bundle"}
|
||
]
|
||
assert payload["counts"]["skipped"] == 1
|
||
|
||
|
||
def test_navigate_bundle_refuses_a_directory_that_is_no_bundle_with_a_named_code() -> None:
|
||
"""rc-0-kontrollens motstykke: en avvisning må IKKE være 0. Exit-koden er det eneste
|
||
signalet en agent som kaller døren har."""
|
||
proc = _toolbox("navigate-bundle", "--bundle-dir", str(_REPO / "docs"))
|
||
assert proc.returncode == 3, proc.stdout
|
||
assert json.loads(proc.stdout)["error"]["kind"]
|
||
|
||
|
||
# --- cost-baseline ----------------------------------------------------------------------------
|
||
|
||
|
||
def test_cost_baseline_from_outside_derives_exactly_what_the_priced_table_says() -> None:
|
||
proc = _toolbox("cost-baseline", "--bundle-dir", str(_PRISSKJEMA), "--project-id", "K2")
|
||
assert proc.returncode == 0, proc.stderr
|
||
payload = json.loads(proc.stdout)
|
||
assert payload["project_id"] == "K2"
|
||
assert set(payload["items"]) == set(_PRICED_ROWS)
|
||
for code, (quantity, unit_cost) in _PRICED_ROWS.items():
|
||
assert payload["items"][code]["quantity"] == quantity, code
|
||
assert payload["items"][code]["unit_cost"] == unit_cost, code
|
||
|
||
|
||
def test_cost_baseline_refuses_an_unpriced_schedule_and_names_the_row() -> None:
|
||
proc = _toolbox(
|
||
"cost-baseline", "--bundle-dir", str(_MIKRO), "--project-id", "bygg-kontor-nord"
|
||
)
|
||
assert proc.returncode == 3, proc.stdout
|
||
assert json.loads(proc.stdout)["error"]["message"]
|
||
|
||
|
||
# --- retrieve-chunks --------------------------------------------------------------------------
|
||
|
||
|
||
def test_retrieve_chunks_from_outside_matches_the_in_process_seam_exactly() -> None:
|
||
"""Den ene armen der fasiten ER det in-prosess API-et: verktøykassen skal gi en ekstern
|
||
kaller NØYAKTIG det MAF-agentene får gjennom sin FunctionTool, ikke en egen variant."""
|
||
proc = _toolbox(
|
||
"retrieve-chunks", "--query", "LED retrofit", "--docs-dir", str(_MIKRO), "--top-k", "2"
|
||
)
|
||
assert proc.returncode == 0, proc.stderr
|
||
payload = json.loads(proc.stdout)
|
||
assert payload == datasource.retrieve_chunks("LED retrofit", str(_MIKRO), 2)
|
||
assert len(payload) == 2
|
||
assert payload[0]["snippet"]
|
||
|
||
|
||
def test_retrieve_chunks_refuses_a_top_k_that_asks_for_nothing() -> None:
|
||
proc = _toolbox("retrieve-chunks", "--query", "x", "--docs-dir", str(_MIKRO), "--top-k", "0")
|
||
assert proc.returncode == 3, proc.stdout
|
||
assert "top_k" in json.loads(proc.stdout)["error"]["message"]
|
||
|
||
|
||
# --- prepass-admit ----------------------------------------------------------------------------
|
||
|
||
|
||
def _base_declaring_its_id(tmp_path: Path) -> str:
|
||
"""Mikro-basen kopiert med `bundle_id` erklært — `shared/` er et pull-only subtree."""
|
||
root = tmp_path / "en-annen-mount"
|
||
shutil.copytree(_MIKRO, root)
|
||
index = root / "index.md"
|
||
lines = index.read_text(encoding="utf-8").split("\n")
|
||
assert lines[0].strip() == "---", "basen åpner ikke lenger med frontmatter"
|
||
lines.insert(1, f"bundle_id: {_DECLARED_ID}")
|
||
index.write_text("\n".join(lines), encoding="utf-8")
|
||
return str(root)
|
||
|
||
|
||
def test_prepass_admit_from_outside_admits_the_producers_own_payload(tmp_path: Path) -> None:
|
||
"""Produsentens innsjekkede output, uendret. Uten denne rc-0-kontrollen måler avvisnings-
|
||
armen under ingenting — den ville vært grønn mot en dør som avviste alt."""
|
||
proc = _toolbox(
|
||
"prepass-admit",
|
||
"--payload",
|
||
str(_PREPASS_PAYLOAD),
|
||
"--bundle-dir",
|
||
_base_declaring_its_id(tmp_path),
|
||
)
|
||
assert proc.returncode == 0, proc.stderr
|
||
payload = json.loads(proc.stdout)
|
||
assert payload["admitted"] is True
|
||
assert payload["bundle_id"] == _DECLARED_ID
|
||
assert payload["delivered"] == 4
|
||
|
||
|
||
def test_prepass_admit_refuses_a_payload_that_names_another_base(tmp_path: Path) -> None:
|
||
raw = json.loads(_PREPASS_PAYLOAD.read_text(encoding="utf-8"))
|
||
raw["bundle"]["bundle_id"] = "en-helt-annen-base"
|
||
endret = tmp_path / "endret.payload.json"
|
||
endret.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
|
||
proc = _toolbox(
|
||
"prepass-admit",
|
||
"--payload",
|
||
str(endret),
|
||
"--bundle-dir",
|
||
_base_declaring_its_id(tmp_path),
|
||
)
|
||
assert proc.returncode == 3, proc.stdout
|
||
assert "en-helt-annen-base" in json.loads(proc.stdout)["error"]["message"]
|
||
|
||
|
||
# --- døren selv ---------------------------------------------------------------------------------
|
||
|
||
|
||
def test_the_toolbox_has_no_chat_client_on_any_of_its_paths() -> None:
|
||
"""Verktøykassens ene forutsetning, målt i kilden: ingen av de fire kommandoene drar en
|
||
modell med seg. Gaten måler det samme på rad 1; her står det som en egen påstand, fordi det
|
||
er DENNE modulens grunn til å finnes."""
|
||
modul = _REPO / "src" / "portfolio_optimiser" / "toolbox.py"
|
||
tre = ast.parse(modul.read_text(encoding="utf-8"))
|
||
kalt = {
|
||
node.func.attr if isinstance(node.func, ast.Attribute) else getattr(node.func, "id", "")
|
||
for node in ast.walk(tre)
|
||
if isinstance(node, ast.Call)
|
||
}
|
||
for navn in ("create_chat_client", "client_factory", "BaseChatClient", "get_backend"):
|
||
assert navn not in kalt, navn
|
||
|
||
importert: set[str] = set()
|
||
for node in ast.walk(tre):
|
||
if isinstance(node, ast.ImportFrom):
|
||
importert |= {f"{node.module}.{a.name}" for a in node.names}
|
||
elif isinstance(node, ast.Import):
|
||
importert |= {a.name for a in node.names}
|
||
assert not any(navn.startswith("portfolio_optimiser.run") for navn in importert), importert
|
||
assert "portfolio_optimiser.run" not in importert
|
||
# Kontroll: armen KAN se en import — uten den ville den vært grønn mot et tomt sett.
|
||
assert "portfolio_optimiser.okf" in importert or "portfolio_optimiser.prepass" in importert
|
||
|
||
|
||
def test_wrong_usage_is_exit_two_and_never_a_silent_zero() -> None:
|
||
assert _toolbox().returncode == 2
|
||
assert _toolbox("ingen-slik-kommando").returncode == 2
|
||
assert _toolbox("navigate-bundle").returncode == 2
|
||
|
||
|
||
# --- dommen: validate-proposal / verdict-key / capture-verdict ----------------------------------
|
||
#
|
||
# Steg 3 i B-gatens kø: de tre stegene som avgjør et forslag. Samme dør, samme regel om fasit —
|
||
# den hentes UTENFOR døren i hver arm: basens egen innsjekkede golden-suite (validate-proposal),
|
||
# den publike myntingsregelen `verdicts.verdict_key` (verdict-key) og `verdicts.verdict_to_dict`
|
||
# over en dom bygget i testen (capture-verdict).
|
||
|
||
_IR = _MIKRO / "validator-input.json"
|
||
_GOLDEN = json.loads((_MIKRO / "golden.json").read_text(encoding="utf-8"))["validator"]
|
||
|
||
#: Mikro-basens IR, transkribert fra `validator-input.json` — ikke lest ut av den. En arm som
|
||
#: leste fila ville flyttet seg med den, og da måler den ikke lenger at døren gir DISSE tallene.
|
||
_IR_CODE = "ENERGI-TOTAL-EL"
|
||
_IR_PROJECT = "BYGG-KONTOR-NORD"
|
||
_IR_QUANTITY = 300000.0
|
||
_IR_UNIT_COST = 1.0
|
||
_IR_CLAIMED = 30000.0
|
||
|
||
|
||
def _measure() -> str:
|
||
"""Tiltakets tekst, lest fra basen: den ER lang og står ordrett i domsnøkkelens kanoniske form,
|
||
så en kopi her ville vært en transkribering som kunne drive fra basen uten at noe ble rødt."""
|
||
return str(json.loads(_IR.read_text(encoding="utf-8"))["measure"])
|
||
|
||
|
||
def _baseline_file(path: Path, codes: dict[str, tuple[float, float]]) -> str:
|
||
"""En kostnadsgrunnlags-JSON på PROSJEKTETS form (`okf.load_cost_baseline_file`)."""
|
||
path.write_text(
|
||
json.dumps(
|
||
{
|
||
"project_id": _IR_PROJECT,
|
||
"items": {c: {"quantity": q, "unit_cost": u} for c, (q, u) in codes.items()},
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
return str(path)
|
||
|
||
|
||
def test_validate_proposal_from_outside_reproduces_the_bases_own_golden() -> None:
|
||
"""Fasiten er basens INNSJEKKEDE golden-suite — produsentens frosne utfall av
|
||
`validate_proposal` på nøyaktig denne IR-en, med seedet Monte Carlo. Den ble skrevet før
|
||
verktøykassen fantes, så den kan ikke ha blitt tilpasset døren."""
|
||
proc = _toolbox("validate-proposal", "--proposal", str(_IR))
|
||
assert proc.returncode == 0, proc.stderr
|
||
payload = json.loads(proc.stdout)
|
||
assert payload["decision"] == "validated"
|
||
assert payload["nominal_feasible"] == _GOLDEN["nominal_feasible"]
|
||
assert payload["p10"] == _GOLDEN["p10"]
|
||
assert payload["p50"] == _GOLDEN["p50"]
|
||
assert payload["p90"] == _GOLDEN["p90"]
|
||
assert payload["claimed_saving_nok"] == _GOLDEN["claimed_saving_nok"]
|
||
# Uten grunnlag kjørte stadium 0 ALDRI, og døren sier det framfor å la kalleren tro at
|
||
# tallene ble holdt mot prosjektets egne kostnadslinjer.
|
||
assert payload["cost_baseline_anchored"] is False
|
||
|
||
|
||
def test_validate_proposal_refuses_a_code_the_cost_baseline_never_priced(tmp_path: Path) -> None:
|
||
"""Stadium 0 gjennom døren, med den ORDRETTE setningen løpet ellers skriver — det er den
|
||
Steg 5 mater tilbake til neste forsøk, så en dør som omskrev den ville brutt sløyfa."""
|
||
ukjent = _baseline_file(tmp_path / "annen.json", {"VARME-TOTAL": (1.0, 2.0)})
|
||
proc = _toolbox("validate-proposal", "--proposal", str(_IR), "--cost-baseline", ukjent)
|
||
assert proc.returncode == 3, proc.stdout
|
||
payload = json.loads(proc.stdout)
|
||
assert payload["decision"] == "rejected"
|
||
assert payload["reason"] == (
|
||
f"unknown cost code '{_IR_CODE}': not in project {_IR_PROJECT}'s cost baseline "
|
||
f"(1 known codes: 'VARME-TOTAL')"
|
||
)
|
||
assert payload["stage"] == "stage0-baseline"
|
||
assert payload["cost_baseline_anchored"] is True
|
||
# rc-0-kontroll: SAMME forslag mot et grunnlag som priser koden. Uten den måler armen over
|
||
# ingenting — den ville vært grønn mot en dør som avviste alt.
|
||
priset = _baseline_file(tmp_path / "priset.json", {_IR_CODE: (_IR_QUANTITY, _IR_UNIT_COST)})
|
||
ok = _toolbox("validate-proposal", "--proposal", str(_IR), "--cost-baseline", priset)
|
||
assert ok.returncode == 0, ok.stdout
|
||
assert json.loads(ok.stdout)["decision"] == "validated"
|
||
assert json.loads(ok.stdout)["cost_baseline_anchored"] is True
|
||
|
||
|
||
def test_validate_proposal_applies_the_method_cap_through_the_same_door(tmp_path: Path) -> None:
|
||
"""Metodetaket er DATA (F8), og døren rekker det: et strengere tak enn den generiske P90
|
||
avviser et forslag P90-stadiet slapp gjennom — samme dør, samme ordrette setning.
|
||
|
||
Taket regnes UAVHENGIG her: 0,05 × (300 000 × 1,0) = 15 000, transkribert fra basen."""
|
||
tak = tmp_path / "tak.json"
|
||
tak.write_text(json.dumps({_measure(): 0.05}), encoding="utf-8")
|
||
ventet_tak = 0.05 * _IR_QUANTITY * _IR_UNIT_COST
|
||
assert ventet_tak == 15000.0
|
||
proc = _toolbox("validate-proposal", "--proposal", str(_IR), "--method-caps", str(tak))
|
||
assert proc.returncode == 3, proc.stdout
|
||
payload = json.loads(proc.stdout)
|
||
assert payload["reason"] == (
|
||
f"claimed {_IR_CLAIMED:.0f} exceeds the {_measure()} method cap {ventet_tak:.0f} "
|
||
f"(stricter than the generic P90)"
|
||
)
|
||
assert payload["stage"] == "stage5-method-cap"
|
||
# rc-0-kontroll: et tak som IKKE er strengere enn kravet slipper samme forslag gjennom.
|
||
romslig = tmp_path / "romslig.json"
|
||
romslig.write_text(json.dumps({_measure(): 0.30}), encoding="utf-8")
|
||
ok = _toolbox("validate-proposal", "--proposal", str(_IR), "--method-caps", str(romslig))
|
||
assert ok.returncode == 0, ok.stdout
|
||
assert json.loads(ok.stdout)["decision"] == "validated"
|
||
|
||
|
||
def test_verdict_key_from_outside_is_the_key_the_learning_loop_mints(tmp_path: Path) -> None:
|
||
"""A5: én publik domsnøkkel. Fasiten er `verdicts.verdict_key` kalt på trekk bygget HER —
|
||
og armen skiller de to formene som ellers ser like ut: IR-en skriver `30000`, kjørestien
|
||
sender en pydantic-FLOAT videre, og `_mint_id` hasher den rå verdien. En dør som mintet fra
|
||
JSON-en slik den står ville gitt en ANNEN nøkkel enn løpet, på samme forslag."""
|
||
trekk = ProposalFeatures(
|
||
affected_codes=frozenset({_IR_CODE}),
|
||
measure_type=_measure(),
|
||
claimed_saving_nok=_IR_CLAIMED,
|
||
description=_measure(),
|
||
)
|
||
som_heltall = ProposalFeatures(
|
||
affected_codes=frozenset({_IR_CODE}),
|
||
measure_type=_measure(),
|
||
claimed_saving_nok=30000,
|
||
description=_measure(),
|
||
)
|
||
assert verdict_key(trekk) != verdict_key(som_heltall), "de to formene er ikke lenger ulike"
|
||
|
||
proc = _toolbox("verdict-key", "--proposal", str(_IR))
|
||
assert proc.returncode == 0, proc.stderr
|
||
payload = json.loads(proc.stdout)
|
||
assert payload["verdict_id"] == verdict_key(trekk)
|
||
assert payload["verdict_id"] != verdict_key(som_heltall)
|
||
assert payload["affected_codes"] == [_IR_CODE]
|
||
assert payload["claimed_saving_nok"] == _IR_CLAIMED
|
||
|
||
# Nøkkelen er ingen konstant: et forslag med et annet beløp nøkles annerledes.
|
||
annet = json.loads(_IR.read_text(encoding="utf-8"))
|
||
annet["claimed_saving_nok"] = 12345
|
||
sti = tmp_path / "annet.json"
|
||
sti.write_text(json.dumps(annet), encoding="utf-8")
|
||
andre = _toolbox("verdict-key", "--proposal", str(sti))
|
||
assert andre.returncode == 0, andre.stderr
|
||
assert json.loads(andre.stdout)["verdict_id"] != payload["verdict_id"]
|
||
|
||
|
||
def test_capture_verdict_from_outside_is_the_verdict_the_store_would_hold() -> None:
|
||
"""Dommen fanget utenfra skal være NØYAKTIG den `capture_verdict` bygger i løpet — samme
|
||
felter, samme id, samme serialisering (`verdict_to_dict`, som er formen `write_verdict`
|
||
legger på disk)."""
|
||
grunn = "realiseringsgapet er dokumentert i basen; tallene holder"
|
||
ventet = verdict_to_dict(
|
||
capture_verdict(
|
||
ProposalFeatures(
|
||
affected_codes=frozenset({_IR_CODE}),
|
||
measure_type=_measure(),
|
||
claimed_saving_nok=_IR_CLAIMED,
|
||
description=_measure(),
|
||
),
|
||
"approved",
|
||
grunn,
|
||
)
|
||
)
|
||
proc = _toolbox(
|
||
"capture-verdict",
|
||
"--proposal",
|
||
str(_IR),
|
||
"--decision",
|
||
"approved",
|
||
"--rationale",
|
||
grunn,
|
||
)
|
||
assert proc.returncode == 0, proc.stderr
|
||
assert json.loads(proc.stdout) == ventet
|
||
|
||
|
||
def test_capture_verdict_refuses_a_decision_the_feedback_contract_does_not_know() -> None:
|
||
"""Vokabularet er kontraktens (`FeedbackContract`), ikke dørens egen kopi: en halvgitt eller
|
||
oppfunnet dom avvises med FELTNAVNET, aldri fullført på fagpersonens vegne."""
|
||
proc = _toolbox(
|
||
"capture-verdict",
|
||
"--proposal",
|
||
str(_IR),
|
||
"--decision",
|
||
"kanskje",
|
||
"--rationale",
|
||
"x",
|
||
)
|
||
assert proc.returncode == 3, proc.stdout
|
||
melding = json.loads(proc.stdout)["error"]["message"]
|
||
assert "decision" in melding and "kanskje" in melding
|
||
# rc-0-kontroll: samme kall med en dom kontrakten KJENNER.
|
||
ok = _toolbox(
|
||
"capture-verdict",
|
||
"--proposal",
|
||
str(_IR),
|
||
"--decision",
|
||
"rejected",
|
||
"--rationale",
|
||
"x",
|
||
)
|
||
assert ok.returncode == 0, ok.stdout
|
||
assert json.loads(ok.stdout)["decision"] == "rejected"
|