feat(round-builder): one command turns a run's outbox into a round the gate can read [skip-docs]

python -m portfolio_optimiser.evals.round_builder --outbox <dir> --round <n> --ran-at <ISO>
writes <rounds-dir>/<n>/ with the run's artefacts COPIED in, outcome.json derived from that
copy, and report.md -- the one artefact in a round a domain expert reads and corrects. Round 0
of the v1 criterion can now be made; it counted 0 of 3 because it could not be, which is a
different failure from a round nobody had held.

What it derives it derives with the gate's own functions rather than a second copy: verify_run
decides whether the run stands up to itself (an artefact contradicting its coverage row, a
half-missing family and a stray artefact are all refused AT THE SOURCE, before a byte is
written), stage_of gives column (c), row_changed gives the report's "changed since the previous
round", parse_time refuses a stamp without a zone, safe_rounds_dir refuses a round directory the
repo would commit. The validated total is ledger.to_ore per amount, summed as integers.

Two things it never does, and both are the point. It never writes the operator's attestation --
the gate stops at FORM OK without one, and that is correct, because no arrangement of files can
witness that a run happened. And it never invents: --ran-at is required because no outbox
artefact carries a clock, and feedback_ids stays empty because no run records which feedback
item produced which row. The report says "ingen tilbakemelding forklarer dette" on every changed
row rather than hiding that model noise and an answered objection look alike.

Chosen and why: --ran-at as a required argument rather than the coverage file's mtime, because
an mtime is a filesystem attribute one call sets and reading it as evidence made row 2 green on
a tree nothing had run in (18.09). The report carries no raw stage identifier -- every stage
sentence is "<short name>: <explanation>" so the one-line diff of what changed has words a
reader can act on. A citation shows its COUNT, because a run that cited 446 places and one that
cited one must not look the same.

[skip-docs]: the ledger row is in docs/invarianter.md, which is where this repo's rules live.
README is the product's front door and this is an operator tool behind `python -m`, the same
class as costsim/hitl/preflight, which README deliberately does not carry; v1-rounds/ is
gitignored internal machinery and the gate itself is not in README either. CLAUDE.md was emptied
of exactly this kind of row in session 130 and is not the place to put one back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-19 06:09:12 +02:00
commit db38cfc1a4
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 605 additions and 27 deletions

View file

@ -430,6 +430,9 @@ def test_the_stage_vocabulary_covers_every_stage_the_validator_can_name(tmp_path
assert stages | {"other", "not_evaluated"} == set(rb.STAGE_PROSE)
assert len(stages) == 6
assert all(len(text) > 20 for text in rb.STAGE_PROSE.values())
# Both halves are load-bearing: the short name before the colon is what a one-line diff of
# what changed can carry, and without it the diff falls back on the raw stage id.
assert all(": " in text for text in rb.STAGE_PROSE.values())
def test_the_report_carries_no_json_dump_and_no_unexplained_identifier(tmp_path: Path) -> None:
@ -438,6 +441,7 @@ def test_the_report_carries_no_json_dump_and_no_unexplained_identifier(tmp_path:
text = _report(tmp_path)
assert "verdict_id" not in text
assert "claimed_saving_nok" not in text
assert not any(stage in text for stage in rb.STAGE_PROSE if stage.startswith("stage"))
assert '{"' not in text and "```json" not in text
@ -475,7 +479,8 @@ def test_the_report_follows_the_runs_own_order_not_a_sorted_one(tmp_path: Path)
considered = considered.split("\n## ", 1)[0]
at = [considered.index(label) for _a, label, *_ in reversed_spec]
assert at == sorted(at), "the report does not follow the coverage's own order"
assert at != sorted(considered.index(label) for _a, label, *_ in _SPEC), "unreversed"
forward = [considered.index(label) for _a, label, *_ in _SPEC]
assert forward == sorted(forward, reverse=True), "the report sorted instead of following it"
# ---------------------------------------------------------------------------------------------
@ -547,6 +552,8 @@ def test_round_one_reports_what_changed_and_admits_when_nothing_explains_it(
section = text.split("## Endret siden forrige runde", 1)[1].split("\n## ", 1)[0]
assert flipped[1] in section
assert "ingen tilbakemelding forklarer dette" in section.lower()
assert "stage4-p90" not in section, "a raw stage id reached the expert"
assert rb.STAGE_PROSE["stage4-p90"].split(":")[0] in section
# ---------------------------------------------------------------------------------------------