feat(toolbox): the seven outbox writers get their own door -- row 1 moves 8 -> 15 of 17
Every one of the run path's seven `outbox.write_*` steps was reachable only through `run.main`, and every path through that builds a chat client. The steps need no model: they take already-rendered data and put it on disk in a byte-deterministic form. Seven subcommands, seven thin adapters. The outbox directory is always the caller's to name -- never a default, never the repository's own, because a step that wrote into a folder the framework also reads as an inbox would bypass the Step-8 promotion gate. `write-outbox` is the one that is not purely mechanical: `outbox.write_outbox` branches on the outcome TYPE, so a door that took the outcome as an argument would let anyone author an outbox of claims and hand it to Step 8 as results. The door DERIVES it through `validate_proposal` -- the run path's own composition -- and a blocked proposal exits 3 with the artefacts still written, since that is where the rejection is recorded. `verdict_id` stays an argument: `verdict-key` already owns that minting. `--stop-reason` is required rather than defaulted to the empty string, inheriting the core writer's measured reason: "the run finished" and "we never found out" must not be the same value. Eight probes, each a subprocess with the subcommand in argv, each asserting on the FILE the command wrote. The ground truth is composed in the test -- the payload it wrote and counted itself, and the byte form the contract requires -- never `outbox._dump`, which would have measured the module against itself. The refusal arm carries its rc-0 control. Measured, own run of the B gate: row 1 8 of 17 -> 15 of 17, exit 1 unchanged, no other row moved. 0 chat-client names reachable from the toolbox (known-positive control: 24 in run.py). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1111924000
commit
6375ce5af3
5 changed files with 691 additions and 30 deletions
393
tests/test_toolbox_outbox_doors.py
Normal file
393
tests/test_toolbox_outbox_doors.py
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
"""B-gatens rad 1, steg 4: de SJU utboks-skriverne, drevet UTENFRA som kommandoer.
|
||||
|
||||
Kjørestien skriver sju artefakter (`outbox.write_*`) som ingen utenfor po kunne be om: veien til
|
||||
dem gikk gjennom `run.main`, og hver vei gjennom den bygger en chatklient. Stegene selv trenger
|
||||
ingen modell — de tar ferdig rendret data og legger den på disk i en byte-deterministisk form.
|
||||
Denne fila måler at de nå har en dør, og at det som kom ut av døren er artefaktet kjørestien
|
||||
ville skrevet.
|
||||
|
||||
Hver arm er en **atferdsprobe** i gatens forstand: `portfolio_optimiser.toolbox` startes som en
|
||||
subprosess med underkommandoens navn i argv, og asserten leser FILA kommandoen skrev. Ingen arm
|
||||
importerer skriveren og kaller den in-prosess.
|
||||
|
||||
Fasiten er hentet UTENFOR døren i hver arm: nyttelasten testen selv skrev (og teller selv), og
|
||||
den byte-formen kontrakten krever — `json.dumps(sort_keys=True, indent=2)` pluss avsluttende
|
||||
linjeskift — komponert her, ikke lest fra `outbox._dump`. En arm som sammenlignet døren med
|
||||
dørens egen hjelper ville vært grønn uansett hva den skrev.
|
||||
|
||||
Artefaktene skrives ALLTID til `tmp_path`. En probe som skrev i repoets egen utboks ville lagt
|
||||
igjen filer en senere kjøring leser som sine egne.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_MIKRO = _REPO / "shared" / "examples" / "bygg-energi-mikro"
|
||||
_IR = _MIKRO / "validator-input.json"
|
||||
_GOLDEN = json.loads((_MIKRO / "golden.json").read_text(encoding="utf-8"))["validator"]
|
||||
_RUN = "probe-utboks-01"
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _json_arg(path: Path, payload: Any) -> str:
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def _deterministic(payload: Any) -> str:
|
||||
"""Kontraktens byte-form, komponert HER. Ikke `outbox._dump` — da målte armen en likhet
|
||||
modulen har med seg selv."""
|
||||
return json.dumps(payload, sort_keys=True, indent=2) + "\n"
|
||||
|
||||
|
||||
# --- write-run-config -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_run_config_from_outside_is_the_byte_deterministic_artefact(tmp_path: Path) -> None:
|
||||
"""Kjørekonfigurasjonen beskriver en kjøring UTEN et modellkall — og er derfor nøyaktig det
|
||||
steget en kaller utenfor po har bruk for før han bestemmer seg for å betale for en."""
|
||||
modeller = _json_arg(tmp_path / "modeller.json", {"proposer": "gpt-x", "checker": "gpt-y"})
|
||||
ut = tmp_path / "ut"
|
||||
proc = _toolbox(
|
||||
"write-run-config",
|
||||
"--out-dir",
|
||||
str(ut),
|
||||
"--run-id",
|
||||
_RUN,
|
||||
"--profile",
|
||||
"local",
|
||||
"--resolved-models",
|
||||
modeller,
|
||||
"--max-rounds",
|
||||
"5",
|
||||
"--max-tokens",
|
||||
"600000",
|
||||
"--top-k",
|
||||
"3",
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
skrevet = Path(json.loads(proc.stdout)["path"])
|
||||
assert skrevet == ut / f"{_RUN}-runconfig.json"
|
||||
assert skrevet.read_text(encoding="utf-8") == _deterministic(
|
||||
{
|
||||
"run_id": _RUN,
|
||||
"profile": "local",
|
||||
"resolved_models": {"proposer": "gpt-x", "checker": "gpt-y"},
|
||||
"max_rounds": 5,
|
||||
"max_tokens": 600000,
|
||||
"top_k": 3,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# --- write-coverage ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_coverage_from_outside_keeps_every_row_and_the_stop_reason(tmp_path: Path) -> None:
|
||||
"""Uavhengig telling: raden-lista skrives HER, og armen teller den her. `stop_reason` er
|
||||
påkrevd i kjernefunksjonen fordi «kjøringen ble ferdig» og «vi fikk aldri vite» ikke må være
|
||||
samme verdi — døren arver det kravet framfor å defaulte til tom streng."""
|
||||
rader = [
|
||||
{
|
||||
"id": "a1",
|
||||
"label": "Etterisolering",
|
||||
"status": "validated",
|
||||
"detail": "",
|
||||
"saving_nok": 30000.0,
|
||||
},
|
||||
{
|
||||
"id": "a2",
|
||||
"label": "Styring",
|
||||
"status": "not_evaluated",
|
||||
"detail": "budsjett",
|
||||
"saving_nok": None,
|
||||
},
|
||||
]
|
||||
kilde = _json_arg(tmp_path / "rader.json", rader)
|
||||
ut = tmp_path / "ut"
|
||||
proc = _toolbox(
|
||||
"write-coverage",
|
||||
"--outbox-dir",
|
||||
str(ut),
|
||||
"--run-id",
|
||||
_RUN,
|
||||
"--rows",
|
||||
kilde,
|
||||
"--stop-reason",
|
||||
"tokens",
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
skrevet = Path(json.loads(proc.stdout)["path"])
|
||||
assert skrevet == ut / f"{_RUN}-coverage.json"
|
||||
paa_disk = json.loads(skrevet.read_text(encoding="utf-8"))
|
||||
assert paa_disk["stop_reason"] == "tokens"
|
||||
assert len(paa_disk["rows"]) == len(rader)
|
||||
assert paa_disk["rows"] == rader
|
||||
assert [r["id"] for r in paa_disk["rows"]] == ["a1", "a2"]
|
||||
|
||||
|
||||
# --- write-outbox -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stamp(path: Path, *, decision: str, anchored: bool) -> str:
|
||||
"""En provenance-stempel-JSON på modellens egen form (`ProvenanceStamp`)."""
|
||||
return _json_arg(
|
||||
path,
|
||||
{
|
||||
"citations": [
|
||||
{
|
||||
"file": "index.md",
|
||||
"locator": {"start_index": 0, "end_index": 12},
|
||||
"snippet": "energiprofil",
|
||||
}
|
||||
],
|
||||
"model": "ingen-modell",
|
||||
"role": "proposer",
|
||||
"validator_decision": decision,
|
||||
"token_usage": 0,
|
||||
"cost_baseline_anchored": anchored,
|
||||
"bundle_id_source": None,
|
||||
"code_forms": {"ENERGI-TOTAL-EL": "identifier"},
|
||||
"external_calls": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _baseline(path: Path, codes: dict[str, tuple[float, float]]) -> str:
|
||||
return _json_arg(
|
||||
path,
|
||||
{
|
||||
"project_id": "BYGG-KONTOR-NORD",
|
||||
"items": {c: {"quantity": q, "unit_cost": u} for c, (q, u) in codes.items()},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_write_outbox_from_outside_writes_the_pair_the_run_path_writes(tmp_path: Path) -> None:
|
||||
"""Fasiten er basens INNSJEKKEDE golden-suite — produsentens frosne utfall av den samme
|
||||
deterministiske porten, skrevet før verktøykassen fantes. Utfallet er ikke noe kalleren kan
|
||||
OPPGI: døren utleder det gjennom `validate_proposal`, ellers ville utboksen vært en samling
|
||||
påstander framfor resultater."""
|
||||
ut = tmp_path / "ut"
|
||||
proc = _toolbox(
|
||||
"write-outbox",
|
||||
"--outbox-dir",
|
||||
str(ut),
|
||||
"--run-id",
|
||||
_RUN,
|
||||
"--proposal",
|
||||
str(_IR),
|
||||
"--provenance",
|
||||
_stamp(tmp_path / "stempel.json", decision="validated", anchored=False),
|
||||
"--verdict-id",
|
||||
"v-probe",
|
||||
"--checker-verdict",
|
||||
"approve",
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
skrevet = json.loads(proc.stdout)
|
||||
forslag = Path(skrevet["proposal_path"])
|
||||
utfall = Path(skrevet["outcome_path"])
|
||||
assert forslag == ut / f"{_RUN}-proposal.json"
|
||||
assert utfall == ut / f"{_RUN}-outcome.json"
|
||||
ir = json.loads(_IR.read_text(encoding="utf-8"))
|
||||
paa_disk = json.loads(forslag.read_text(encoding="utf-8"))
|
||||
assert paa_disk["run_id"] == _RUN
|
||||
assert paa_disk["proposal"]["project_id"] == ir["project_id"]
|
||||
assert paa_disk["proposal"]["measure"] == ir["measure"]
|
||||
assert paa_disk["provenance"]["validator_decision"] == "validated"
|
||||
dom = json.loads(utfall.read_text(encoding="utf-8"))
|
||||
assert dom["p50"] == _GOLDEN["p50"]
|
||||
assert dom["p10"] == _GOLDEN["p10"]
|
||||
assert dom["p90"] == _GOLDEN["p90"]
|
||||
assert dom["verdict_id"] == "v-probe"
|
||||
assert dom["checker_verdict"] == "approve"
|
||||
|
||||
|
||||
def test_write_outbox_carries_a_blocked_proposal_out_as_a_refusal(tmp_path: Path) -> None:
|
||||
"""Et forslag den deterministiske porten BLOKKERER er exit 3 gjennom denne døren også —
|
||||
artefaktene skrives (det er dem dommen bor i), men en kaller som bare leser exit-koden skal
|
||||
aldri se et blokkert forslag som et klarert."""
|
||||
stempel = _stamp(tmp_path / "stempel.json", decision="rejected", anchored=True)
|
||||
ukjent = _baseline(tmp_path / "ukjent.json", {"VARME-TOTAL": (1.0, 2.0)})
|
||||
ut = tmp_path / "nekt"
|
||||
proc = _toolbox(
|
||||
"write-outbox",
|
||||
"--outbox-dir",
|
||||
str(ut),
|
||||
"--run-id",
|
||||
_RUN,
|
||||
"--proposal",
|
||||
str(_IR),
|
||||
"--provenance",
|
||||
stempel,
|
||||
"--verdict-id",
|
||||
"v-nekt",
|
||||
"--cost-baseline",
|
||||
ukjent,
|
||||
)
|
||||
assert proc.returncode == 3, proc.stdout
|
||||
skrevet = json.loads(proc.stdout)
|
||||
assert skrevet["decision"] == "rejected"
|
||||
dom = json.loads(Path(skrevet["outcome_path"]).read_text(encoding="utf-8"))
|
||||
assert dom["reason"].startswith("unknown cost code 'ENERGI-TOTAL-EL'")
|
||||
assert "p50" not in dom
|
||||
# rc-0-kontroll: SAMME forslag og SAMME stempel mot et grunnlag som priser koden. Uten den
|
||||
# målte armen over ingenting — den ville vært grønn mot en dør som avviste alt.
|
||||
priset = _baseline(tmp_path / "priset.json", {"ENERGI-TOTAL-EL": (300000.0, 1.0)})
|
||||
ok = _toolbox(
|
||||
"write-outbox",
|
||||
"--outbox-dir",
|
||||
str(tmp_path / "ok"),
|
||||
"--run-id",
|
||||
_RUN,
|
||||
"--proposal",
|
||||
str(_IR),
|
||||
"--provenance",
|
||||
stempel,
|
||||
"--verdict-id",
|
||||
"v-ok",
|
||||
"--cost-baseline",
|
||||
priset,
|
||||
)
|
||||
assert ok.returncode == 0, ok.stdout
|
||||
assert json.loads(ok.stdout)["decision"] == "validated"
|
||||
|
||||
|
||||
# --- write-prepass ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_prepass_from_outside_records_the_cut_the_run_was_given(tmp_path: Path) -> None:
|
||||
"""Artefaktets TILSTEDEVÆRELSE er det som skiller «tilbaketrukket med vilje» fra «regrederte»
|
||||
når debatt-sporet er tomt — derfor måler armen både at fila finnes og hva den sier."""
|
||||
erklaering = {"bundle_id": "bygg-energi-mikro-fixture", "considered": 12, "delivered": 4}
|
||||
kilde = _json_arg(tmp_path / "kutt.json", erklaering)
|
||||
ut = tmp_path / "ut"
|
||||
proc = _toolbox(
|
||||
"write-prepass", "--outbox-dir", str(ut), "--run-id", _RUN, "--declaration", kilde
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
skrevet = Path(json.loads(proc.stdout)["path"])
|
||||
assert skrevet == ut / f"{_RUN}-prepass.json"
|
||||
assert skrevet.read_text(encoding="utf-8") == _deterministic(
|
||||
{"run_id": _RUN, "prepass": erklaering}
|
||||
)
|
||||
|
||||
|
||||
# --- write-parse-failures ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_parse_failures_from_outside_keeps_every_reply_that_did_not_parse(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Uavhengig telling: to feil inn, to feil ut — og rekkefølgen er kjøringens, ikke sortert."""
|
||||
feil = [
|
||||
{"text": "{ nesten json", "error": "Expecting property name"},
|
||||
{"text": "prosa uten IR", "error": "Expecting value"},
|
||||
]
|
||||
kilde = _json_arg(tmp_path / "feil.json", feil)
|
||||
ut = tmp_path / "ut"
|
||||
proc = _toolbox(
|
||||
"write-parse-failures", "--outbox-dir", str(ut), "--run-id", _RUN, "--failures", kilde
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
skrevet = Path(json.loads(proc.stdout)["path"])
|
||||
assert skrevet == ut / f"{_RUN}-parse-failures.json"
|
||||
paa_disk = json.loads(skrevet.read_text(encoding="utf-8"))
|
||||
assert len(paa_disk["parse_failures"]) == len(feil)
|
||||
assert paa_disk["parse_failures"] == feil
|
||||
|
||||
|
||||
# --- write-proposal-reviews -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_proposal_reviews_from_outside_states_an_empty_review_list(tmp_path: Path) -> None:
|
||||
"""Regelen er «hvis en vurderer ble gitt, OGSÅ når lista er tom» (D4): en vurderer som ble
|
||||
tilbudt og aldri konsultert er et faktum artefaktet skal SI, ikke noe en operatør må slutte
|
||||
seg til av en fil som ikke er der. Døren arver regelen ved å skrive det den får."""
|
||||
ut = tmp_path / "ut"
|
||||
tom = _json_arg(tmp_path / "tom.json", {"reviews": []})
|
||||
proc = _toolbox(
|
||||
"write-proposal-reviews", "--outbox-dir", str(ut), "--run-id", _RUN, "--payload", tom
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
skrevet = Path(json.loads(proc.stdout)["path"])
|
||||
assert skrevet == ut / f"{_RUN}-proposal-reviews.json"
|
||||
assert skrevet.read_text(encoding="utf-8") == _deterministic({"run_id": _RUN, "reviews": []})
|
||||
# Kontroll mot en arm som ville vært grønn for enhver nyttelast: en ikke-tom liste må komme
|
||||
# ut som den gikk inn, med samme telling.
|
||||
en = _json_arg(tmp_path / "en.json", {"reviews": [{"verdict_id": "v1", "decision": "accept"}]})
|
||||
andre = _toolbox(
|
||||
"write-proposal-reviews",
|
||||
"--outbox-dir",
|
||||
str(ut),
|
||||
"--run-id",
|
||||
"probe-utboks-02",
|
||||
"--payload",
|
||||
en,
|
||||
)
|
||||
assert andre.returncode == 0, andre.stderr
|
||||
paa_disk = json.loads(Path(json.loads(andre.stdout)["path"]).read_text(encoding="utf-8"))
|
||||
assert len(paa_disk["reviews"]) == 1
|
||||
assert paa_disk["reviews"][0]["verdict_id"] == "v1"
|
||||
|
||||
|
||||
# --- write-debate-tools -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_debate_tools_from_outside_writes_an_empty_trace_as_a_statement(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Skrives på HVER kjøring, også en debatt som åpnet ingenting — det tomme sporet ER S2c-
|
||||
regresjonen, og den må kunne leses av artefaktet framfor sluttes av en fil som mangler.
|
||||
`requirements` defaulter til tom av samme grunn: «denne debatten erklærte ingenting» er en
|
||||
ærlig positiv påstand."""
|
||||
ut = tmp_path / "ut"
|
||||
tomt = _json_arg(tmp_path / "ingen.json", [])
|
||||
proc = _toolbox(
|
||||
"write-debate-tools", "--outbox-dir", str(ut), "--run-id", _RUN, "--tool-calls", tomt
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
skrevet = Path(json.loads(proc.stdout)["path"])
|
||||
assert skrevet == ut / f"{_RUN}-debate.json"
|
||||
assert skrevet.read_text(encoding="utf-8") == _deterministic(
|
||||
{"run_id": _RUN, "tool_calls": [], "requirements": []}
|
||||
)
|
||||
# Kontroll: en debatt som FAKTISK åpnet noe skal se annerledes ut — ellers målte armen over
|
||||
# bare at fila finnes.
|
||||
kall = [
|
||||
{"tool": "retrieve", "arguments": {"query": "energi"}},
|
||||
{"tool": "retrieve", "arguments": {"query": "varme"}},
|
||||
]
|
||||
krav = [{"id": "R1", "text": "U-verdi"}]
|
||||
andre = _toolbox(
|
||||
"write-debate-tools",
|
||||
"--outbox-dir",
|
||||
str(ut),
|
||||
"--run-id",
|
||||
"probe-utboks-02",
|
||||
"--tool-calls",
|
||||
_json_arg(tmp_path / "kall.json", kall),
|
||||
"--requirements",
|
||||
_json_arg(tmp_path / "krav.json", krav),
|
||||
)
|
||||
assert andre.returncode == 0, andre.stderr
|
||||
paa_disk = json.loads(Path(json.loads(andre.stdout)["path"]).read_text(encoding="utf-8"))
|
||||
assert len(paa_disk["tool_calls"]) == len(kall)
|
||||
assert len(paa_disk["requirements"]) == len(krav)
|
||||
assert paa_disk["tool_calls"][1]["arguments"]["query"] == "varme"
|
||||
Loading…
Add table
Add a link
Reference in a new issue