"""Data rules, torch-free: labels, the time split, thin classes, weights, the report.""" from __future__ import annotations import json from pathlib import Path from trainer.cli import main from trainer.data import ( class_weights, load_labelled, load_reviewed_since, load_unlabelled, make_split, summarise, ) from trainer.preprocess import CROP_MARGIN, Sidecar, load_input from trainer.report import compute_metrics, render_report def test_loads_only_reviewed_usable_rows_with_a_crop_on_disk(collector_dir: Path) -> None: samples, missing = load_labelled(collector_dir) assert missing == 1 # the labelled row whose file is gone assert len(samples) == 125 # 3×40 + 5 trucks; unusable and pending excluded assert all(s.path.is_file() for s in samples) assert {s.label for s in samples} == {"sedan", "suv", "van", "truck"} assert summarise(samples)["byClass"] == {"sedan": 40, "suv": 40, "van": 40, "truck": 5} assert len(load_unlabelled(collector_dir)) == 6 assert len(load_reviewed_since(collector_dir, "2026-09-02T00:00:00Z")) == 125 assert load_reviewed_since(collector_dir, "2030-01-01T00:00:00Z") == [] def test_split_is_by_time_and_drops_thin_classes(collector_dir: Path) -> None: samples, _ = load_labelled(collector_dir) split = make_split(samples, val_fraction=0.2, min_per_class=20) assert split.classes == ("sedan", "suv", "van") # canonical order, truck dropped assert split.dropped == {"truck": 5} assert len(split.train) + len(split.val) == 120 assert len(split.val) == 24 assert max(s.at for s in split.train) < min(s.at for s in split.val) # newest = validation assert all(v > 0 for v in split.counts("val").values()) def test_class_weights_lean_against_imbalance_but_gently(collector_dir: Path) -> None: samples, _ = load_labelled(collector_dir) vans = [s for s in samples if s.label == "van"] keep = set(vans[::10]) # 4 of 40 vans survive split = make_split([s for s in samples if s.label != "van" or s in keep], 0.2, 3) w = dict(zip(split.classes, class_weights(split), strict=True)) assert w["van"] > w["sedan"] > 0 # the rare class weighs more assert w["van"] / w["sedan"] < 4 # but not the full inverse ratio (damped) assert abs(sum(w.values()) / len(w) - 1.0) < 1e-9 def test_metrics_and_report() -> None: classes = ("sedan", "suv") m = compute_metrics(classes, [0, 0, 1, 1], [0, 1, 1, 1], camera=["car"] * 4) assert m.accuracy == 0.75 assert m.per_class["sedan"].recall == 0.5 and m.per_class["suv"].precision == 2 / 3 assert m.confusion == [[1, 1], [0, 2]] assert m.camera_agreement == 0.0 text = render_report( version="v1", trained_at="t", mode="features", backbone="resnet18", epochs=3, classes=classes, train_counts={"sedan": 10, "suv": 8}, val_counts={"sedan": 2, "suv": 2}, dropped={"truck": 2}, missing_files=1, weights=[0.9, 1.1], metrics=m, min_accuracy=0.85, written=False, ) assert "MODEL NOT WRITTEN" in text and "| **sedan** | 1 | 1 |" in text and "truck (2)" in text def test_preprocess_contract(tmp_path: Path, collector_dir: Path) -> None: samples, _ = load_labelled(collector_dir) x = load_input(samples[0].path, 32) assert x.shape == (3, 32, 32) and x.dtype.name == "float32" and 0 <= x.min() and x.max() <= 255 assert x[0].mean() > x[2].mean() # a sedan crop is red: RGB order, not BGR assert load_input(tmp_path / "nope.jpg", 32) is None side = Sidecar(version="v1", classes=["sedan", "suv"]) side.write(tmp_path / "s.json") back = Sidecar.read(tmp_path / "s.json") assert back == side and back.crop_margin == CROP_MARGIN == 0.08 and back.normalization == "in-graph" def test_inspect_prints_the_run_shape(collector_dir: Path, capsys) -> None: # type: ignore[no-untyped-def] assert main(["inspect", "--data", str(collector_dir)]) == 0 out = json.loads(capsys.readouterr().out) assert out["ready"] is True and out["run"]["classes"] == ["sedan", "suv", "van"] assert out["run"]["dropped"] == {"truck": 5} and out["missingCrops"] == 1 assert main(["inspect", "--data", str(collector_dir), "--min-per-class", "100"]) == 2