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:
parent
68079469c3
commit
0fa612a22f
3 changed files with 807 additions and 5 deletions
85
src/portfolio_optimiser/evals/round_builder.py
Normal file
85
src/portfolio_optimiser/evals/round_builder.py
Normal 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())
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue