All findings · Lucene finding · LFV-771 · rectangle-frompointdistance-asin-arg-above-one-nan

A valid LatLon distance query that stops just short of a pole can fail with "invalid longitude NaN"

For a few valid circles that stop a few centimetres short of a pole, LatLonPoint.newDistanceQuery throws IllegalArgumentException: invalid longitude NaN: the bounding-box code takes asin of 1.0000000000000002, a rounding of a value just below 1. The inputs are contrived; the fix is a one-line clamp.

Summary

Priority
Low, rank 1135It is a loud IllegalArgumentException for contrived, ulp-precise legal circles in core LatLon distance queries (0 of 2M random circles hit it), the same valid-input-rejected boundary profile as LFV-219, in core code rather than spatial3d like LFV-742.
Impact
crash
Since
5.5.0 (LUCENE-6908, 92b659ab9e1a, 2015-12-07)introduced 2015-12-07 · over 10 years
Affected
5.5.0 to 10.6.0 and main (18 releases executed); no failure found on 5.4.1 (bounded scan)
Status
documented
Found by
semi-formal: bounded-exhaustive scan of SloppyMath.cos table switches and of the box's asin argument

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

The circle's bounding box takes asin of a ratio of two fast cosines; at one of SloppyMath's table switches the ratio rounds just above 1, and asin of more than 1 is NaN.

Background: the bounding box of a distance query

  1. A LatLon distance query first draws a latitude/longitude box around the circle (Rectangle.fromPointDistance) and uses it to skip index cells and points far away; candidates inside the box are then checked by their real distance.
  2. If the circle reaches a pole, the box spans every longitude. Otherwise its half-width in longitude is asin(sin(r) / cos(lat)), where r is the radius as an angle and lat the centre latitude. In exact arithmetic that ratio is at most 1 whenever the circle reaches neither pole.
  3. Lucene computes sin, cos and asin with SloppyMath, fast approximations (the cosine is documented as accurate to about 1e-15), not exact. asin of anything above 1 is NaN, as in Math.asin.
Diagram of the bug. A distance query centred at latitude 0.43945312499999994, longitude 0, with radius 9,958,692.080819247 m stops 7 cm short of the north pole; the bounding box, padded by 7 cm, stops 47 nanometres short, so Lucene computes the box's half-width as asin(sin(r) / cos(lat)). In exact arithmetic the ratio is 0.99999999999999994. SloppyMath.cos switches table points at 0.439453125 degrees; the two cosines lie on either side, and sin(r) = 0.9999705864309741 comes out one step above cos(lat) = 0.999970586430974. The ratio is 1.0000000000000002, asin returns NaN, and the query throws IllegalArgumentException: invalid longitude NaN. Expected: 1 hit.
The circle stopping 7 cm short of the pole, its padded box, and the two fast cosines on either side of the table switch at 0.439453125 degrees: the ratio rounds to 1.0000000000000002 and asin gives NaN. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one point at (0.43945312499999994, 0) and count LatLonPoint.newDistanceQuery("p", 0.43945312499999994, 0, 9958692.080819247). Centre and radius are valid: the pole is 9,958,692.15 m away, so the circle stops 7 cm short of it. You expect 1 hit, the point itself.
  2. Before searching, Lucene draws a lat/lon box around the circle. It pads the radius by 7 cm, so the box's top edge is 47 nanometres below the pole: no pole is inside, and the box's half-width in longitude is asin(sin(r) / cos(lat)). In exact arithmetic that ratio is 0.99999999999999994, just below 1.
  3. Lucene's fast cosine (a table with a step of 2pi/2048 plus a short polynomial) is not monotone at some of the points where it switches table points, here at 0.439453125 degrees. sin(r), computed as cos(r - pi/2), comes out one rounding step above cos(lat): 0.9999705864309741 vs 0.999970586430974.
  4. The quotient is 1.0000000000000002, asin returns NaN, the box's longitudes are NaN, and the search throws IllegalArgumentException: invalid longitude NaN; must be between -180.0 and 180.0. The same centre with the radius 1 m smaller or larger works.

Who is affected

Any application running a LatLon circle query on user-supplied coordinates: LatLonPoint.newDistanceQuery (default configuration, not experimental), newGeometryQuery with a Circle, LatLonDocValuesField.newSlowDistanceQuery. Only for a centre within about 1e-13 degree below one of five latitudes (0.44, 0.79, 2.02, 2.55, 12.22 degrees, or their southern mirrors) and a radius within about 1e-7 m of "distance to the pole minus 7 cm". Contrived.

Mechanism, terms and numbers547 words

Before a LatLon distance query runs, Rectangle.fromPointDistance draws a latitude/longitude box around the circle. When the circle, padded by 7 cm, reaches neither pole, it computes the box's half-width in longitude as asin(sin(radDistance) / cos(radLat)) (Rectangle.java:157) with no clamp. The quotient is at most 1 in exact arithmetic, but sin, cos and asin are SloppyMath's fast versions, and SloppyMath.cos is not monotone where it switches table entries. For the circle centred at (0.43945312499999994, 0) with radius 9,958,692.080819247 m, which stops 7 cm short of the north pole, the quotient comes out 1.0000000000000002 (StrictMath: 0.9999999999999999), asin returns NaN, and the Rectangle constructor throws IllegalArgumentException: invalid longitude NaN; must be between -180.0 and 180.0. LatLonPoint.newDistanceQuery therefore fails at search time, and LatLonDocValuesField.newSlowDistanceQuery when built, although their javadoc accepts every valid centre and finite, non-negative radius. No wrong hits and nothing corrupted; the inputs are contrived. It dates from LUCENE-6908 (5.5.0, 2015). Fix: asin(Math.min(1, ...)), the clamp SloppyMath.haversinMeters already uses.

