"""Tests for tuning/held-out split selection (``eval.splits``). The property that matters is that the split is a function of *which documents are cached*, not of the order they happen to be read in. A positional rule ("the first 100 files") would silently reassign documents between tuning and held-out when the glob order changes, when the cache is regenerated on another machine, or when new documents are added -- turning a held-out claim into an in-sample one with no visible failure. These tests pin the id-set semantics. """ from __future__ import annotations import json import random from pathlib import Path from typing import Any import pytest from eval.score import build_report, format_report from eval.splits import SplitError, describe, load_tuning_ids, manifest_path, select from tests.test_eval_revalidate import _clean_document, _real_entry def _write_manifest(tmp_path: Path, dataset: str, ids: list[str]) -> Path: path = manifest_path(dataset, splits_dir=tmp_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps({"dataset": dataset, "ids": ids}), encoding="utf-8") return path def _entries(ids: list[str]) -> list[dict[str, Any]]: return [{"id": i} for i in ids] # --------------------------------------------------------------------------- # Selection semantics # --------------------------------------------------------------------------- def test_all_returns_every_entry(tmp_path: Path) -> None: entries = _entries(["a", "b", "c"]) assert select(entries, "all", dataset="d", splits_dir=tmp_path) == entries def test_tuning_and_heldout_partition_the_cache(tmp_path: Path) -> None: """Every cached document lands in exactly one of the two splits.""" _write_manifest(tmp_path, "d", ["a", "b"]) entries = _entries(["a", "b", "c", "d"]) tuning = select(entries, "tuning", dataset="d", splits_dir=tmp_path) heldout = select(entries, "heldout", dataset="d", splits_dir=tmp_path) assert [e["id"] for e in tuning] == ["a", "b"] assert [e["id"] for e in heldout] == ["c", "d"] assert len(tuning) + len(heldout) == len(entries) assert not {e["id"] for e in tuning} & {e["id"] for e in heldout} def test_split_is_independent_of_read_order(tmp_path: Path) -> None: """Shuffling the cache does not move a single document between splits.""" _write_manifest(tmp_path, "d", ["a", "c", "e"]) ids = ["a", "b", "c", "d", "e", "f"] baseline = { name: {e["id"] for e in select(_entries(ids), name, dataset="d", splits_dir=tmp_path)} for name in ("tuning", "heldout") } rng = random.Random(0) for _ in range(20): shuffled = ids[:] rng.shuffle(shuffled) for name in ("tuning", "heldout"): got = {e["id"] for e in select(_entries(shuffled), name, dataset="d", splits_dir=tmp_path)} assert got == baseline[name], (name, shuffled) def test_selection_preserves_input_order(tmp_path: Path) -> None: """Surviving entries keep their relative order, so reports are stable.""" _write_manifest(tmp_path, "d", ["c", "a"]) entries = _entries(["a", "b", "c"]) assert [e["id"] for e in select(entries, "tuning", dataset="d", splits_dir=tmp_path)] == [ "a", "c", ] def test_manifest_membership_ignores_absent_ids(tmp_path: Path) -> None: """A manifest id not present in the cache simply selects nothing extra.""" _write_manifest(tmp_path, "d", ["a", "ghost"]) entries = _entries(["a", "b"]) tuning = select(entries, "tuning", dataset="d", splits_dir=tmp_path) assert [e["id"] for e in tuning] == ["a"] # --------------------------------------------------------------------------- # Failure modes -- a split must never resolve silently to the wrong thing # --------------------------------------------------------------------------- def test_missing_manifest_raises_with_guidance(tmp_path: Path) -> None: with pytest.raises(SplitError, match="No tuning manifest"): select(_entries(["a"]), "tuning", dataset="nope", splits_dir=tmp_path) def test_unknown_split_name_raises(tmp_path: Path) -> None: with pytest.raises(SplitError, match="Unknown split"): select(_entries(["a"]), "train", dataset="d", splits_dir=tmp_path) def test_empty_selection_raises_rather_than_reporting_nothing(tmp_path: Path) -> None: """A held-out split on an unexpanded cache is an error, not an empty report.""" _write_manifest(tmp_path, "d", ["a", "b"]) with pytest.raises(SplitError, match="selected 0 of"): select(_entries(["a", "b"]), "heldout", dataset="d", splits_dir=tmp_path) def test_malformed_manifest_raises(tmp_path: Path) -> None: path = manifest_path("d", splits_dir=tmp_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text("{not json", encoding="utf-8") with pytest.raises(SplitError, match="not valid JSON"): load_tuning_ids("d", splits_dir=tmp_path) def test_manifest_without_ids_raises(tmp_path: Path) -> None: path = manifest_path("d", splits_dir=tmp_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps({"dataset": "d", "ids": []}), encoding="utf-8") with pytest.raises(SplitError, match="declares no 'ids'"): load_tuning_ids("d", splits_dir=tmp_path) # --------------------------------------------------------------------------- # Reporting # --------------------------------------------------------------------------- def test_describe_marks_tuning_as_contaminated() -> None: assert "not independent" in describe("tuning", 100, 361) assert "HELD OUT" in describe("heldout", 261, 361) assert "mixes tuning and held-out" in describe("all", 361, 361) def test_report_header_states_the_split(tmp_path: Path) -> None: """A rendered report names its split and the size it was drawn from.""" from eval.cache import write_entry cache = tmp_path / "cache" splits = tmp_path / "splits" for name in ("keep", "drop"): write_entry( cache, "d", _real_entry(name, _clean_document(), gold={"total": "11.00"}) ) _write_manifest(splits, "d", ["keep"]) report = build_report( "d", cache_base=cache, split="heldout", splits_dir=splits, revalidate=True ) text = format_report(report) assert report.n == 1 assert report.n_cached == 2 assert "Split: heldout (1 of 2 cached)" in text assert "HELD OUT" in text