test(round-builder): 26 red tests for a round directory built from a run's outbox

Round 0 of the v1 criterion cannot be made today: nothing binds a finished run's outbox to
<rounds-dir>/<n>/, and nothing in src writes markdown a domain expert could read. These tests
state what a builder has to do before one exists, and every number they assert is counted a
second time from the fixture's own table rather than read back from the builder.

Red on assertions, not on import: round_builder.py lands as a contract -- dataclass, signatures,
neutral returns -- so each test fails in its own body.

Two gate helpers become public rather than being copied: row_changed (the report's "changed since
the previous round" section must not disagree with the gate about what changed) and
safe_rounds_dir (the builder CREATES the directory the gate only reads, and the writer is where a
leak of the expert's feedback has to be stopped).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-19 05:55:50 +02:00
commit 0fa612a22f
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 807 additions and 5 deletions

View file

@ -0,0 +1,85 @@
"""From one run's outbox to a round directory the v1 gate can read.
One command, deterministic, offline: no model call, no network. It takes a finished run's outbox
and a round number and writes ``<rounds-dir>/<n>/`` in the shape ``v1_gate --help`` states
``outbox/`` copied from the run, ``outcome.json`` DERIVED from that copy, and ``report.md``, the
one artefact in the round a domain expert is meant to read and correct.
Two things it never does, and both are the point. It never writes ``attestering.txt``: that file
is the operator's statement that a round was actually held, and a builder that could produce it
would turn rows 1-2 back into something a directory can fake. And it never invents ``ran_at`` is
an argument because no outbox artefact carries a clock (they are byte-deterministic by contract),
and ``feedback_ids`` stays empty because no run records which feedback item produced which row.
An empty list is the honest reading of a run that tracked nothing.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
class RoundBuildError(Exception):
"""A round that cannot be built from what is on disk, with the reason a reader can act on."""
@dataclass(frozen=True)
class Built:
"""What one build produced, in numbers the caller can check against the source directory."""
round_dir: Path
run_id: str
copied: tuple[str, ...]
ignored: tuple[str, ...]
evaluated: tuple[str, ...]
not_evaluated: tuple[str, ...]
validated_ore: int
#: Every rejection stage ``validator.rejection_stage`` can name, in the words a domain expert
#: reads — plus the two non-rejection labels a coverage row can carry.
STAGE_PROSE: dict[str, str] = {}
ROUND_BUILD_CONTRACT = ""
def read_run(outbox_dir: Path) -> Any:
"""The run an outbox directory holds, or ``RoundBuildError`` saying why it holds none."""
return None
def derive_outcome(
run: Any, *, ran_at: str, previous: Mapping[str, Any] | None = None
) -> dict[str, Any]:
"""``outcome.json`` computed from the run's own coverage and per-approach artefacts."""
return {}
def build_report(
run: Any, n: int, outcome: Mapping[str, Any], *, previous: Mapping[str, Any] | None = None
) -> str:
"""``report.md`` — what a domain expert reads and corrects, in Norwegian prose."""
return ""
def build_round(
outbox_dir: Path,
rounds_dir: Path,
n: int,
*,
ran_at: str,
feedback: Path | None = None,
) -> Built:
"""Write ``<rounds-dir>/<n>/`` from ``outbox_dir``, or refuse without touching the tree."""
return Built(rounds_dir / str(n), "", (), (), (), (), 0)
def main(argv: Sequence[str] | None = None) -> int:
"""Command-line front door; 0 on a built round, 1 on a refusal, 2 on wrong usage."""
return 0
if __name__ == "__main__": # pragma: no cover - exercised by a subprocess test
raise SystemExit(main())

View file

@ -738,7 +738,12 @@ def _nok_changed(before: Any, after: Any) -> bool:
return abs(float(after) - float(before)) >= max(1.0, _NOK_NOISE * abs(float(before)))
def _changed(before: Mapping[str, Any], after: Mapping[str, Any]) -> bool:
def row_changed(before: Mapping[str, Any], after: Mapping[str, Any]) -> bool:
"""Whether one approach's row moved on (b), (c) or (d) — the noise floor included.
Public because ``round_builder`` writes the "changed since the previous round" section of the
report a domain expert reads, and a second copy of this comparison would let the report and
the gate disagree about what changed. One rule, two readers."""
b, a = _row_key(before), _row_key(after)
return b[:2] != a[:2] or _nok_changed(b[2], a[2])
@ -748,7 +753,7 @@ def outcomes_changed(prev: Outcome, cur: Outcome, feedback_ids: set[str]) -> tup
given before ``cur`` ran. The second half is what keeps model noise out."""
changed: dict[str, set[str]] = {}
for aid, row in cur.rows.items():
if aid not in prev.rows or _changed(prev.rows[aid], row):
if aid not in prev.rows or row_changed(prev.rows[aid], row):
changed[aid] = set(map(str, row.get("feedback_ids", ())))
for aid in prev.rows.keys() - cur.rows.keys():
changed[aid] = cur.removed.get(aid, set())
@ -1380,8 +1385,11 @@ def render(rows: Sequence[Row]) -> str:
return "\n".join(out)
def _safe_rounds_dir(path: Path) -> bool:
"""Outside the repository, or inside it and ignored by git."""
def safe_rounds_dir(path: Path) -> bool:
"""Outside the repository, or inside it and ignored by git.
Public because ``round_builder`` CREATES the directory this gate only reads, and the place a
leak has to be stopped is the writer. Same rule, checked on both sides."""
resolved = path.resolve()
try:
resolved.relative_to(_REPO_ROOT)
@ -1421,7 +1429,7 @@ def main(argv: Sequence[str] | None = None) -> int:
if args.rounds_dir is not None and not Path(args.rounds_dir).is_dir():
parser.error(f"--rounds-dir {args.rounds_dir} finnes ikke")
if args.rounds_dir is not None and not _safe_rounds_dir(Path(args.rounds_dir)):
if args.rounds_dir is not None and not safe_rounds_dir(Path(args.rounds_dir)):
parser.error(
f"--rounds-dir {args.rounds_dir} ligger i repoet uten å være gitignored — "
"fagpersonens tilbakemelding kunne da bli committet til den offentlige remoten"