Condition on material/particle physical properties instead of learned embeddings

Adds model.conditioning = "physical" | "embedding": physical mode routes
particle mass/charge and material Z_eff/A_eff/density/X0/lambda_int through
small MLPs to replace the learned PDG/material embedding tables, so the
surrogate generalizes to PDG codes/materials outside the training vocab
instead of memorizing it. "embedding" stays available as the comparison
baseline (old checkpoints without the key default to it).

Stage 2 now regresses a secondary's mass/charge directly against a fixed
physics-derived target instead of a learned/snapped embedding, and uses no
snapping at inference — the model's raw predicted (mass, charge) is the
secondary's physical identity, including for its own further rollout steps.
A separate reporting-only nearest-known-PDG lookup (never fed back into the
model) populates output pdg columns / the embedding-mode rollout fallback.

giant/materials.py's table is populated with Geant4's own built-in NIST
constants (Z_eff, A_eff, density, X0, lambda_int), extracted directly from
the Geant4 11.4.1 build vendored in minicalosim via G4NistManager rather
than hand-typed literature values. G4_LYSO is left unfilled: confirmed (both
by runtime lookup and by searching minicalosim's history) that it's never
actually a constructed Geant4 material there, only documentation/UI color-map
text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 15:12:54 +02:00
parent a6bb142a40
commit 68fb99bed8
23 changed files with 1252 additions and 304 deletions
+105
View File
@@ -0,0 +1,105 @@
import numpy as np
import pytest
from giant.materials import (
MaterialProperties,
MaterialPropertiesNotFilledError,
UnknownMaterialError,
get_material_properties,
material_properties_array,
)
def test_get_material_properties_unknown_name_raises():
with pytest.raises(UnknownMaterialError):
get_material_properties("G4_Unobtainium")
def test_get_material_properties_unfilled_entry_raises():
"""G4_LYSO is not a stock Geant4 NIST material (confirmed against the
vendored Geant4 11.4.1 build) and is the one entry still shipped unfilled."""
with pytest.raises(MaterialPropertiesNotFilledError):
get_material_properties("G4_LYSO")
def test_get_material_properties_returns_filled_entry_from_injected_table():
table = {
"G4_Pb": MaterialProperties(
z_eff=82.0, a_eff=207.2, density=11.35, x0=0.5612, lambda_int=17.59
)
}
props = get_material_properties("G4_Pb", table)
assert props.z_eff == 82.0
assert props.a_eff == 207.2
assert props.density == 11.35
assert props.x0 == 0.5612
assert props.lambda_int == 17.59
def test_material_properties_array_shape_and_values():
table = {
"G4_Pb": MaterialProperties(82.0, 207.2, 11.35, 0.5612, 17.59),
"G4_W": MaterialProperties(74.0, 183.84, 19.3, 0.3504, 9.95),
}
names = np.array(["G4_Pb", "G4_W", "G4_Pb"], dtype=object)
arr = material_properties_array(names, table)
assert arr.shape == (3, 5)
assert arr.dtype == np.float32
np.testing.assert_allclose(arr[0], [82.0, 207.2, 11.35, 0.5612, 17.59], rtol=1e-5)
np.testing.assert_allclose(arr[1], [74.0, 183.84, 19.3, 0.3504, 9.95], rtol=1e-5)
def test_all_known_materials_present_in_stub_table():
"""Every material referenced elsewhere in the repo must at least have a
stub entry (even if unfilled) -- an unknown name should never be the
failure mode a physicist hits when populating the table."""
from giant.materials import MATERIAL_PROPERTIES
expected = {
"G4_PbWO4",
"G4_CESIUM_IODIDE",
"G4_Pb",
"G4_W",
"G4_Cu",
"G4_Fe",
"G4_BRASS",
"G4_POLYSTYRENE",
"G4_PLASTIC_SC_VINYLTOLUENE",
"G4_BGO",
"G4_LYSO",
"G4_AIR",
"G4_lAr",
}
assert expected <= set(MATERIAL_PROPERTIES.keys())
def test_all_materials_filled_except_lyso():
"""G4_LYSO is the sole intentionally-unfilled entry (not a stock Geant4
NIST material); every other known material has real Geant4-derived
values -- see the module docstring for provenance."""
from giant.materials import MATERIAL_PROPERTIES
for name, props in MATERIAL_PROPERTIES.items():
if name == "G4_LYSO":
assert all(v is None for v in props)
else:
assert all(v is not None for v in props), f"{name} unexpectedly unfilled"
def test_elemental_material_z_eff_matches_atomic_number():
"""Single-element materials' z_eff must equal the element's real Z."""
pb = get_material_properties("G4_Pb")
assert pb.z_eff == pytest.approx(82.0)
w = get_material_properties("G4_W")
assert w.z_eff == pytest.approx(74.0)
fe = get_material_properties("G4_Fe")
assert fe.z_eff == pytest.approx(26.0)
def test_pbwo4_values_match_known_cms_ecal_reference():
"""PbWO4 (CMS ECAL crystal) has well-known reference values: X0~0.89cm,
density 8.28 g/cm^3 -- sanity check the Geant4-derived numbers land there."""
pbwo4 = get_material_properties("G4_PbWO4")
assert pbwo4.density == pytest.approx(8.28)
assert pbwo4.x0 == pytest.approx(0.89, abs=0.01)
assert pbwo4.z_eff == pytest.approx(31.33, abs=0.01)
+118
View File
@@ -0,0 +1,118 @@
import numpy as np
import pytest
from giant.particles import (
nearest_known_pdg,
particle_mass_charge,
particle_phys_array,
)
def test_photon_massless_neutral():
mass, charge = particle_mass_charge(22)
assert mass == pytest.approx(0.0)
assert charge == pytest.approx(0.0)
def test_electron_mass_charge():
mass, charge = particle_mass_charge(11)
assert mass == pytest.approx(0.51099895069, rel=1e-6)
assert charge == pytest.approx(-1.0)
def test_positron_is_charge_conjugate_of_electron():
mass_e, charge_e = particle_mass_charge(11)
mass_p, charge_p = particle_mass_charge(-11)
assert mass_p == pytest.approx(mass_e)
assert charge_p == pytest.approx(-charge_e)
def test_proton_mass_charge():
mass, charge = particle_mass_charge(2212)
assert mass == pytest.approx(938.27208943, rel=1e-6)
assert charge == pytest.approx(1.0)
def test_neutrino_unmeasured_mass_treated_as_zero():
"""PDG tables store an unmeasured neutrino mass as None -- must not
propagate a None/NaN into a physical conditioning feature."""
mass, charge = particle_mass_charge(12)
assert mass == pytest.approx(0.0)
assert charge == pytest.approx(0.0)
def test_ground_state_nucleus_resolved_via_particle_package():
"""He-4 (Z=2, A=4) is a common nuclide in `particle`'s ground-state table."""
mass, charge = particle_mass_charge(1000020040)
assert charge == pytest.approx(2.0)
assert mass == pytest.approx(
4 * 931.494, rel=0.05
) # near A*amu, binding-energy-corrected
def test_nuclear_isomer_falls_back_to_z_a_decode():
"""An excited/isomer nuclear code (nonzero trailing digit) is absent from
`particle`'s ground-state-only nuclide table -- confirmed necessary for
~32% of the nuclear codes in the multi-material dataset. Fe-56 isomer:
Z=26, A=56, isomer level 1 -> pdgid 1000260561."""
pdg = 1000260561
mass, charge = particle_mass_charge(pdg)
assert charge == pytest.approx(26.0)
assert mass == pytest.approx(56 * 931.494, rel=1e-6)
def test_invalid_pdg_code_raises():
with pytest.raises(ValueError):
particle_mass_charge(999999999)
def test_particle_mass_charge_is_cached():
particle_mass_charge.cache_clear()
particle_mass_charge(22)
particle_mass_charge(22)
info = particle_mass_charge.cache_info()
assert info.hits >= 1
def test_particle_phys_array_shape_and_dtype():
arr = particle_phys_array(np.array([22, 11, 2212]))
assert arr.shape == (3, 2)
assert arr.dtype == np.float32
np.testing.assert_allclose(arr[0], [0.0, 0.0])
np.testing.assert_allclose(arr[2], [938.27208943, 1.0], rtol=1e-5)
# ── nearest_known_pdg (reporting-only nearest-neighbour label) ──────────────
def test_nearest_known_pdg_exact_match():
candidates = [22, 11, -11, 2212, 2112]
mass_e, charge_e = particle_mass_charge(11)
result = nearest_known_pdg(np.array([mass_e]), np.array([charge_e]), candidates)
assert result[0] == 11
def test_nearest_known_pdg_prioritises_charge_match():
"""Charge is a small conserved quantum number and should usually match
exactly even when the queried mass is noisy/imperfect."""
candidates = [22, 11, -11, 2212]
# Close to electron mass but not exact, positive charge like the positron.
result = nearest_known_pdg(np.array([0.6]), np.array([1.0]), candidates)
assert result[0] == -11
def test_nearest_known_pdg_empty_candidates_raises():
with pytest.raises(ValueError):
nearest_known_pdg(np.array([1.0]), np.array([0.0]), [])
def test_nearest_known_pdg_shape():
candidates = [22, 11, -11, 2212, 2112]
n = 10
result = nearest_known_pdg(
np.random.default_rng(0).uniform(0, 1000, n),
np.random.default_rng(1).uniform(-1, 1, n),
candidates,
)
assert result.shape == (n,)
assert set(result.tolist()) <= set(candidates)
+136 -52
View File
@@ -4,21 +4,33 @@ import numpy as np
import pytest
import torch
from giant.constants import COND_DIM, EMB_DIM, K_MAX, SEC_DIM, X_DIM
from giant.constants import COND_DIM, K_MAX, PARTICLE_PHYS_DIM, SEC_DIM, X_DIM
from giant.model.network import DenoisingMLP, SecondaryDecoder
from giant.model.schedule import flow_matching_loss_secondary
from giant.sample import sample_secondaries, snap_type_to_pdg_idx
from giant.sample import sample_secondaries
# ── helpers ──────────────────────────────────────────────────────────────────
def _stage1(pdg=3, mat=2):
return DenoisingMLP(pdg_vocab=pdg, mat_vocab=mat, hidden_dim=32, n_blocks=2)
def _stage1(pdg=3, mat=2, conditioning="embedding"):
return DenoisingMLP(
pdg_vocab=pdg,
mat_vocab=mat,
hidden_dim=32,
n_blocks=2,
conditioning=conditioning,
)
def _sec_decoder(pdg=3, mat=2):
return SecondaryDecoder(pdg_vocab=pdg, mat_vocab=mat, hidden_dim=32, n_blocks=2)
def _sec_decoder(pdg=3, mat=2, conditioning="embedding"):
return SecondaryDecoder(
pdg_vocab=pdg,
mat_vocab=mat,
hidden_dim=32,
n_blocks=2,
conditioning=conditioning,
)
def _cond(B=8, pdg=3, mat=2):
@@ -48,18 +60,35 @@ def test_predict_n_sec_no_nan():
assert torch.isfinite(logits).all()
def test_pdg_embedding_weight_shape():
model = _stage1(pdg=5, mat=2)
w = model.pdg_embedding_weight()
assert w.shape == (5, EMB_DIM)
@pytest.mark.parametrize("conditioning", ["embedding", "physical"])
def test_no_pdg_embedding_weight_method(conditioning):
"""The Stage-2 species output no longer needs a shared embedding table."""
model = _stage1(pdg=5, mat=2, conditioning=conditioning)
assert not hasattr(model, "pdg_embedding_weight")
def test_condition_encoder_physical_mode_has_no_embedding_tables():
model = _stage1(pdg=5, mat=2, conditioning="physical")
assert not hasattr(model.cond_enc, "pdg_emb")
assert not hasattr(model.cond_enc, "mat_emb")
assert hasattr(model.cond_enc, "particle_mlp")
assert hasattr(model.cond_enc, "material_mlp")
def test_condition_encoder_embedding_mode_has_embedding_tables():
model = _stage1(pdg=5, mat=2, conditioning="embedding")
assert hasattr(model.cond_enc, "pdg_emb")
assert hasattr(model.cond_enc, "mat_emb")
assert not hasattr(model.cond_enc, "particle_mlp")
# ── SecondaryDecoder ──────────────────────────────────────────────────────────
def test_sec_decoder_output_shape():
@pytest.mark.parametrize("conditioning", ["embedding", "physical"])
def test_sec_decoder_output_shape(conditioning):
B = 8
decoder = _sec_decoder()
decoder = _sec_decoder(conditioning=conditioning)
x_t = torch.randn(B, SEC_DIM)
t = torch.rand(B)
cond_cont, cond_cat = _cond(B)
@@ -144,11 +173,11 @@ def test_sample_secondaries_shapes():
cond_cont, cond_cat = _cond(B, pdg, mat)
stage1_out = torch.randn(B, X_DIM)
n_sec_pred = torch.randint(0, K_MAX + 1, (B,))
sec_cont, sec_type_emb, sec_valid = sample_secondaries(
sec_cont, sec_phys, sec_valid = sample_secondaries(
decoder, cond_cont, cond_cat, stage1_out, n_sec_pred, steps=3
)
assert sec_cont.shape == (B, K_MAX, 4)
assert sec_type_emb.shape == (B, K_MAX, EMB_DIM)
assert sec_phys.shape == (B, K_MAX, PARTICLE_PHYS_DIM)
assert sec_valid.shape == (B, K_MAX)
assert sec_valid.dtype == torch.bool
@@ -167,16 +196,6 @@ def test_sample_secondaries_valid_mask_matches_n_sec():
assert not sec_valid[i, n:].any()
def test_snap_type_to_pdg_idx_shape():
B, pdg_vocab = 4, 5
emb_weight = torch.randn(pdg_vocab, EMB_DIM)
sec_type_emb = torch.randn(B, K_MAX, EMB_DIM)
idx = snap_type_to_pdg_idx(sec_type_emb, emb_weight)
assert idx.shape == (B, K_MAX)
assert idx.dtype == torch.int64
assert (idx >= 0).all() and (idx < pdg_vocab).all()
# ── encode_secondaries round-trip ─────────────────────────────────────────────
@@ -192,6 +211,7 @@ def test_encode_secondaries_energy_conservation():
sec_E_list = np.zeros((N, K_MAX), dtype=np.float32)
sec_dir_list = np.zeros((N, K_MAX, 3), dtype=np.float32)
sec_dir_list[:, :, 2] = 1.0
sec_pdg_list = np.zeros((N, K_MAX), dtype=np.int64)
sec_valid = np.zeros((N, K_MAX), dtype=bool)
for i in range(N):
k = n_sec[i]
@@ -199,12 +219,15 @@ def test_encode_secondaries_energy_conservation():
energies = np.sort(energies)[::-1]
sec_E_list[i, :k] = energies.astype(np.float32)
sec_valid[i, :k] = True
sec_pdg_list[i, :k] = 22 # photon — resolvable by giant.particles
pre_dir = rng.standard_normal((N, 3)).astype(np.float32)
pre_dir /= np.linalg.norm(pre_dir, axis=1, keepdims=True)
sec_cont = encode_secondaries(sec_E_list, sec_dir_list, sec_valid, e_sec, pre_dir)
assert sec_cont.shape == (N, K_MAX, 4)
sec_cont = encode_secondaries(
sec_E_list, sec_dir_list, sec_valid, e_sec, pre_dir, sec_pdg_list=sec_pdg_list
)
assert sec_cont.shape == (N, K_MAX, 6)
assert np.isfinite(sec_cont).all()
@@ -233,13 +256,54 @@ def test_encode_secondaries_direction_encoding():
np.testing.assert_allclose(norms_out, 1.0, atol=1e-5)
def test_encode_secondaries_physical_columns_without_pdg_list():
"""Omitting sec_pdg_list zero-fills the physical columns (no crash)."""
from giant.data.transforms import encode_secondaries
N = 3
e_sec = np.ones(N, dtype=np.float32)
sec_E_list = np.zeros((N, K_MAX), dtype=np.float32)
sec_dir_list = np.zeros((N, K_MAX, 3), dtype=np.float32)
sec_dir_list[:, :, 2] = 1.0
sec_valid = np.zeros((N, K_MAX), dtype=bool)
pre_dir = np.tile([0.0, 0.0, 1.0], (N, 1)).astype(np.float32)
sec_cont = encode_secondaries(sec_E_list, sec_dir_list, sec_valid, e_sec, pre_dir)
np.testing.assert_allclose(sec_cont[:, :, 4:6], 0.0)
def test_encode_secondaries_physical_columns_match_ground_truth_pdg():
"""log_mass/charge for a valid slot match giant.particles for that PDG."""
from giant.data.transforms import encode_secondaries, log_transform
from giant.particles import particle_mass_charge
N = 1
e_sec = np.array([5.0], dtype=np.float32)
sec_E_list = np.zeros((N, K_MAX), dtype=np.float32)
sec_E_list[0, 0] = 5.0
sec_dir_list = np.zeros((N, K_MAX, 3), dtype=np.float32)
sec_dir_list[0, 0] = [0, 0, 1]
sec_pdg_list = np.zeros((N, K_MAX), dtype=np.int64)
sec_pdg_list[0, 0] = 11 # electron
sec_valid = np.zeros((N, K_MAX), dtype=bool)
sec_valid[0, 0] = True
pre_dir = np.array([[0.0, 0.0, 1.0]], dtype=np.float32)
sec_cont = encode_secondaries(
sec_E_list, sec_dir_list, sec_valid, e_sec, pre_dir, sec_pdg_list=sec_pdg_list
)
mass, charge = particle_mass_charge(11)
assert sec_cont[0, 0, 4] == pytest.approx(log_transform(np.array([mass]))[0])
assert sec_cont[0, 0, 5] == pytest.approx(charge)
# ── decode_secondaries: exact energy conservation ────────────────────────────
def _random_sec_cont(rng, N, stick_logit_scale=1.0):
sec_cont = rng.standard_normal((N, K_MAX, 4)).astype(np.float32)
sec_cont = rng.standard_normal((N, K_MAX, 6)).astype(np.float32)
sec_cont[:, :, 0] *= stick_logit_scale
dirs = sec_cont[:, :, 1:]
dirs = sec_cont[:, :, 1:4]
dirs /= np.linalg.norm(dirs, axis=-1, keepdims=True)
return sec_cont
@@ -257,13 +321,12 @@ def test_decode_secondaries_valid_slots_sum_to_e_sec():
rng = np.random.default_rng(0)
N = 200
sec_cont = _random_sec_cont(rng, N)
sec_pdg_pred = np.zeros((N, K_MAX), dtype=np.int64)
n_sec = rng.integers(0, K_MAX + 1, size=N)
e_sec = rng.uniform(0.0, 50.0, size=N).astype(np.float32)
pre_dir = np.tile([0.0, 0.0, 1.0], (N, 1)).astype(np.float32)
sec_E, _sec_dir, _sec_pdg, sec_valid = decode_secondaries(
sec_cont, sec_pdg_pred, n_sec, e_sec, pre_dir, {0: 22}
sec_E, _sec_dir, _mass, _charge, sec_valid = decode_secondaries(
sec_cont, n_sec, e_sec, pre_dir
)
valid_sum = (sec_E * sec_valid).sum(axis=1)
@@ -283,13 +346,12 @@ def test_decode_secondaries_zero_n_sec_has_zero_energy():
rng = np.random.default_rng(1)
N = 10
sec_cont = _random_sec_cont(rng, N)
sec_pdg_pred = np.zeros((N, K_MAX), dtype=np.int64)
n_sec = np.zeros(N, dtype=np.int64)
e_sec = rng.uniform(1.0, 10.0, size=N).astype(np.float32)
pre_dir = np.tile([0.0, 0.0, 1.0], (N, 1)).astype(np.float32)
sec_E, _sec_dir, _sec_pdg, sec_valid = decode_secondaries(
sec_cont, sec_pdg_pred, n_sec, e_sec, pre_dir, {0: 22}
sec_E, _sec_dir, _mass, _charge, sec_valid = decode_secondaries(
sec_cont, n_sec, e_sec, pre_dir
)
assert not sec_valid.any()
@@ -307,12 +369,11 @@ def test_decode_secondaries_degenerate_row_falls_back_to_even_split():
n_sec = np.array([0, 1, 3, K_MAX])
for i, k in enumerate(n_sec):
sec_cont[i, :k, 0] = -80.0
sec_pdg_pred = np.zeros((N, K_MAX), dtype=np.int64)
e_sec = np.array([0.0, 4.0, 9.0, 30.0], dtype=np.float32)
pre_dir = np.tile([0.0, 0.0, 1.0], (N, 1)).astype(np.float32)
sec_E, _sec_dir, _sec_pdg, sec_valid = decode_secondaries(
sec_cont, sec_pdg_pred, n_sec, e_sec, pre_dir, {0: 22}
sec_E, _sec_dir, _mass, _charge, sec_valid = decode_secondaries(
sec_cont, n_sec, e_sec, pre_dir
)
for i, k in enumerate(n_sec):
@@ -336,25 +397,48 @@ def test_decode_secondaries_rescale_preserves_relative_shares():
sec_cont = _random_sec_cont(rng, N)
n_sec = np.array([4])
pre_dir = np.array([[0.0, 0.0, 1.0]], dtype=np.float32)
sec_pdg_pred = np.zeros((N, K_MAX), dtype=np.int64)
sec_E_small, _, _, sec_valid = decode_secondaries(
sec_cont,
sec_pdg_pred,
n_sec,
np.array([5.0], dtype=np.float32),
pre_dir,
{0: 22},
sec_E_small, _, _, _, sec_valid = decode_secondaries(
sec_cont, n_sec, np.array([5.0], dtype=np.float32), pre_dir
)
sec_E_large, _, _, _ = decode_secondaries(
sec_cont,
sec_pdg_pred,
n_sec,
np.array([50.0], dtype=np.float32),
pre_dir,
{0: 22},
sec_E_large, _, _, _, _ = decode_secondaries(
sec_cont, n_sec, np.array([50.0], dtype=np.float32), pre_dir
)
ratio_small = sec_E_small[0, :4] / sec_E_small[0, 0]
ratio_large = sec_E_large[0, :4] / sec_E_large[0, 0]
np.testing.assert_allclose(ratio_small, ratio_large, rtol=1e-4)
def test_decode_secondaries_mass_charge_round_trip_with_normalizer():
from giant.data.transforms import Normalizer, decode_secondaries, encode_secondaries
N = 1
e_sec = np.array([5.0], dtype=np.float32)
sec_E_list = np.zeros((N, K_MAX), dtype=np.float32)
sec_E_list[0, 0] = 5.0
sec_dir_list = np.zeros((N, K_MAX, 3), dtype=np.float32)
sec_dir_list[0, 0] = [0, 0, 1]
sec_pdg_list = np.zeros((N, K_MAX), dtype=np.int64)
sec_pdg_list[0, 0] = 2212 # proton
sec_valid = np.zeros((N, K_MAX), dtype=bool)
sec_valid[0, 0] = True
pre_dir = np.array([[0.0, 0.0, 1.0]], dtype=np.float32)
sec_cont = encode_secondaries(
sec_E_list, sec_dir_list, sec_valid, e_sec, pre_dir, sec_pdg_list=sec_pdg_list
)
norm = Normalizer()
norm.mean = np.array([-2.0, 0.5], dtype=np.float32)
norm.std = np.array([3.0, 1.5], dtype=np.float32)
sec_cont_normed = sec_cont.copy()
sec_cont_normed[:, :, 4:6] = norm.transform(
sec_cont[:, :, 4:6].reshape(-1, 2)
).reshape(N, K_MAX, 2)
n_sec = np.array([1])
_, _, sec_mass, sec_charge, _ = decode_secondaries(
sec_cont_normed, n_sec, e_sec, pre_dir, sec_phys_normalizer=norm
)
assert sec_mass[0, 0] == pytest.approx(938.27208943, abs=1e-2)
assert sec_charge[0, 0] == pytest.approx(1.0, abs=1e-4)
+42 -8
View File
@@ -20,17 +20,22 @@ PDG_MAP = {22: 0, 11: 1, -11: 2}
MAT_MAP = {"G4_AIR": 0, "G4_PbWO4": 1}
def _models():
s1 = DenoisingMLP(pdg_vocab=3, mat_vocab=2, hidden_dim=32, n_blocks=2)
s2 = SecondaryDecoder(pdg_vocab=3, mat_vocab=2, hidden_dim=32, n_blocks=2)
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, 8)).astype(np.float32))
cond = Normalizer().fit(rng.standard_normal((1000, 15)).astype(np.float32))
tgt = Normalizer().fit(rng.standard_normal((1000, 9)).astype(np.float32))
return cond, tgt
sec_phys = Normalizer().fit(rng.standard_normal((1000, 2)).astype(np.float32))
return cond, tgt, sec_phys
def _oracle():
@@ -63,11 +68,12 @@ def _run(
max_steps=30,
max_tracks_per_event=300,
seeds=None,
conditioning="embedding",
):
torch.manual_seed(0)
np.random.seed(0)
s1, s2 = _models()
cond, tgt = _norms()
s1, s2 = _models(conditioning)
cond, tgt, sec_phys = _norms()
return rollout(
s1,
s2,
@@ -75,6 +81,7 @@ def _run(
seeds or _seeds(),
cond,
tgt,
sec_phys,
PDG_MAP,
MAT_MAP,
energy_cutoff=energy_cutoff,
@@ -83,9 +90,35 @@ def _run(
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)
@@ -171,7 +204,7 @@ def _run_streaming(on_chunk, **kwargs):
torch.manual_seed(0)
np.random.seed(0)
s1, s2 = _models()
cond, tgt = _norms()
cond, tgt, sec_phys = _norms()
seeds = kwargs.pop("seeds", None) or _seeds()
return rollout(
s1,
@@ -180,6 +213,7 @@ def _run_streaming(on_chunk, **kwargs):
seeds,
cond,
tgt,
sec_phys,
PDG_MAP,
MAT_MAP,
energy_cutoff=kwargs.pop("energy_cutoff", 1.0),
+2 -4
View File
@@ -576,11 +576,9 @@ def test_routed_denoising_mlp_predict_n_sec_shape():
assert logits.shape == (B, K_MAX + 1)
def test_routed_denoising_mlp_pdg_embedding_weight_shape():
def test_routed_denoising_mlp_has_no_pdg_embedding_weight_method():
model = _routed_stage1(pdg=5, mat=2)
from giant.constants import EMB_DIM
assert model.pdg_embedding_weight().shape == (5, EMB_DIM)
assert not hasattr(model, "pdg_embedding_weight")
# ── RoutedSecondaryDecoder ───────────────────────────────────────────────────
+83 -4
View File
@@ -1,7 +1,8 @@
import numpy as np
import pytest
from giant.constants import K_MAX
from giant.constants import COND_DIM, COND_DIM_BASE, K_MAX
from giant.data.transforms import (
build_cond_features,
build_features,
energy_simplex_decode,
energy_simplex_encode,
@@ -235,7 +236,7 @@ def test_build_features_clamps_n_sec_label_to_k_max():
pdg_map = {11: 0}
mat_map = {"PbWO4": 0}
_, _, _, n_sec, _, _, _, _, _ = build_features(data, pdg_map, mat_map)
_, _, _, n_sec, _, _, _, _ = build_features(data, pdg_map, mat_map)
assert n_sec.max() <= K_MAX
np.testing.assert_array_equal(n_sec, [0, 5, K_MAX])
@@ -312,9 +313,87 @@ def test_build_features_require_secondaries_ok_when_no_secondaries():
data = _step_data_no_sec_lists(np.zeros(3, dtype=np.int32))
pdg_map, mat_map = {11: 0}, {"PbWO4": 0}
_, _, _, _, sec_cont, sec_pdg_idx, *_ = build_features(
_, _, _, _, sec_cont, *_ = build_features(
data, pdg_map, mat_map, require_secondaries=True
)
assert not sec_cont.any()
assert not sec_pdg_idx.any()
# ── physical-property conditioning ────────────────────────────────────────────
@pytest.fixture
def fake_material_props(monkeypatch):
"""Inject a fully-populated fake materials table for "physical" mode
tests, independent of when the real giant/materials.py table is filled
in by the user (see giant.materials.MaterialPropertiesNotFilledError)."""
import giant.materials as gm
fake = {
"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_build_features_embedding_mode_zero_fills_physical_columns():
data = _minimal_step_data(3)
pdg_map, mat_map = {11: 0}, {"PbWO4": 0}
cond_cont, *_ = build_features(data, pdg_map, mat_map, conditioning="embedding")
assert cond_cont.shape[1] == COND_DIM
np.testing.assert_allclose(cond_cont[:, COND_DIM_BASE:], 0.0)
def test_build_features_physical_mode_shape_and_values(fake_material_props):
from giant.particles import particle_mass_charge
data = _minimal_step_data(3)
pdg_map, mat_map = {11: 0}, {"PbWO4": 0}
cond_cont, *_ = build_features(data, pdg_map, mat_map, conditioning="physical")
assert cond_cont.shape[1] == COND_DIM
mass, charge = particle_mass_charge(11)
expected_log_mass = log_transform(np.array([mass]))[0]
np.testing.assert_allclose(
cond_cont[:, COND_DIM_BASE], expected_log_mass, atol=1e-5
)
np.testing.assert_allclose(cond_cont[:, COND_DIM_BASE + 1], charge)
np.testing.assert_allclose(cond_cont[:, COND_DIM_BASE + 2], 75.6) # z_eff
np.testing.assert_allclose(cond_cont[:, COND_DIM_BASE + 3], 205.3) # a_eff
def test_build_features_physical_mode_unfilled_material_raises():
"""G4_LYSO is the one material giant/materials.py still ships unfilled
(not a stock Geant4 NIST material) — must fail loudly, not silently."""
from giant.materials import MaterialPropertiesNotFilledError
data = _minimal_step_data(2)
data["material"] = np.full(2, "G4_LYSO", dtype=object)
pdg_map, mat_map = {11: 0}, {"G4_LYSO": 0}
with pytest.raises(MaterialPropertiesNotFilledError):
build_features(data, pdg_map, mat_map, conditioning="physical")
def test_build_cond_features_mass_charge_override(fake_material_props):
"""rollout.py's secondaries carry their own predicted mass/charge — when
present in `data`, these bypass the pdg-based lookup entirely (the "no
snapping" design: a track's own future conditioning must use its actual
predicted physical identity, not a value re-derived from a PDG code)."""
data = _minimal_step_data(2)
data["mass"] = np.array([123.0, 456.0], dtype=np.float32)
data["charge"] = np.array([2.0, -2.0], dtype=np.float32)
pdg_map, mat_map = {11: 0}, {"PbWO4": 0}
cond_cont, _ = build_cond_features(data, pdg_map, mat_map, conditioning="physical")
np.testing.assert_allclose(
cond_cont[:, COND_DIM_BASE], log_transform(np.array([123.0, 456.0]))
)
np.testing.assert_allclose(cond_cont[:, COND_DIM_BASE + 1], [2.0, -2.0])