🐛 fix: ten guards admitted NaN and returned it silently - #773
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes several guard predicates that previously admitted NaN (because direct comparisons like x >= hi are False for NaN), causing silent propagation of non-finite values instead of raising.
Changes:
- Make
LorentzBoost.gammaandLorentzBoost.rapidityrejectNaN/non-finite inputs by switching from direct comparisons to negated “in-range” predicates. - Make chart bound checks (
leq/geq) and curveframes guards (_orthonormalize, arc-length solved-domain check) rejectNaNby using negated “in-range” predicates. - Add regression tests to ensure
NaNno longer passes through these guards.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/coordinax/transforms/_src/actions/lorentz.py | Updates superluminal guards to also reject NaN and prevent non-finite leakage. |
| src/coordinax/_src/charts/checks.py | Updates leq/geq predicates so NaN fails bounds checks. |
| packages/coordinaxs.curveframes/src/coordinaxs/curveframes/_src/bishop.py | Updates _orthonormalize degeneracy guard to reject NaN inputs. |
| packages/coordinaxs.curveframes/src/coordinaxs/curveframes/_src/arclength.py | Updates solved-domain guard so NaN can’t fall through to interpolation. |
| packages/coordinaxs.curveframes/tests/unit/test_guards_reject_nan.py | Adds regression tests for NaN rejection across the updated guards. |
Suppressed comments (3)
packages/coordinaxs.curveframes/tests/unit/test_guards_reject_nan.py:60
- These
pytest.raises(Exception, ...)assertions are too broad and can hide unrelated failures.ArcLength's solved-domain guard is already exercised elsewhere as aRuntimeError(e.g.test_arclength_smax.py).
fast = cxfc.ArcLength(helix, "s", s_max=u.Q(5.0, "km"))
with pytest.raises(Exception, match="solved domain"):
fast(u.Q(jnp.nan, "km"))
# in-domain and genuinely-outside both behave as before
packages/coordinaxs.curveframes/tests/unit/test_guards_reject_nan.py:82
- Catching
Exceptionhere is too broad for a regression test (it can let unexpected errors satisfy the assertion). Prefer the expectedRuntimeError-family exception raised by the guard (core tests useeqx.EquinoxRuntimeError, which is aRuntimeError).
bad = cxfm.LorentzBoost(u.Q(jnp.array([jnp.nan, 0.0, 0.0]), ""))
with pytest.raises(Exception, match="subluminal"):
_ = bad.gamma
packages/coordinaxs.curveframes/tests/unit/test_guards_reject_nan.py:95
pytest.raises(Exception, ...)is too broad for theseleq/geqguard checks and can mask unrelated failures. In core tests these checks are expected to raise either aneqx.EquinoxRuntimeError(traced path) or aValueError(concrete path), both of which are narrower thanException.
with pytest.raises(Exception, match="less than or equal"):
leq(u.Q(jnp.nan, "m"), u.Q(2, "m"))
with pytest.raises(Exception, match="greater than or equal"):
geq(u.Q(jnp.nan, "m"), u.Q(2, "m"))
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Found by auditing what each guard admits rather than what it claims.
`x <= tol` and `x > hi` are both False for a NaN, so a guard written as a
direct comparison lets one through and returns a NaN result with nothing
raised -- worse than the case the guard exists for, which at least errors.
_orthonormalize([1, nan, 0], T0) -> NaN triad, no error
ArcLength(..., s_max=5km)(Q(nan,"km")) -> NaN position, no error
`TubularChart`'s reach guard already avoids this, written `~(f > 0)` in GalacticDynamics#699
after the same bug. These two were still in the direct form:
bishop.py norm <= 1e-12 * |v| -> ~(norm > tol)
arclength.py (s < -margin) | (s > s_max + margin) -> ~in_domain
The cases each guard was written for are unaffected: an exactly parallel
normal, an all-zero normal, and an `s` genuinely outside the solved domain
all still raise, and a well-conditioned normal and an in-domain `s` still
pass.
Four tests, mutation-verified against reverting both predicates.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same class as the previous commit, found by continuing the audit into core.
`x >= hi` and `x > hi` are both False for a NaN, so a guard written as a
direct comparison admits it:
LorentzBoost(Q([nan,0,0], "")).gamma -> non-finite, no error
leq(Q(nan, "m"), Q(2, "m")) -> admits
geq(Q(nan, "m"), Q(2, "m")) -> admits
`lorentz.py`'s own comment states the intent it was failing: guard "so every
derived quantity reports the same superluminal error rather than one of them
leaking a non-finite value".
Four predicates negated:
beta_sq >= 1.0 -> ~(beta_sq < 1.0)
speed >= 1.0 -> ~(speed < 1.0)
jnp.any(x > max_val) -> jnp.any(~(x <= max_val))
jnp.any(x < min_val) -> jnp.any(~(x >= min_val))
Valid inputs are unaffected: gamma(beta=0.5) = 1.154701, and in-range values
still pass `leq`/`geq`.
Not changed, and worth a separate decision: the zero/degeneracy guards
(`norm == 0`, `jnp.isclose(s, 0)`, `jnp.allclose(norm, 0)` in `builders.py`,
`scale.py`, `reflect.py`, `lorentz.py`) admit NaN for the same reason. A NaN
is not zero, so whether they should reject it is a question about intent
rather than a mechanical fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`nox -s "pytest(package='coordinax')"` collects only the root `tests/` tree, so the `LorentzBoost.gamma` and `leq`/`geq` regressions did not run when core was tested alone. They now live beside the tests for the guards they cover: the boost one folds into the existing subluminal check as a second parameter, and `TestLeq`/`TestGeq` each gain a NaN case. Narrow the remaining curveframes guards from `Exception` to `eqx.EquinoxRuntimeError`, which is what all of them raise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
305e44d to
80eda59
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #773 +/- ##
==========================================
- Coverage 96.71% 96.70% -0.01%
==========================================
Files 268 269 +1
Lines 9125 9179 +54
==========================================
+ Hits 8825 8877 +52
- Misses 300 302 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
`norm == 0`, `allclose(norm, 0)` and `isclose(s, 0)` each test one point of their own precondition. `axis / norm` is a unit vector only where the norm is finite and positive -- it is NaN iff a component is, `inf` iff a component is, and 0 iff the vector is -- so the equality form caught the zero case and let the other two normalise to exactly the silent NaN each guard's own comment says it exists to prevent: nine NaN entries in `R`, in `H`, and three in `beta`. `Scale`'s `inf` factor was the quietest of the lot. Its reciprocal is 0.0, so `from_factors([2, inf]).inverse` came back finite, singular, and with nothing to notice. Each guard now tests the precondition itself, and the messages say so. The four existing tests gain `nan` and `inf` parameters; reverting any one guard fails both of its new cases and neither of the old. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`~((norm > 0) & jnp.isfinite(norm))` stood verbatim in `builders.py`, `lorentz.py` and `reflect.py`, each under its own restatement of why. Three copies of a predicate can drift, and this PR exists because a guard drifted from its own stated intent. It moves to `actions/utils.py` with the reasoning in its docstring; the three call sites become one line each. Also inline two one-use locals (`tol`, `in_domain`), fold `bishop.py`'s two comments into one that matches the code now that `norm <= tol` is gone, and cut the test docstrings back to what each rejects rather than re-deriving the mechanism the source already states. Net -24 lines. No behaviour change: all twelve bad inputs still raise, all four valid ones still pass, and reverting the helper alone fails all six `nan`/`inf` cases across the three modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ten guards admitted
NaNand returned aNaNresult with nothing raised — worse than the case each guard was written for, which at least errors.x >= hi,x > hi,x <= tolare allFalsefor aNaN, so a guard written as a direct comparison lets one through.Measured, before
lorentz.py's own comment states the intent it was failing to enforce — guard "so every derived quantity reports the same superluminal error rather than one of them leaking a non-finite value".The fix was already in the repo
TubularChart's reach guard is written~(f > 0), added in #699 after this exact bug, with a comment saying why. Six other guards were still written the direct way. This applies the known lesson to each:bishop.pynorm <= tol~(norm > tol)arclength.py(s < -m) | (s > max + m)~in_domainlorentz.pygammabeta_sq >= 1.0~(beta_sq < 1.0)lorentz.pyrapidityspeed >= 1.0~(speed < 1.0)checks.pyleqjnp.any(x > max)jnp.any(~(x <= max))checks.pygeqjnp.any(x < min)jnp.any(~(x >= min))Valid inputs are untouched:
gamma(beta=0.5) = 1.154701, an exactly-parallel or all-zero initial normal still raises, an in-domainsstill passes, andleq/geqstill admit in-range values.The four degeneracy guards, added after review
These were first left out as a question about intent. Measuring them settled it: each one's own comment names the failure it was letting through.
builders.pynorm == 0Rall 9 entries NaN~((norm > 0) & isfinite(norm))lorentz.pyfrom_rapiditynorm == 0.0beta = [nan, nan, nan]~((norm > 0.0) & isfinite(norm))reflect.pyallclose(norm, 0)Hall 9 entries NaN~((norm > 0) & isfinite(norm))scale.pyany(isclose(s, 0))s = [2, nan],s = [2, inf]any(isclose(s, 0) | ~isfinite(s))Three of them compute
v / ‖v‖, which is a unit vector only wherevis finite and non-zero — exactly a finite positive norm, since‖v‖is NaN iff a component is,infiff a component is, and0iffvis. The equality test captured one of those three cases. The new predicate is the precondition itself, with no slack.Scale'sinffactor was the quietest failure in the whole audit, because it produced no NaN at all:That "inverse" is singular. Composed with the original it gives
diag(1, 0), not the identity.Still open, not in this PR
Scalehas a bypass. The singular check lives only infrom_factors;Scale(jnp.diag([2., 0.]))constructs fine and its.inverseis[0.5, inf].__init__validates square and diagonal, not invertibility — so the comment atscale.pyclaiming "ansthat exists has already passed" is wrong for singularity. Moving the check to_from_diagonalwould close it, at the cost of a check perinverse/_merge. Worth measuring first.isclose(s, 0)is an absolute threshold.rtolmultiplies|0|, so it reduces to|s| <= 1e-8:[2, 1e-9]is refused as singular while[2, 1e9]is accepted and inverts to1e-9. Same conditioning, opposite verdicts. Pre-existing design choice, left alone.Provenance
Found by auditing what each guard admits against what its docstring claims, across every
error_ifpredicate in the repo — prompted by three guards in recent PRs that each turned out narrower than advertised.Verification
Six tests, each mutation-verified by reverting the predicate it covers. 11030 tests pass across the repo;
prekclean includingty.🤖 Generated with Claude Code