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>
393 lines
15 KiB
Python
393 lines
15 KiB
Python
"""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"
|