From d0381728ee650456c775e52cbdbe85e53e8ebea3 Mon Sep 17 00:00:00 2001 From: Lars Bogner Date: Fri, 14 Aug 2026 12:08:26 +0200 Subject: [PATCH] Fix negative secondary mass crashing log_transform during rollout decode_secondaries() applied inv_log_transform() to the model's raw predicted log_mass directly. Since that value isn't itself the output of log_transform, exp(log_mass) can undershoot _EPS, making inv_log_transform(log_mass) = exp(log_mass) - _EPS go slightly negative. Once that secondary spawns a track and its mass is fed back in as conditioning for a further rollout step, log_transform(mass) computes log(mass + eps) with mass <= -eps, producing a non-finite value and raising. Clip log_mass to log(_EPS) before inverting so the resulting mass is guaranteed >= 0 (matching the invariant the surrounding comment already assumed, but didn't enforce). Co-Authored-By: Claude Sonnet 5 --- giant/data/transforms.py | 13 ++++++++++--- tests/test_phase2.py | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/giant/data/transforms.py b/giant/data/transforms.py index e41041e..be57fb6 100644 --- a/giant/data/transforms.py +++ b/giant/data/transforms.py @@ -629,9 +629,16 @@ def decode_secondaries( pre_dir[valid], dir_local[valid, i] ) - # 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. + # log_mass is a raw model prediction, not itself the output of + # log_transform, so exp(log_mass) can undershoot _EPS and make + # inv_log_transform(log_mass) = exp(log_mass) - _EPS go slightly + # negative. That negative mass then blows up the next log_transform + # call on this track's mass once it's fed back in as conditioning for a + # further rollout step (giant/rollout.py -> build_cond_features -> + # _physical_cond_columns). Clip log_mass so its inverse is guaranteed + # >= 0 before that can happen; clip to 0 separately for padded/invalid + # slots rather than leaving a spurious small positive floor. + log_mass = np.clip(log_mass, np.log(_EPS), None) 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) diff --git a/tests/test_phase2.py b/tests/test_phase2.py index c8e7fd1..ac9ba8d 100644 --- a/tests/test_phase2.py +++ b/tests/test_phase2.py @@ -540,3 +540,29 @@ def test_decode_secondaries_mass_charge_round_trip_with_normalizer(): ) assert sec_mass[0, 0] == pytest.approx(938.27208943, abs=1e-2) assert sec_charge[0, 0] == pytest.approx(1.0, abs=1e-4) + + +def test_decode_secondaries_extreme_negative_log_mass_stays_nonnegative(): + from giant.data.transforms import decode_secondaries, log_transform + + N = 1 + e_sec = np.array([5.0], dtype=np.float32) + n_sec = np.array([1]) + pre_dir = np.array([[0.0, 0.0, 1.0]], dtype=np.float32) + + sec_cont = np.zeros((N, K_MAX, 6), dtype=np.float32) + sec_cont[0, 0, 0] = 10.0 # stick logit -> ~all of e_sec + sec_cont[0, 0, 1:4] = [0, 0, 1] + sec_cont[0, 0, 4] = -50.0 # raw model prediction: extremely negative log_mass + sec_cont[0, 0, 5] = 1.0 + + _, _, sec_mass, _, _ = decode_secondaries(sec_cont, n_sec, e_sec, pre_dir) + + # A raw model prediction isn't itself the output of log_transform, so + # naively applying inv_log_transform can undershoot zero (see + # decode_secondaries) — which then crashes the next log_transform call + # once this mass is fed back in as conditioning during rollout. The + # float32 residual from clipping can land a hair below zero, but must + # stay well above -eps so log_transform(mass) stays finite. + assert sec_mass[0, 0] > -1e-8 + log_transform(sec_mass[0, 0])