The bug, step by step (file:line at 645d4fe14a20)

  1. LatLonPoint.newDistanceQuery accepts the query: the constructor checks only a non-null field, a finite non-negative radius and the centre (LatLonPointDistanceQuery.java:57-71). At search, createWeight calls Rectangle.fromPointDistance(latitude, longitude, radiusMeters) (:83).
  2. fromPointDistance pads the radius by 7 cm, radDistance = (radiusMeters + 7E-2) / EARTH_MEAN_RADIUS_METERS (Rectangle.java:150), and takes the non-pole branch when minLat > MIN_LAT_RADIANS && maxLat < MAX_LAT_RADIANS (:156). For the repro the padded top edge is 7.3e-15 rad (47 nm) below the pole, so this branch runs.
  3. double deltaLon = asin(sin(radDistance) / cos(radLat)); (:157), with asin, sin and cos imported from SloppyMath (:31-33). SloppyMath.sin(a) is cos(a - PIO2) (SloppyMath.java:203-205), so the ratio is cos(0.007669903939435452) / cos(0.007669903939428205). The numerator's angle is 7.2e-15 rad larger, so exactly the ratio is below 1.
  4. SloppyMath.cos (SloppyMath.java:92-114) rounds the angle to the nearest table point (a step of 2pi/2048; 2049 entries), index = (int) (a * SIN_COS_INDEXER + 0.5) (:100), and adds a 4th-order Taylor correction. The two angles straddle the switch between points 2 and 3, at 2.5 x 2pi/2048 = 0.007669903939428206 rad (0.439453125 degrees). The correction from point 3 lands one ulp higher: 0.9999705864309741 against 0.999970586430974. The documented error, "around 1E-15" (:80), still holds; monotonicity is not promised.
  5. The ratio is 1.0000000000000002; SloppyMath.asin returns NaN above 1, as documented (:124-125, code :171-176). minLon and maxLon become NaN, and new Rectangle(...) (Rectangle.java:174-178) rejects them in GeoUtils.checkLongitude (Rectangle.java:88, GeoUtils.java:98-100).

The numbers

valueSloppyMathexact / StrictMath
sin(radDistance)0.9999705864309741
cos(radLat)0.999970586430974
ratio1.00000000000000020.99999999999999994 exact; 0.9999999999999999 StrictMath
half-width in longitudeNaN89.99999914622636 degrees

Neighbours pass: radius 1 m smaller gives longitudes -89.997 to 89.997; 1 m larger crosses the pole (-180 to 180); the exact knot latitude 0.439453125 gives -90 to 90; a centre 1e-9 degree north or south also gives a box.

Other entry points

LatLonPoint.newGeometryQuery(INTERSECTS, Circle) becomes a distance query (LatLonPoint.java:333-334) and fails at search. LatLonDocValuesField.newSlowDistanceQuery and LatLonShape.newGeometryQuery with a Circle fail when built (Circle2D.java:417). Read only, not executed: the same call sits behind GeoEncodingUtils.createDistancePredicate (GeoEncodingUtils.java:174), distance sort (LatLonPointDistanceComparator.java:94), NearestNeighbor (NearestNeighbor.java:131) and distance-feature pruning (LatLonPointDistanceFeatureQuery.java:375), there with computed radii.

Exposure and likelihood323 words
  • Who is hit. Applications that run a LatLon circle query on a user-supplied centre and radius: LatLonPoint.newDistanceQuery, LatLonPoint.newGeometryQuery(INTERSECTS, Circle), LatLonDocValuesField.newSlowDistanceQuery and LatLonShape.newGeometryQuery with a Circle. Default codec, writer and searcher.
  • What it takes. A centre latitude within about 1e-14 to 1e-13 degree below one of five latitudes, k x 360/4096 degrees for k = 5, 9, 23, 29, 139 (0.44, 0.79, 2.02, 2.55, 12.22 degrees), together with a radius within about 1e-7 m of "distance to the pole minus 7 cm". The repro's centre is one double (5.6e-17 degree) below 0.439453125; in a scan of the 4,097 doubles at and below each latitude the throwing centres reach at most 5.6e-14, 1.45e-13, 9.7e-14, 9.3e-15 and 1.07e-14 degree below. The southern mirrors fail too (the repro's centre negated throws the same). 0 of 2,000,000 random legal circles throw, drawn as TestGeoUtils does (latitude in [-90, 90], longitude in [-180, 180], radius in [0, 50,000 km)); an independent run with radius in [0, 20,000 km) also found 0 of 2,000,000. The five latitudes lie on LatLonPoint's 32-bit encoding grid (step 4.2e-8 degree) and the exact knot passes, so a centre decoded from an indexed LatLonPoint cannot trigger it (the repro's indexed point decodes to 0.43945308309048414, which gives a box); the repro itself passes the raw double as the query centre.
  • What happens. That one query fails with an IllegalArgumentException that blames a longitude the caller never passed, before any index data is read.
  • What does not happen. No wrong hits, no corruption, no hang, no effect on other searches or on the index (a larger query that contains such a clause, for example a BooleanQuery, fails as a whole).
  • Why fix it anyway. It is a one-line guard, and the unclamped ratio is fragile in general: with Math.sin/Math.cos instead of SloppyMath the same formula goes above 1 in 450 of 1,280,000 near-pole test cases (OpenJDK 25, Linux aarch64, JIT intrinsics).

