Skip to content

Harden spherical routines for near-antipodal inputs (S2 parity)#399

Merged
asinghvi17 merged 6 commits into
mainfrom
slerp-near-antipodal
Apr 21, 2026
Merged

Harden spherical routines for near-antipodal inputs (S2 parity)#399
asinghvi17 merged 6 commits into
mainfrom
slerp-near-antipodal

Conversation

@asinghvi17
Copy link
Copy Markdown
Member

Summary

  • spherical_distance: switch to atan2(‖x×y‖, x·y) — numerically stable across the full [0, π] range, unlike acos(x·y) which loses precision for near-identical points. Adapted from S2's Vector3::Angle.
  • robust_cross_product: port S2's three RobustCrossProd tests (Coverage, Magnitude, Error — the 5000-iter randomized identity/antisymmetry probe) as literal parity tripwires, which surfaced three genuine divergences from S2. Fix all three.
  • slerp: rewrite around S2's tangent-vector form cos(r)·a + sin(r)·dir with dir = normalize(robust_cross_product(a, b) × a). Mathematically identical to the old sin((1−t)Ω)/sin(Ω)·a + sin(tΩ)/sin(Ω)·b in the well-behaved regime, but never divides by sin(Ω), so it's well-conditioned for near-antipodal inputs. For exactly antipodal inputs (ambiguous great circle), robust_cross_product's symbolic-perturbation branch yields a deterministic perpendicular; the result is a unit vector on some great circle through both points.

Motivating user-visible bug

Regression test for ConservativeRegridding.jl#83: a half-sphere equatorial band produces a truly antipodal diagonal between (0°, −15°) and (180°, +15°). Under the old slerp this returned (0, 0, 0), poisoning the SphericalCap radius in cell_range_extent and silently breaking the dual DFS. Now returns a valid unit vector.

robust_cross_product bugs fixed

All three were silent — the pre-existing tests had loose assertions or placeholder comments ("this is what s2 has but we have it on the other axis, IDK why") that masked them.

  1. RobustCrossProduct.jl:214-222 — exact path used a 1e-300 magnitude threshold; S2's s2pred::IsZero is a literal per-component zero test. Non-zero subnormal cross results were wrongly routed to symbolic.
  2. utils.jl:102-141 (normalizableFromExact) — converted BigFloat to Float64 before the scaling multiply, flushing subnormal axes to the Float64 subnormal range before rescale could preserve them. Fixed by scaling in BigFloat first.
  3. RobustCrossProduct.jl:326-332 (symbolic_cross_product_sorted a-fallback) — returned (-a[2], a[1], 0); S2 returns (a[1], −a[0], 0) in 0-indexing = (a[2], −a[1], 0) in 1-indexing. Both signs flipped. The in-code comment even misquoted S2's return. This compounded with bugs 1–2 to produce the ~80/5000 per-iteration failures in the randomized TestRobustCrossProdError loop.

Tests added

  • test/utils/unitspherical.jl grows 118 → ~430 lines of coverage for spherical_distance and slerp, including near-identical, near-antipodal, exactly antipodal, and the ConservativeRegridding#83 regression case.
  • test/utils/robustcrossproduct.jl grows by ~430 lines with a new @testset "s2geometry RobustCrossProd parity" block — literal ports of S2's three dedicated tests (Coverage L191, Magnitude L264, Error L321 from s2edge_crossings_test.cc at commit a4f0cf5), plus the helpers they require. Uses a BigFloat-determinant sign oracle (bigsign) that is independent of robust_cross_product itself.

Test plan

  • test/utils/unitspherical.jl: 889 pass / 1 broken (pre-existing Spherical caps tripwire, unrelated) / 0 fail
  • test/utils/robustcrossproduct.jl: all pass / 0 broken / 0 fail (including failures[] == 0 in the 5000-iter randomized loop)
  • Existing jldoctests for spherical_distance and slerp updated where the rewritten formula differs by one ulp

🤖 Generated with Claude Code

asinghvi17 and others added 6 commits April 19, 2026 12:27
Replace `acos(clamp(x ⋅ y, -1, 1))` with `atan(norm(cross(x, y)), x ⋅ y)`,
which stays well-conditioned across the full `[0, π]` range — the acos
form collapses to 0 once points are within ~1e-8 of each other. Adapted
from Google's S2 `Vector3::Angle`.

Add tests for basic correctness, near-identical points (where the naive
form fails), near-antipodal points, symmetry, and the two `S2Point` cases
from S2's `TEST(S1Angle, ConstructorsThatMeasureAngles)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `sin(Ω)` divisor in `slerp` collapses for near-antipodal inputs:
cancellation-prone geometries drift off the unit sphere, and exactly
antipodal inputs return the zero vector. Document basic correctness
plus both failure modes, with `@test_broken` on the near-antipodal /
antipodal cases so they fire "unexpected pass" once `slerp` is
rewritten around S2's robust-cross-product tangent form.

