Files
giant/giant/data/transforms.py
T
lars 4692cee699
CI / Lint (ruff check) (push) Successful in 30s
CI / Format (ruff format) (push) Successful in 30s
CI / Sync project version with tag (push) Has been skipped
CI / Type check (ty) (push) Successful in 43s
CI / Lint (ruff check) (pull_request) Successful in 40s
CI / Format (ruff format) (pull_request) Successful in 43s
CI / Sync project version with tag (pull_request) Has been skipped
CI / Type check (ty) (pull_request) Successful in 45s
CI / Tests (pull_request) Successful in 3m24s
CI / Tests (push) Successful in 3m33s
Give the cond_cat/cond_cont column layout one owner (gitea #37)
The conditioning arrays' column order was written down three times — twice
in giant/data/transforms.py (build_cond_features and build_features each
built cond_cont and cond_cat from scratch) and again in
giant/model/encoders.py (cat_col_layout, plus hand-written
COND_DIM_BASE + PARTICLE_PHYS_DIM slicing in ConditionEncoder). The three
were held in sync only by parallel comments, so a wrong column order
produced silently mis-indexed features rather than an exception.

The drift had already happened, twice, both times in build_features:

- 5b63dfd added per-axis vocab-lookup strictness (an out-of-vocab
  pdg/material must not KeyError under "physical"/"onehot", where the
  index is never read) to build_cond_features only.
- _cond_normalizer_transform's legacy-normalizer padding, which keeps a
  pre-physical-conditioning 8-wide cond normalizer loadable, was likewise
  only wired into build_cond_features — so `giant predict` on such a
  checkpoint died with a broadcast error.

New giant/cond_layout.py holds a frozen CondLayout built from the
(particle, material) mode pair, exposing named cond_cont slices
(base/particle_phys/material_phys) and cond_cat columns
(PDG_COL/MAT_COL/particle_topn_col/material_topn_col/cat_dim). Both
builders now share one _build_cond_arrays, ConditionEncoder reads its
slices off the same object, and PdgRouter/ProcessRouter use the named
dense-vocab columns instead of literal 0/1. CondLayout also absorbs the
two duplicated axis-type validations, keeping their message text verbatim.

Decisions taken while planning:

- Scope is CondLayout only. The issue's second half — a
  CONDITIONING_AXIS_REGISTRY registering (feature_columns, encoder_module)
  as a pair — is deferred: it would force ConditioningConfig's fixed
  particle/material fields into a dynamic axis map and ripple through
  pipeline.py, checkpoint_io.py and rollout.py, i.e. a config-schema break
  with no consumer yet.
- The two divergences above are unified onto build_cond_features'
  behaviour rather than preserved as parameters, so the new single source
  of truth doesn't carry the old split forward. Each gets a regression
  test that fails before this commit.
- cat_col_layout is replaced outright (deleted, dropped from network.py's
  __all__, its four tests rewritten against CondLayout) rather than kept
  as a wrapper — two spellings of the same fact is the defect itself.

cond_cat's width is now the layout's call rather than "did the caller pass
a map", so an "onehot" axis without its top-N map raises instead of
yielding a narrower array that ConditionEncoder would index out of bounds.
pipeline.py's normalizer-fitting pass reads only cond_cont but had to be
handed the maps to satisfy that.

No parameter, buffer or state_dict change; existing checkpoints load
unchanged, and the protected migration surfaces are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:16:20 +02:00

1068 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import warnings
from typing import NamedTuple
import numpy as np
from giant.cond_layout import CondLayout
from giant.constants import K_MAX
_EPS = 1e-8
# Floor added to each energy fraction before taking log-ratios so the simplex
# coordinates stay finite on the heavily populated boundary: e_sec is 0 in ~half
# of all steps and post_E is 0 at every track end. Kept tiny (1e-5 of pre_E) so
# the conservation it slightly softens is physically negligible (~0.001%).
_SIMPLEX_FLOOR = 1e-5
def log_transform(x: np.ndarray, eps: float = _EPS) -> np.ndarray:
x = np.asarray(x, dtype=np.float32)
y = np.log(x + eps)
if not np.all(np.isfinite(y)):
bad = int(np.sum(~np.isfinite(y)))
raise ValueError(
f"log_transform: {bad} value(s) produced non-finite output (input "
f"< -eps={eps:g}, or already NaN/Inf); every quantity this is "
"applied to should be non-negative, so this indicates upstream "
"data corruption rather than expected float noise."
)
return y
def inv_log_transform(y: np.ndarray, eps: float = _EPS) -> np.ndarray:
return np.exp(np.asarray(y, dtype=np.float32)) - eps
def energy_simplex_encode(
edep: np.ndarray,
e_sec: np.ndarray,
post_E: np.ndarray,
pre_E: np.ndarray,
) -> np.ndarray:
"""Encode (edep, e_sec, post_E) as 2 additive-log-ratio (ALR) coordinates.
The three energies are first expressed as fractions of pre_E that sum to 1:
post_E is kept exact (so the particle's retained energy — the next step's
input during a rollout — is preserved), and the energy actually lost,
`delta_e = pre_E - post_E`, is split between local deposit and secondaries
in the recorded edep:e_sec ratio (re-attributing any sub-threshold / rest-mass
leakage proportionally so the three fractions sum to exactly 1). A small floor
(`_SIMPLEX_FLOOR`) keeps the log-ratios finite where a fraction is 0.
Returns an (N, 2) array of ALR coordinates referenced to the post fraction;
`energy_simplex_decode` is its inverse (up to the floor softening).
"""
pre_E = np.maximum(np.asarray(pre_E, dtype=np.float32), _EPS)
raw_post_E = np.asarray(post_E, dtype=np.float32)
post_E = np.clip(raw_post_E, 0.0, pre_E)
delta_e = pre_E - post_E
edep = np.asarray(edep, dtype=np.float32)
e_sec = np.asarray(e_sec, dtype=np.float32)
lost = edep + e_sec
has_loss = lost > _EPS
# Clipping post_E down to pre_E forces delta_e (and thus the rescaled
# edep/e_sec below) to 0 even on rows where edep/e_sec were genuinely
# recorded as nonzero — warn so this doesn't silently discard real data.
discarded = (raw_post_E > pre_E) & has_loss
if np.any(discarded):
n = int(np.sum(discarded))
warnings.warn(
f"energy_simplex_encode: {n}/{len(discarded)} step(s) had "
"post_E > pre_E (clipped) while recording nonzero edep/e_sec; "
"that recorded energy deposit is discarded to keep delta_e "
"consistent with the clip.",
stacklevel=2,
)
scale = np.where(has_loss, delta_e / np.maximum(lost, _EPS), 0.0)
# Where nothing was recorded as deposited/secondary but energy was lost,
# attribute all of delta_e to local deposit.
edep_r = np.where(has_loss, edep * scale, delta_e)
e_sec_r = np.where(has_loss, e_sec * scale, 0.0)
f = np.stack([edep_r, e_sec_r, post_E], axis=-1) / pre_E[:, None] # (N,3), sums≈1
f = (f + _SIMPLEX_FLOOR) / (1.0 + 3.0 * _SIMPLEX_FLOOR)
log_f = np.log(f)
z = log_f[:, :2] - log_f[:, 2:3] # ALR vs post reference
return z.astype(np.float32)
def energy_simplex_decode(z: np.ndarray, pre_E: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Inverse of `energy_simplex_encode`: ALR coords + pre_E → physical energies.
A softmax over `[z_edep, z_sec, 0]` recovers the three simplex fractions, so
`edep + e_sec + post_E == pre_E` holds by construction (the energy-conservation
inductive bias). Returns `(edep, e_sec, post_E, delta_e)` in physical units.
"""
z = np.asarray(z, dtype=np.float32)
pre_E = np.asarray(pre_E, dtype=np.float32)
logits = np.concatenate([z, np.zeros((len(z), 1), dtype=np.float32)], axis=1)
logits = logits - logits.max(axis=1, keepdims=True)
f = np.exp(logits)
f /= f.sum(axis=1, keepdims=True) # sums to 1 exactly → exact conservation
E = f * pre_E[:, None]
edep, e_sec, post_E = E[:, 0], E[:, 1], E[:, 2]
delta_e = pre_E - post_E
return (
edep.astype(np.float32),
e_sec.astype(np.float32),
post_E.astype(np.float32),
delta_e.astype(np.float32),
)
def _rodrigues_axis(pre_dir: np.ndarray) -> np.ndarray:
"""Unit rotation axis `pre_dir × ẑ`, closed-form since ẑ = [0, 0, 1] is constant.
`cross(a, [0,0,1]) = [a_y, -a_x, 0]` — substituting the constant operand
avoids a generic `np.cross` call (shape/broadcast handling for an
arbitrary second operand) on every row; profiling on a 114M-row file
showed `np.cross` as the single hottest call inside this rotation.
"""
axis = np.stack([pre_dir[:, 1], -pre_dir[:, 0], np.zeros_like(pre_dir[:, 0])], axis=1)
axis_norm = np.linalg.norm(axis, axis=1, keepdims=True) # (N,1)
# axis_norm ~ 0 happens at BOTH poles: pre_dir ~ +ẑ (forward) and
# pre_dir ~ -ẑ (near-exact backscatter) — ‖pre_dir × ẑ‖ = sin(angle to
# ẑ) vanishes at both. The "choice is irrelevant" claim below only holds
# at +ẑ, where sin_t~0 AND (1-cos_t)~0 so every axis-dependent Rodrigues
# term vanishes. At -ẑ, sin_t~0 but (1-cos_t)~2 — not negligible — so
# snapping to a fixed x̂ there is a genuine (if physically rare)
# modeling choice, not a no-op: it picks one representative out of an
# inherently ambiguous family of 180°-about-any-transverse-axis
# rotations (no single-valued frame convention can be continuous through
# this antipode — same obstruction as a sphere's tangent frame having no
# continuous choice at a pole). x̂ is still fine to use — it's a fixed,
# self-consistent convention that `local_frame_rotation`/
# `inv_local_frame_rotation` (same threshold) round-trip correctly
# through — but steps whose pre_dir falls in this tiny near-backscatter
# cone get a discontinuous "roll" relative to their non-degenerate
# neighbors, injecting a small amount of label noise there.
safe_norm = np.where(axis_norm < 1e-7, 1.0, axis_norm)
return np.where(axis_norm < 1e-7, np.array([[1.0, 0.0, 0.0]]), axis / safe_norm)
def _cross_with_z_axis(axis: np.ndarray, v: np.ndarray) -> np.ndarray:
"""`axis × v`, closed-form since `axis` from `_rodrigues_axis` always has z = 0.
`cross([ax,ay,0], [bx,by,bz]) = [ay*bz, -ax*bz, ax*by - ay*bx]`.
"""
ax, ay = axis[:, 0:1], axis[:, 1:2]
bx, by, bz = v[:, 0:1], v[:, 1:2], v[:, 2:3]
return np.concatenate([ay * bz, -ax * bz, ax * by - ay * bx], axis=1)
def _validate_unit_pre_dir(pre_dir: np.ndarray) -> np.ndarray:
"""Normalize pre_dir and raise if any row is too degenerate to define a frame.
`local_frame_rotation`/`inv_local_frame_rotation` treat pre_dir[:, 2] as
cos(angle to ẑ), which is only correct for a unit vector. Small float32
drift is corrected silently; a near-zero-norm row has no well-defined
direction, so it's raised loudly instead of producing a meaningless
rotation (previously it fell through to an arbitrary axis with no error).
NaN/Inf rows are also raised on explicitly: `norm < 1e-6` is False for a
NaN norm, so without this check a non-finite row would silently pass
through and poison everything downstream (e.g. the persisted normalizer
stats in `setup_cache`, if the row is swept into a Welford accumulator).
"""
pre_dir = np.asarray(pre_dir, dtype=np.float32)
if not np.all(np.isfinite(pre_dir)):
bad = int(np.sum(~np.all(np.isfinite(pre_dir), axis=1)))
raise ValueError(
f"pre_dir has {bad} row(s) with non-finite (NaN/Inf) components; "
"local/inv_local_frame_rotation require a well-defined incoming "
"direction for every row."
)
norm = np.linalg.norm(pre_dir, axis=1, keepdims=True)
if np.any(norm < 1e-6):
raise ValueError(
f"pre_dir has {int(np.sum(norm < 1e-6))} row(s) with near-zero norm "
"(< 1e-6); local/inv_local_frame_rotation require a well-defined "
"incoming direction for every row."
)
return pre_dir / norm
def local_frame_rotation(pre_dir: np.ndarray, post_dir: np.ndarray) -> np.ndarray:
"""Rotate post_dir into the local frame where pre_dir maps to ẑ (Rodrigues).
Preserves the angle between pre_dir and post_dir; the result has post_dir
expressed relative to a coordinate system in which the incoming particle
travels along +z.
"""
pre_dir = _validate_unit_pre_dir(pre_dir)
cos_t = np.clip(pre_dir[:, 2:3], -1.0, 1.0) # (N,1); dot with ẑ = z-component
sin_t = np.sqrt(np.maximum(0.0, 1.0 - cos_t**2)) # (N,1)
axis = _rodrigues_axis(pre_dir) # (N,3); zero-z, zero-norm when pre_dir ∥ ẑ
kxv = _cross_with_z_axis(axis, post_dir) # (N,3)
kdv = (axis * post_dir).sum(axis=1, keepdims=True) # (N,1)
return (post_dir * cos_t + kxv * sin_t + axis * kdv * (1.0 - cos_t)).astype(np.float32)
class Normalizer:
def __init__(self) -> None:
self.mean: np.ndarray | None = None
self.std: np.ndarray | None = None
def fit(self, X: np.ndarray) -> "Normalizer":
self.mean = X.mean(axis=0).astype(np.float32)
self.std = X.std(axis=0).astype(np.float32)
self.std = np.where(self.std < _EPS, 1.0, self.std).astype(np.float32)
return self
def transform(self, X: np.ndarray) -> np.ndarray:
return ((X - self.mean) / self.std).astype(np.float32)
def inverse_transform(self, X: np.ndarray) -> np.ndarray:
return (X * self.std + self.mean).astype(np.float32)
def to_dict(self) -> dict:
assert self.mean is not None and self.std is not None, "Normalizer not fitted"
return {"mean": self.mean.tolist(), "std": self.std.tolist()}
@classmethod
def from_dict(cls, d: dict) -> "Normalizer":
obj = cls()
obj.mean = np.array(d["mean"], dtype=np.float32)
obj.std = np.array(d["std"], dtype=np.float32)
return obj
class _WelfordAccumulator:
"""Streaming mean/variance (Chan/Golub/LeVeque 1979 parallel algorithm).
Use to fit a Normalizer over data that doesn't fit in memory:
acc = _WelfordAccumulator(n_features)
for chunk in data:
acc.update(chunk)
normalizer = acc.to_normalizer()
"""
def __init__(self, n_features: int) -> None:
self.n = 0
self._mean = np.zeros(n_features, dtype=np.float64)
self._M2 = np.zeros(n_features, dtype=np.float64)
def update(self, X: np.ndarray) -> None:
# Computes the chunk's own local mean/M2 (two passes over X, no
# reference to the running mean) and merges it into the running
# totals with the O(F) Chan/Golub/LeVeque combination formula.
# Equivalent to the textbook single-pass streaming update (which
# instead re-derives two full (B, F) arrays from the running mean,
# before and after updating it) but ~40% cheaper here since it
# avoids one of those (B, F) passes and its temporary array.
X = np.asarray(X, dtype=np.float64)
B = X.shape[0]
mean_b = X.mean(0)
diff = X - mean_b
M2_b = np.einsum("ij,ij->j", diff, diff)
new_n = self.n + B
delta = mean_b - self._mean
self._mean += delta * (B / new_n)
self._M2 += M2_b + delta * delta * (self.n * B / new_n)
self.n = new_n
def to_normalizer(self) -> "Normalizer":
norm = Normalizer()
norm.mean = self._mean.astype(np.float32)
std = np.sqrt(self._M2 / max(self.n, 1)).astype(np.float32)
norm.std = np.where(std < _EPS, 1.0, std).astype(np.float32)
return norm
class _ReservoirSampler:
"""Uniform random sample of a fixed capacity drawn from a data stream.
Algorithm R (Vitter 1985), vectorized per chunk so it stays cheap over
hundreds of millions of rows: use to get a representative subsample of
a column for a distribution estimate (e.g. quantiles) without
materializing the full column.
sampler = _ReservoirSampler(capacity=100_000)
for chunk in data:
sampler.update(chunk)
sample = sampler.sample
"""
def __init__(self, capacity: int, seed: int = 0) -> None:
self.capacity = capacity
self.n_seen = 0
self._rng = np.random.default_rng(seed)
self._reservoir = np.empty(0, dtype=np.float64)
def update(self, values: np.ndarray) -> None:
values = np.asarray(values, dtype=np.float64).reshape(-1)
if values.size == 0:
return
n_before = self.n_seen
if n_before < self.capacity:
take = min(values.size, self.capacity - n_before)
self._reservoir = np.concatenate([self._reservoir, values[:take]])
values = values[take:]
n_before += take
self.n_seen = n_before + values.size
if values.size == 0 or self.capacity == 0:
return
# remaining elements are past the fill phase: element at 1-based
# stream position j replaces a uniformly random reservoir slot with
# probability capacity/j, which yields a uniform sample overall.
positions = n_before + np.arange(1, values.size + 1)
accept = self._rng.random(values.size) < (self.capacity / positions)
accept_idx = np.nonzero(accept)[0]
if accept_idx.size > 0:
slots = self._rng.integers(0, self.capacity, size=accept_idx.size)
self._reservoir[slots] = values[accept_idx]
@property
def sample(self) -> np.ndarray:
return self._reservoir.astype(np.float32)
def sorted_membership(values: np.ndarray, sorted_arr: np.ndarray) -> np.ndarray:
"""Boolean membership of `values` (any order) in `sorted_arr` (ascending, unique).
Equivalent to `np.isin(values, sorted_arr)`, but `np.isin`'s default path
sorts both inputs on every call — costly when `sorted_arr` is a large,
already-sorted array (e.g. all train-split event ids) reused across many
chunks. This does one `searchsorted` per call instead. `values` need not
be sorted; `sorted_arr` must be ascending and duplicate-free.
"""
values = np.asarray(values)
if sorted_arr.size == 0:
return np.zeros(values.shape, dtype=bool)
idx = np.searchsorted(sorted_arr, values)
idx = np.clip(idx, 0, len(sorted_arr) - 1)
return sorted_arr[idx] == values
def _vectorized_map_lookup(values: np.ndarray, mapping: dict, strict: bool = True, default: int = 0) -> np.ndarray:
"""Vectorized equivalent of `np.array([mapping[v] for v in values], dtype=np.int64)`.
Replaces a per-element Python dict lookup with one `searchsorted` call.
Raises `KeyError` if any value in `values` isn't a key of `mapping`,
matching the dict-comprehension it replaces (never silently misassigns)
— unless `strict=False`, in which case unmapped values get `default`
instead. Only pass `strict=False` where the caller has independently
verified the resulting index is either never actually read (e.g.
`build_cond_features` under `conditioning="physical"`, where
`ConditionEncoder` ignores `cond_cat` entirely) or where `default` is a
deliberate fallback class (e.g. a top-N map's "other" index for a raw
value outside the training vocab). It exists so a rollout can be seeded
with a species/material outside the training vocab without a spurious
`KeyError`, which is the entire point of physical-property conditioning.
"""
keys = np.asarray(list(mapping.keys()))
vals = np.asarray(list(mapping.values()), dtype=np.int64)
order = np.argsort(keys, kind="stable")
keys_sorted, vals_sorted = keys[order], vals[order]
values = np.asarray(values)
pos = np.searchsorted(keys_sorted, values)
pos = np.clip(pos, 0, len(keys_sorted) - 1)
found = keys_sorted[pos] == values
if not found.all():
if not strict:
out = np.full(values.shape, default, dtype=np.int64)
out[found] = vals_sorted[pos[found]]
return out
missing = np.unique(values[~found])
raise KeyError(f"value(s) not in mapping: {missing[:10].tolist()}")
return vals_sorted[pos]
def travel_direction(pre_pos: np.ndarray, post_pos: np.ndarray) -> np.ndarray:
"""World-frame unit vector pointing from pre_pos to post_pos.
Kept independent of `step_length`: that scalar already encodes the
magnitude of this displacement, so this function only ever returns
direction (norm-guarded the same way as `local_frame_rotation`'s axis).
"""
disp = post_pos - pre_pos
norm = np.linalg.norm(disp, axis=1, keepdims=True)
safe_norm = np.where(norm < 1e-7, 1.0, norm)
return np.where(norm < 1e-7, np.array([[0.0, 0.0, 1.0]]), disp / safe_norm).astype(np.float32)
def reconstruct_post_pos(
pre_pos: np.ndarray,
pre_dir: np.ndarray,
step_length: np.ndarray,
travel_dir_local: np.ndarray,
) -> np.ndarray:
"""Inverse of the travel_direction/local_frame_rotation encoding.
Single source of truth for combining the magnitude (`step_length`) and
direction (`travel_dir_local`) back into a world-frame post_pos, so
`step_length` and post_pos stay consistent by construction.
"""
travel_dir_world = inv_local_frame_rotation(pre_dir, travel_dir_local)
return (pre_pos + step_length.reshape(-1, 1) * travel_dir_world).astype(np.float32)
def inv_local_frame_rotation(pre_dir: np.ndarray, post_dir_local: np.ndarray) -> np.ndarray:
"""Inverse of local_frame_rotation: rotate from local frame back to world frame.
Applies R^T (same axis, negative angle) to post_dir_local.
"""
pre_dir = _validate_unit_pre_dir(pre_dir)
cos_t = np.clip(pre_dir[:, 2:3], -1.0, 1.0) # dot with ẑ = z-component
sin_t = np.sqrt(np.maximum(0.0, 1.0 - cos_t**2))
axis = _rodrigues_axis(pre_dir)
kxv = _cross_with_z_axis(axis, post_dir_local)
kdv = (axis * post_dir_local).sum(axis=1, keepdims=True)
# Negative angle: sin_t → -sin_t
return (post_dir_local * cos_t - kxv * sin_t + axis * kdv * (1.0 - cos_t)).astype(np.float32)
_STICK_LOGIT_CLIP = 10.0 # logit value used for the last valid secondary slot
def encode_secondaries(
sec_E_list: np.ndarray,
sec_dir_list: np.ndarray,
sec_valid: np.ndarray,
e_sec: np.ndarray,
pre_dir: np.ndarray,
sec_pdg_list: np.ndarray | None = None,
phys_only: bool = False,
) -> np.ndarray:
"""Encode per-secondary attributes into continuous per-slot targets.
Secondaries must already be sorted descending by energy (as stored in the
parquet). Returns sec_cont of shape (N, K_MAX, SEC_SLOT_DIM=6):
slot[i] = [stick_break_logit, local_dir_x, local_dir_y, local_dir_z,
log_mass, charge]
Stick-breaking logit: for slot i, f_i = E_i / remaining_budget, where
remaining_budget = e_sec - sum(E_0..E_{i-1}). The logit is log(f/(1-f)),
clipped to ±_STICK_LOGIT_CLIP. The last valid slot gets +_STICK_LOGIT_CLIP
(takes the full remaining budget). Padding slots get 0.
log_mass/charge are the secondary's real physical identity, looked up
from its ground-truth PDG code (`sec_pdg_list`) via
`giant.particles.particle_phys_array` — a fixed physics-derived
regression target, not a learned/moving one (unlike the embedding-table
target this replaced), so nothing needs to be detached at training time.
`sec_pdg_list` is optional so callers that only need the continuous
stick/dir block (e.g. inference-time re-encoding) can omit it; omitting
it zero-fills the last two columns, matching the padding-slot convention.
`phys_only=True` skips the stick-breaking and direction-rotation blocks
(zero-filling them instead) and computes only log_mass/charge — for
callers (normalizer fitting) that discard the other four columns anyway,
so computing them would be wasted work repeated over the whole dataset.
"""
N, K = sec_E_list.shape
e_sec = np.asarray(e_sec, dtype=np.float64)
if phys_only:
stick_logits = np.zeros((N, K), dtype=np.float32)
dir_local = np.zeros((N, K, 3), dtype=np.float32)
else:
cumsum = np.cumsum(sec_E_list.astype(np.float64), axis=1)
stick_logits = np.zeros((N, K), dtype=np.float32)
# A valid slot whose cumulative secondary energy so far exceeds
# e_sec by more than float noise means sec_E_list sums to more than
# e_sec — a real upstream data mismatch, not something to paper
# over. Flagged once after the loop rather than let `remaining`'s
# np.maximum(..., _EPS) floor silently absorb it by saturating that
# slot's stick-breaking logit with no signal that anything was off.
_SHORTFALL_TOL = 1e-3
shortfall_flagged = np.zeros(N, dtype=bool)
for i in range(K):
if i == 0:
remaining_raw = e_sec
else:
remaining_raw = e_sec - cumsum[:, i - 1]
shortfall_flagged |= sec_valid[:, i] & (remaining_raw < -_SHORTFALL_TOL)
remaining = np.maximum(remaining_raw, _EPS)
f = np.clip(sec_E_list[:, i].astype(np.float64) / remaining, _EPS, 1.0 - _EPS)
logit = np.log(f / (1.0 - f)).astype(np.float32)
# Last valid slot: give it the full remaining budget
is_last = sec_valid[:, i] & ~(sec_valid[:, i + 1] if i + 1 < K else np.zeros(N, dtype=bool))
logit = np.where(is_last, _STICK_LOGIT_CLIP, logit)
logit = np.where(
sec_valid[:, i],
np.clip(logit, -_STICK_LOGIT_CLIP, _STICK_LOGIT_CLIP),
0.0,
)
stick_logits[:, i] = logit.astype(np.float32)
if shortfall_flagged.any():
n = int(shortfall_flagged.sum())
warnings.warn(
f"encode_secondaries: {n}/{N} row(s) have sec_E_list summing "
"to more than e_sec (beyond float noise) — the overflowing "
"slot(s)' stick-breaking logit was saturated instead of "
"reflecting a real fraction; check upstream secondary "
"energy accounting for these rows.",
stacklevel=2,
)
# Rotate each slot's direction into the local frame of the primary.
# pre_dir is broadcast across all K slots.
dir_local = np.zeros((N, K, 3), dtype=np.float32)
for i in range(K):
# Only rotate valid slots; leave padded slots as (0,0,1) or whatever.
valid_mask = sec_valid[:, i]
if valid_mask.any():
dir_local[valid_mask, i] = local_frame_rotation(pre_dir[valid_mask], sec_dir_list[valid_mask, i])
if sec_pdg_list is not None:
from giant.particles import particle_phys_array
# Padding slots carry sentinel pdg 0 (see loader._pad_list_col_int),
# which isn't a resolvable particle — substitute a dummy resolvable
# code (22, photon) there, since the result is discarded below by
# the sec_valid mask regardless.
safe_pdg = np.where(sec_valid, sec_pdg_list, 22)
flat_mass_charge = particle_phys_array(safe_pdg.reshape(-1)) # (N*K, 2)
mass = flat_mass_charge[:, 0].reshape(N, K)
charge = flat_mass_charge[:, 1].reshape(N, K)
log_mass = np.where(sec_valid, log_transform(mass), 0.0).astype(np.float32)
charge = np.where(sec_valid, charge, 0.0).astype(np.float32)
else:
log_mass = np.zeros((N, K), dtype=np.float32)
charge = np.zeros((N, K), dtype=np.float32)
sec_cont = np.concatenate(
[stick_logits[:, :, None], dir_local, log_mass[:, :, None], charge[:, :, None]],
axis=-1,
) # (N, K, 6)
return sec_cont.astype(np.float32)
def encode_secondary_type_idx(sec_pdg_list: np.ndarray, sec_valid: np.ndarray, class_map: dict) -> np.ndarray:
"""Per-secondary-slot class index into `class_map` — (N, K_MAX) int64.
`class_map` is either a top-N-plus-other map's `class_map`
(`stage2_model.particle_type.target = "onehot"`, see
`giant.data.loader.build_pdg_topn_map_from_files`) or the dense `pdg_map`
(`target = "embedding"`). Not used at all for `target = "physical"` —
that target keeps using `encode_secondaries`'s (log_mass, charge)
columns unchanged.
Padding slots get index 0 (their looked-up value is discarded downstream
by the `sec_valid`/`n_sec` mask regardless, so any in-vocabulary dummy
code works). A *real, valid* secondary whose code is missing from
`class_map` raises `KeyError` (`strict=True`) rather than silently
misassigning — for `target="onehot"` this should never actually
trigger, since `build_pdg_topn_map_from_files` pools both primary and
secondary occurrences precisely so every secondary species seen in
these files has a key (in "other" at worst); for `target="embedding"`
(which reuses the dense, primary-only `pdg_map`) it's a real signal
that a secondary-only species exists with no primary-role counterpart.
"""
N, K = sec_pdg_list.shape
# An arbitrary already-present key works as the padding-slot dummy code
# (unlike encode_secondaries' physics-derived phys lookup, this is an
# index into class_map's own vocabulary, so a fixed sentinel like 22
# isn't guaranteed to be a key — an arbitrary present one always is).
dummy = next(iter(class_map))
safe_pdg = np.where(sec_valid, sec_pdg_list, dummy)
idx = _vectorized_map_lookup(safe_pdg.reshape(-1), class_map, strict=True).reshape(N, K)
return np.where(sec_valid, idx, 0).astype(np.int64)
def decode_secondary_cont(
sec_cont: np.ndarray,
n_sec: np.ndarray,
e_sec: np.ndarray,
pre_dir: np.ndarray,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Continuous-only half of `decode_secondaries`'s inverse: the
stick-breaking energy split and local->world direction — generator/
`particle_type.target`-independent, since every target (`"physical"`,
`"onehot"`, `"embedding"`) shares the same `CONT_SLOT_DIM`-wide
(stick_logit, dir) prefix and differs only
in what follows it. `decode_secondaries` (target="physical") is the
original all-in-one form built on top of this; `target` in `("onehot",
"embedding")` decodes their type slice separately via
`giant.particles.decode_topn_class`/`decode_embedding_nearest` and calls
this directly instead — see `giant/rollout.py`.
sec_cont: (N, K, >=CONT_SLOT_DIM) — only columns `[:, :, :CONT_SLOT_DIM]`
(stick_logit, local dir) are read; a caller may pass its full
per-slot tensor (continuous + type) unsliced.
n_sec: (N,) integer secondary counts
e_sec: (N,) total secondary energy budget [MeV]
pre_dir: (N, 3) pre-step world-frame direction
Returns (sec_E, sec_dir_world, sec_valid), shapes (N, K), (N, K, 3),
(N, K). The valid slots' energies (`sec_E[sec_valid]`, per row) always
sum to exactly `e_sec` — see the rescaling below.
"""
N, K = sec_cont.shape[0], sec_cont.shape[1]
stick_logits = sec_cont[:, :, 0] # (N, K)
dir_local = sec_cont[:, :, 1:4].copy() # (N, K, 3)
# Flow-matching output isn't guaranteed unit norm; normalise before the
# rotation below, which preserves magnitude rather than fixing it up.
norms = np.linalg.norm(dir_local, axis=-1, keepdims=True)
dir_local /= np.where(norms < 1e-8, 1.0, norms)
fractions = 1.0 / (1.0 + np.exp(-stick_logits.astype(np.float64)))
sec_E = np.zeros((N, K), dtype=np.float64)
e_sec = np.asarray(e_sec, dtype=np.float64)
remaining = e_sec.copy()
for i in range(K):
sec_E[:, i] = fractions[:, i] * remaining
remaining = np.maximum(remaining - sec_E[:, i], 0.0)
sec_valid = np.arange(K)[None, :] < n_sec[:, None] # (N, K)
# Stick-breaking guarantees sum(sec_E[valid]) <= e_sec (each fraction is in
# [0,1] of an already-shrinking remainder) but rarely hits it exactly, so
# rescale the valid slots by one common per-row factor to close that gap —
# rather than dumping the shortfall into whichever slot happens to be last
# by energy rank, which would let one low-energy secondary balloon and
# distort the shower's topology. This preserves each row's relative split
# across its secondaries and only ever scales up (valid_sum <= e_sec).
# Rows where every valid slot decoded to ~zero (scale undefined) fall back
# to an even split of e_sec across the n_sec valid slots.
sec_E = sec_E * sec_valid
valid_sum = sec_E.sum(axis=1)
degenerate = (valid_sum <= _EPS) & (n_sec > 0)
scale = np.where(valid_sum > _EPS, e_sec / np.maximum(valid_sum, _EPS), 0.0)
sec_E = sec_E * scale[:, None]
even_share = e_sec / np.maximum(n_sec, 1).astype(np.float64)
sec_E = np.where(degenerate[:, None] & sec_valid, even_share[:, None], sec_E)
sec_E = sec_E.astype(np.float32)
sec_dir_world = np.zeros((N, K, 3), dtype=np.float32)
for i in range(K):
valid = sec_valid[:, i]
if valid.any():
sec_dir_world[valid, i] = inv_local_frame_rotation(pre_dir[valid], dir_local[valid, i])
return sec_E, sec_dir_world, sec_valid
def decode_secondaries(
sec_cont: np.ndarray,
n_sec: np.ndarray,
e_sec: np.ndarray,
pre_dir: np.ndarray,
sec_phys_normalizer: "Normalizer | None" = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""Inverse of encode_secondaries: continuous targets → physical secondary attrs.
`particle_type.target = "physical"` only (the type slice is a raw
(log_mass, charge) regression target folded straight into `sec_cont`) —
`"onehot"`/`"embedding"` decode through `decode_secondary_cont` +
`giant.particles.decode_topn_class`/`decode_embedding_nearest` instead,
since their type slice isn't (log_mass, charge) at all. See
`decode_secondary_cont`'s docstring for why the two share the energy/
direction logic below.
sec_cont: (N, K_MAX, 6) — [stick_logit, local_dir_x, local_dir_y,
local_dir_z, log_mass, charge] (log_mass/charge normalised iff
`sec_phys_normalizer` was applied when this was produced — e.g. a
raw model prediction; pass the same normalizer here to invert it)
n_sec: (N,) integer secondary counts
e_sec: (N,) total secondary energy budget [MeV]
pre_dir: (N, 3) pre-step world-frame direction
Returns (sec_E, sec_dir_world, sec_mass, sec_charge, sec_valid) each
shape (N, K_MAX). mass/charge are the model's raw predicted physical
identity for each secondary, used as-is (no snapping to a discrete PDG
code) — see giant/particles.py for the separate, reporting-only
nearest-PDG lookup callers may apply on top of this for display/
bookkeeping purposes.
"""
if sec_phys_normalizer is not None:
N_, K_, _ = sec_cont.shape
phys = sec_phys_normalizer.inverse_transform(sec_cont[:, :, 4:6].reshape(-1, 2))
sec_cont = sec_cont.copy()
sec_cont[:, :, 4:6] = phys.reshape(N_, K_, 2)
sec_E, sec_dir_world, sec_valid = decode_secondary_cont(sec_cont, n_sec, e_sec, pre_dir)
log_mass = sec_cont[:, :, 4] # (N, K)
charge = sec_cont[:, :, 5] # (N, K)
# mass is non-negative by construction (inv_log_transform of a real
# number is always > 0); clip to 0 for padded/invalid slots rather than
# leaving a spurious small positive floor from the log inverse.
sec_mass = np.where(sec_valid, inv_log_transform(log_mass), 0.0).astype(np.float32)
sec_charge = np.where(sec_valid, charge, 0.0).astype(np.float32)
return sec_E, sec_dir_world, sec_mass, sec_charge, sec_valid
def _physical_cond_columns(data: dict[str, np.ndarray], layout: CondLayout) -> np.ndarray:
"""(N, PARTICLE_PHYS_DIM + MATERIAL_PHYS_DIM) physical conditioning columns.
The particle and material blocks are gated independently and may mix
freely — e.g. material `physical` with particle `embedding` — so e.g.
`particle_type="embedding"` + `material_type="physical"` zero-fills only
the particle columns and computes the material ones for real.
"embedding"/"onehot" zero-fill their block (cheap, and ConditionEncoder
never reads these columns in either mode — so an unfilled
giant.materials table can never crash an "embedding"/"onehot"-mode run).
"physical" computes it for real: particle columns come from
`data["mass"]`/`data["charge"]` when the caller already knows them
directly (rollout.py, for a track descended from a model-predicted
secondary — see giant/rollout.py's "no snapping" design), else derived
from `data["pdg"]` via giant.particles; material columns always come
from `data["material"]` via giant.materials, since material is never
itself a model prediction.
"""
from giant.constants import MATERIAL_PHYS_DIM, PARTICLE_PHYS_DIM
n = len(next(iter(data.values())))
if layout.particle_type == "physical":
from giant.particles import particle_phys_array
if "mass" in data and "charge" in data:
mass = np.asarray(data["mass"], dtype=np.float32)
charge = np.asarray(data["charge"], dtype=np.float32)
else:
mass, charge = particle_phys_array(data["pdg"]).T
particle_cols = np.column_stack([log_transform(mass), charge])
else:
particle_cols = np.zeros((n, PARTICLE_PHYS_DIM), dtype=np.float32)
if layout.material_type == "physical":
from giant.materials import material_properties_array
z_eff, a_eff, density, x0, lambda_int = material_properties_array(data["material"]).T
material_cols = np.column_stack(
[
z_eff,
a_eff,
log_transform(density),
log_transform(x0),
log_transform(lambda_int),
]
)
else:
material_cols = np.zeros((n, MATERIAL_PHYS_DIM), dtype=np.float32)
return np.column_stack([particle_cols, material_cols]).astype(np.float32)
def _build_cond_arrays(
data: dict[str, np.ndarray],
pdg_map: dict[int, int],
mat_map: dict[str, int],
layout: CondLayout,
pdg_topn_map: dict[int, int] | None,
mat_topn_map: dict[str, int] | None,
) -> tuple[np.ndarray, np.ndarray]:
"""The un-normalized `(cond_cont, cond_cat)` pair, in `layout`'s column order.
Both `build_cond_features` and `build_features` go through here, so the
column order — and everything that depends on it — is stated once. See
`giant.cond_layout.CondLayout` for the layout itself.
"""
cond_cont = np.column_stack(
[
data["pre_pos"],
log_transform(data["pre_E"]),
data["pre_dir"],
data["layer_id"].astype(np.float32),
]
).astype(np.float32) # (N, COND_DIM_BASE=8)
cond_cont = np.column_stack([cond_cont, _physical_cond_columns(data, layout)]).astype(
np.float32
) # (N, COND_DIM=15)
# In "physical" mode cond_cat's first two columns are only a
# reporting/router convenience — ConditionEncoder never reads them
# (giant/model/encoders.py) — so a species/material outside the training
# vocab (the whole point of physical-property conditioning) gets a dummy
# index instead of raising. In "embedding" mode those columns ARE the
# conditioning signal, so an unmapped value must still raise loudly
# rather than silently misassign. In "onehot" mode they again go unread
# (the topN columns below are the real signal), so they're as permissive
# as "physical". Each axis's strictness is independent.
pdg_idx = _vectorized_map_lookup(data["pdg"], pdg_map, strict=layout.particle_type == "embedding")
mat_idx = _vectorized_map_lookup(data["material"], mat_map, strict=layout.material_type == "embedding")
# Which extra columns exist is the layout's call, not "did the caller
# happen to pass a map" — that's what used to let the producer and
# ConditionEncoder disagree. A map for a non-"onehot" axis is unused.
cat_cols = [pdg_idx, mat_idx]
if layout.particle_topn_col is not None:
if pdg_topn_map is None:
raise ValueError("conditioning.particle.type='onehot' needs pdg_topn_map")
cat_cols.append(_vectorized_map_lookup(data["pdg"], pdg_topn_map))
if layout.material_topn_col is not None:
if mat_topn_map is None:
raise ValueError("conditioning.material.type='onehot' needs mat_topn_map")
cat_cols.append(_vectorized_map_lookup(data["material"], mat_topn_map))
cond_cat = np.column_stack(cat_cols) # (N, layout.cat_dim)
return cond_cont, cond_cat
def build_cond_features(
data: dict[str, np.ndarray],
pdg_map: dict[int, int],
mat_map: dict[str, int],
cond_normalizer: "Normalizer | None" = None,
particle_conditioning: str = "embedding",
material_conditioning: str = "embedding",
pdg_topn_map: dict[int, int] | None = None,
mat_topn_map: dict[str, int] | None = None,
) -> tuple[np.ndarray, np.ndarray]:
"""Build conditioning arrays only — no target, no post-step variables.
`particle_conditioning`/`material_conditioning` are independent —
e.g. `particle_conditioning="embedding"` +
`material_conditioning="physical"` is a valid mix.
`pdg_topn_map`/`mat_topn_map` (a top-N-plus-other `class_map`, see
`giant.data.loader.build_topn_map_from_files`) supply the extra `cond_cat`
columns read by `ConditionEncoder`'s `"onehot"` mode, and are required
whenever the corresponding axis is `"onehot"`. See
`giant.cond_layout.CondLayout` for which columns exist where.
"""
layout = CondLayout.from_types(particle_conditioning, material_conditioning)
cond_cont, cond_cat = _build_cond_arrays(data, pdg_map, mat_map, layout, pdg_topn_map, mat_topn_map)
if cond_normalizer is not None:
cond_cont = _cond_normalizer_transform(cond_cont, cond_normalizer, layout)
return cond_cont, cond_cat
def _cond_normalizer_transform(
cond_cont: np.ndarray,
cond_normalizer: "Normalizer",
layout: CondLayout,
) -> np.ndarray:
"""Apply ``cond_normalizer``, padding a legacy narrower normalizer if needed.
Checkpoints trained before physical-property conditioning (``COND_DIM``
8->15, ``giant/constants.py``) saved a ``COND_DIM_BASE``-wide (8) cond
normalizer, fit before ``build_cond_features`` grew the extra physical
columns. When NEITHER axis is "physical" those columns are never read by
``ConditionEncoder`` (``giant/model/encoders.py``), so padding the missing
entries with mean=0/std=1 is a safe no-op that keeps such checkpoints
usable under the current, always-``COND_DIM``-wide contract. If EITHER
axis is "physical" its columns are load-bearing, so a mismatch there is a
real incompatibility, not something to paper over.
"""
mean, std = cond_normalizer.mean, cond_normalizer.std
assert mean is not None and std is not None, "Normalizer not fitted"
width = cond_cont.shape[-1]
if mean.shape[-1] < width:
physical_load_bearing = "physical" in (
layout.particle_type,
layout.material_type,
)
if physical_load_bearing:
raise ValueError(
f"cond normalizer has {mean.shape[-1]} columns, expected "
f"{width}, and particle_conditioning={layout.particle_type!r}/"
f"material_conditioning={layout.material_type!r} reads the "
"physical columns directly — this checkpoint predates "
"physical-property conditioning and can't be safely padded; "
"retrain it under the current code."
)
pad = width - mean.shape[-1]
mean = np.concatenate([mean, np.zeros(pad, dtype=mean.dtype)])
std = np.concatenate([std, np.ones(pad, dtype=std.dtype)])
return ((cond_cont - mean) / std).astype(np.float32)
class StepFeatures(NamedTuple):
"""Output of `build_features`. Field order is load-bearing for existing
positional unpacking (tests, `StreamingStepsDataset`) — append only,
never insert or reorder.
target_s1: (N, 9) Stage-1 primary post-step target (unchanged from Phase 1)
n_sec: (N,) integer secondary counts (target for n_sec head)
sec_cont: (N, K_MAX, SEC_SLOT_DIM=6) continuous secondary targets
[stick_logit, dir_local, log_mass, charge] — mass/charge are
the secondary's real physical identity (from its ground-truth
PDG code), a fixed regression target, not a learned/snapped one.
Always computed the same way regardless of
`stage2_model.particle_type.target` — only actually used
downstream under `target = "physical"`.
proc_idx: (N,) integer process-class label (ProcessRouter supervision only —
never conditioning). Zeros when `proc_map` is None or the loaded
data has no "process" column (e.g. pre-conversion parquet files).
sec_type_idx: (N, K_MAX) integer secondary class index into
`sec_type_class_map`, for `stage2_model.particle_type.target`
in `("onehot", "embedding")` — see `encode_secondary_type_idx`.
Zero-filled (and unused) when `sec_type_class_map` is None
(i.e. `target = "physical"`).
"""
cond_cont: np.ndarray
cond_cat: np.ndarray
target_s1: np.ndarray
n_sec: np.ndarray
sec_cont: np.ndarray
proc_idx: np.ndarray
sec_type_idx: np.ndarray
cond_normalizer: Normalizer | None
target_normalizer: Normalizer | None
def build_features(
data: dict[str, np.ndarray],
pdg_map: dict[int, int],
mat_map: dict[str, int],
cond_normalizer: Normalizer | None = None,
target_normalizer: Normalizer | None = None,
sec_phys_normalizer: Normalizer | None = None,
fit: bool = False,
proc_map: dict[str, int] | None = None,
require_secondaries: bool = False,
particle_conditioning: str = "embedding",
material_conditioning: str = "embedding",
sec_phys_only: bool = False,
pdg_topn_map: dict[int, int] | None = None,
mat_topn_map: dict[str, int] | None = None,
sec_type_class_map: dict | None = None,
k_max: int = K_MAX,
) -> StepFeatures:
"""Assemble a `StepFeatures` of (cond_cont, cond_cat, target_s1, n_sec,
sec_cont, proc_idx, sec_type_idx, cond_normalizer, target_normalizer) —
see `StepFeatures` for field meanings.
require_secondaries: when True, raise if any step has n_sec > 0 but the
per-secondary list columns are absent (a mis-converted file that would
otherwise silently zero all Stage-2 targets). Training paths set this;
Stage-1-only callers (e.g. `giant predict`) leave it False.
sec_phys_only: passed straight through to `encode_secondaries` — skips
the stick-breaking/direction-rotation blocks of `sec_cont` (zero-filled
instead) for callers (normalizer fitting) that only read
`sec_cont[:, :, 4:6]` and would otherwise discard that work.
pdg_topn_map/mat_topn_map: source of the extra `cond_cat` columns for
`ConditionEncoder`'s `"onehot"` mode — see `build_cond_features`.
sec_type_class_map: the map `sec_type_idx` is looked up against — a
top-N-plus-other map's `class_map` for `target = "onehot"`, or the
dense `pdg_map` for `target = "embedding"` (pass `pdg_map` itself).
`None` for `target = "physical"`.
k_max: should match `stage2_model.k_max` —
overridden internally by `data["sec_E_list"]`'s own padded width when
present (the loader already padded it to some k_max; that width is
authoritative), so this only actually matters when secondary list
columns are absent (Stage-1-only reads, or a pre-secondary-join
file), where it sets `sec_cont`/`sec_type_idx`'s zero-filled width.
"""
post_dir_local = local_frame_rotation(data["pre_dir"], data["post_dir"])
travel_dir_local = local_frame_rotation(data["pre_dir"], travel_direction(data["pre_pos"], data["post_pos"]))
energy_z = energy_simplex_encode(data["edep"], data["e_sec"], data["post_E"], data["pre_E"]) # (N, 2)
target_s1 = np.column_stack(
[
log_transform(data["step_length"]),
energy_z,
post_dir_local,
travel_dir_local,
]
).astype(np.float32) # (N, 9)
# Phase 2: conditioning drops n_sec and log(e_sec)
layout = CondLayout.from_types(particle_conditioning, material_conditioning)
cond_cont, cond_cat = _build_cond_arrays(data, pdg_map, mat_map, layout, pdg_topn_map, mat_topn_map)
n_sec_raw = data["n_sec"].astype(np.int64) # (N,) unclamped, for the valid-slot mask
# Secondary continuous targets
sec_E_list = data.get("sec_E_list")
sec_dir_list = data.get("sec_dir_list")
sec_pdg_list = data.get("sec_pdg_list")
if sec_E_list is not None:
# The loader already padded sec_*_list to some k_max (see
# giant.data.loader.iter_file_chunks); that padded width is
# authoritative over whatever this call happened to pass in, so the
# two can never drift apart.
k_max = sec_E_list.shape[1]
# Clamp the classification label to k_max: the head only has k_max+1
# classes (0..k_max), and truncating here mirrors the k_max-slot
# truncation already applied to sec_cont by the loader's list padding.
# Without this, a rare high-multiplicity step (real data goes up to ~37)
# hands cross_entropy an out-of-range target and CUDA asserts.
n_sec = np.minimum(n_sec_raw, k_max).astype(np.int64) # (N,)
if sec_E_list is not None and sec_dir_list is not None and sec_pdg_list is not None:
sec_valid = np.arange(k_max)[None, :] < n_sec_raw[:, None] # (N, k_max)
sec_cont = encode_secondaries(
sec_E_list,
sec_dir_list,
sec_valid,
data["e_sec"],
data["pre_dir"],
sec_pdg_list=sec_pdg_list,
phys_only=sec_phys_only,
) # (N, k_max, 6)
sec_type_idx = (
encode_secondary_type_idx(sec_pdg_list, sec_valid, sec_type_class_map)
if sec_type_class_map is not None
else np.zeros((len(n_sec), k_max), dtype=np.int64)
)
else:
# Guard against silently training Stage 2 on zeroed targets: if any step
# actually spawned secondaries (n_sec > 0, from child_track_ids) but the
# per-secondary columns are absent, the file was never run through the
# parent->child join (steps_to_parquet._add_secondary_attributes /
# `dwarf convert`). Zero-filling here would collapse every secondary to
# PDG index 0 and a constant energy fraction — a broken Stage 2 with no
# error. Callers that only need Stage-1 (e.g. `giant predict`) keep the
# default require_secondaries=False.
if require_secondaries and n_sec_raw.max(initial=0) > 0:
n_with_sec = int((n_sec_raw > 0).sum())
raise ValueError(
f"{n_with_sec} step(s) have secondaries (n_sec > 0) but the "
"per-secondary columns (sec_E_list / sec_pdg_list / sec_dx_list "
"…) are missing. This parquet was not run through the "
"parent->child join (steps_to_parquet._add_secondary_attributes "
"/ `dwarf convert`); training on it would silently zero all "
"Stage-2 targets. Re-convert the file, or pass "
"require_secondaries=False for Stage-1-only use."
)
N = len(n_sec)
sec_cont = np.zeros((N, k_max, 6), dtype=np.float32)
sec_type_idx = np.zeros((N, k_max), dtype=np.int64)
if fit:
cond_normalizer = Normalizer().fit(cond_cont)
target_normalizer = Normalizer().fit(target_s1)
if cond_normalizer is not None:
cond_cont = _cond_normalizer_transform(cond_cont, cond_normalizer, layout)
if target_normalizer is not None:
target_s1 = target_normalizer.transform(target_s1)
if sec_phys_normalizer is not None:
N_, K_, _ = sec_cont.shape
phys = sec_phys_normalizer.transform(sec_cont[:, :, 4:6].reshape(-1, 2))
sec_cont = sec_cont.copy()
sec_cont[:, :, 4:6] = phys.reshape(N_, K_, 2)
process = data.get("process")
if proc_map is not None and process is not None:
proc_idx = _vectorized_map_lookup(process, proc_map)
else:
proc_idx = np.zeros(len(cond_cat), dtype=np.int64)
return StepFeatures(
cond_cont=cond_cont,
cond_cat=cond_cat,
target_s1=target_s1,
n_sec=n_sec,
sec_cont=sec_cont,
proc_idx=proc_idx,
sec_type_idx=sec_type_idx,
cond_normalizer=cond_normalizer,
target_normalizer=target_normalizer,
)