Minimal repro

One JUnit test on the public API: index one LatLonPoint at the centre, count LatLonPoint.newDistanceQuery with the radius that stops 7 cm short of the pole, and expect 1 hit. Deterministic, single thread; the same with assertions on and off. Keep the two double literals unchanged.

TestDistanceQueryNearPoleNaN.java (JUnit, one test)49 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.document;

import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDistanceQueryNearPoleNaN extends LuceneTestCase {

  public void testLegalCircleEndingJustShortOfPole() throws Exception {
    double lat = 0.43945312499999994;
    double radiusMeters = 9958692.080819247;
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      doc.add(new LatLonPoint("p", lat, 0));
      w.addDocument(doc);
      try (DirectoryReader r = DirectoryReader.open(w)) {
        Query q = LatLonPoint.newDistanceQuery("p", lat, 0, radiusMeters);
        try {
          // expected: the centre point matches (1 hit); actual: IllegalArgumentException
          assertEquals(1, new IndexSearcher(r).count(q));
        } catch (IllegalArgumentException e) {
          throw new AssertionError("expected 1 hit for a legal circle, got " + e, e);
        }
      }
    }
  }
}
Command
cp TestDistanceQueryNearPoleNaN.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestDistanceQueryNearPoleNaN -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
1 hit: the indexed centre point lies inside the valid circle.
Observed
IllegalArgumentException: invalid longitude NaN; must be between -180.0 and 180.0 from Rectangle.fromPointDistance, with assertions on and off.

All files of this repro: repro/LFV-771/

More files, environment, full output219 words

Public API only: IndexWriter with the default IndexWriterConfig, ByteBuffersDirectory, LatLonPoint, IndexSearcher.count. The inputs are exact doubles (bits 0x3fdc1fffffffffff and 0x4162fea48296123f); the seed does not matter (10 of 10 random-seed iterations fail). The code is pure Java double arithmetic; a run in the interpreter with SVE off (-Xint -XX:UseSVE=0) fails the same way; x86 was not run.

As the regression test: this same test, for example as a method in TestLatLonPoint.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same test also fails on 10.6.0; every release from 5.5.0 is affected
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestDistanceQueryNearPoleNaN.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestDistanceQueryNearPoleNaN -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

TestDistanceQueryNearPoleNaN > testLegalCircleEndingJustShortOfPole FAILED
    java.lang.AssertionError: expected 1 hit for a legal circle, got java.lang.IllegalArgumentException: invalid longitude NaN; must be between -180.0 and 180.0
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:B3F9BEAC2208C74F]:0)
        at org.apache.lucene.document.TestDistanceQueryNearPoleNaN.testLegalCircleEndingJustShortOfPole(TestDistanceQueryNearPoleNaN.java:44)

        Caused by:
        java.lang.IllegalArgumentException: invalid longitude NaN; must be between -180.0 and 180.0
            at org.apache.lucene.geo.GeoUtils.checkLongitude(GeoUtils.java:100)
            at org.apache.lucene.geo.Rectangle.<init>(Rectangle.java:88)
            at org.apache.lucene.geo.Rectangle.fromPointDistance(Rectangle.java:178)
            at org.apache.lucene.document.LatLonPointDistanceQuery.createWeight(LatLonPointDistanceQuery.java:83)
            at org.apache.lucene.search.IndexSearcher.createWeight(IndexSearcher.java:978)
            at org.apache.lucene.search.ConstantScoreQuery.createWeight(ConstantScoreQuery.java:146)
            at org.apache.lucene.search.IndexSearcher.createWeight(IndexSearcher.java:978)
            at org.apache.lucene.search.IndexSearcher.count(IndexSearcher.java:527)

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF, assertions on; identical with -Ptests.asserts=false

# the arithmetic (stock 645d4fe14a20 core jar)
SloppyMath.sin(rd) = 0.9999705864309741
SloppyMath.cos(radLat) = 0.999970586430974
sloppy quotient = 1.0000000000000002  asin -> NaN
StrictMath quotient = 0.9999999999999999  asin(deg) -> 89.99999914622636

Why it looks unintended

  • newDistanceQuery documents IllegalArgumentException only for a null field, invalid coordinates or an invalid radius (LatLonPoint.java:293-294); none holds here.
  • The non-pole branch appears intended to keep the asin argument at most 1; SloppyMath.haversinMeters clamps the same hazard with asin(Math.min(1, ...)) (SloppyMath.java:58).
  • TestGeoUtils builds boxes for random legal circles and expects a box every time.
