Commit ·
6a17a0a
1
Parent(s): 8be0098
test: expand comprehensive regression coverage
Browse files- README.md +3 -0
- docs/testing.md +29 -0
- pyproject.toml +4 -0
- src/gnn4colliders/data/cache.py +14 -1
- tests/integration/test_real_root_model.py +2 -0
- tests/integration/test_real_root_sample.py +2 -0
- tests/parity/conftest.py +4 -0
- tests/unit/config/test_factories.py +18 -0
- tests/unit/config/test_validation.py +38 -0
- tests/unit/data/test_batching.py +33 -0
- tests/unit/data/test_orchestration.py +80 -1
- tests/unit/data/test_root_dataset.py +34 -0
- tests/unit/distributed/test_ddp_cpu.py +2 -0
- tests/unit/export/test_onnx_export.py +3 -2
- tests/unit/features/test_objects.py +21 -0
- tests/unit/graphs/test_edge_features.py +31 -0
- tests/unit/graphs/test_topology.py +26 -0
- tests/unit/inference/test_predictor.py +24 -0
- tests/unit/models/root_gnn/test_edge_network.py +37 -0
- tests/unit/tasks/test_classification.py +18 -0
- tests/unit/training/test_checkpoint.py +20 -0
- tests/unit/training/test_trainer.py +11 -0
README.md
CHANGED
|
@@ -223,6 +223,7 @@ project does not promise bitwise GPU reproducibility.
|
|
| 223 |
|
| 224 |
```bash
|
| 225 |
uv run pytest
|
|
|
|
| 226 |
GNN4COLLIDERS_REQUIRE_ROOT_GNN=1 uv run pytest tests/parity -v
|
| 227 |
uv run ruff check .
|
| 228 |
uv run ruff format --check .
|
|
@@ -235,6 +236,8 @@ and parity tests compare deterministic behavior with the frozen legacy
|
|
| 235 |
reference. Performance guidance and measured caveats are in
|
| 236 |
[`docs/performance.md`](docs/performance.md) and
|
| 237 |
[`benchmarks/README.md`](benchmarks/README.md).
|
|
|
|
|
|
|
| 238 |
|
| 239 |
## Architecture and migration status
|
| 240 |
|
|
|
|
| 223 |
|
| 224 |
```bash
|
| 225 |
uv run pytest
|
| 226 |
+
uv run pytest tests/unit
|
| 227 |
GNN4COLLIDERS_REQUIRE_ROOT_GNN=1 uv run pytest tests/parity -v
|
| 228 |
uv run ruff check .
|
| 229 |
uv run ruff format --check .
|
|
|
|
| 236 |
reference. Performance guidance and measured caveats are in
|
| 237 |
[`docs/performance.md`](docs/performance.md) and
|
| 238 |
[`benchmarks/README.md`](benchmarks/README.md).
|
| 239 |
+
See [`docs/testing.md`](docs/testing.md) for test layers, optional dependency
|
| 240 |
+
markers, and package smoke validation.
|
| 241 |
|
| 242 |
## Architecture and migration status
|
| 243 |
|
docs/testing.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Testing
|
| 2 |
+
|
| 3 |
+
The suite is layered by dependency and purpose:
|
| 4 |
+
|
| 5 |
+
```bash
|
| 6 |
+
# Fast shared-package tests
|
| 7 |
+
uv run pytest tests/unit
|
| 8 |
+
|
| 9 |
+
# Full CPU suite, including integration and parity tests
|
| 10 |
+
uv run pytest
|
| 11 |
+
|
| 12 |
+
# Required ROOT-GNN parity gate (must fail if DGL is unavailable)
|
| 13 |
+
GNN4COLLIDERS_REQUIRE_ROOT_GNN=1 uv run pytest
|
| 14 |
+
|
| 15 |
+
# Optional layers
|
| 16 |
+
uv run pytest -m distributed -v
|
| 17 |
+
uv run pytest -m onnx -v
|
| 18 |
+
uv run pytest -m gpu -v
|
| 19 |
+
GNN4COLLIDERS_ROOT_FIXTURE=/path/to/reduced.root uv run pytest -m real_data -v
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
Unit tests use deterministic, small tensors and generated ROOT files. DGL,
|
| 23 |
+
ONNX, CUDA, distributed execution, and reduced real-data fixtures remain
|
| 24 |
+
optional layers. Tests should assert public contracts and scientific
|
| 25 |
+
invariants rather than private call sequences. New regression tests should
|
| 26 |
+
use `tmp_path`, explicit seeds, and justified numerical tolerances.
|
| 27 |
+
|
| 28 |
+
Release validation additionally builds a wheel and runs the CLI help command
|
| 29 |
+
in a clean environment; it is not part of the ordinary pytest suite.
|
pyproject.toml
CHANGED
|
@@ -76,6 +76,10 @@ markers = [
|
|
| 76 |
"distributed: tests requiring torch distributed execution",
|
| 77 |
"legacy_env: tests requiring the historical runtime environment",
|
| 78 |
"gpu: tests requiring CUDA",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
]
|
| 80 |
|
| 81 |
[tool.ruff]
|
|
|
|
| 76 |
"distributed: tests requiring torch distributed execution",
|
| 77 |
"legacy_env: tests requiring the historical runtime environment",
|
| 78 |
"gpu: tests requiring CUDA",
|
| 79 |
+
"parity: comparisons with the frozen legacy implementation",
|
| 80 |
+
"onnx: tests requiring ONNX export/runtime dependencies",
|
| 81 |
+
"real_data: tests requiring an optional reduced ROOT fixture",
|
| 82 |
+
"slow: tests intentionally excluded from the fast development loop",
|
| 83 |
]
|
| 84 |
|
| 85 |
[tool.ruff]
|
src/gnn4colliders/data/cache.py
CHANGED
|
@@ -38,7 +38,11 @@ class GraphSampleCache:
|
|
| 38 |
|
| 39 |
def load(self) -> tuple[GraphSample, ...]:
|
| 40 |
payload = torch.load(self.path, map_location="cpu", weights_only=False)
|
|
|
|
|
|
|
| 41 |
actual = payload.get("cache_metadata", {})
|
|
|
|
|
|
|
| 42 |
expected = asdict(self.metadata)
|
| 43 |
for key in (
|
| 44 |
"feature_schema_version",
|
|
@@ -50,7 +54,16 @@ class GraphSampleCache:
|
|
| 50 |
f"cache schema mismatch for {key}: "
|
| 51 |
f"found {actual.get(key)!r}, expected {expected[key]!r}"
|
| 52 |
)
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
def exists(self) -> bool:
|
| 56 |
return self.path.is_file()
|
|
|
|
| 38 |
|
| 39 |
def load(self) -> tuple[GraphSample, ...]:
|
| 40 |
payload = torch.load(self.path, map_location="cpu", weights_only=False)
|
| 41 |
+
if not isinstance(payload, dict):
|
| 42 |
+
raise ValueError("cache payload must be a mapping")
|
| 43 |
actual = payload.get("cache_metadata", {})
|
| 44 |
+
if not isinstance(actual, dict):
|
| 45 |
+
raise ValueError("cache metadata is missing or malformed")
|
| 46 |
expected = asdict(self.metadata)
|
| 47 |
for key in (
|
| 48 |
"feature_schema_version",
|
|
|
|
| 54 |
f"cache schema mismatch for {key}: "
|
| 55 |
f"found {actual.get(key)!r}, expected {expected[key]!r}"
|
| 56 |
)
|
| 57 |
+
if actual.get("preprocessing", {}) != expected["preprocessing"]:
|
| 58 |
+
raise ValueError("cache preprocessing fingerprint mismatch")
|
| 59 |
+
if "samples" not in payload:
|
| 60 |
+
raise ValueError("cache samples are missing")
|
| 61 |
+
samples = payload["samples"]
|
| 62 |
+
if not isinstance(samples, (list, tuple)):
|
| 63 |
+
raise ValueError("cache samples are malformed")
|
| 64 |
+
if any(not isinstance(sample, GraphSample) for sample in samples):
|
| 65 |
+
raise ValueError("cache contains invalid graph samples")
|
| 66 |
+
return tuple(samples)
|
| 67 |
|
| 68 |
def exists(self) -> bool:
|
| 69 |
return self.path.is_file()
|
tests/integration/test_real_root_model.py
CHANGED
|
@@ -15,6 +15,8 @@ from gnn4colliders.graphs import build_dgl_graph
|
|
| 15 |
from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
|
| 16 |
|
| 17 |
pytest.importorskip("dgl")
|
|
|
|
|
|
|
| 18 |
uproot = pytest.importorskip("uproot")
|
| 19 |
|
| 20 |
FEATURE_BRANCHES = [
|
|
|
|
| 15 |
from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
|
| 16 |
|
| 17 |
pytest.importorskip("dgl")
|
| 18 |
+
|
| 19 |
+
pytestmark = [pytest.mark.integration, pytest.mark.real_data]
|
| 20 |
uproot = pytest.importorskip("uproot")
|
| 21 |
|
| 22 |
FEATURE_BRANCHES = [
|
tests/integration/test_real_root_sample.py
CHANGED
|
@@ -15,6 +15,8 @@ from gnn4colliders.graphs import build_dgl_graph
|
|
| 15 |
|
| 16 |
pytest.importorskip("dgl")
|
| 17 |
|
|
|
|
|
|
|
| 18 |
FEATURE_BRANCHES = [
|
| 19 |
["jet_pt", "ele_pt", "mu_pt", "ph_pt", "MET_met"],
|
| 20 |
["jet_eta", "ele_eta", "mu_eta", "ph_eta", 0],
|
|
|
|
| 15 |
|
| 16 |
pytest.importorskip("dgl")
|
| 17 |
|
| 18 |
+
pytestmark = [pytest.mark.integration, pytest.mark.real_data]
|
| 19 |
+
|
| 20 |
FEATURE_BRANCHES = [
|
| 21 |
["jet_pt", "ele_pt", "mu_pt", "ph_pt", "MET_met"],
|
| 22 |
["jet_eta", "ele_eta", "mu_eta", "ph_eta", 0],
|
tests/parity/conftest.py
CHANGED
|
@@ -7,6 +7,10 @@ from pathlib import Path
|
|
| 7 |
|
| 8 |
import pytest
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
def _dgl_is_importable():
|
| 12 |
try:
|
|
|
|
| 7 |
|
| 8 |
import pytest
|
| 9 |
|
| 10 |
+
pytestmark = pytest.mark.parity
|
| 11 |
+
|
| 12 |
+
pytestmark = pytest.mark.parity
|
| 13 |
+
|
| 14 |
|
| 15 |
def _dgl_is_importable():
|
| 16 |
try:
|
tests/unit/config/test_factories.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from gnn4colliders.config import build_task
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_factory_builds_semantic_task_names():
|
| 7 |
+
from gnn4colliders.tasks import (
|
| 8 |
+
BinaryClassificationTask,
|
| 9 |
+
MulticlassClassificationTask,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
assert isinstance(build_task({"type": "binary"}), BinaryClassificationTask)
|
| 13 |
+
assert isinstance(build_task({"type": "multiclass"}), MulticlassClassificationTask)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def test_factory_rejects_unknown_task_name():
|
| 17 |
+
with pytest.raises(ValueError, match="unsupported task"):
|
| 18 |
+
build_task({"type": "not-a-task"})
|
tests/unit/config/test_validation.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
|
| 3 |
+
from gnn4colliders.config import validate_config
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@pytest.mark.parametrize(
|
| 7 |
+
("section", "values", "message"),
|
| 8 |
+
[
|
| 9 |
+
("data", {"batch_size": 0}, "batch_size"),
|
| 10 |
+
("trainer", {"max_epochs": 0}, "max_epochs"),
|
| 11 |
+
("model", {"out_size": 2}, "out_size"),
|
| 12 |
+
],
|
| 13 |
+
)
|
| 14 |
+
def test_invalid_cross_config_values_fail_before_initialization(
|
| 15 |
+
section, values, message
|
| 16 |
+
):
|
| 17 |
+
config = {"data": {}, "trainer": {}, "model": {}, "task": {"type": "binary"}}
|
| 18 |
+
config[section].update(values)
|
| 19 |
+
with pytest.raises(ValueError, match=message):
|
| 20 |
+
validate_config(config)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_validation_rejects_conflicting_resume_and_transfer():
|
| 24 |
+
config = {"checkpoint": {"resume": "resume.pt", "pretrained": "base.pt"}}
|
| 25 |
+
with pytest.raises(ValueError, match="distinct workflows"):
|
| 26 |
+
validate_config(config)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_validation_rejects_overlapping_split_folds():
|
| 30 |
+
config = {"data": {"splits": {"train_folds": [0], "test_folds": [0]}}}
|
| 31 |
+
with pytest.raises(ValueError, match="disjoint"):
|
| 32 |
+
validate_config(config)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_fine_tuning_requires_pretrained_checkpoint():
|
| 36 |
+
config = {"model": {"name": "fine_tuned_edge_network"}}
|
| 37 |
+
with pytest.raises(ValueError, match="pretrained"):
|
| 38 |
+
validate_config(config)
|
tests/unit/data/test_batching.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
from gnn4colliders.data import EventMetadata, GraphSample, batch_graph_samples
|
| 5 |
+
from gnn4colliders.graphs import build_dgl_graph
|
| 6 |
+
|
| 7 |
+
dgl = pytest.importorskip("dgl")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _sample(index: int, node_count: int) -> GraphSample:
|
| 11 |
+
nodes = torch.arange(node_count * 3, dtype=torch.float32).reshape(node_count, 3)
|
| 12 |
+
return GraphSample(
|
| 13 |
+
graph=build_dgl_graph(nodes),
|
| 14 |
+
label=torch.tensor(index),
|
| 15 |
+
global_features=torch.tensor([float(index), -float(index)]),
|
| 16 |
+
metadata=EventMetadata(index, index + 0.5, f"sample_{index}"),
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.mark.parametrize("counts", [(1,), (1, 2), (1, 3, 2)])
|
| 21 |
+
def test_graph_batch_preserves_heterogeneous_order_and_metadata(counts):
|
| 22 |
+
samples = [_sample(index, count) for index, count in enumerate(counts)]
|
| 23 |
+
batch = batch_graph_samples(samples)
|
| 24 |
+
assert batch.labels.tolist() == list(range(len(counts)))
|
| 25 |
+
assert batch.metadata.sample_id == tuple(f"sample_{i}" for i in range(len(counts)))
|
| 26 |
+
assert batch.metadata.weight.tolist() == pytest.approx(
|
| 27 |
+
[i + 0.5 for i in range(len(counts))]
|
| 28 |
+
)
|
| 29 |
+
assert batch.global_features[:, 0].tolist() == list(map(float, range(len(counts))))
|
| 30 |
+
assert batch.graph.batch_num_nodes().tolist() == list(counts)
|
| 31 |
+
assert batch.graph.batch_num_edges().tolist() == [
|
| 32 |
+
count * (count - 1) if count > 1 else 1 for count in counts
|
| 33 |
+
]
|
tests/unit/data/test_orchestration.py
CHANGED
|
@@ -5,12 +5,13 @@ import pytest
|
|
| 5 |
|
| 6 |
from gnn4colliders.data import (
|
| 7 |
FEATURE_SCHEMA_VERSION,
|
|
|
|
| 8 |
EventMetadata,
|
| 9 |
SplitDefinition,
|
| 10 |
select_folds,
|
| 11 |
)
|
| 12 |
from gnn4colliders.data.cache import CacheMetadata, GraphSampleCache
|
| 13 |
-
from gnn4colliders.data.graph_dataset import GraphSample
|
| 14 |
|
| 15 |
|
| 16 |
def _sample(sample_id: str, fold: int) -> GraphSample:
|
|
@@ -34,6 +35,42 @@ def test_select_folds_is_named_deterministic_and_non_mutating():
|
|
| 34 |
assert [sample.metadata.sample_id for sample in samples] == ["a", "b", "c"]
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def test_split_definition_rejects_overlapping_folds():
|
| 38 |
with pytest.raises(ValueError, match="disjoint"):
|
| 39 |
SplitDefinition(train_folds=frozenset({0}), test_folds=frozenset({0}))
|
|
@@ -53,3 +90,45 @@ def test_graph_cache_round_trip_and_schema_check(tmp_path: Path):
|
|
| 53 |
)
|
| 54 |
with pytest.raises(ValueError, match="schema mismatch"):
|
| 55 |
stale.load()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
from gnn4colliders.data import (
|
| 7 |
FEATURE_SCHEMA_VERSION,
|
| 8 |
+
BatchMetadata,
|
| 9 |
EventMetadata,
|
| 10 |
SplitDefinition,
|
| 11 |
select_folds,
|
| 12 |
)
|
| 13 |
from gnn4colliders.data.cache import CacheMetadata, GraphSampleCache
|
| 14 |
+
from gnn4colliders.data.graph_dataset import GraphDataLoader, GraphDataset, GraphSample
|
| 15 |
|
| 16 |
|
| 17 |
def _sample(sample_id: str, fold: int) -> GraphSample:
|
|
|
|
| 35 |
assert [sample.metadata.sample_id for sample in samples] == ["a", "b", "c"]
|
| 36 |
|
| 37 |
|
| 38 |
+
def test_batch_metadata_preserves_order_and_extra_fields():
|
| 39 |
+
events = [
|
| 40 |
+
EventMetadata(2, 0.0, "sample_10", {"run": 10}),
|
| 41 |
+
EventMetadata(0, -3.5, "sample_2", {"run": 2, "tag": "b"}),
|
| 42 |
+
]
|
| 43 |
+
batch = BatchMetadata.from_events(events)
|
| 44 |
+
assert batch.sample_id == ("sample_10", "sample_2")
|
| 45 |
+
assert batch.fold.tolist() == [2, 0]
|
| 46 |
+
assert batch.weight.tolist() == [0.0, -3.5]
|
| 47 |
+
assert batch.extra == {"run": (10, 2), "tag": (None, "b")}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_graph_loader_shuffle_is_seeded_by_epoch_without_global_rng_mutation():
|
| 51 |
+
pytest.importorskip("dgl")
|
| 52 |
+
import torch
|
| 53 |
+
|
| 54 |
+
from gnn4colliders.graphs import build_dgl_graph
|
| 55 |
+
|
| 56 |
+
samples = [
|
| 57 |
+
GraphSample(
|
| 58 |
+
build_dgl_graph(torch.ones(1, 3)),
|
| 59 |
+
torch.tensor(i),
|
| 60 |
+
None,
|
| 61 |
+
EventMetadata(0, 1.0, str(i)),
|
| 62 |
+
)
|
| 63 |
+
for i in range(5)
|
| 64 |
+
]
|
| 65 |
+
loader = GraphDataLoader(GraphDataset(samples), 2, shuffle=True, seed=11)
|
| 66 |
+
first = [item for batch in loader for item in batch.metadata.sample_id]
|
| 67 |
+
second = [item for batch in loader for item in batch.metadata.sample_id]
|
| 68 |
+
loader.set_epoch(1)
|
| 69 |
+
third = [item for batch in loader for item in batch.metadata.sample_id]
|
| 70 |
+
assert first == second
|
| 71 |
+
assert third != first
|
| 72 |
+
|
| 73 |
+
|
| 74 |
def test_split_definition_rejects_overlapping_folds():
|
| 75 |
with pytest.raises(ValueError, match="disjoint"):
|
| 76 |
SplitDefinition(train_folds=frozenset({0}), test_folds=frozenset({0}))
|
|
|
|
| 90 |
)
|
| 91 |
with pytest.raises(ValueError, match="schema mismatch"):
|
| 92 |
stale.load()
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@pytest.mark.parametrize(
|
| 96 |
+
"payload, message",
|
| 97 |
+
[
|
| 98 |
+
({"samples": ()}, "schema mismatch"),
|
| 99 |
+
({"cache_metadata": {}, "samples": ()}, "schema mismatch"),
|
| 100 |
+
(
|
| 101 |
+
{
|
| 102 |
+
"cache_metadata": {
|
| 103 |
+
"feature_schema_version": 1,
|
| 104 |
+
"graph_schema_version": 1,
|
| 105 |
+
"cache_schema_version": 1,
|
| 106 |
+
}
|
| 107 |
+
},
|
| 108 |
+
"samples",
|
| 109 |
+
),
|
| 110 |
+
],
|
| 111 |
+
)
|
| 112 |
+
def test_cache_rejects_missing_or_incomplete_metadata(tmp_path, payload, message):
|
| 113 |
+
path = tmp_path / "broken.pt"
|
| 114 |
+
import torch
|
| 115 |
+
|
| 116 |
+
torch.save(payload, path)
|
| 117 |
+
with pytest.raises(ValueError, match=message):
|
| 118 |
+
GraphSampleCache(path).load()
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_cache_rejects_preprocessing_fingerprint_mismatch(tmp_path):
|
| 122 |
+
path = tmp_path / "graphs.pt"
|
| 123 |
+
GraphSampleCache(path, CacheMetadata(preprocessing={"features": "a"})).save(
|
| 124 |
+
[_sample("event:0", 0)]
|
| 125 |
+
)
|
| 126 |
+
with pytest.raises(ValueError, match="fingerprint"):
|
| 127 |
+
GraphSampleCache(path, CacheMetadata(preprocessing={"features": "b"})).load()
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_cache_rejects_truncated_artifact(tmp_path):
|
| 131 |
+
path = tmp_path / "truncated.pt"
|
| 132 |
+
path.write_bytes(b"not a torch archive")
|
| 133 |
+
with pytest.raises(Exception):
|
| 134 |
+
GraphSampleCache(path).load()
|
tests/unit/data/test_root_dataset.py
CHANGED
|
@@ -89,3 +89,37 @@ def test_multiple_files_keep_input_order_and_feed_feature_builder(tmp_path: Path
|
|
| 89 |
)
|
| 90 |
assert features.shape == (2, 4)
|
| 91 |
assert lengths == [2]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
)
|
| 90 |
assert features.shape == (2, 4)
|
| 91 |
assert lengths == [2]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_root_boundary_reports_missing_tree_and_branch(tmp_path: Path):
|
| 95 |
+
path = tmp_path / "events.root"
|
| 96 |
+
_write_fixture(path)
|
| 97 |
+
with pytest.raises(KeyError, match="tree"):
|
| 98 |
+
read_tree(path, "missing", ["weight"])
|
| 99 |
+
with pytest.raises(KeyError, match="missing requested branches"):
|
| 100 |
+
read_tree(path, "events", ["missing_branch"])
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_root_boundary_reports_missing_file(tmp_path: Path):
|
| 104 |
+
with pytest.raises((OSError, FileNotFoundError)):
|
| 105 |
+
read_tree(tmp_path / "does-not-exist.root", "events", ["weight"])
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_sample_identity_is_stable_and_distinguishes_files(tmp_path: Path):
|
| 109 |
+
first = tmp_path / "first.root"
|
| 110 |
+
second = tmp_path / "second.root"
|
| 111 |
+
_write_fixture(first)
|
| 112 |
+
_write_fixture(second)
|
| 113 |
+
kwargs = dict(
|
| 114 |
+
tree_name="events",
|
| 115 |
+
label="label_branch",
|
| 116 |
+
feature_branches=[["jet_pt"]],
|
| 117 |
+
fold_var="eventNumber",
|
| 118 |
+
weight_var="weight",
|
| 119 |
+
)
|
| 120 |
+
left = RootEventDataset(first, **kwargs)
|
| 121 |
+
repeated = RootEventDataset(first, **kwargs)
|
| 122 |
+
both = RootEventDataset([first, second], **kwargs)
|
| 123 |
+
assert left[0].metadata.sample_id == repeated[0].metadata.sample_id
|
| 124 |
+
assert left[0].metadata.sample_id != left[1].metadata.sample_id
|
| 125 |
+
assert both[0].metadata.sample_id != both[3].metadata.sample_id
|
tests/unit/distributed/test_ddp_cpu.py
CHANGED
|
@@ -8,6 +8,8 @@ from torch import nn
|
|
| 8 |
|
| 9 |
from gnn4colliders.distributed import finalize, initialize, prepare_model
|
| 10 |
|
|
|
|
|
|
|
| 11 |
|
| 12 |
def _port() -> int:
|
| 13 |
with socket.socket() as sock:
|
|
|
|
| 8 |
|
| 9 |
from gnn4colliders.distributed import finalize, initialize, prepare_model
|
| 10 |
|
| 11 |
+
pytestmark = pytest.mark.distributed
|
| 12 |
+
|
| 13 |
|
| 14 |
def _port() -> int:
|
| 15 |
with socket.socket() as sock:
|
tests/unit/export/test_onnx_export.py
CHANGED
|
@@ -1,8 +1,6 @@
|
|
| 1 |
import pytest
|
| 2 |
import torch
|
| 3 |
|
| 4 |
-
pytest.importorskip("dgl")
|
| 5 |
-
|
| 6 |
from gnn4colliders.data import EventMetadata, GraphSample, batch_graph_samples
|
| 7 |
from gnn4colliders.export import (
|
| 8 |
RootGNNExportAdapter,
|
|
@@ -12,6 +10,9 @@ from gnn4colliders.export import (
|
|
| 12 |
from gnn4colliders.graphs import build_dgl_graph
|
| 13 |
from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
def _batch():
|
| 17 |
samples = []
|
|
|
|
| 1 |
import pytest
|
| 2 |
import torch
|
| 3 |
|
|
|
|
|
|
|
| 4 |
from gnn4colliders.data import EventMetadata, GraphSample, batch_graph_samples
|
| 5 |
from gnn4colliders.export import (
|
| 6 |
RootGNNExportAdapter,
|
|
|
|
| 10 |
from gnn4colliders.graphs import build_dgl_graph
|
| 11 |
from gnn4colliders.models.root_gnn import EdgeNetwork, FineTunedEdgeNetwork
|
| 12 |
|
| 13 |
+
pytestmark = pytest.mark.onnx
|
| 14 |
+
dgl = pytest.importorskip("dgl")
|
| 15 |
+
|
| 16 |
|
| 17 |
def _batch():
|
| 18 |
samples = []
|
tests/unit/features/test_objects.py
CHANGED
|
@@ -87,3 +87,24 @@ def test_empty_vectors_and_input_immutability(event, schema):
|
|
| 87 |
assert lengths == [0, 1, 1, 1, 1]
|
| 88 |
assert features.shape == (4, 7)
|
| 89 |
np.testing.assert_array_equal(event["ele_pt"], before)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
assert lengths == [0, 1, 1, 1, 1]
|
| 88 |
assert features.shape == (4, 7)
|
| 89 |
np.testing.assert_array_equal(event["ele_pt"], before)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_reordering_vector_objects_reorders_feature_rows(event, schema):
|
| 93 |
+
names, object_types, scales = schema
|
| 94 |
+
reordered = dict(event)
|
| 95 |
+
for name in ("jet_pt", "jet_eta", "jet_phi", "jet_btag"):
|
| 96 |
+
reordered[name] = event[name][::-1].copy()
|
| 97 |
+
first, _ = build_node_features(event, names, object_types, scales)
|
| 98 |
+
second, _ = build_node_features(reordered, names, object_types, scales)
|
| 99 |
+
torch.testing.assert_close(second[:2], first[[1, 0]])
|
| 100 |
+
torch.testing.assert_close(second[2:], first[2:])
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_feature_scaling_changes_only_the_requested_columns(event, schema):
|
| 104 |
+
names, object_types, scales = schema
|
| 105 |
+
base, _ = build_node_features(event, names, object_types, scales)
|
| 106 |
+
changed_scales = list(scales)
|
| 107 |
+
changed_scales[0] *= 2
|
| 108 |
+
scaled, _ = build_node_features(event, names, object_types, changed_scales)
|
| 109 |
+
torch.testing.assert_close(scaled[:, 0], base[:, 0] * 2)
|
| 110 |
+
torch.testing.assert_close(scaled[:, 1:], base[:, 1:])
|
tests/unit/graphs/test_edge_features.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import torch
|
| 2 |
|
| 3 |
from gnn4colliders.graphs import build_edge_features, fully_connected_edges
|
|
@@ -18,3 +19,33 @@ def test_empty_edges_keep_three_feature_columns():
|
|
| 18 |
features = build_edge_features(nodes, src, dst, eta_index=1, phi_index=2)
|
| 19 |
assert features.shape == (0, 3)
|
| 20 |
assert features.dtype == torch.float32
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
import torch
|
| 3 |
|
| 4 |
from gnn4colliders.graphs import build_edge_features, fully_connected_edges
|
|
|
|
| 19 |
features = build_edge_features(nodes, src, dst, eta_index=1, phi_index=2)
|
| 20 |
assert features.shape == (0, 3)
|
| 21 |
assert features.dtype == torch.float32
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_edge_features_have_symmetry_and_nonnegative_distance():
|
| 25 |
+
nodes = torch.tensor([[1.0, 2.0, 3.13], [2.0, -1.0, -3.13], [3.0, 0.5, 0.2]])
|
| 26 |
+
src, dst = fully_connected_edges(3)
|
| 27 |
+
actual = build_edge_features(nodes, src, dst, eta_index=1, phi_index=2)
|
| 28 |
+
reverse = build_edge_features(nodes, dst, src, eta_index=1, phi_index=2)
|
| 29 |
+
torch.testing.assert_close(actual[:, 0], -reverse[:, 0])
|
| 30 |
+
torch.testing.assert_close(actual[:, 2], reverse[:, 2])
|
| 31 |
+
assert torch.all(actual[:, 2] >= 0)
|
| 32 |
+
assert torch.all(actual[:, 1].abs() <= torch.pi)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.mark.parametrize("bad_nodes", [torch.zeros(3), torch.zeros(2, 2)])
|
| 36 |
+
def test_edge_features_reject_malformed_node_features(bad_nodes):
|
| 37 |
+
with pytest.raises((ValueError, IndexError)):
|
| 38 |
+
build_edge_features(
|
| 39 |
+
bad_nodes, torch.tensor([0]), torch.tensor([0]), eta_index=1, phi_index=2
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_edge_features_reject_mismatched_edge_indices():
|
| 44 |
+
with pytest.raises(ValueError, match="equal shape"):
|
| 45 |
+
build_edge_features(
|
| 46 |
+
torch.zeros(2, 3),
|
| 47 |
+
torch.tensor([0]),
|
| 48 |
+
torch.tensor([1, 0]),
|
| 49 |
+
eta_index=1,
|
| 50 |
+
phi_index=2,
|
| 51 |
+
)
|
tests/unit/graphs/test_topology.py
CHANGED
|
@@ -50,3 +50,29 @@ def test_self_loops_can_be_enabled_and_zero_nodes_is_empty():
|
|
| 50 |
def test_negative_node_count_is_rejected():
|
| 51 |
with pytest.raises(ValueError, match="non-negative"):
|
| 52 |
fully_connected_edges(-1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
def test_negative_node_count_is_rejected():
|
| 51 |
with pytest.raises(ValueError, match="non-negative"):
|
| 52 |
fully_connected_edges(-1)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@pytest.mark.parametrize("num_nodes", [0, 1, 2, 4, 7])
|
| 56 |
+
def test_topology_invariants_hold_for_multiple_graph_sizes(num_nodes):
|
| 57 |
+
src, dst = fully_connected_edges(num_nodes)
|
| 58 |
+
expected = (
|
| 59 |
+
0 if num_nodes == 0 else 1 if num_nodes == 1 else num_nodes * (num_nodes - 1)
|
| 60 |
+
)
|
| 61 |
+
assert src.numel() == dst.numel() == expected
|
| 62 |
+
pairs = list(zip(src.tolist(), dst.tolist()))
|
| 63 |
+
assert len(pairs) == len(set(pairs))
|
| 64 |
+
if num_nodes > 1:
|
| 65 |
+
assert all(source != destination for source, destination in pairs)
|
| 66 |
+
assert [src.tolist().count(index) for index in range(num_nodes)] == [
|
| 67 |
+
num_nodes - 1
|
| 68 |
+
] * num_nodes
|
| 69 |
+
assert [dst.tolist().count(index) for index in range(num_nodes)] == [
|
| 70 |
+
num_nodes - 1
|
| 71 |
+
] * num_nodes
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_topology_rejects_bool_and_non_integer_counts():
|
| 75 |
+
with pytest.raises(TypeError, match="integer"):
|
| 76 |
+
fully_connected_edges(True)
|
| 77 |
+
with pytest.raises(TypeError, match="integer"):
|
| 78 |
+
fully_connected_edges(2.0)
|
tests/unit/inference/test_predictor.py
CHANGED
|
@@ -81,3 +81,27 @@ def test_npz_round_trip(tmp_path):
|
|
| 81 |
np.testing.assert_array_equal(values["logits"], result.logits.numpy())
|
| 82 |
np.testing.assert_array_equal(values["labels"], result.labels.numpy())
|
| 83 |
np.testing.assert_array_equal(values["fold"], result.fold.numpy())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
np.testing.assert_array_equal(values["logits"], result.logits.numpy())
|
| 82 |
np.testing.assert_array_equal(values["labels"], result.labels.numpy())
|
| 83 |
np.testing.assert_array_equal(values["fold"], result.fold.numpy())
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_predictor_is_reentrant_and_returns_detached_cpu_results():
|
| 87 |
+
predictor = Predictor(_Model([[-1.0], [1.0]]), BinaryClassificationTask())
|
| 88 |
+
first = predictor.predict(_loader([[-1.0], [1.0]], [0, 1]))
|
| 89 |
+
second = predictor.predict(_loader([[-1.0], [1.0]], [0, 1]))
|
| 90 |
+
torch.testing.assert_close(first.logits, second.logits)
|
| 91 |
+
assert first.logits.device.type == "cpu"
|
| 92 |
+
assert not first.logits.requires_grad
|
| 93 |
+
assert first.logits.grad_fn is None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_npz_omits_labels_for_unlabeled_results(tmp_path):
|
| 97 |
+
metadata = BatchMetadata(
|
| 98 |
+
fold=torch.tensor([0]), weight=torch.tensor([1.0]), sample_id=("sample_7",)
|
| 99 |
+
)
|
| 100 |
+
batch = SimpleNamespace(metadata=metadata, offset=0, to=lambda device: batch)
|
| 101 |
+
result = Predictor(_Model([[2.0, 1.0]]), MulticlassClassificationTask()).predict(
|
| 102 |
+
[batch]
|
| 103 |
+
)
|
| 104 |
+
path = write_npz(result, tmp_path / "unlabeled.npz")
|
| 105 |
+
with np.load(path) as values:
|
| 106 |
+
assert "labels" not in values
|
| 107 |
+
assert values["sample_id"].tolist() == ["sample_7"]
|
tests/unit/models/root_gnn/test_edge_network.py
CHANGED
|
@@ -39,3 +39,40 @@ def test_edge_network_supports_multiclass_and_transfer_freezing():
|
|
| 39 |
parameter.requires_grad for parameter in transferred.classifier.parameters()
|
| 40 |
)
|
| 41 |
assert model(graph, torch.zeros(1, 2)).shape == (1, 12)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
parameter.requires_grad for parameter in transferred.classifier.parameters()
|
| 40 |
)
|
| 41 |
assert model(graph, torch.zeros(1, 2)).shape == (1, 12)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@pytest.mark.parametrize("out_size", [1, 3])
|
| 45 |
+
@pytest.mark.parametrize("n_proc_steps", [0, 1])
|
| 46 |
+
def test_model_shape_matrix_and_state_dict_round_trip(out_size, n_proc_steps):
|
| 47 |
+
graph = build_dgl_graph(torch.tensor([[10.0, 0.2, 0.1], [8.0, -0.3, -0.2]]))
|
| 48 |
+
globals_ = torch.tensor([[1.0, 2.0]])
|
| 49 |
+
torch.manual_seed(7)
|
| 50 |
+
model = EdgeNetwork(graph, globals_, 4, out_size, 1, n_proc_steps).eval()
|
| 51 |
+
with torch.no_grad():
|
| 52 |
+
expected = model(graph, globals_)
|
| 53 |
+
fresh = EdgeNetwork(graph, globals_, 4, out_size, 1, n_proc_steps).eval()
|
| 54 |
+
fresh.load_state_dict(model.state_dict())
|
| 55 |
+
with torch.no_grad():
|
| 56 |
+
actual = fresh(graph, globals_)
|
| 57 |
+
torch.testing.assert_close(actual, expected)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_model_backward_produces_finite_gradients_and_update():
|
| 61 |
+
model, graph = _model()
|
| 62 |
+
model.train()
|
| 63 |
+
before = [parameter.detach().clone() for parameter in model.parameters()]
|
| 64 |
+
loss = model(graph, torch.zeros(1, 2)).square().mean()
|
| 65 |
+
assert torch.isfinite(loss)
|
| 66 |
+
loss.backward()
|
| 67 |
+
gradients = [
|
| 68 |
+
parameter.grad for parameter in model.parameters() if parameter.requires_grad
|
| 69 |
+
]
|
| 70 |
+
assert gradients and all(
|
| 71 |
+
gradient is not None and torch.isfinite(gradient).all()
|
| 72 |
+
for gradient in gradients
|
| 73 |
+
)
|
| 74 |
+
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
|
| 75 |
+
optimizer.step()
|
| 76 |
+
assert any(
|
| 77 |
+
not torch.equal(old, new) for old, new in zip(before, model.parameters())
|
| 78 |
+
)
|
tests/unit/tasks/test_classification.py
CHANGED
|
@@ -64,3 +64,21 @@ def test_multiclass_loss_and_metrics():
|
|
| 64 |
output = task.predict(logits)
|
| 65 |
assert output["predictions"].tolist() == [0, 1, 2]
|
| 66 |
assert task.metrics(logits, batch)["accuracy"] == 1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
output = task.predict(logits)
|
| 65 |
assert output["predictions"].tolist() == [0, 1, 2]
|
| 66 |
assert task.metrics(logits, batch)["accuracy"] == 1.0
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_binary_loss_is_invariant_under_joint_event_permutation():
|
| 70 |
+
task = BinaryClassificationTask()
|
| 71 |
+
batch = _batch([0, 1, 0, 1], [1.0, 2.0, 3.0, 4.0])
|
| 72 |
+
logits = torch.tensor([[-2.0], [1.0], [0.5], [-0.5]])
|
| 73 |
+
permutation = torch.tensor([2, 0, 3, 1])
|
| 74 |
+
shuffled = _batch(batch.labels[permutation].tolist(), batch.metadata.weight[permutation].tolist())
|
| 75 |
+
assert task.loss(logits, batch) == pytest.approx(task.loss(logits[permutation], shuffled).item())
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_multiclass_metrics_are_invariant_under_joint_event_permutation():
|
| 79 |
+
task = MulticlassClassificationTask()
|
| 80 |
+
batch = _batch([0, 1, 2, 1], [1.0, 2.0, 1.0, 3.0])
|
| 81 |
+
logits = torch.tensor([[3.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 4.0], [0.0, 1.0, 0.0]])
|
| 82 |
+
permutation = torch.tensor([3, 0, 2, 1])
|
| 83 |
+
shuffled = _batch(batch.labels[permutation].tolist(), batch.metadata.weight[permutation].tolist())
|
| 84 |
+
assert task.metrics(logits, batch) == task.metrics(logits[permutation], shuffled)
|
tests/unit/training/test_checkpoint.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import random
|
| 2 |
|
| 3 |
import numpy as np
|
|
|
|
| 4 |
import torch
|
| 5 |
from torch import nn
|
| 6 |
|
|
@@ -105,3 +106,22 @@ def test_rng_state_is_restored(tmp_path):
|
|
| 105 |
assert expected[0] == actual[0]
|
| 106 |
assert expected[1] == actual[1]
|
| 107 |
assert torch.equal(expected[2], actual[2])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import random
|
| 2 |
|
| 3 |
import numpy as np
|
| 4 |
+
import pytest
|
| 5 |
import torch
|
| 6 |
from torch import nn
|
| 7 |
|
|
|
|
| 106 |
assert expected[0] == actual[0]
|
| 107 |
assert expected[1] == actual[1]
|
| 108 |
assert torch.equal(expected[2], actual[2])
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_checkpoint_rejects_unsupported_schema_and_missing_weights(tmp_path):
|
| 112 |
+
unsupported = tmp_path / "unsupported.pt"
|
| 113 |
+
torch.save({"schema_version": 999}, unsupported)
|
| 114 |
+
with pytest.raises(ValueError, match="unsupported checkpoint schema"):
|
| 115 |
+
CheckpointManager.load(unsupported)
|
| 116 |
+
|
| 117 |
+
missing = tmp_path / "missing.pt"
|
| 118 |
+
torch.save({"schema_version": 1, "epoch": 0}, missing)
|
| 119 |
+
with pytest.raises(ValueError, match="incompatible"):
|
| 120 |
+
load_model_weights(nn.Linear(2, 1), CheckpointManager.load(missing))
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_checkpoint_loading_rejects_architecture_mismatch(tmp_path):
|
| 124 |
+
manager = CheckpointManager(tmp_path)
|
| 125 |
+
path = manager.save(model=nn.Linear(2, 1), trainer_state=TrainerState(epoch=0))
|
| 126 |
+
with pytest.raises(ValueError, match="incompatible"):
|
| 127 |
+
load_model_weights(nn.Linear(3, 1), manager.load(path))
|
tests/unit/training/test_trainer.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import torch
|
| 2 |
from torch import nn
|
| 3 |
|
|
@@ -79,3 +80,13 @@ def test_fit_uses_validation_for_early_stopping_and_records_history():
|
|
| 79 |
assert len(history.train) == len(history.validation)
|
| 80 |
assert 1 <= len(history.train) <= 4
|
| 81 |
assert all(isinstance(item.loss, float) for item in history.train)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
import torch
|
| 3 |
from torch import nn
|
| 4 |
|
|
|
|
| 80 |
assert len(history.train) == len(history.validation)
|
| 81 |
assert 1 <= len(history.train) <= 4
|
| 82 |
assert all(isinstance(item.loss, float) for item in history.train)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@pytest.mark.gpu
|
| 86 |
+
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA unavailable")
|
| 87 |
+
def test_graph_batch_to_cuda_preserves_named_metadata():
|
| 88 |
+
batch = _batch([1], [0])
|
| 89 |
+
moved = batch.to("cuda")
|
| 90 |
+
assert moved.labels.device.type == "cuda"
|
| 91 |
+
assert moved.metadata.weight.device.type == "cuda"
|
| 92 |
+
assert moved.metadata.sample_id == ("event:0",)
|