Also includes a regression test for ConservativeRegridding.jl#83,
where the half-sphere equatorial band produced a truly antipodal
diagonal that poisoned SphericalCap radii via a (0,0,0) midpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a new `@testset "s2geometry RobustCrossProd parity"` block that
literally reproduces S2's three dedicated `RobustCrossProd` tests
(`RobustCrossProdCoverage`, `RobustCrossProdMagnitude`,
`RobustCrossProdError`) from `s2edge_crossings_test.cc` at commit
`a4f0cf5`, along with the helpers they require (`ChoosePoint`,
`PerturbLength`, `TestRobustCrossProdError`).

The sign oracle uses a BigFloat 3x3 determinant (`bigsign`), so it is
independent of `robust_cross_product`. Straddle-plane probes use that
same oracle; tautological perpendicularity substitutes were avoided.
The 5000-iteration randomized `RobustCrossProdError` loop aggregates
per-iteration failures into a single `@test failures[] == 0` assertion
so no failing iterations are silently swallowed.

Five `@test_broken` entries register genuine Julia-side behavioral
gaps vs S2, confirmed against `s2edge_crossings.cc`:

- `RobustCrossProduct.jl:216` drops to the symbolic path on a
  magnitude threshold (1e-300) where S2 uses literal `IsZero`.
- `utils.jl:104` converts to `Float64` before scaling, flushing
  subnormal BigFloats to zero and defeating the rescaling.
- `RobustCrossProduct.jl:327` returns `(-a[2], a[1], 0)` in the
  `a`-fallback symbolic branch; S2 returns `(a[2], -a[1], 0)` (the
  inline comment misquotes S2's return value).

These bugs compound: the ~80/5000 failures in the randomized loop are
all attributable to subnormal inputs routing through the broken
symbolic path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The parity tests added in the previous commit revealed three places
where GeometryOps' port of `S2::RobustCrossProd` silently diverged
from S2's reference. Fix them:

1. `RobustCrossProduct.jl:214-222` — the exact path fell through to
   symbolic when the BigFloat cross had every component below a
   `1e-300` magnitude threshold. S2's `s2pred::IsZero` is a literal
   per-component zero test, so subnormal-but-nonzero results like
   `(0, 0, 5e-324)` were wrongly routed to symbolic. Replace the
   threshold with per-component `iszero`.

2. `utils.jl:102-141` (`normalizableFromExact`) — the rescaling
   step converted the BigFloat cross to Float64 *before* applying
   the scaling multiply, flushing subnormal components to the
   Float64 subnormal range and destroying the axis. Compute the
   shift from BigFloat exponents, `ldexp` in BigFloat, then cast.

3. `RobustCrossProduct.jl:326-332` (`symbolic_cross_product_sorted`
   a-fallback) — returned `(-a[2], a[1], 0)`; S2 returns
   `(a[1], -a[0], 0)` in 0-indexing, i.e. `(a[2], -a[1], 0)` in
   1-indexing. Both components were sign-flipped. The prior in-code
   comment even misquoted S2's return value as `(-a[1], a[0], 0)`;
   correct the comment too.

Bug 3 also drove the ~80 per-iteration failures in the 5000-iter
randomized `TestRobustCrossProdError` loop — those compound with
subnormal inputs routed through the broken symbolic path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With the three bug fixes in place, the five `@test_broken` entries
in the parity block now pass — convert them to `@test` and note the
source-fix location in each block's comment.

Also tighten two loose assertions in the pre-existing
`S2 RobustCrossProdCoverage` testset that were silently masking the
same bugs: both `(5e-324, 1, 0) × (0, 1, 0)` and the related
subnormal case had been patched with a comment reading "this is what
s2 has but we have it on the other axis, IDK why" and the expected
axis swapped. Restore S2's expected `(0, 0, 1)`.

Bump `bigsign`'s BigFloat precision from 256 to 1024 bits. At 256
bits, the randomized `TestRobustCrossProdError` loop reported ~28
spurious oracle disagreements on near-antipodal iterations — not
`robust_cross_product` bugs (the identity / antisymmetry checks
held), but float-rounding artifacts in the BigFloat-derived sign.
1024 bits clears the artifacts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace `sin((1-t)Ω)/sin(Ω) · a + sin(tΩ)/sin(Ω) · b` with
`cos(r) · a + sin(r) · dir` where `r = t · spherical_distance(a, b)`
and `dir = normalize(robust_cross_product(a, b) × a)`. The two forms
are mathematically identical in the well-behaved regime, but the new
form never divides by `sin(Ω)` and therefore stays well-conditioned
for near-antipodal inputs. For exactly antipodal inputs the great
circle is mathematically ambiguous, but `robust_cross_product`'s
symbolic-perturbation branch yields a deterministic perpendicular,
so `dir` is defined and the result is a unit vector on *some* great
circle through both points.

Adapted from S2's `Interpolate` / `GetPointOnLine` in
`s2edge_distances.cc`.

Flip the seven `@test_broken` tripwires in the `slerp` testset to
`@test`: cancellation-prone near-antipodal (5 ε values), exactly
antipodal, and the ConservativeRegridding#83 antipodal diagonal all
now return unit-norm results.

The orthogonal midpoint doctest changes by one ulp in the first
component (`0.7071067811865475` → `0.7071067811865476`) because the
operand order in the tangent-form sum differs from the old formula;
both values round to the same real number. Update the jldoctest
literal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@asinghvi17 asinghvi17 merged commit 7ed2002 into main Apr 21, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant