ho22joshua commited on
Commit
6a17a0a
·
1 Parent(s): 8be0098

test: expand comprehensive regression coverage

Browse files
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
- return tuple(payload["samples"])
 
 
 
 
 
 
 
 
 
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",)