From 8826f02f813e5862e679031f7bb5e2aaf417fa98 Mon Sep 17 00:00:00 2001 From: drewOrc <36374426+drewOrc@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:52:15 +0800 Subject: [PATCH 1/3] Check curve, ablation and baseline completion in code A shell loop once passed `make` one argument, `curve MODEL=bert`; make read it as a variable assignment, ran its default target and exited 0, so two curves were reported done with no point run. Exit 0 is not evidence of completion, so each index command now checks its own work. - Before running: the plan must be exactly the expected points, each once (curve: 6 k x 3 seeds; ablation: ModernBERT, k=100, 0 OOS rows x 3 seeds; baselines: 2 x 6 x 3). Otherwise it raises before any training, fitting or data loading. - After running: the index is written to a temporary file, read back, and must hold the same points, each once, with every archive's SHA-256 equal on disk, in the manifest and in the index. Only then is it moved into place and `completed 18/18 encoder points (bert)`, `completed 3/3 ablation points` or `completed 36/36 baseline points` printed. Any failure raises, exits non-zero and leaves the previous index untouched. The expected sets are literals in completeness.py rather than read from CURVE_KS / SEEDS, so a wrong loop cannot agree with its own check; a test pins that the two agree today. Three curve tests that ran a subset of a curve now run the full curve and filter to k=100. --- src/tinyrouter/baselines.py | 44 ++-- src/tinyrouter/completeness.py | 120 ++++++++++ src/tinyrouter/curves.py | 70 +++++- tests/test_completeness.py | 422 +++++++++++++++++++++++++++++++++ tests/test_curves.py | 16 +- 5 files changed, 637 insertions(+), 35 deletions(-) create mode 100644 src/tinyrouter/completeness.py create mode 100644 tests/test_completeness.py diff --git a/src/tinyrouter/baselines.py b/src/tinyrouter/baselines.py index c1dd2b3..57ca2b5 100644 --- a/src/tinyrouter/baselines.py +++ b/src/tinyrouter/baselines.py @@ -39,13 +39,19 @@ (docs/PLAN.md section 4.1). The factor was chosen so that the temperature fit (T in [0.05, 100]) has an interior optimum: on plain cosines it hit the lower bound at every k >= 5. + +``run_all`` checks its own completion (completeness.py): the plan must be +exactly 2 baselines x 6 values of k x 3 seeds, each once, before anything +is fit; the index is read back and must hold the same 36 points with +every archive's SHA-256 equal on disk, in the manifest and in the index +before it is moved into place and ``completed 36/36 baseline points`` is +printed. """ from __future__ import annotations import argparse import json -import os from collections.abc import Callable from pathlib import Path @@ -61,6 +67,7 @@ utc_now, ) from tinyrouter.calibrate import SplitLogits +from tinyrouter.completeness import BASELINE_KEY, BASELINE_POINTS, check_points, publish_index from tinyrouter.data import DATASET_REVISION, Split, load_split from tinyrouter.evaluate import RunPaths, score from tinyrouter.labels import load_label_space @@ -221,25 +228,34 @@ def environment() -> dict[str, object]: } +def planned_points() -> list[tuple[str, int, int]]: + """(baseline, k, seed) in run order: every baseline of one (k, seed) shares its sample.""" + return [(name, k, seed) for k in CURVE_KS for seed in SEEDS for name in BASELINES] + + def run_all( results_root: Path, sample_fn: SampleFn = curve_sample, eval_fn: EvalFn = load_split, # type: ignore[assignment] log: Log = print, ) -> dict[str, object]: - """Every baseline at every (k, seed); writes results/curves/baselines.json.""" + """Every baseline at every (k, seed); writes and verifies results/curves/baselines.json.""" + plan = planned_points() + check_points(plan, BASELINE_POINTS, "baselines plan") evals = {name: eval_fn(name) for name in ("validation", "test")} entries = [] - for k in CURVE_KS: - for seed in SEEDS: - train = sample_fn(k, seed) - for name in BASELINES: - record = run_baseline(name, k, seed, train, evals, results_root) - entries.append(index_entry(name, k, seed, record)) + samples: dict[tuple[int, int], Split] = {} + for name, k, seed in plan: + if (k, seed) not in samples: + samples[(k, seed)] = sample_fn(k, seed) + record = run_baseline(name, k, seed, samples[(k, seed)], evals, results_root) + entries.append(index_entry(name, k, seed, record)) + if name == BASELINES[-1]: log(f"baselines k={k} seed={seed}: done") body = {"baselines": {n: definition(n) for n in BASELINES}, "points": entries} out = results_root / "curves" / "baselines.json" - write_index(out, body) + count = publish_index(out, body, BASELINE_KEY, BASELINE_POINTS, results_root, "baselines") + log(f"completed {count}/{len(BASELINE_POINTS)} baseline points") return body @@ -259,19 +275,11 @@ def index_entry(name: str, k: int, seed: int, record: dict[str, object]) -> dict } -def write_index(path: Path, body: dict[str, object]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_name(path.name + ".tmp") - tmp.write_text(json.dumps(body, indent=2) + "\n", encoding="utf-8") - os.replace(tmp, path) - - def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--results-root", default="results") args = parser.parse_args(argv) - body = run_all(Path(args.results_root)) - print(f"wrote {len(body['points'])} baseline runs") # type: ignore[arg-type] + run_all(Path(args.results_root)) if __name__ == "__main__": diff --git a/src/tinyrouter/completeness.py b/src/tinyrouter/completeness.py new file mode 100644 index 0000000..50a845f --- /dev/null +++ b/src/tinyrouter/completeness.py @@ -0,0 +1,120 @@ +"""Completion checks for the curve, ablation and baseline indexes. + +A command that exits 0 is not evidence that it ran anything: a shell +loop once handed ``make`` a single argument ``curve MODEL=bert``, make +read it as a variable assignment, ran its default target and exited 0, +and two curves were reported done with no point run. So each index +command checks its own work twice and says so in one line only when +both checks pass: + +1. before running, the planned points are exactly the expected set, + each once (``check_points``); +2. after running, the index read back from disk has exactly the expected + set, each once, and every point's archive has the same SHA-256 on + disk, in the manifest and in the index (``publish_index``). The index + is moved into place only after this passes. + +The expected sets are written out here as literals, not derived from +``sampling.CURVE_KS`` or ``protocol.SEEDS``: a check that reads the same +constant as the loop it guards would agree with a wrong loop. +""" + +from __future__ import annotations + +import json +import os +from collections import Counter +from collections.abc import Iterable, Sequence +from pathlib import Path + +from tinyrouter.archive import MANIFEST_NAME, read_manifest +from tinyrouter.data import sha256_of +from tinyrouter.evaluate import RunPaths + +EXPECTED_KS = (1, 5, 10, 25, 50, 100) +EXPECTED_SEEDS = (42, 43, 44) +EXPECTED_BASELINES = ("majority", "tfidf-centroid") +ABLATION_K = 100 +ABLATION_OOS_ROWS = 0 + +Key = tuple[object, ...] +# Index fields that identify a point; ``oos_train`` is None on a curve and 0 on the ablation. +ENCODER_KEY = ("k", "seed", "oos_train") +BASELINE_KEY = ("baseline", "k", "seed") +CURVE_POINTS: frozenset[Key] = frozenset((k, s, None) for k in EXPECTED_KS for s in EXPECTED_SEEDS) +ABLATION_POINTS: frozenset[Key] = frozenset( + (ABLATION_K, s, ABLATION_OOS_ROWS) for s in EXPECTED_SEEDS +) +BASELINE_POINTS: frozenset[Key] = frozenset( + (b, k, s) for b in EXPECTED_BASELINES for k in EXPECTED_KS for s in EXPECTED_SEEDS +) + + +class IncompleteError(RuntimeError): + """A planned or written set of points is not exactly the expected one.""" + + +def check_points(keys: Iterable[Key], expected: frozenset[Key], what: str) -> None: + """``keys`` must be ``expected`` with every key exactly once.""" + counts = Counter(keys) + duplicated = sorted((k for k, n in counts.items() if n > 1), key=repr) + missing = sorted(expected - set(counts), key=repr) + unexpected = sorted(set(counts) - expected, key=repr) + if duplicated or missing or unexpected: + raise IncompleteError( + f"{what}: expected {len(expected)} unique points, got {sum(counts.values())} " + f"({len(counts)} unique); missing {missing}, duplicated {duplicated}, " + f"unexpected {unexpected}" + ) + + +def check_archives(points: Sequence[dict], results_root: Path, what: str) -> None: + """Each point's archive exists, with one SHA-256 on disk, in the manifest and in the index.""" + manifest = read_manifest(results_root / MANIFEST_NAME) + for point in points: + paths = RunPaths.named(results_root, str(point["run_name"])) + name = paths.logits.name + if point.get("logits_file") != name: + raise IncompleteError( + f"{what}: {point['run_name']} lists archive {point.get('logits_file')}" + ) + if not paths.logits.exists(): + raise IncompleteError(f"{what}: {paths.logits} is missing") + listed = manifest.get(name, {}).get("sha256") + on_disk = sha256_of(paths.logits) + if not point.get("logits_sha256") == listed == on_disk: + raise IncompleteError( + f"{what}: {name} SHA-256 differs: index {point.get('logits_sha256')}, " + f"manifest {listed}, file {on_disk}" + ) + + +def verify_index( + path: Path, key: Sequence[str], expected: frozenset[Key], results_root: Path, what: str +) -> int: + """Read the index at ``path`` and run both checks on it; returns the number of points.""" + points = json.loads(path.read_text(encoding="utf-8"))["points"] + check_points((tuple(p[f] for f in key) for p in points), expected, what) + check_archives(points, results_root, what) + return len(points) + + +def publish_index( + out: Path, + body: dict[str, object], + key: Sequence[str], + expected: frozenset[Key], + results_root: Path, + what: str, +) -> int: + """Write ``body`` to ``out`` only if, read back from disk, it passes ``verify_index``.""" + out.parent.mkdir(parents=True, exist_ok=True) + tmp = out.with_name(out.name + ".tmp") + tmp.write_text(json.dumps(body, indent=2) + "\n", encoding="utf-8") + try: + count = verify_index(tmp, key, expected, results_root, what) + except Exception: + tmp.unlink(missing_ok=True) + raise + os.replace(tmp, out) + return count diff --git a/src/tinyrouter/curves.py b/src/tinyrouter/curves.py index e828fba..f9f4166 100644 --- a/src/tinyrouter/curves.py +++ b/src/tinyrouter/curves.py @@ -22,6 +22,16 @@ ``min_train_steps``), the steps actually run, and ``reused_from`` when the point is an AC2 run. +Completion is checked, not inferred from the exit code (completeness.py). +Before any point runs, the configs must be exactly 6 values of k x 3 +seeds (the ablation: k=100 with 0 OOS rows x 3 seeds) of the named +model, each once. After, the index is written to a temporary file, read +back and checked again: the same points, each once, and every archive's +SHA-256 equal on disk, in the manifest and in the index. Only then is it +moved into place and ``completed 18/18 encoder points (bert)`` (or +``completed 3/3 ablation points``) printed; any failure raises and the +command exits non-zero. + Every point's training sample is checked against ``curve_sample(k, seed)`` recomputed now (``sample_fingerprint``). The encoder's resume check compares configs only, so a numpy release that changed the sampler's @@ -34,16 +44,28 @@ from __future__ import annotations import argparse -import json -import os from collections.abc import Callable from functools import cache from pathlib import Path from tinyrouter.baselines import run_all as run_baselines +from tinyrouter.completeness import ( + ABLATION_POINTS, + CURVE_POINTS, + ENCODER_KEY, + Key, + check_points, + publish_index, +) from tinyrouter.config import RunConfig from tinyrouter.evaluate import RunPaths -from tinyrouter.protocol import MODELS, CurveProtocol, ac2_donor, load_protocol +from tinyrouter.protocol import ( + ABLATION_MODEL, + MODELS, + CurveProtocol, + ac2_donor, + load_protocol, +) from tinyrouter.runs import ( EvaluateFn, Log, @@ -60,6 +82,7 @@ FingerprintFn = Callable[[RunConfig], str] BACKFILLED_SAMPLE = "computed at index time from curve_sample(k, seed); the run predates the field" +ABLATION_NAME = "oos-ablation" @cache @@ -154,6 +177,31 @@ def run_point( return index_entry(config, record, None, fingerprint_fn) +def planned_shape(name: str, protocol: CurveProtocol) -> tuple[frozenset[Key], str]: + """The (k, seed, oos_train) points and the model the index called ``name`` must hold.""" + if name == ABLATION_NAME: + return ABLATION_POINTS, protocol.base(ABLATION_MODEL).model_name + if name in MODELS: + return CURVE_POINTS, protocol.base(name).model_name + raise CurveError(f"unknown curve '{name}'; expected one of {(*MODELS, ABLATION_NAME)}") + + +def check_plan(name: str, configs: list[RunConfig], protocol: CurveProtocol) -> frozenset[Key]: + """Refuse to start unless ``configs`` are the expected points of one model, each once.""" + expected, model = planned_shape(name, protocol) + others = sorted({c.model_name for c in configs} - {model}) + if others: + raise CurveError(f"{name}: configs for {others}; this index holds only {model}") + check_points(((c.k_shot, c.seed, c.oos_train) for c in configs), expected, f"{name} plan") + return expected + + +def completion_message(name: str, count: int, expected: int) -> str: + if name == ABLATION_NAME: + return f"completed {count}/{expected} ablation points" + return f"completed {count}/{expected} encoder points ({name})" + + def run_curve( name: str, configs: list[RunConfig], @@ -163,7 +211,8 @@ def run_curve( log: Log = print, fingerprint_fn: FingerprintFn | None = None, ) -> dict[str, object]: - """Run or resume every config, then write results/curves/.json.""" + """Run or resume every config, then write and verify results/curves/.json.""" + expected = check_plan(name, configs, protocol) entries = [run_point(c, protocol, train_fn, evaluate_fn, log, fingerprint_fn) for c in configs] shas = [e["logits_sha256"] for e in entries] if len(set(shas)) != len(shas): @@ -177,12 +226,11 @@ def run_curve( "min_train_steps": first.min_train_steps, "points": entries, } - out = Path(first.results_root) / "curves" / f"{name}.json" - out.parent.mkdir(parents=True, exist_ok=True) - tmp = out.with_name(out.name + ".tmp") - tmp.write_text(json.dumps(body, indent=2) + "\n", encoding="utf-8") - os.replace(tmp, out) - log(f"wrote {out} ({len(entries)} points)") + root = Path(first.results_root) + out = root / "curves" / f"{name}.json" + count = publish_index(out, body, ENCODER_KEY, expected, root, name) + log(f"wrote {out} ({count} points)") + log(completion_message(name, count, len(expected))) return body @@ -197,7 +245,7 @@ def main(argv: list[str] | None = None) -> None: # Build every config first: a protocol still missing a pilot value stops here, # before anything is computed or written. if args.ablation: - run_curve("oos-ablation", protocol.ablation_configs(), protocol) + run_curve(ABLATION_NAME, protocol.ablation_configs(), protocol) return configs = protocol.curve_configs(args.model) run_baselines(Path(configs[0].results_root)) diff --git a/tests/test_completeness.py b/tests/test_completeness.py new file mode 100644 index 0000000..75e4d2e --- /dev/null +++ b/tests/test_completeness.py @@ -0,0 +1,422 @@ +"""Completion checks: a curve, the ablation or the baselines say "completed" only when they are. + +Each index is checked twice, before running (the plan) and after (the +index read back from disk plus every archive's SHA-256), and each check +has its own missing-point and duplicated-point test here. +""" + +import json +import shutil +from pathlib import Path + +import pytest + +import tinyrouter.curves as curves +from run_fakes import FakeRuns, fake_fingerprint +from test_baselines import EVALS, TRAIN +from test_pilots import make_protocol +from tinyrouter import baselines, completeness +from tinyrouter.archive import read_manifest, write_manifest +from tinyrouter.completeness import IncompleteError +from tinyrouter.curves import CurveError, run_curve +from tinyrouter.evaluate import RunPaths +from tinyrouter.protocol import SEEDS +from tinyrouter.sampling import CURVE_KS + +pytestmark = pytest.mark.filterwarnings("ignore::tinyrouter.calibrate.TemperatureBoundWarning") + + +@pytest.fixture(autouse=True) +def fake_samples(monkeypatch): + monkeypatch.setattr(curves, "expected_fingerprint", fake_fingerprint) + + +@pytest.fixture +def protocol(tmp_path): + return make_protocol(tmp_path, s_min=400, bert_lr=5e-5, modern_lr=2e-5) + + +class Log: + def __init__(self, index: Path | None = None) -> None: + self.lines: list[str] = [] + self.index = index + self.index_existed_at_completion: bool | None = None + + def __call__(self, line: str) -> None: + if line.startswith("completed") and self.index is not None: + self.index_existed_at_completion = self.index.exists() + self.lines.append(line) + + def completed(self) -> list[str]: + return [line for line in self.lines if line.startswith("completed")] + + +def curve_index(tmp_path: Path, name: str) -> Path: + return tmp_path / "res" / "curves" / f"{name}.json" + + +def assert_nothing_published(tmp_path: Path, name: str, log: Log) -> None: + index = curve_index(tmp_path, name) + assert not index.exists() + assert not index.with_name(index.name + ".tmp").exists() + assert log.completed() == [] + + +def test_the_pinned_expectations_match_the_constants_the_loops_use_today(): + assert completeness.EXPECTED_KS == CURVE_KS + assert completeness.EXPECTED_SEEDS == SEEDS + assert completeness.EXPECTED_BASELINES == baselines.BASELINES + assert len(completeness.CURVE_POINTS) == 18 + assert len(completeness.ABLATION_POINTS) == 3 + assert len(completeness.BASELINE_POINTS) == 36 + + +# Encoder curves ------------------------------------------------------------- + + +def test_a_full_curve_says_completed_18_of_18_after_its_index_is_in_place(tmp_path, protocol): + fake = FakeRuns() + log = Log(curve_index(tmp_path, "modernbert")) + run_curve( + "modernbert", protocol.curve_configs("modernbert"), protocol, fake.train, fake.evaluate, log + ) + assert log.completed() == ["completed 18/18 encoder points (modernbert)"] + assert log.lines[-1] == "completed 18/18 encoder points (modernbert)" + assert log.index_existed_at_completion is True + + +def test_curve_plan_missing_a_point_is_refused_before_anything_trains(tmp_path, protocol): + fake, log = FakeRuns(), Log() + configs = protocol.curve_configs("bert")[:-1] + with pytest.raises(IncompleteError, match=r"bert plan: .*missing \[\(100, 44, None\)\]"): + run_curve("bert", configs, protocol, fake.train, fake.evaluate, log) + assert fake.trained == [] + assert_nothing_published(tmp_path, "bert", log) + + +def test_curve_plan_repeating_a_point_is_refused_before_anything_trains(tmp_path, protocol): + fake, log = FakeRuns(), Log() + configs = protocol.curve_configs("bert") + configs = [*configs[:-1], configs[0]] # still 18 configs, 17 distinct + with pytest.raises(IncompleteError, match=r"duplicated \[\(1, 42, None\)\]"): + run_curve("bert", configs, protocol, fake.train, fake.evaluate, log) + assert fake.trained == [] + assert_nothing_published(tmp_path, "bert", log) + + +def test_curve_plan_for_another_model_is_refused(tmp_path, protocol): + fake, log = FakeRuns(), Log() + with pytest.raises(CurveError, match="holds only"): + run_curve( + "modernbert", protocol.curve_configs("bert"), protocol, fake.train, fake.evaluate, log + ) + assert fake.trained == [] + + +def test_an_unknown_index_name_is_refused(protocol): + with pytest.raises(CurveError, match="unknown curve"): + run_curve("bert-v2", protocol.curve_configs("bert"), protocol, log=Log()) + + +def tamper_point(monkeypatch, target: tuple[int, int], change) -> None: + """Let run_point run for real, then pass ``change(entry, config)`` over one point's entry.""" + real = curves.run_point + + def tampered(config, *args): + entry = real(config, *args) + if (config.k_shot, config.seed) == target: + change(entry, config) + return entry + + monkeypatch.setattr(curves, "run_point", tampered) + + +def run_modernbert(protocol, log: Log) -> None: + fake = FakeRuns() + run_curve( + "modernbert", protocol.curve_configs("modernbert"), protocol, fake.train, fake.evaluate, log + ) + + +def test_curve_index_missing_a_point_is_refused_after_running(tmp_path, protocol, monkeypatch): + tamper_point(monkeypatch, (5, 44), lambda entry, _: entry.update(k=7)) + log = Log() + with pytest.raises(IncompleteError, match=r"modernbert: .*missing \[\(5, 44, None\)\]"): + run_modernbert(protocol, log) + assert_nothing_published(tmp_path, "modernbert", log) + + +def test_curve_index_repeating_a_point_is_refused_after_running(tmp_path, protocol, monkeypatch): + tamper_point(monkeypatch, (5, 44), lambda entry, _: entry.update(seed=43)) + log = Log() + with pytest.raises(IncompleteError, match=r"modernbert: .*duplicated \[\(5, 43, None\)\]"): + run_modernbert(protocol, log) + assert_nothing_published(tmp_path, "modernbert", log) + + +def test_curve_index_is_refused_when_an_archive_changed_after_its_point_ran( + tmp_path, protocol, monkeypatch +): + def corrupt(_, config): + archive = RunPaths.of(config).logits + archive.write_bytes(archive.read_bytes() + b"x") + + tamper_point(monkeypatch, (10, 43), corrupt) + log = Log() + with pytest.raises(IncompleteError, match="SHA-256 differs"): + run_modernbert(protocol, log) + assert_nothing_published(tmp_path, "modernbert", log) + + +def test_curve_index_is_refused_when_the_manifest_disagrees(tmp_path, protocol, monkeypatch): + def relist(_, config): + paths = RunPaths.of(config) + files = read_manifest(paths.manifest) + files[paths.logits.name]["sha256"] = "0" * 64 + write_manifest(paths.manifest, files) + + tamper_point(monkeypatch, (10, 43), relist) + log = Log() + with pytest.raises(IncompleteError, match="SHA-256 differs"): + run_modernbert(protocol, log) + assert_nothing_published(tmp_path, "modernbert", log) + + +def test_curve_index_is_refused_when_an_archive_is_gone(tmp_path, protocol, monkeypatch): + tamper_point(monkeypatch, (1, 42), lambda _, config: RunPaths.of(config).logits.unlink()) + log = Log() + with pytest.raises(IncompleteError, match="is missing"): + run_modernbert(protocol, log) + assert_nothing_published(tmp_path, "modernbert", log) + + +def test_a_failed_rerun_leaves_the_previous_verified_index_untouched( + tmp_path, protocol, monkeypatch +): + run_modernbert(protocol, Log()) + before = curve_index(tmp_path, "modernbert").read_bytes() + tamper_point(monkeypatch, (5, 44), lambda entry, _: entry.update(seed=43)) + with pytest.raises(IncompleteError): + run_modernbert(protocol, Log()) + assert curve_index(tmp_path, "modernbert").read_bytes() == before + + +# OOS ablation --------------------------------------------------------------- + + +def test_the_ablation_says_completed_3_of_3(tmp_path, protocol): + fake = FakeRuns() + log = Log(curve_index(tmp_path, "oos-ablation")) + run_curve("oos-ablation", protocol.ablation_configs(), protocol, fake.train, fake.evaluate, log) + assert log.completed() == ["completed 3/3 ablation points"] + assert log.index_existed_at_completion is True + + +def test_ablation_plan_missing_a_seed_is_refused_before_anything_trains(tmp_path, protocol): + fake, log = FakeRuns(), Log() + with pytest.raises(IncompleteError, match=r"oos-ablation plan: .*missing \[\(100, 44, 0\)\]"): + run_curve( + "oos-ablation", + protocol.ablation_configs()[:2], + protocol, + fake.train, + fake.evaluate, + log, + ) + assert fake.trained == [] + assert_nothing_published(tmp_path, "oos-ablation", log) + + +def test_ablation_plan_repeating_a_seed_is_refused_before_anything_trains(tmp_path, protocol): + fake, log = FakeRuns(), Log() + first, second, _ = protocol.ablation_configs() + with pytest.raises(IncompleteError, match=r"duplicated \[\(100, 42, 0\)\]"): + run_curve("oos-ablation", [first, second, first], protocol, fake.train, fake.evaluate, log) + assert fake.trained == [] + assert_nothing_published(tmp_path, "oos-ablation", log) + + +def test_ablation_plan_with_oos_rows_is_refused(tmp_path, protocol): + fake, log = FakeRuns(), Log() + with_oos = [protocol.curve_config("modernbert", 100, seed) for seed in (42, 43, 44)] + with pytest.raises(IncompleteError, match=r"unexpected \[\(100, 42, None\)"): + run_curve("oos-ablation", with_oos, protocol, fake.train, fake.evaluate, log) + assert fake.trained == [] + + +def run_ablation(protocol, log: Log) -> None: + fake = FakeRuns() + run_curve("oos-ablation", protocol.ablation_configs(), protocol, fake.train, fake.evaluate, log) + + +def test_ablation_index_missing_a_point_is_refused_after_running(tmp_path, protocol, monkeypatch): + tamper_point(monkeypatch, (100, 44), lambda entry, _: entry.update(oos_train=None)) + log = Log() + with pytest.raises(IncompleteError, match=r"oos-ablation: .*missing \[\(100, 44, 0\)\]"): + run_ablation(protocol, log) + assert_nothing_published(tmp_path, "oos-ablation", log) + + +def test_ablation_index_repeating_a_point_is_refused_after_running(tmp_path, protocol, monkeypatch): + tamper_point(monkeypatch, (100, 44), lambda entry, _: entry.update(seed=42)) + log = Log() + with pytest.raises(IncompleteError, match=r"oos-ablation: .*duplicated \[\(100, 42, 0\)\]"): + run_ablation(protocol, log) + assert_nothing_published(tmp_path, "oos-ablation", log) + + +# Baselines ------------------------------------------------------------------ + + +@pytest.fixture(scope="module") +def baseline_results(tmp_path_factory): + """One full baseline run on the synthetic data, shared read-only by the tests below.""" + import tinyrouter.sampling as sampling + + root = tmp_path_factory.mktemp("baselines") + patch = pytest.MonkeyPatch() + patch.setattr(sampling, "load_split", lambda name: TRAIN) + log = Log(root / "curves" / "baselines.json") + try: + baselines.run_all(root, eval_fn=EVALS.__getitem__, log=log) + finally: + patch.undo() + return root, log + + +@pytest.fixture +def baseline_copy(baseline_results, tmp_path, monkeypatch): + """A private copy of the finished run; rerunning on it refits nothing (every run is intact).""" + import tinyrouter.sampling as sampling + + monkeypatch.setattr(sampling, "load_split", lambda name: TRAIN) + root = tmp_path / "results" + shutil.copytree(baseline_results[0], root) + return root + + +def rerun_baselines(root: Path, log: Log) -> None: + baselines.run_all(root, eval_fn=EVALS.__getitem__, log=log) + + +def never_load(name: str): + raise AssertionError(f"loaded {name}: the plan check must come before any work") + + +def test_the_baselines_say_completed_36_of_36_after_their_index_is_in_place(baseline_results): + root, log = baseline_results + assert log.completed() == ["completed 36/36 baseline points"] + assert log.lines[-1] == "completed 36/36 baseline points" + assert log.index_existed_at_completion is True + assert len(json.loads((root / "curves" / "baselines.json").read_text())["points"]) == 36 + + +def test_baseline_plan_missing_a_k_is_refused_before_anything_runs(tmp_path, monkeypatch): + monkeypatch.setattr(baselines, "CURVE_KS", (1, 5, 10, 25, 50)) + log = Log() + with pytest.raises(IncompleteError, match=r"baselines plan: expected 36 unique points, got 30"): + baselines.run_all(tmp_path, eval_fn=never_load, log=log) + assert list(tmp_path.iterdir()) == [] and log.completed() == [] + + +def test_baseline_plan_repeating_a_k_is_refused_before_anything_runs(tmp_path, monkeypatch): + monkeypatch.setattr(baselines, "CURVE_KS", (1, 5, 10, 25, 50, 100, 100)) + log = Log() + with pytest.raises(IncompleteError, match=r"baselines plan: .*missing \[\], duplicated \[\("): + baselines.run_all(tmp_path, eval_fn=never_load, log=log) + assert list(tmp_path.iterdir()) == [] and log.completed() == [] + + +def tamper_baseline(monkeypatch, target: tuple[str, int, int], **changes) -> None: + real = baselines.index_entry + + def tampered(name, k, seed, record): + entry = real(name, k, seed, record) + if (name, k, seed) == target: + entry.update(changes) + return entry + + monkeypatch.setattr(baselines, "index_entry", tampered) + + +def test_baseline_index_missing_a_point_is_refused_after_running(baseline_copy, monkeypatch): + before = (baseline_copy / "curves" / "baselines.json").read_bytes() + tamper_baseline(monkeypatch, ("majority", 25, 44), baseline="knn") + log = Log() + with pytest.raises(IncompleteError, match=r"baselines: .*missing \[\('majority', 25, 44\)\]"): + rerun_baselines(baseline_copy, log) + assert log.completed() == [] + assert (baseline_copy / "curves" / "baselines.json").read_bytes() == before + + +def test_baseline_index_repeating_a_point_is_refused_after_running(baseline_copy, monkeypatch): + tamper_baseline(monkeypatch, ("majority", 25, 44), seed=43) + log = Log() + with pytest.raises(IncompleteError, match=r"duplicated \[\('majority', 25, 43\)\]"): + rerun_baselines(baseline_copy, log) + assert log.completed() == [] + assert not (baseline_copy / "curves" / "baselines.json.tmp").exists() + + +def test_baseline_index_is_refused_when_an_archive_changes_during_the_run( + baseline_copy, monkeypatch +): + real = baselines.index_entry + + def corrupting(name, k, seed, record): + entry = real(name, k, seed, record) + if (name, k, seed) == ("tfidf-centroid", 1, 42): + archive = RunPaths.named(baseline_copy, entry["run_name"]).logits + archive.write_bytes(archive.read_bytes() + b"x") + return entry + + monkeypatch.setattr(baselines, "index_entry", corrupting) + log = Log() + with pytest.raises(IncompleteError, match="SHA-256 differs"): + rerun_baselines(baseline_copy, log) + assert log.completed() == [] + + +# The index checker on its own ------------------------------------------------ + + +def written_index(root: Path, points: list[dict]) -> Path: + path = root / "check.json" + path.write_text(json.dumps({"points": points})) + return path + + +def test_verify_index_counts_a_complete_baseline_index(baseline_results, tmp_path): + root, _ = baseline_results + points = json.loads((root / "curves" / "baselines.json").read_text())["points"] + path = written_index(tmp_path, points) + count = completeness.verify_index( + path, completeness.BASELINE_KEY, completeness.BASELINE_POINTS, root, "baselines" + ) + assert count == 36 + + +def test_verify_index_refuses_an_index_one_point_short(baseline_results, tmp_path): + root, _ = baseline_results + points = json.loads((root / "curves" / "baselines.json").read_text())["points"][:-1] + with pytest.raises(IncompleteError, match="got 35 .35 unique.; missing"): + completeness.verify_index( + written_index(tmp_path, points), + completeness.BASELINE_KEY, + completeness.BASELINE_POINTS, + root, + "baselines", + ) + + +def test_verify_index_refuses_an_index_with_a_point_twice(baseline_results, tmp_path): + root, _ = baseline_results + points = json.loads((root / "curves" / "baselines.json").read_text())["points"] + with pytest.raises(IncompleteError, match="got 37 .36 unique.; missing \\[\\], duplicated"): + completeness.verify_index( + written_index(tmp_path, [*points, points[0]]), + completeness.BASELINE_KEY, + completeness.BASELINE_POINTS, + root, + "baselines", + ) diff --git a/tests/test_curves.py b/tests/test_curves.py index 26fcf18..5f84090 100644 --- a/tests/test_curves.py +++ b/tests/test_curves.py @@ -82,10 +82,12 @@ def test_an_ac2_seed_whose_archive_changed_is_retrained_not_reused(tmp_path): ac2_runs(protocol) archive = RunPaths.of(protocol.base("bert").with_seed(43)).logits archive.write_bytes(archive.read_bytes() + b"x") - configs = [c for c in protocol.curve_configs("bert") if c.k_shot == 100] fake = FakeRuns() - body = run_curve("bert", configs, protocol, fake.train, fake.evaluate, quiet) - assert [p["reused_from"] is not None for p in body["points"]] == [True, False, True] + body = run_curve( + "bert", protocol.curve_configs("bert"), protocol, fake.train, fake.evaluate, quiet + ) + full = [p for p in body["points"] if p["k"] == 100] + assert [p["reused_from"] is not None for p in full] == [True, False, True] def test_curve_keeps_no_weights_and_leaves_ac2_seed_42_weights(tmp_path): @@ -242,7 +244,7 @@ def truncating(config, model_dir): def test_a_point_trained_on_another_sample_than_curve_sample_now_gives_is_refused(tmp_path): protocol = make_protocol(tmp_path, s_min=400, bert_lr=5e-5, modern_lr=2e-5) - configs = protocol.curve_configs("modernbert")[:3] + configs = protocol.curve_configs("modernbert") fake = FakeRuns() run_curve("modernbert", configs, protocol, fake.train, fake.evaluate, quiet) @@ -258,10 +260,12 @@ def test_reused_ac2_points_get_the_fingerprint_computed_now_and_say_so(tmp_path) protocol = make_protocol(tmp_path, s_min=400, bert_lr=5e-5, modern_lr=2e-5) ac2_runs(protocol) - configs = [c for c in protocol.curve_configs("bert") if c.k_shot == 100] + configs = protocol.curve_configs("bert") fake = FakeRuns() body = run_curve("bert", configs, protocol, fake.train, fake.evaluate, quiet) - for point, config in zip(body["points"], configs, strict=True): + full = [(p, c) for p, c in zip(body["points"], configs, strict=True) if c.k_shot == 100] + assert len(full) == 3 + for point, config in full: assert point["reused_from"] is not None assert point["train_sample_sha256"] == fake_fingerprint(config) assert point["train_sample_sha256_source"] == BACKFILLED_SAMPLE From 62f3dd242946e945a65dc6a22d7c798b306d6973 Mon Sep 17 00:00:00 2001 From: drewOrc <36374426+drewOrc@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:15:32 +0800 Subject: [PATCH 2/3] Refuse a make call with no target; check the written index and shared archives A quoted `make "curve MODEL=bert"` is one argument that make reads as a variable assignment; with no goal it ran the first target (setup) and exited 0. The Makefile now stops with an error when no target is given, so the 2026-09-23 failure is non-zero at its entry point, not only missing a completion line. Nothing in the README or CI relied on a bare make running setup. tests/test_makefile.py pins both the refused quoted form and the working split form (`make -n` only, nothing runs). Also: - A test that the check reads the temporary index file from disk, not the body in memory: a write that loses a point must fail. - check_archives refuses two points that list the same archive file or SHA-256, which the baseline index did not check before. - Makefile comments and README say to match the whole completion line (`completed 18/18 encoder points ()`), since `make curve` prints the baselines' `completed 36/36` line before the curve runs. --- Makefile | 17 ++++++++++++++ README.md | 4 ++++ src/tinyrouter/completeness.py | 16 ++++++++++--- tests/test_completeness.py | 40 ++++++++++++++++++++++++++++++++ tests/test_makefile.py | 42 ++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 tests/test_makefile.py diff --git a/Makefile b/Makefile index 3a36775..b17fe40 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,16 @@ UV_ENV := $(if $(ENV_FILE),--env-file $(ENV_FILE),) # checkpoint, and can evict files mid-training. See README "iCloud". NOSYNC_LINKS := .venv checkpoints +# No default target. On 2026-09-23 a zsh loop ran `make $t` with +# t="curve MODEL=bert"; make got one argument, read it as a variable +# assignment, ran the first target (setup) and exited 0, and two curves +# were reported done with no point run. A bare `make` (or only variable +# assignments) now fails instead of silently running setup. +# tests/test_makefile.py pins this. +ifeq ($(strip $(MAKECMDGOALS)),) +$(error no target given; a quoted "curve MODEL=bert" is one variable assignment, not a target) +endif + setup: @for name in $(NOSYNC_LINKS); do \ if [ -L "$$name" ]; then continue; fi; \ @@ -73,6 +83,13 @@ ac2: # never edit configs/curve.yaml. Curves and the ablation resume like ac2 and # keep no weights, only logits. `curve` runs the cheap baselines first. # `make baselines` runs them alone. +# Completion: each command's last line is `completed N/N ...` only after its +# index passed the checks in src/tinyrouter/completeness.py. `make curve` +# prints `completed 36/36 baseline points` before the curve starts, so a +# curve that fails later still has one completed line in its output. To +# decide a curve is done, match the whole line +# `completed 18/18 encoder points ()` (the ablation: +# `completed 3/3 ablation points`), never just `completed`. pilot-lr: uv run $(UV_ENV) python -m tinyrouter.pilots lr diff --git a/README.md b/README.md index 6a304ac..9217418 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ Other targets: Run every target from the repository root: `checkpoint_root` and `results_root` in the configs are relative paths. +There is no default target: a bare `make`, or a quoted `make "curve MODEL=bert"` (one argument, which make reads as a variable assignment), stops with an error instead of running `make setup` and exiting 0. + +**How to tell a curve finished.** Not from the exit code alone. `make baselines`, `make curve` and `make oos-ablation` end with one line that is printed only after the index was read back from disk and checked: exactly the expected points, each once, and every archive's SHA-256 equal on disk, in the manifest and in the index. Match the whole line: `completed 36/36 baseline points`, `completed 18/18 encoder points (bert)` (or `(modernbert)`), `completed 3/3 ablation points`. `make curve` runs the baselines first and prints their `completed 36/36 baseline points` before any encoder point, so a curve that fails afterwards still has a line starting with `completed` in its output; grepping for `completed` alone is not enough. + **Tuning after an AC2 FAIL uses validation only.** `results/ac2.json` and the `make ac2` printout list each seed's validation in-scope accuracy, 8-way accuracy and OOS recall for that purpose; the test numbers are the verdict and are not looked at while choosing hyperparameters. Put `HF_TOKEN` and `ANTHROPIC_API_KEY` in a `.env` (copy `.env.example`) if you need them; the Makefile passes it to `uv run`. Nothing in the tests or the smoke run calls a paid API. diff --git a/src/tinyrouter/completeness.py b/src/tinyrouter/completeness.py index 50a845f..0ac603a 100644 --- a/src/tinyrouter/completeness.py +++ b/src/tinyrouter/completeness.py @@ -11,8 +11,11 @@ each once (``check_points``); 2. after running, the index read back from disk has exactly the expected set, each once, and every point's archive has the same SHA-256 on - disk, in the manifest and in the index (``publish_index``). The index - is moved into place only after this passes. + disk, in the manifest and in the index, and no two points share an + archive (``publish_index``). The index is written to a temporary file + and that file, not the in-memory body, is what gets checked, so a + short or failed write is caught too; it is moved into place only after + this passes. The expected sets are written out here as literals, not derived from ``sampling.CURVE_KS`` or ``protocol.SEEDS``: a check that reads the same @@ -69,11 +72,18 @@ def check_points(keys: Iterable[Key], expected: frozenset[Key], what: str) -> No def check_archives(points: Sequence[dict], results_root: Path, what: str) -> None: - """Each point's archive exists, with one SHA-256 on disk, in the manifest and in the index.""" + """Each point has its own archive, whose SHA-256 agrees on disk, in manifest and index.""" manifest = read_manifest(results_root / MANIFEST_NAME) + owner: dict[object, object] = {} for point in points: paths = RunPaths.named(results_root, str(point["run_name"])) name = paths.logits.name + for held in (point.get("logits_file"), point.get("logits_sha256")): + if held in owner: + raise IncompleteError( + f"{what}: {point['run_name']} and {owner[held]} share one logits archive" + ) + owner[held] = point["run_name"] if point.get("logits_file") != name: raise IncompleteError( f"{what}: {point['run_name']} lists archive {point.get('logits_file')}" diff --git a/tests/test_completeness.py b/tests/test_completeness.py index 75e4d2e..c928ee5 100644 --- a/tests/test_completeness.py +++ b/tests/test_completeness.py @@ -420,3 +420,43 @@ def test_verify_index_refuses_an_index_with_a_point_twice(baseline_results, tmp_ root, "baselines", ) + + +def test_the_index_checked_is_the_file_on_disk_not_the_body_in_memory( + tmp_path, protocol, monkeypatch +): + """A write that loses a point must fail the check even though the body in memory is whole.""" + real_write = Path.write_text + + def short_write(self, text, *args, **kwargs): + if self.name == "modernbert.json.tmp": + body = json.loads(text) + body["points"] = body["points"][:-1] + text = json.dumps(body) + return real_write(self, text, *args, **kwargs) + + monkeypatch.setattr(Path, "write_text", short_write) + log = Log() + with pytest.raises(IncompleteError, match=r"modernbert: .*missing \[\(100, 44, None\)\]"): + run_modernbert(protocol, log) + assert_nothing_published(tmp_path, "modernbert", log) + + +def test_baseline_index_is_refused_when_two_points_share_one_archive(baseline_copy, monkeypatch): + real = baselines.index_entry + seen: dict[tuple[str, int, int], dict] = {} + + def sharing(name, k, seed, record): + entry = real(name, k, seed, record) + seen[(name, k, seed)] = entry + if (name, k, seed) == ("majority", 25, 44): + donor = seen[("majority", 25, 43)] + for field in ("run_name", "logits_file", "logits_sha256"): + entry[field] = donor[field] + return entry + + monkeypatch.setattr(baselines, "index_entry", sharing) + log = Log() + with pytest.raises(IncompleteError, match="share one logits archive"): + rerun_baselines(baseline_copy, log) + assert log.completed() == [] diff --git a/tests/test_makefile.py b/tests/test_makefile.py new file mode 100644 index 0000000..c7ccc27 --- /dev/null +++ b/tests/test_makefile.py @@ -0,0 +1,42 @@ +"""The Makefile has no default target (incident of 2026-09-23). + +A zsh loop ran ``make $t`` with ``t="curve MODEL=bert"``. zsh does not +split an unquoted parameter, so make got one argument, read it as the +variable assignment ``curve MODEL`` = ``bert``, ran the first target +(``setup``) and exited 0; two curves were reported done with no point +run. ``make -n`` only prints the recipe, so these tests run nothing. +""" + +import subprocess +from pathlib import Path + +ROOT = Path(__file__).parent.parent + + +def make_dry_run(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["make", "-n", *args], cwd=ROOT, capture_output=True, text=True, check=False + ) + + +def test_a_quoted_target_and_variable_is_refused_not_run_as_setup(): + result = make_dry_run("curve MODEL=bert") + assert result.returncode != 0 + assert "no target given" in result.stderr + assert "uv sync" not in result.stdout + + +def test_a_bare_make_is_refused(): + assert make_dry_run().returncode != 0 + + +def test_the_same_words_split_into_target_and_variable_still_work(): + result = make_dry_run("curve", "MODEL=bert") + assert result.returncode == 0, result.stderr + assert "tinyrouter.curves --model bert" in result.stdout + + +def test_an_explicit_setup_still_works(): + result = make_dry_run("setup") + assert result.returncode == 0, result.stderr + assert "uv sync --locked" in result.stdout From b351cde6061b6c2ffd7b0f9c13484bf624bdaab4 Mon Sep 17 00:00:00 2001 From: drewOrc <36374426+drewOrc@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:20:53 +0800 Subject: [PATCH 3/3] Make the no-target tests hold on make 3.81 and 4.x GNU make 4.x (the CI runner) reads the quoted "curve MODEL=bert" as a target name and stops with "No rule to make target"; make 3.81 (macOS) reads it as an assignment and now stops at the guard. The quoted-form test accepts either refusal; the guard itself is pinned by a bare `make` and by `make MODEL=bert`, which every version treats as an assignment with no goal. The dry runs drop inherited MAKEFLAGS and MAKELEVEL so they behave the same under `make test`. --- README.md | 2 +- tests/test_makefile.py | 26 +++++++++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9217418..cbd5296 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Other targets: Run every target from the repository root: `checkpoint_root` and `results_root` in the configs are relative paths. -There is no default target: a bare `make`, or a quoted `make "curve MODEL=bert"` (one argument, which make reads as a variable assignment), stops with an error instead of running `make setup` and exiting 0. +There is no default target: a bare `make`, or a quoted `make "curve MODEL=bert"` (one argument: GNU make 3.81, the macOS default, reads it as a variable assignment and used to run `make setup` and exit 0; make 4.x reads it as an unknown target), stops with an error. **How to tell a curve finished.** Not from the exit code alone. `make baselines`, `make curve` and `make oos-ablation` end with one line that is printed only after the index was read back from disk and checked: exactly the expected points, each once, and every archive's SHA-256 equal on disk, in the manifest and in the index. Match the whole line: `completed 36/36 baseline points`, `completed 18/18 encoder points (bert)` (or `(modernbert)`), `completed 3/3 ablation points`. `make curve` runs the baselines first and prints their `completed 36/36 baseline points` before any encoder point, so a curve that fails afterwards still has a line starting with `completed` in its output; grepping for `completed` alone is not enough. diff --git a/tests/test_makefile.py b/tests/test_makefile.py index c7ccc27..58e005f 100644 --- a/tests/test_makefile.py +++ b/tests/test_makefile.py @@ -5,8 +5,15 @@ variable assignment ``curve MODEL`` = ``bert``, ran the first target (``setup``) and exited 0; two curves were reported done with no point run. ``make -n`` only prints the recipe, so these tests run nothing. + +How make reads the quoted argument depends on its version: GNU make 3.81 +(macOS) takes it as an assignment and, with no goal left, now stops at +the Makefile's no-target guard; GNU make 4.x (the CI runner) takes it as +a target name and stops with "No rule to make target". Either way it must +exit non-zero without reaching setup, which is what these tests check. """ +import os import subprocess from pathlib import Path @@ -14,20 +21,33 @@ def make_dry_run(*args: str) -> subprocess.CompletedProcess[str]: + # Run as a top-level make even under `make test`: no inherited flags or level. + env = {k: v for k, v in os.environ.items() if k not in {"MAKEFLAGS", "MFLAGS", "MAKELEVEL"}} return subprocess.run( - ["make", "-n", *args], cwd=ROOT, capture_output=True, text=True, check=False + ["make", "-n", *args], cwd=ROOT, env=env, capture_output=True, text=True, check=False ) def test_a_quoted_target_and_variable_is_refused_not_run_as_setup(): result = make_dry_run("curve MODEL=bert") assert result.returncode != 0 + assert "no target given" in result.stderr or "No rule to make target" in result.stderr + assert "uv sync" not in result.stdout + + +def test_a_bare_make_is_refused_by_the_guard(): + result = make_dry_run() + assert result.returncode != 0 assert "no target given" in result.stderr assert "uv sync" not in result.stdout -def test_a_bare_make_is_refused(): - assert make_dry_run().returncode != 0 +def test_only_a_variable_assignment_is_refused_by_the_guard(): + """What make 3.81 made of the quoted argument, spelled so every make version sees it.""" + result = make_dry_run("MODEL=bert") + assert result.returncode != 0 + assert "no target given" in result.stderr + assert "uv sync" not in result.stdout def test_the_same_words_split_into_target_and_variable_still_work():