The full argument228 words
  • Contract. LatLonPoint.newDistanceQuery: "@param radiusMeters maximum distance from the center in meters: must be non-negative and finite", "@throws IllegalArgumentException if {@code field} is null, location has invalid coordinates, or radius is invalid." (LatLonPoint.java:290-294); the same for LatLonDocValuesField.newSlowDistanceQuery (LatLonDocValuesField.java:204-208). fromPointDistance checks only the centre (Rectangle.java:145-146): "Compute Bounding Box for a circle using WGS-84 parameters" (:142).
  • The guard shows the intent. The non-pole branch (:156) appears intended to keep asin's argument at most 1 (no comment says so, but in exact arithmetic that is what the guard ensures); rounding breaks that at the boundary.
  • Lucene clamps the same hazard elsewhere. SloppyMath.haversinMeters: asin(Math.min(1, Math.sqrt(sortKey * 0.5))) (SloppyMath.java:58). spatial3d Tools.safeAcos: "Java acos yields a NAN if you take an arc-cos of an angle that's just a tiny bit greater than 1.0, so here's a more resilient version" (Tools.java:27-38). XYRectangle.fromPointDistance rounds its box up "to avoid numerical errors" (LUCENE-9243, XYRectangle.java:79-81). The 7 cm pad in this method (Rectangle.java:149-150, comment "LUCENE-7143") is the same kind of guard.
  • Tests. TestGeoUtils.testRandomCircleToBBox calls fromPointDistance on random legal circles with radii up to 50,000 km and expects a box every time (TestGeoUtils.java:34-51); it even carries "TODO: randomly quantize radius too, to provoke exact math errors?" (:49). No test expects an exception for a valid circle.
The devil's-advocate check179 words

