Error on missing secondary lists instead of silently zeroing Stage-2 targets

A parquet that carries child_track_ids/e_sec but was never run through the
parent->child join lacks the per-secondary columns (sec_E_list/sec_pdg_list/
sec_dir_list). build_features would fall back to all-zero sec_cont/sec_pdg_idx,
collapsing every secondary to PDG index 0 and a constant energy fraction — a
broken Stage 2 that trained with no error (single-species validation tables).

Add an opt-in require_secondaries flag that raises when n_sec > 0 but the lists
are absent, and enable it on the training paths (StreamingStepsDataset and the
normalizer-fit pass). giant predict keeps the default False for Stage-1-only use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 09:02:51 +02:00
parent 0b3ece52ed
commit f387178dbf
4 changed files with 57 additions and 1 deletions
+1
View File
@@ -109,6 +109,7 @@ class StreamingStepsDataset(IterableDataset):
cond_normalizer=self.cond_normalizer,
target_normalizer=self.target_normalizer,
proc_map=self.proc_map,
require_secondaries=True,
)
buf_cont.append(cond_cont)
buf_cat.append(cond_cat)
+25
View File
@@ -434,6 +434,7 @@ def build_features(
target_normalizer: Normalizer | None = None,
fit: bool = False,
proc_map: dict[str, int] | None = None,
require_secondaries: bool = False,
) -> tuple[
np.ndarray,
np.ndarray,
@@ -455,6 +456,11 @@ def build_features(
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).
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.
"""
from giant.constants import K_MAX
@@ -515,6 +521,25 @@ def build_features(
sec_pdg_list
).astype(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, 4), dtype=np.float32)
sec_pdg_idx = np.zeros((N, K_MAX), dtype=np.int64)
+4 -1
View File
@@ -72,7 +72,10 @@ def run_train_job(
continue
chunk_tr = {k: v[mask] for k, v in chunk.items()}
cond_cont, _, target_s1, _n_sec, _sec_cont, _sec_pdg, _proc, _, _ = (
build_features(chunk_tr, pdg_map, mat_map, proc_map=proc_map)
build_features(
chunk_tr, pdg_map, mat_map, proc_map=proc_map,
require_secondaries=True,
)
)
cond_acc.update(cond_cont)
tgt_acc.update(target_s1)
+27
View File
@@ -280,3 +280,30 @@ def test_build_features_proc_idx_looks_up_proc_map():
*_, proc_idx, _, _ = build_features(data, pdg_map, mat_map, proc_map=proc_map)
np.testing.assert_array_equal(proc_idx, [0, 1, 2])
def test_build_features_require_secondaries_raises_when_lists_missing():
"""A parquet with n_sec > 0 but no per-secondary list columns was never run
through the parent->child join; require_secondaries must catch it instead of
silently zeroing every Stage-2 target (regression: this collapsed the
secondary species to a single PDG index during training)."""
data = _minimal_step_data(3)
data["n_sec"] = np.array([0, 2, 1], dtype=np.int32) # secondaries, but no lists
pdg_map, mat_map = {11: 0}, {"PbWO4": 0}
with pytest.raises(ValueError, match="per-secondary columns"):
build_features(data, pdg_map, mat_map, require_secondaries=True)
def test_build_features_require_secondaries_ok_when_no_secondaries():
"""require_secondaries only fires when secondaries actually exist; a file
with n_sec == 0 everywhere (e.g. Stage-1-only) must still load."""
data = _minimal_step_data(3) # n_sec all zero
pdg_map, mat_map = {11: 0}, {"PbWO4": 0}
_, _, _, _, sec_cont, sec_pdg_idx, *_ = build_features(
data, pdg_map, mat_map, require_secondaries=True
)
assert not sec_cont.any()
assert not sec_pdg_idx.any()