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
+102
View File
@@ -0,0 +1,102 @@
"""Particle physical-property lookup (mass, charge) for "physical" conditioning.
Uses the scikit-HEP `particle` package (PDG data tables) for standard particles
and ground-state nuclei; falls back to the Z/A decode formula (PDG's 10-digit
ion scheme `10LZZZAAAI`: Z and A decoded straight from the digits, no lookup
table involved) for isomer/excited nuclear codes the package's ground-state-only
nuclide table doesn't cover — confirmed necessary for ~32% of the nuclear codes
actually present in the multi-material dataset
(`0932fb02-f2ce-43ca-a4ef-60a2b1221bbc.parquet`).
"""
from __future__ import annotations
from functools import lru_cache
import numpy as np
from particle import InvalidParticle, Particle, ParticleNotFound
from particle import pdgid as _pdgid
# First-pass nuclear mass approximation (A * atomic mass unit); no
# binding-energy correction. Only used for codes missing from `particle`'s
# ground-state nuclide table -- ground-state codes get the package's real
# (binding-energy-corrected) mass.
_AMU_MEV = 931.494
# Nearest-neighbour distance weight for `nearest_known_pdg`: charge is a
# small conserved quantum number and should usually match exactly, so it's
# weighted far more heavily than the (already log-scaled) mass term.
_CHARGE_WEIGHT = 50.0
_LOG_EPS = 1e-8
@lru_cache(maxsize=None)
def particle_mass_charge(pdg: int) -> tuple[float, float]:
"""Return (mass_MeV, charge_e) for a raw PDG code.
Cached per unique code: the training vocabulary is typically O(100)
unique codes while a dataset can have O(1e8) rows, and each entry's
lookup (package query + possible ion decode) is nontrivial enough to be
worth memoizing rather than repeating per row.
"""
pdg = int(pdg)
try:
p = Particle.from_pdgid(pdg)
except (ParticleNotFound, InvalidParticle):
if _pdgid.is_nucleus(pdg):
z, a = _pdgid.Z(pdg), _pdgid.A(pdg)
if z is None or a is None:
raise ValueError(
f"PDG {pdg}: is_nucleus but Z/A decode failed"
) from None
return float(a) * _AMU_MEV, float(z)
raise ValueError(
f"PDG code {pdg} could not be resolved via the `particle` package "
"and is not a nuclear/ion code (is_nucleus=False) -- no fallback "
"available; add explicit handling if this is a legitimate code"
) from None
# Neutrinos have unmeasured mass in the PDG tables (Particle.mass is
# None) -- treat as exactly 0, same physical treatment as the photon.
mass = 0.0 if p.mass is None else float(p.mass)
charge = 0.0 if p.charge is None else float(p.charge)
return mass, charge
def particle_phys_array(pdg_codes: np.ndarray) -> np.ndarray:
"""(N,) int PDG codes -> (N, 2) float32 [mass_MeV, charge_e]."""
out = np.array(
[particle_mass_charge(int(p)) for p in np.asarray(pdg_codes)],
dtype=np.float32,
)
return out.reshape(-1, 2)
def nearest_known_pdg(mass: np.ndarray, charge: np.ndarray, candidates) -> np.ndarray:
"""Reporting-only nearest-PDG label for predicted (mass, charge) pairs.
Never used in the inference/training path -- a Stage-2 secondary's
physical identity is always its raw predicted (mass, charge). This is
only for populating an output row's nominal "pdg" column and as an
"embedding" conditioning-mode fallback lookup key for tracks with no
real PDG code (see giant/rollout.py). Nearest neighbour in
(log_mass, charge) space over `candidates` (an iterable of PDG codes,
typically a `pdg_map`'s keys — the training vocabulary), weighting
charge heavily since it's a small conserved quantum number that should
usually match exactly.
"""
codes = np.array(sorted({int(c) for c in candidates}), dtype=np.int64)
if len(codes) == 0:
raise ValueError("nearest_known_pdg: candidates is empty")
table = particle_phys_array(codes) # (C, 2)
table_log_mass = np.log(table[:, 0].astype(np.float64) + _LOG_EPS)
table_charge = table[:, 1].astype(np.float64)
mass = np.asarray(mass, dtype=np.float64)
charge = np.asarray(charge, dtype=np.float64)
query_log_mass = np.log(np.maximum(mass, 0.0) + _LOG_EPS)
d2 = (query_log_mass[:, None] - table_log_mass[None, :]) ** 2 + _CHARGE_WEIGHT * (
charge[:, None] - table_charge[None, :]
) ** 2
idx = d2.argmin(axis=1)
return codes[idx]