Skip to content

🐛 fix: ten guards admitted NaN and returned it silently - #773

Open
nstarman wants to merge 5 commits into
GalacticDynamics:mainfrom
nstarman:claude/guards-reject-nan
Open

🐛 fix: ten guards admitted NaN and returned it silently#773
nstarman wants to merge 5 commits into
GalacticDynamics:mainfrom
nstarman:claude/guards-reject-nan

Conversation

@nstarman

@nstarman nstarman commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Ten guards admitted NaN and returned a NaN result with nothing raised — worse than the case each guard was written for, which at least errors.

x >= hi, x > hi, x <= tol are all False for a NaN, so a guard written as a direct comparison lets one through.

Measured, before

LorentzBoost(Q([nan, 0, 0], "")).gamma      -> non-finite, no error
_orthonormalize([1, nan, 0], T0)            -> NaN triad,   no error
ArcLength(..., s_max=5km)(Q(nan, "km"))     -> NaN position, 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 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:

file was now
bishop.py norm <= tol ~(norm > tol)
arclength.py (s < -m) | (s > max + m) ~in_domain
lorentz.py gamma beta_sq >= 1.0 ~(beta_sq < 1.0)
lorentz.py rapidity speed >= 1.0 ~(speed < 1.0)
checks.py leq jnp.any(x > max) jnp.any(~(x <= max))
checks.py geq jnp.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-domain s still passes, and leq/geq still 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.

file was admitted now
builders.py norm == 0 R all 9 entries NaN ~((norm > 0) & isfinite(norm))
lorentz.py from_rapidity norm == 0.0 beta = [nan, nan, nan] ~((norm > 0.0) & isfinite(norm))
reflect.py allclose(norm, 0) H all 9 entries NaN ~((norm > 0) & isfinite(norm))
scale.py any(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 where v is finite and non-zero — exactly a finite positive norm, since ‖v‖ is NaN iff a component is, inf iff a component is, and 0 iff v is. The equality test captured one of those three cases. The new predicate is the precondition itself, with no slack.

Scale's inf factor was the quietest failure in the whole audit, because it produced no NaN at all:

Scale.from_factors([2, inf]).inverse.s   ->  [0.5, 0.0]

That "inverse" is singular. Composed with the original it gives diag(1, 0), not the identity.

Still open, not in this PR

  • Scale has a bypass. The singular check lives only in from_factors; Scale(jnp.diag([2., 0.])) constructs fine and its .inverse is [0.5, inf]. __init__ validates square and diagonal, not invertibility — so the comment at scale.py claiming "an s that exists has already passed" is wrong for singularity. Moving the check to _from_diagonal would close it, at the cost of a check per inverse/_merge. Worth measuring first.
  • isclose(s, 0) is an absolute threshold. rtol multiplies |0|, so it reduces to |s| <= 1e-8: [2, 1e-9] is refused as singular while [2, 1e9] is accepted and inverts to 1e-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_if predicate 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; prek clean including ty.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 20, 2026 04:17
@github-actions github-actions Bot added the 🐛 Fix a bug Fix a bug. label Aug 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.gamma and LorentzBoost.rapidity reject NaN/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) reject NaN by using negated “in-range” predicates.
  • Add regression tests to ensure NaN no 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 a RuntimeError (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 Exception here is too broad for a regression test (it can let unexpected errors satisfy the assertion). Prefer the expected RuntimeError-family exception raised by the guard (core tests use eqx.EquinoxRuntimeError, which is a RuntimeError).
    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 these leq/geq guard checks and can mask unrelated failures. In core tests these checks are expected to raise either an eqx.EquinoxRuntimeError (traced path) or a ValueError (concrete path), both of which are narrower than Exception.

    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.

Comment thread packages/coordinaxs.curveframes/tests/unit/test_guards_reject_nan.py Outdated
Comment thread packages/coordinaxs.curveframes/tests/unit/test_guards_reject_nan.py Outdated
@nstarman nstarman added this to the v0.24.0 milestone Aug 20, 2026
nstarman and others added 3 commits August 20, 2026 11:33
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>
@nstarman
nstarman force-pushed the claude/guards-reject-nan branch from 305e44d to 80eda59 Compare August 20, 2026 15:45
@github-actions github-actions Bot added the ✅ Add / update / pass tests Add, update, or pass tests. label Aug 20, 2026
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.70%. Comparing base (015e547) to head (ac7b610).
⚠️ Report is 10 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`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>
@nstarman nstarman changed the title 🐛 fix: six guards admitted NaN and returned NaN silently 🐛 fix: ten guards admitted NaN and returned it silently Aug 20, 2026
`~((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>
@github-actions github-actions Bot added the ♻️ Refactor code Refactor code. label Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✅ Add / update / pass tests Add, update, or pass tests. 🐛 Fix a bug Fix a bug. ♻️ Refactor code Refactor code.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants