Files
giant/giant/particles.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

114 lines
4.8 KiB
Python

"""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.
"""
# Resolve each candidate individually and skip ones giant.particles can't
# resolve, rather than letting one bad code in the training vocabulary
# crash every rollout/predict run over this reporting-only lookup — an
# unresolvable code was never a valid label to begin with.
resolved = []
for c in sorted({int(c) for c in candidates}):
try:
resolved.append((c, *particle_mass_charge(c)))
except ValueError:
continue
if len(resolved) == 0:
raise ValueError("nearest_known_pdg: no resolvable candidates")
codes = np.array([r[0] for r in resolved], dtype=np.int64)
table_log_mass = np.log(
np.array([r[1] for r in resolved], dtype=np.float64) + _LOG_EPS
)
table_charge = np.array([r[2] for r in resolved], dtype=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]