Files
giant/tests/test_rollout.py
T
lars 06c9ad8e5f Fix crashes in physical-property conditioning edge cases
- make_seed_frontier only resolves particle mass/charge in "physical"
  mode, so "embedding"-mode rollouts no longer crash on a seed PDG code
  giant.particles can't resolve (the TERM_UNKNOWN_PDG gate now handles it).
- nearest_known_pdg skips unresolvable candidate PDG codes instead of
  raising and killing the whole rollout/predict run.
- predict/rollout fail with a clear message when a checkpoint predates
  the sec_phys normalizer, instead of a bare KeyError.
- validate_marginals' phys_kl degrades to NaN (matching the
  energy_fraction_kl pattern) instead of crashing when a validated batch
  has zero secondaries on either side.
- Correct CLAUDE.md's stale claim that the materials table is unfilled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 10:36:25 +02:00

313 lines
10 KiB
Python

"""Tests for the autoregressive shower rollout driver."""
from collections import Counter
from pathlib import Path
from unittest.mock import patch
import numpy as np
import pytest
import torch
from giant.constants import TERM_ESCAPED, TERM_MAX_STEPS, TERM_UNKNOWN_PDG
from giant.data.transforms import Normalizer
from giant.model.network import DenoisingMLP, SecondaryDecoder
from giant.rollout import make_seed_frontier, rollout
pytest.importorskip("sklearn")
from giant import geometry as g # noqa: E402
PDG_MAP = {22: 0, 11: 1, -11: 2}
MAT_MAP = {"G4_AIR": 0, "G4_PbWO4": 1}
def _models(conditioning="embedding"):
s1 = DenoisingMLP(
pdg_vocab=3, mat_vocab=2, hidden_dim=32, n_blocks=2, conditioning=conditioning
)
s2 = SecondaryDecoder(
pdg_vocab=3, mat_vocab=2, hidden_dim=32, n_blocks=2, conditioning=conditioning
)
return s1.eval(), s2.eval()
def _norms():
rng = np.random.default_rng(0)
cond = Normalizer().fit(rng.standard_normal((1000, 15)).astype(np.float32))
tgt = Normalizer().fit(rng.standard_normal((1000, 9)).astype(np.float32))
sec_phys = Normalizer().fit(rng.standard_normal((1000, 2)).astype(np.float32))
return cond, tgt, sec_phys
def _oracle():
rng = np.random.default_rng(0)
pos = rng.uniform(-200, 200, (20000, 3)).astype(np.float32)
inside = (np.abs(pos) < 100).all(axis=1)
mat = np.where(inside, "G4_PbWO4", "G4_AIR").astype(object)
lay = np.where(inside, 0, -1).astype(np.int64)
with patch.object(g, "_iter_point_batches", lambda p: iter([(pos, mat, lay)])):
# Pinned to "knn" explicitly: this test's escape-threshold semantics
# (tiny threshold -> escape even at a valid interior point, because no
# training point is that close) are KNN-specific, and the fixture's
# box geometry isn't a layer stack the "slab" method could fit anyway.
return g.build_geometry_oracle([Path("x")], method="knn", subsample=20000)
def _seeds(n=6):
return {
"event_id": np.arange(n, dtype=np.int64),
"pdg": np.full(n, 11, dtype=np.int64),
"pre_pos": np.zeros((n, 3)),
"pre_E": np.linspace(30.0, 90.0, n),
"pre_dir": np.tile([0.0, 0.0, 1.0], (n, 1)),
}
def _run(
escape_threshold=1e9,
energy_cutoff=1.0,
max_steps=30,
max_tracks_per_event=300,
seeds=None,
conditioning="embedding",
):
torch.manual_seed(0)
np.random.seed(0)
s1, s2 = _models(conditioning)
cond, tgt, sec_phys = _norms()
return rollout(
s1,
s2,
_oracle(),
seeds or _seeds(),
cond,
tgt,
sec_phys,
PDG_MAP,
MAT_MAP,
energy_cutoff=energy_cutoff,
max_steps=max_steps,
steps=4,
batch_size=128,
max_tracks_per_event=max_tracks_per_event,
escape_threshold=escape_threshold,
conditioning=conditioning,
)
@pytest.fixture
def fake_material_props(monkeypatch):
import giant.materials as gm
fake = {
"G4_AIR": gm.MaterialProperties(
z_eff=7.3, a_eff=14.4, density=1.2e-3, x0=3.0e4, lambda_int=7.0e5
),
"G4_PbWO4": gm.MaterialProperties(
z_eff=75.6, a_eff=205.3, density=8.28, x0=0.89, lambda_int=20.7
),
}
monkeypatch.setattr(gm, "MATERIAL_PROPERTIES", fake)
return fake
def test_rollout_physical_conditioning_end_to_end(fake_material_props):
"""Physical-mode rollout runs to completion; spawned secondaries carry
mass/charge forward (no snapping) and the output pdg column is populated
via the reporting-only nearest-known-PDG label."""
rec = _run(conditioning="physical")
assert len(rec["event_id"]) > 0
assert set(np.unique(rec["pdg"]).tolist()) <= set(PDG_MAP.keys())
def test_seed_frontier_track_ids():
seeds = _seeds(3)
fr, counts = make_seed_frontier(**seeds)
assert (fr["track_id"] == [0, 0, 0]).all() # one primary per event -> id 0
assert (fr["parent_id"] == -1).all()
assert (fr["generation"] == 0).all()
assert all(counts[e] == 1 for e in range(3))
# pre_dir is normalised.
np.testing.assert_allclose(np.linalg.norm(fr["pre_dir"], axis=1), 1.0, atol=1e-6)
def test_seed_frontier_embedding_mode_skips_unresolvable_pdg_lookup():
""" "embedding" mode must not call giant.particles at all, so a seed PDG
code it can't resolve (e.g. a fabricated/garbage code) must not crash
frontier construction — mass/charge are simply zero-filled, unused."""
seeds = _seeds(3)
seeds["pdg"] = np.full(3, 999999999, dtype=np.int64)
fr, _counts = make_seed_frontier(**seeds, conditioning="embedding")
np.testing.assert_array_equal(fr["mass"], 0.0)
np.testing.assert_array_equal(fr["charge"], 0.0)
def test_rollout_embedding_mode_unresolvable_pdg_terminates_gracefully():
"""A rollout seeded with a PDG code giant.particles can't resolve, and
which isn't in the training vocabulary either, must terminate via the
existing TERM_UNKNOWN_PDG gate rather than crash in make_seed_frontier —
"embedding" mode has no dependency on giant.particles at all."""
seeds = _seeds(3)
seeds["pdg"] = np.full(3, 999999999, dtype=np.int64)
rec = _run(seeds=seeds, conditioning="embedding")
assert len(rec["event_id"]) > 0
assert set(rec["termination_reason"].tolist()) == {TERM_UNKNOWN_PDG}
def test_rollout_terminates_and_has_rows():
rec = _run()
assert len(rec["event_id"]) > 0
# Every seed event appears.
assert set(rec["event_id"].tolist()) == set(range(6))
def test_max_steps_respected():
# Disable the energy cutoff so tracks survive long enough to hit the step cap.
rec = _run(max_steps=5, energy_cutoff=0.0)
assert rec["step_no"].max() <= 5
assert (rec["termination_reason"] == TERM_MAX_STEPS).any()
def test_energy_conserved_deposit_plus_leak():
seeds = _seeds()
rec = _run(seeds=seeds)
for i, ev in enumerate(seeds["event_id"]):
m = rec["event_id"] == ev
dep = rec["edep"][m].sum()
leak = rec["pre_E"][m & (rec["termination_reason"] == TERM_ESCAPED)].sum()
assert dep + leak == pytest.approx(seeds["pre_E"][i], rel=1e-4)
def test_secondaries_have_valid_parents():
rec = _run()
orphans = 0
for ev in np.unique(rec["event_id"]):
m = rec["event_id"] == ev
tids = set(rec["track_id"][m].tolist())
for pid in rec["parent_id"][m]:
if pid >= 0 and pid not in tids:
orphans += 1
assert orphans == 0
# At least one secondary (generation > 0) is produced by the tiny model.
assert (rec["generation"] > 0).any()
def test_escape_terminates_immediately():
# A tight escape threshold makes even the seed position (origin) escape.
rec = _run(escape_threshold=1e-3)
assert (rec["termination_reason"] == TERM_ESCAPED).all()
assert rec["step_no"].max() == 0
def test_output_schema_complete():
from giant.rollout import _RECORD_KEYS
rec = _run()
assert set(rec.keys()) == set(_RECORD_KEYS)
n = len(rec["event_id"])
assert all(len(v) == n for v in rec.values())
def test_max_tracks_cap_conserves_energy():
# A very small cap forces sub-cap secondaries to deposit in place; energy
# must still balance.
seeds = _seeds()
rec = _run(seeds=seeds, max_tracks_per_event=3)
for i, ev in enumerate(seeds["event_id"]):
m = rec["event_id"] == ev
dep = rec["edep"][m].sum()
leak = rec["pre_E"][m & (rec["termination_reason"] == TERM_ESCAPED)].sum()
assert dep + leak == pytest.approx(seeds["pre_E"][i], rel=1e-4)
assert len(np.unique(rec["track_id"][m])) <= 3
# ── Streaming output (on_chunk) ──────────────────────────────────────────────
def _run_streaming(on_chunk, **kwargs):
torch.manual_seed(0)
np.random.seed(0)
s1, s2 = _models()
cond, tgt, sec_phys = _norms()
seeds = kwargs.pop("seeds", None) or _seeds()
return rollout(
s1,
s2,
_oracle(),
seeds,
cond,
tgt,
sec_phys,
PDG_MAP,
MAT_MAP,
energy_cutoff=kwargs.pop("energy_cutoff", 1.0),
max_steps=kwargs.pop("max_steps", 30),
steps=4,
batch_size=128,
max_tracks_per_event=kwargs.pop("max_tracks_per_event", 300),
escape_threshold=kwargs.pop("escape_threshold", 1e9),
on_chunk=on_chunk,
)
def test_on_chunk_receives_every_row_exactly_once():
"""Concatenating the streamed chunks must reproduce the buffered result."""
from giant.rollout import _RECORD_KEYS
buffered = _run()
chunks: list[dict[str, np.ndarray]] = []
summary = _run_streaming(chunks.append)
streamed = {k: np.concatenate([c[k] for c in chunks]) for k in _RECORD_KEYS}
assert summary["n_rows"] == len(buffered["event_id"])
assert len(streamed["event_id"]) == len(buffered["event_id"])
for k in _RECORD_KEYS:
np.testing.assert_array_equal(streamed[k], buffered[k])
def test_on_chunk_summary_termination_reason_counts_match_buffered():
buffered = _run()
summary = _run_streaming(lambda row: None)
expected = Counter(r for r in buffered["termination_reason"].tolist() if r)
assert summary["termination_reason_counts"] == dict(expected)
def test_on_chunk_never_buffers_full_records():
"""Streaming mode must not accumulate rows for later to_dict() retrieval."""
from giant.rollout import _Recorder
rec = _Recorder(sink=lambda row: None)
rec.add(
event_id=np.array([0]),
track_id=np.array([0]),
parent_id=np.array([-1]),
generation=np.array([0]),
step_no=np.array([0]),
pdg=np.array([11]),
pre_x=np.array([0.0]),
pre_y=np.array([0.0]),
pre_z=np.array([0.0]),
pre_E=np.array([1.0]),
pre_dx=np.array([0.0]),
pre_dy=np.array([0.0]),
pre_dz=np.array([1.0]),
post_x=np.array([0.0]),
post_y=np.array([0.0]),
post_z=np.array([1.0]),
post_E=np.array([0.0]),
post_dx=np.array([0.0]),
post_dy=np.array([0.0]),
post_dz=np.array([1.0]),
edep=np.array([1.0]),
step_length=np.array([1.0]),
material=np.array(["G4_AIR"], dtype=object),
layer_id=np.array([0]),
n_sec_pred=np.array([0]),
termination_reason=np.array(["natural_end"], dtype=object),
)
assert rec.n_rows == 1
assert rec.termination_reason_counts == {"natural_end": 1}
with pytest.raises(AssertionError):
rec.to_dict()