The strongest case for "intended" (the study's intended-behaviour verifier and cross-model reviewers): SloppyMath trades accuracy for speed on purpose ("This is performance sensitive, check SloppySinBenchmark and RectangleBenchmark", SloppyMath.java:183); its asin is specified to return NaN above 1 and TestSloppyMath asserts asin(2) is NaN (TestSloppyMath.java:95); Circle is @lucene.experimental and says "For very large circles or circles near poles, consider using polygons instead" (Circle.java:33); the Rectangle class notes that rectangles near poles are distorted (Rectangle.java:63-64); and the witness needs nanometre-scale tuning.

Why it fails: SloppyMath keeps its own contract (one ulp is within "around 1E-15"), which is exactly why the caller must keep asin's argument in range, as haversinMeters does. The pole notes come from a docs-only change (#14534) and are accuracy advice, not a precondition that permits an exception. LatLonPoint.newDistanceQuery is not experimental and documents no such case. A Math.min leaves the speed trade-off untouched. The argument supports low priority, not intended behaviour; every verifier rated it weak. Verdict: not intended, a defect. Not security: a clean exception on one query.

Suggested fix

Recommended A. Clamp the asin argument to 1 (one line)

Clamp the ratio: asin(Math.min(1, sin(radDistance) / cos(radLat))) at Rectangle.java:157, the idiom SloppyMath.haversinMeters already uses. It is conservative (asin(1) = pi/2 is at least the exact half-width) and the box only pre-filters, so hits do not change. With it the repro passes (assertions on and off), as do 433 geo and LatLon tests and the core module's static checks.

--- a/lucene/core/src/java/org/apache/lucene/geo/Rectangle.java
+++ b/lucene/core/src/java/org/apache/lucene/geo/Rectangle.java
@@ -154,7 +154,9 @@ public class Rectangle extends LatLonGeometry {
     double maxLon;
 
     if (minLat > MIN_LAT_RADIANS && maxLat < MAX_LAT_RADIANS) {
-      double deltaLon = asin(sin(radDistance) / cos(radLat));
+      // the quotient is <= 1 in exact arithmetic, but rounding can push it just above 1 (asin would
+      // return NaN); clamp it, as SloppyMath.haversinMeters does
+      double deltaLon = asin(Math.min(1, sin(radDistance) / cos(radLat)));
       minLon = radLon - deltaLon;
       if (minLon < MIN_LON_RADIANS) {
         minLon += 2d * PI;
All options and trade-offs3 options · 497 words

Fix A was validated through gradle in a scratch worktree of 645d4fe14a20. B is an alternative that was not run; C is not recommended.

A. Clamp the asin argument to 1 (one line) (validated by the checks) (recommended)

Same idiom as SloppyMath.haversinMeters (SloppyMath.java:58). It covers every caller of fromPointDistance: distance and Circle queries, the distance predicate, distance sort, nearest-neighbour and distance-feature pruning.

--- a/lucene/core/src/java/org/apache/lucene/geo/Rectangle.java
+++ b/lucene/core/src/java/org/apache/lucene/geo/Rectangle.java
@@ -154,7 +154,9 @@ public class Rectangle extends LatLonGeometry {
     double maxLon;
 
     if (minLat > MIN_LAT_RADIANS && maxLat < MAX_LAT_RADIANS) {
-      double deltaLon = asin(sin(radDistance) / cos(radLat));
+      // the quotient is <= 1 in exact arithmetic, but rounding can push it just above 1 (asin would
+      // return NaN); clamp it, as SloppyMath.haversinMeters does
+      double deltaLon = asin(Math.min(1, sin(radDistance) / cos(radLat)));
       minLon = radLon - deltaLon;
       if (minLon < MIN_LON_RADIANS) {
         minLon += 2d * PI;

Trade-offs

  • Conservative: SloppyMath.asin(1) is exactly pi/2 (SloppyMath.java:172-173), at least the exact half-width (89.99999914622636 degrees here, 90.0 with the clamp), so the box grows by a hair and still contains the circle.
  • The box is only a pre-filter; LatLonPointDistanceQuery then checks each candidate's distance (LatLonPointDistanceQuery.java:115, :187), so hits do not change.
  • Cost: one Math.min per box (not measured). Low-risk rather than proven risk-free: no regression in the validation suites listed under "What the checks validated", which are not the full test suite.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, 2026-11-01: without the fix the repro fails with assertions on and off; with it, it passes with assertions on and off.
  • ./gradlew :lucene:core:test --tests 'org.apache.lucene.geo.*' --tests 'org.apache.lucene.document.TestLatLon*' --tests 'org.apache.lucene.search.TestLatLon*' --tests org.apache.lucene.search.TestNearest: 39 classes (all 17 geo test classes including TestGeoUtils, TestCircle, TestCircle2D; 19 TestLatLon* document classes; TestLatLonPointQueries, TestLatLonDocValuesQueries, TestNearest), 433 tests, 0 failures, 26 skipped.
  • ./gradlew :lucene:core:check -x test with the fix and the repro as a new test file: passes (google-java-format, ecj lint, forbidden APIs). The full Lucene test suite was not run.
  • Earlier: with the clamp shadowed into the stock jars, the finder's and the independent verifier's tests (including a 49M-pair scan) pass, the history probe finds 0 failures, and eight upstream geo test classes pass as without the fix (116 tests).

B. Send a ratio >= 1 to the pole branch (not run)

Compute q = sin(radDistance) / cos(radLat); if q >= 1, use the full longitude range as the pole branch does (Rectangle.java:166-172).

Trade-offs

  • Also safe, but the box spans every longitude instead of a half-width of 90 degrees, so a few more candidates are checked.
  • Slightly more code than A.

C. Exact trig in this method (not recommended)

Replace the SloppyMath calls with StrictMath or Math.

Trade-offs

  • Math.sin/Math.cos also push the same ratio above 1 (450 of 1,280,000 near-pole cases on Linux aarch64), so a clamp is still needed.
  • The sloppy versions are kept here for speed (SloppyMath.java:181-183).

Downstream note

A service that forwards user-supplied distance-query parameters returns an error for such a request instead of results; other requests and the index are not affected.

How it was found

Semi-formal: while checking SloppyMath's documented error bounds, an AI tool found the table switches of SloppyMath.cos where a larger angle gets a larger cosine, evaluated the box's asin argument for every input pair in a bounded window around each switch, and replayed the first hit at each switch through Rectangle.fromPointDistance. SMT solvers timed out on the same question.

The method in full194 words

Method: bounded-exhaustive translation validation of the stock main jar (study unit on SloppyMath and geo distance error bounds, main a9779ef3c193, 2026-10-27).

  • Step 1: a scan of every SloppyMath.cos table switch in [0, pi] for the two doubles on either side, looking for a cosine that goes up as the angle grows: 12 such switches.
  • Step 2: around each of the 512 switches in [0, pi/2], an exhaustive window scan of the ratio sin(radDistance) / cos(radLat) at Rectangle.java:157 for circles that stay in the non-pole branch, evaluated with the same public SloppyMath.sin/SloppyMath.cos calls; any ratio above 1 is a witness, and the first one at each switch was replayed through Rectangle.fromPointDistance. The south side was not scanned (it is the mirror image).
  • SMT encodings of the same branch (QF_FP, with Z3 4.12.2 and cvc5 1.4.2) returned unknown or timed out, so the witness comes from the executed scans, not from a solver model.
  • The finder then wrote a public-API JUnit with controls and the clamp check; an independent verifier found its own witness with its own scan (487 failing legal pairs in 49M). The one-test minimal repro was reduced from them.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, the JIRA archive, the Lucene mailing lists, downstream projects or the web (searched 2026-10-27, refreshed 2026-11-01). Closest: #8198 (the same box too small for tiny radii) and #8178 (created haversinMeters with the clamp haversin had since 2013; not the box). Not fixed upstream as of 2026-10-30.

Searches4 searches

Closest items, none a report of this bug: #8198 (LUCENE-7143, open: the same function's box was too small for tiny radii, the origin of the 7 cm pad; a different defect and line); #14516 (merged 2025: moved sloppySin into SloppyMath.sin unchanged; review about speed and accuracy); #7966 (LUCENE-6908, the introducing change; its 19 comments are about pole-crossing relations); #8178 (LUCENE-7123: created haversinMeters, keeping the asin(Math.min(1, ...)) clamp haversin had since LUCENE-5258 in 2013; it did not clamp the box); elastic/elasticsearch#55851 and #56755 (the same message from an unrelated encodeLongitude call in a downstream test). No open PR touches Rectangle.java, SloppyMath.java or Circle2D.java (2026-10-27). Gaps: keyword search only; GitHub search does not index PR diffs (the open-PR file scan covers that); private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits2026-10-27two scripted prior-report searches (45 and 51 queries: fromPointDistance, circleToBBox, "invalid longitude" NaN, asin NaN, SloppyMath monotonic, sloppySin, 1.0000000000000002, ...); 25 manual searches (open and closed, migrated JIRA issues); the changed files of all 408 open PRs; git log -S on the formula and the clampno report; #8198, #14516, #7966 and #8178 are context only
JIRA archive, mailing lists, downstream, web2026-10-276 JIRA queries; 33 searches of dev@, java-user@, issues@, builds@ and commits@; 10 Elasticsearch, OpenSearch and Solr searches; 3 GitHub-wide searches; 7 web searchesno report; elastic/elasticsearch#55851 and #56755 show the same message from unrelated code
apache/lucene issues (refresh)2026-11-01fromPointDistance and "invalid longitude NaN"only the unrelated #8267 and #8207; nothing new
Upstream code2026-11-01Rectangle.java on upstream main 2f424fd428da (2026-10-30)line 157 still unclamped

History

Introduced by LUCENE-6908 (92b659ab9e1a / 3a189ff68dec, 2015-12-07, first released in 5.5.0), which switched this formula from StrictMath to SloppyMath trig. There never was a clamp here; the failure came in with that change (bounded scans of 5.4.1 found no failure); 18 releases from 5.5.0 to 10.6.0 and main were executed and all fail.

Versions tested6 rows

Introduced by LUCENE-6908 (migrated issue #7966, Nick Knize, JIRA patch era): 92b659ab9e1a on trunk, 3a189ff68dec on branch_5x, both 2015-12-07. Its diff replaces StrictMath.asin(StrictMath.sin(radDistance) / StrictMath.cos(radLat)) with the SloppyMath versions and adds sin(a) = cos(a - PIO2). The unclamped ratio itself is older (d369057766ae, LUCENE-6780, 2015-10, sandbox, StrictMath), but that form threw for no input in the scans.

Later commits moved the code and kept the formula: LUCENE-6997 (to lucene-spatial), LUCENE-7123 (GeoUtils.sloppySin), LUCENE-7147 (the 7 cm pad, 6.1.0; it only moves the witness radius), LUCENE-7163 (core Rectangle.fromPointDistance, 6.1.0), LUCENE-7314 (LatLonPoint in core, 7.5.0), #14516 (renamed to SloppyMath.sin, 10.3.0). SloppyMath.cos and asin have the same bodies from 5.5.0 to main.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal repro fails through gradle and standalone, assertions on and off; upstream main 2f424fd428da still has the line
10.6.0 affectedthe minimal repro fails, assertions on and off
9.12.3, 10.0.0, 10.2.2, 10.3.0, 10.5.2 affectedthe study's JUnit repro fails 3 of 3
5.5.0, 5.5.5, 6.0.0, 6.0.1, 6.1.0, 6.6.6, 7.4.0, 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0 affecteda version-adapted public-API probe finds 17,844 throwing legal circles in each line (2,359,296 scanned); 5.5.x and 6.0.x need other witness radii
5.4.1 StrictMath trigno failure found: 0 throws in a bounded scan of 100.7M circles next to the knots (sandbox code only)
main with the clamp fixthe repro and the probe pass

Independent checks

Confirmed by the finder's controls, independent verifiers, an adjudication, a credibility audit and registry re-runs (assertions on and off, interpreter with SVE off), and by this doc's gradle run on main. Cross-model reviews (OpenAI GPT-5.6) could only read code; both found the intended-behaviour case weak. The clamp passed every check it was given.

Checks and verdicts346 words

The study's checks: finder, a read-only cross-model refute pass, the independent reproduction, a read-only cross-model review, the intended-behaviour check, adjudication; then registry re-runs, a credibility audit, the minimal repro and this doc's gradle run on 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
finder fails 3 of 3 on main, 10.5.2, 9.12.3Public-API JUnit with controls (radius +-1 m, centre +-1e-9 degree, the exact knot: all pass); passes with the clamp.
Independent reproducer realOwn scan and own witness (centre 0.4394531249999943): 5 of 6 tests fail on main, 10.5.2 and 9.12.3; 487 failing legal pairs among 49M; exact arithmetic puts the padded circle 4.7e-8 m inside the non-pole branch; 6 of 6 pass with its own clamp.
Intended-behaviour devil's advocate real, highBuilt the strongest "intended" case and rejected it; 0 of 2,000,000 random circles; Math.sin/Math.cos overshoot in 450 of 1,280,000 near-pole cases; corrected the entry points.
cross-model passes (OpenAI GPT-5.6, read-only) real (medium); not real (low, procedural)Could not run code. The intended lens agreed on the root cause and rated the intended case weak; the refute pass's "not real" is procedural ("this is not a substantive refutation"), and of the evidence items it listed one is neutral, one (the experimental Circle and its near-pole advice) supports not real, and the rest support real.
adjudication confirmed, highRe-ran the independent reproduction's test with a fresh seed (5 of 6 fail, 6 of 6 with the clamp) and narrowed the claims: entry points, five knots, aarch64 only.
registry re-runs and audit still reproduces; confirmedFails with -ea, -da and -Xint -XX:UseSVE=0; eight upstream geo test classes pass with the fix as without (116 tests); the audit's own probe and rarity scan agree.
this doc's gradle run fails stock, passes fixedGradle in a scratch worktree of 645d4fe14a20: the repro fails with assertions on and off and passes with fix A; 39 geo and LatLon test classes (433 tests) and :lucene:core:check -x test pass with it. The repro also fails on 10.6.0.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,435 words

Finding record

LFV-771, snapshot of 2026-11-06

Registry title
Rectangle.fromPointDistance throws 'invalid longitude NaN' for some legal circles whose edge stops just short of a pole, so LatLon distance queries on them fail with IllegalArgumentException
Area
core / geo (Rectangle.fromPointDistance circle bounding box, used by LatLon distance and Circle queries)
Priority
Low, rank 1135: It is a loud IllegalArgumentException for contrived, ulp-precise legal circles in core LatLon distance queries (0 of 2M random circles hit it), the same valid-input-rejected boundary profile as LFV-219, in core code rather than spatial3d like LFV-742.
Impact class
crash
Impact note
Hits applications that run a LatLon circle query with the default configuration (LatLonPoint.newDistanceQuery and LatLonPoint.newGeometryQuery(INTERSECTS, Circle) fail at search time in LatLonPointDistanceQuery.createWeight; LatLonDocValuesField.newSlowDistanceQuery and LatLonShape.newGeometryQuery with a Circle fail when the query is built), but only for contrived legal circles: a centre latitude about 1e-14 to 1e-13 degree below one of five dyadic latitudes (0.44, 0.79, 2.02, 2.55 or 12.22 degrees, or their southern mirrors) together with a radius within about 1e-7 m of 'distance to the pole minus 7 cm'; the valid query is then rejected with IllegalArgumentException 'invalid longitude NaN' (no wrong hits, nothing corrupted; 0 of 2,000,000 random legal circles hit it, and a centre decoded from an indexed point cannot), in every release from 5.5.0 to 10.6.0 and on main [exec, -ea and -da].
Summary
You run LatLonPoint.newDistanceQuery("p", 0.43945312499999994, 0, 9958692.080819247), a valid centre and a finite, non-negative radius whose circle stops about 7 cm short of the north pole, and expect it to match the two indexed points inside the circle. Instead IndexSearcher.count throws IllegalArgumentException 'invalid longitude NaN; must be between -180.0 and 180.0' from Rectangle.fromPointDistance, and LatLonDocValuesField.newSlowDistanceQuery throws the same when it is built. The bounding box computes asin(sin(r) / cos(lat)) without a clamp, and SloppyMath.cos, which is not monotone across its lookup-table switches, makes the quotient 1.0000000000000002 although its exact value is below 1, so asin returns NaN.
Introduced
92b659ab9e1a (trunk) / 3a189ff68dec (branch_5x, shipped in 5.5.0): LUCENE-6908 (migrated as #7966) by nknize, which replaced StrictMath.asin/sin/cos with SloppyMath in the non-pole branch deltaLon = asin(sin(radDistance) / cos(radLat)) and added sin(a) = cos(a - pi/2) [read: git log -S and the blame chain of Rectangle.java:157 in the history clone; exec: 5.4.1 passes, 5.5.0 fails]. The unclamped quotient itself is older (d369057766ae, LUCENE-6780, 2015-10-21, sandbox, StrictMath trig), but that form threw for no input in the bounded scans. Later commits moved or renamed the code and kept the formula: 665041c52f08 (LUCENE-6997, to lucene-spatial), ce3114233bdc (LUCENE-7123, GeoUtils.sloppySin), 6c219e99e4b / 951563aa427 (LUCENE-7163, core Rectangle.fromPointDistance, 6.1.0) and 3fcc87752a42 (#14516, renamed to SloppyMath.sin, 10.3.0); 045659533cd (LUCENE-7147, the 7 cm pad, 6.1.0) only moved the witness radius. Never clamped: not a regression (2015-12-07)
First affected version
5.5.0 [exec: lucene-spatial GeoUtils.circleToBBox and the GeoPointDistanceQuery constructor throw 'invalid minLon NaN' for 17,844 of 2,359,296 scanned legal circles next to the knots; 5.4.1, StrictMath trig in the sandbox module only, 0 of 100,663,296]. The API named in the title, core Rectangle.fromPointDistance, exists from 6.1.0; LatLonPoint.newDistanceQuery from 6.0.0 (sandbox module) and in core from 7.5.0
Affected versions
Every release from 5.5.0 to 10.6.0, and main a9779ef3c193; never fixed (10.6.0: the one-test minimal repro fails on the release jars, SHA-1 checked against Maven Central, -ea and -da, 2026-11-01 [exec]). [exec] 17 releases and main fail, 5.4.1 passes: the version-adapted public-API probe throws for 17,844 legal circles in each line on 5.5.0, 5.5.5, 6.0.0, 6.0.1, 6.1.0, 6.6.6, 7.4.0, 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 10.2.2, 10.5.2 and main (-da also on 5.5.0, 8.11.4 and main); the finder's JUnit fails 3 of 3 on 9.12.3, 10.0.0, 10.2.2, 10.3.0, 10.5.2 (-ea and -da) and main (-ea and -da); the independent reproduction's JUnit fails 5 of 6 on 9.12.3, 10.5.2 and main; the independent reproduction's main() probe fails on 8.11.4 and 7.7.3. The exact repro radius 9958692.080819247 m applies from 6.1.0 (5.5.x and 6.0.x converted radius to angle differently, so the probe finds other radii there). [read] every release tag 5.5.0..10.5.2 in the history clone contains the unclamped line; it is unchanged on upstream main f7bcc0abd816, branch_10x and branch_10_5 (2026-10-27), and no open PR touches Rectangle.java, SloppyMath.java or Circle2D.java. All runs with JDK 25.0.3 on aarch64; the code is pure Java double arithmetic, and a re-run in the interpreter with SVE off (-Xint -XX:UseSVE=0) fails the same way; x86 was not run
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-27, read-only): two scripted prior-report searches (the finder's 45 queries, 51 hits; a second search's 51 queries over GitHub issues/PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, JIRA and the web, 60 hits; 0 high overlap), 25 manual apache/lucene searches (issues, PRs, migrated JIRA issues), 10 Elasticsearch, OpenSearch, Solr and lucene-solr searches, 3 searches of all of GitHub, 6 JIRA and 33 mailing-list searches (builds@ included), 7 web searches, the changed files of all 408 open apache/lucene PRs and the upstream state of Rectangle.java (line 157 unchanged on main f7bcc0abd816, branch_10x and branch_10_5); the intended-behaviour check's own searches agree. 23 hits read, none the same bug. Closest items, context not reports: #8198 (LUCENE-7143, open: the same function's box is too small for tiny radii, the origin of the 7 cm pad; a different defect, line and fix); #14516 (moved sloppySin into SloppyMath.sin unchanged; its review discusses speed and accuracy only); #7966 (LUCENE-6908, the introducing change, about pole-crossing relations); #8178 (LUCENE-7123, added the asin clamp to haversinMeters but not to the box); elastic/elasticsearch#55851 and #56755 (the same message from an unrelated encodeLongitude call in a downstream test). Gaps: keyword search only, GitHub search rate limits, one open PR's file list capped by the API, private channels and security@ not visible
Lucene commit tested
main 645d4fe14a20: the minimal repro with assertions on and off through gradle and standalone; the clamp fix in a scratch worktree with the geo and LatLon test suites and :lucene:core:check -x test; the arithmetic probe on the stock core jar. Release jars (SHA-1-checked): the minimal repro on 10.6.0; the study's JUnit repros and version-adapted probes on 5.4.1 (no failure found in a bounded scan) and on 17 releases from 5.5.0 to 10.5.2, and on main a9779ef3c193, whose geo, SloppyMath and LatLon sources are identical to 645d4fe14a20. Upstream main 2f424fd428da (2026-10-30) still has the unclamped line (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/geo/Rectangle.java:142-178 (at a9779ef3c193) (javadoc 'Compute Bounding Box for a circle using WGS-84 parameters'; :150 the 7 cm pad '(radiusMeters + 7E-2) / EARTH_MEAN_RADIUS_METERS' (LUCENE-7143); :156 the non-pole guard 'minLat > MIN_LAT_RADIANS && maxLat < MAX_LAT_RADIANS'; :157 'double deltaLon = asin(sin(radDistance) / cos(radLat));' with no clamp; :174-178 new Rectangle(...)), :84-90 (constructor: checkLatitude and checkLongitude of every bound, which rejects NaN), :63-64 (class note: rectangles near poles are distorted, about accuracy)
  • lucene/core/src/java/org/apache/lucene/util/SloppyMath.java:58 (at a9779ef3c193) (haversinMeters: 'asin(Math.min(1, Math.sqrt(sortKey * 0.5)))', the clamp idiom), :80 and :92-114 (cos, 'Error is around 1E-15', table plus Taylor polynomial), :119-125 and :136-178 (asin: NaN when the absolute argument is greater than 1), :189-205 (sin(a) = cos(a - pi/2), 'Error is around 1E-12')
  • lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:284-299 (at a9779ef3c193) (newDistanceQuery: radius 'must be non-negative and finite'; '@throws IllegalArgumentException if field is null, location has invalid coordinates, or radius is invalid')
  • lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesField.java:195-209 (at a9779ef3c193) (newSlowDistanceQuery, the same @throws at :207)
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceQuery.java:83 (at a9779ef3c193) (createWeight builds the box) and lucene/core/src/java/org/apache/lucene/geo/Circle2D.java:412-418 (HaversinDistance builds the box at :417 when a Circle query is constructed); lucene/core/src/java/org/apache/lucene/geo/Circle.java:33 (@lucene.experimental note 'For very large circles or circles near poles, consider using polygons instead', accuracy advice from the docs-only #14534)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/geo/Rectangle.java:31-33, :88, :142-179 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/SloppyMath.java:80, :92-114, :100, :124-125, :171-176, :203-205, :222 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceQuery.java:57-71, :83; document/LatLonPoint.java:333-334; geo/Circle2D.java:417; geo/GeoUtils.java:98-100 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/geo/GeoEncodingUtils.java:174; document/LatLonPointDistanceComparator.java:94; document/NearestNeighbor.java:131; document/LatLonPointDistanceFeatureQuery.java:375 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:290-294; document/LatLonDocValuesField.java:204-208; geo/Rectangle.java:63-64, :142, :145-146, :149-150, :156; util/SloppyMath.java:58, :183; geo/Circle.java:33, :47; geo/XYRectangle.java:79-81 (at 645d4fe14a20)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/Tools.java:27-38 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/geo/TestGeoUtils.java:34-51, :49; lucene/core/src/test/org/apache/lucene/util/TestSloppyMath.java:95 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/SloppyMath.java:58, :172-173, :181-183; document/LatLonPointDistanceQuery.java:115, :187; geo/Rectangle.java:166-172 (at 645d4fe14a20)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.