Lucene's distance for a point is SloppyMath.haversinMeters, the number the distance sort and LatLonPoint.nearest report. A distance query should match exactly the points whose distance is at most the radius. Here is a point at exactly the radius that is missed.
- You index a point and sort by distance from a center: Lucene reports 8154434.476304402 m, the same as
SloppyMath.haversinMeters. You runLatLonPoint.newDistanceQuerywith exactly that radius and expect the point back: the query matches points "within the specified distance". - You get 0 hits, with no error.
LatLonDocValuesField.newSlowDistanceQueryand aLatLonShapecircle query give 0 too. The other way round, a point at 1.6056103066824859E7 m is matched by radius 1.6056103066824857E7, one ulp smaller. - Why: per document the query compares sort keys, not metres, against one threshold key computed from the radius. Adjacent keys share distances: here keys -2, -1 and 0 are all 8154434.476304402 m, and the point sits on key 0.
GeoUtils.distanceQuerySortKeybinary-searches for that threshold and returns the first equal-distance key it lands on, key -1, so key 0 failskey <= threshold. When no key has the radius' exact distance, it returns the first key beyond the radius, which can be a point's own key.- It bites when the radius comes from Lucene itself: with radius = the k-th hit's sort distance, that hit was missing in 92 of 400 trials. A wrong match was measured at most about 2e-8 m beyond the radius (radii under 19,000 km). Every release since 6.1.0.
Users of LatLonPoint.newDistanceQuery, LatLonDocValuesField.newSlowDistanceQuery or a Circle query; no special settings are needed. Typed radii practically never hit it; a radius reused from a distance sort does, and then lost that hit in 92 of 400 trials (nearest reports the same value). Only points exactly at the radius, or nanometres beyond it (micrometres near the antipode), are affected.
Mechanism, terms and numbers820 words
A lat/lon distance query is documented to match "points within the specified distance", and Lucene's own distance is SloppyMath.haversinMeters, the value a distance sort and LatLonPoint.nearest report. To avoid an asin per document, the query compares each point's sort key with one threshold key that GeoUtils.distanceQuerySortKey(radius) computes per query. Several adjacent sort keys round to the same distance in metres, so the threshold must be the largest key whose distance is <= radius. The binary search instead returns the first key it meets whose distance equals the radius, which can sit below the point's own key (the point is dropped), When no key has exactly that distance it returns lo, the key where the search ends, whose distance is beyond the radius (its assert checks only that; in the repro it is the first key beyond it), and a point on that key matches. Example: the distance sort and haversinMeters both put a point 8154434.476304402 m from the center, and LatLonPoint.newDistanceQuery, LatLonDocValuesField.newSlowDistanceQuery and LatLonShape.newDistanceQuery(Circle) with exactly that radius all return 0 hits. With radius = the distance-sort value of the k-th hit, that very hit is missing in 92 of 400 trials. No special configuration is needed; every release from 6.1.0 (2016, LUCENE-7215) to 10.6.0 (by reading) and main. Searching on haversinMeters(key) <= radius and returning hi, the last key that passed (+12/-16 in one method), makes the repro pass, and one complete lucene/core test run with it passed (assertions on, seed DEADBEEF).
- Sort key:
SloppyMath.haversinSortKey(lat1, lon1, lat2, lon2), the first half of the haversine formula, a double between 0 and 2 that grows with distance. Its low three bits are cleared "so subsequent rounding does not create ties" (SloppyMath.java:73-74), so only every 8th double can be a point's key. - Distance:
haversinMeters(key) = TO_METERS * 2 * asin(min(1, sqrt(key * 0.5)))(SloppyMath.java:57-59).haversinMeters(lat1, lon1, lat2, lon2)is exactlyhaversinMeters(haversinSortKey(...))(:47-49), so a point's Lucene distance is a function of its key. - Threshold: to skip the
asinper document, a distance query computes onceGeoUtils.distanceQuerySortKey(radius)and matches a point when its key is<=that threshold (LatLonPointDistanceQuery.java:107-108,GeoEncodingUtils.java:176and:381-383;Circle2D.java:415,:438,:443for everyCirclegeometry, includingLatLonDocValuesField.newSlowDistanceQueryandLatLonShape).
- Consecutive keys often give the same distance value (around the first repro point, runs of 2 or 3 keys; tables below). "key <= threshold" equals "distance <= radius" only if the threshold is the largest key whose distance is
<= radius. distanceQuerySortKey(GeoUtils.java:114-141) binary-searches the bit patterns 0 toDouble.MAX_VALUE(about 63 steps). WhenhaversinMeters(mid) == radiusit returnsmidat once (:127-128), whichever key of the run it hit first: a point on a later key of the run is at exactly the radius but fails.- When no key has exactly the radius' distance, it returns
lo(:136-140), the key where the search ends: it tested above the radius and the key just before it tested below, so where distance does not decrease as the key grows it is the smallest key beyond the radius. Theassert haversinMeters(ceil) > radiuschecks only the first part. A point on that key matches although it is beyond the radius.
Radius 8154434.476304402 m, the point's own distance (keys as offsets from the point's key):
| key offset | key | distance (m) | |
|---|---|---|---|
| -3 | 0.7132161089225871 | 8154434.4763044 | below |
| -2 | 0.7132161089225872 | 8154434.476304402 | equal |
| -1 | 0.7132161089225874 | 8154434.476304402 | equal: the search stops here (threshold) |
| 0 | 0.7132161089225875 | 8154434.476304402 | equal: the point's key, the largest key within the radius |
| +1 | 0.7132161089225876 | 8154434.476304404 | above |
Radius 1.6056103066824857E7 m, one ulp below the second point's distance:
| key offset | key | distance (m) | |
|---|---|---|---|
| -2 | 1.8130581393366394 | 1.6056103066824853E7 | below |
| -1 | 1.8130581393366396 | 1.6056103066824853E7 | below: the largest key within the radius |
| 0 | 1.8130581393366398 | 1.6056103066824859E7 | above: the point's key and the threshold returned |
No key has distance exactly 1.6056103066824857E7 (the distances jump from ...853 to ...859), so the search returns the point's own key and the point matches. With the fix the thresholds are key 0 and key -1, and both points are classified as haversinMeters says.
| query, center (-23.073417, -155.660403) | expected | stock main and 10.5.2 | with the fix |
|---|---|---|---|
| point at 8154434.476304402 m, radius 8154434.476304402 | 1 hit | 0, for LatLonPoint, LatLonDocValuesField and LatLonShape circle queries | 1 |
| point at 1.6056103066824859E7 m, radius 1.6056103066824857E7 | 0 hits | 1, for all three | 0 |
radius Math.nextUp(distance), or radius 0 for a point at the center (controls, finder's harness) | 1 hit | 1 | 1 |
On random encoded points roughly a fifth to a third of "radius = a point's distance" queries miss that point, and a similar share of "radius one ulp below" queries still match it; the rate depends on the points (494 and 457 of 2,000 random pairs in one run, 154 and 147 of 480 queries per factory in another).
Exposure and likelihood382 words
- Which queries.
LatLonPoint.newDistanceQuery,LatLonDocValuesField.newSlowDistanceQuery, theirIndexOrDocValuesQuerycombination,LatLonShape.newDistanceQuerywith aCircle(INTERSECTS, WITHIN, and DISJOINT with the errors mirrored) andLatLonPoint.newGeometryQuery/LatLonDocValuesField.newSlowGeometryQuerywith aCircle: all nine tested factories share the threshold and show identical counts (400 point documents; CONTAINS and indexed lines or polygons were not run). By reading, the same threshold is used wherever aCircleis evaluated and in the points query's cell relations. The first two are not@lucene.experimental. - Default vs opt-in. Nothing to opt into: the error is in the per-query threshold, not in the codec, writer or searcher. The repro runs under the test framework's randomized writer config, codec and searcher, the finder's harness under a default
IndexWriterConfigwithNoMergePolicyand a plainIndexSearcher; both fail the same way, with assertions on and off. - When it bites. Only a radius equal to a point's own Lucene distance, or less than one sort-key step below it (a few ulps of the distance for radii under 19,000 km). Typed or random radii practically never land there, which is why upstream's random tests pass. Reusing a distance Lucene computed does: "everything at least as close as my k-th nearest hit" (radius = the k-th distance-sort value) lost that very hit in 92 of 400 trials (116 of 400 with another seed), once the nearest hit at 683007.9829461674 m, whose own distance as radius returned 0 hits.
LatLonPoint.nearestreports the same value as the distance sort (by reading, and equal for the repro point), so a radius taken from it should behave the same; that workflow was not run. "Strictly closer than this document" (radiusMath.nextDownof its distance) can still return the document. - Scale. A point is dropped only when its distance equals the radius exactly; a wrongly matched point was at most about 2e-8 m beyond the radius for radii under 19,000 km, and 2.6e-5 m in the worst case near the antipodal maximum (1.2 million radii probed; measured, not proven). That is far below the coordinate encoding (about 4.7 mm of latitude) and the documented 40 cm error of
haversinMeters. The harm is inconsistency: the query disagrees with the distance the same application shows from Lucene's sort. - How likely. Rare; the searches listed under Prior reports found no earlier report.
The test shows that LatLonPoint.newDistanceQuery does not match a point whose SloppyMath.haversinMeters distance equals the radius, because GeoUtils.distanceQuerySortKey does not return the largest sort key within the radius.
/*
* 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.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.SloppyMath;
public class TestDistanceQueryRadiusBoundary extends LuceneTestCase {
public void testPointAtExactlyTheRadiusMatches() throws Exception {
double centerLat = -23.073417018167675, centerLon = -155.66040300764143;
double lat = 49.503454877994955, lon = -167.46806376613677;
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new LatLonPoint("p", lat, lon));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
double radius = SloppyMath.haversinMeters(centerLat, centerLon, lat, lon);
int count =
new IndexSearcher(reader)
.count(LatLonPoint.newDistanceQuery("p", centerLat, centerLon, radius));
// lat/lon are exact encoded values; expected 1 hit at distance == radius, actual 0
assertEquals("hits at radius " + radius + " m", 1, count);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-573/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.document.TestDistanceQueryRadiusBoundary
- Expected vs actual
- Expected count 1 at radius 8154434.476304402 m (the point's own distance); actual 0 (
expected:<1> but was:<0>), with-eaand-da.
More files, environment, full output38 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: hits at radius 8154434.476304402 m expected:<1> but was:<0>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.document.TestDistanceQueryRadiusBoundary.testPointAtExactlyTheRadiusMatches(TestDistanceQueryRadiusBoundary.java:45)- The method promises "the exact sortKey needed to match the specified radius".
- Upstream's test oracle is exactly
haversinMeters(center, point) <= radiusMeters; the code is a 2016 speed-up of that exact check (LUCENE-7215). - It errs in both directions, so it is no lenient cut; the documented 40 cm error is against the true haversine, not Lucene's own distance.
The full argument248 words
- The method promises exactness. "binary search to find the exact sortKey needed to match the specified radius any sort key lte this is a query match" (
GeoUtils.java:110-113);haversinSortKey"always compares the same" ashaversinMeters(SloppyMath.java:61-64), though that reads most naturally as a claim about ordering, not as a promise that a radius maps back to an exact key. - The public contract is inclusive. "matching points within the specified distance",
radiusMetersthe "maximum distance from the center in meters" (LatLonPoint.java:285-292;LatLonDocValuesField.java:196-209says the same). - Upstream's oracle is exact.
SloppyMath.haversinMeters(centerLat, centerLon, lats[id], lons[id]) <= radiusMeters, no tolerance (BaseGeoPointTestCase.java:1061). It never draws a radius equal to a point's distance;TestGeoUtils.java:49carries "TODO: randomly quantize radius too, to provoke exact math errors?". - A speed change, not a semantics change. Until 6.0.1 the leaf check was
SloppyMath.haversinMeters(h1) <= radiusMeters. LUCENE-7215 (#8270, CHANGES "Optimizations") replaced it with the key comparison; its issue says "we just need to compute the exact sort key needed for comparisons" and never discusses the boundary. The original query (LUCENE-7054, #8110) came with a random test "that doesn't have any leniency/tolerance/deltas ... to keep testing exact". - Precedent. LUCENE-7185 (#8240) fixed the same kind of key/metre tie inconsistency for the distance sort, by masking the low bits of the sort key.
- Wrong in both directions. A deliberately lenient threshold would never drop a point at the radius; this one drops them (equality branch) and admits points beyond it (
ceilbranch).
The devil's-advocate check215 words
The strongest case for "works as designed" (built by the intended-behaviour checker; the cross-model reviewers raised it too): SloppyMath is "Math functions that trade off accuracy for speed" with up to 40 cm error (SloppyMath.java:31, :38); GeoUtils and Circle are @lucene.experimental (GeoUtils.java:49, Circle.java:47); the ceil comment and assert show the author knew the returned key is beyond the radius; and no test pins the exact boundary.
Why it fails: the 40 cm figure compares haversinMeters with the true haversine, not the query with haversinMeters, the number the distance sort and nearest report; the equality branch causes false negatives, so the cut is not conservative; the method's own javadoc says "exact"; LatLonPoint.newDistanceQuery is not experimental; and the change that introduced it was filed as an optimization of an exact predicate. The ceil comment explains why an exact match may not exist, not that the boundary may move. What the rebuttal does not cover: the public javadocs ("within the specified distance", "maximum distance from the center in meters") name no distance function, do not promise agreement with the distance sort and define no rounding at the boundary, so maintainers can call the exact boundary unspecified, and documenting it as approximate is a legitimate choice. Expect the "sloppy by design" push-back.
Recommended Return the last key whose distance is <= radius (binary search, return hi)
Make GeoUtils.distanceQuerySortKey search on haversinMeters(key) <= radius and return hi, the last key that passed. The repro, a new TestGeoUtils test and one complete lucene/core test run pass. That is the largest key within the radius wherever distance does not fall as the key grows; 2 billion key pairs showed one 1-ulp dip, where no point key was misclassified.
long lo = 0;
long hi = Double.doubleToRawLongBits(Double.MAX_VALUE);
while (lo <= hi) {
long mid = (lo + hi) >>> 1;
double midRadius = haversinMeters(Double.longBitsToDouble(mid));
if (midRadius <= radius) {
lo = mid + 1;
} else {
hi = mid - 1;
}
}
double floor = Double.longBitsToDouble(hi);
assert haversinMeters(floor) <= radius;
return floor;All options and trade-offs4 options · 990 words
One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle; the alternatives were not run.
Workaround today: to include a point at a known distance d, pass Math.nextUp(d) as the radius (0 misses over 480 such queries for each of the 9 factories in the fact check's run); a point one more ulp beyond can then match by the same bug.
Return the last key whose distance is <= radius (binary search, return hi) (validated by the checks) (recommended)
In GeoUtils.distanceQuerySortKey (GeoUtils.java:114-141) drop the equality early return and the ceil return: move lo past every key with haversinMeters(key) <= radius, move hi below every key above it, and return hi. The search ends on a key hi that tested within the radius while hi + 1 tested beyond it; where distance does not decrease as the key grows, that is the largest key within the radius. The javadoc gains "the largest sort key whose distance is less than or equal to the radius", and an assert haversinMeters(floor) <= radius replaces the old ceil assert (it holds for every radius the query constructors accept: hi ends on the last key that passed). The "effectively infinite" branch is unchanged. The finder wrote this fix and the independent reproducer the same one; this version adds the javadoc sentence and the assert. For the PR, qualify the javadoc sentence and the code comment ("assuming the distance does not decrease as the key grows") or limit them to point keys: at the one measured dip they do not hold for raw keys (below).
long lo = 0;
long hi = Double.doubleToRawLongBits(Double.MAX_VALUE);
while (lo <= hi) {
long mid = (lo + hi) >>> 1;
double midRadius = haversinMeters(Double.longBitsToDouble(mid));
if (midRadius <= radius) {
lo = mid + 1;
} else {
hi = mid - 1;
}
}
double floor = Double.longBitsToDouble(hi);
assert haversinMeters(floor) <= radius;
return floor;Trade-offs
- Cost (not benchmarked): no per-document change; the search stays bounded at about 63
haversinMeterscalls per query, and the stock code already runs the full search whenever no key has exactly the radius' distance. Radii that hit an equal-distance key, such as distances reused from a sort, used to stop early and now run the full search; that per-query cost was not measured. - Premise: exact if
haversinMeters(key)never decreases as the key grows. For arguments up to sin(73 degrees) (keys up to about 1.83)SloppyMath.asininterpolates from one of 8,193 table cells (SloppyMath.java:144-155,:239-243); above that it uses an fdlibm-derived formula (:156-169). So the cell boundaries and the switch are where a dip could hide. A scan of 2,074,872,896 adjacent key pairs (every boundary and the switch +/-65,536 keys, the last 1M keys before 2.0, 1,000 random runs of 1M keys) found exactly one decrease: one ulp (1.86e-9 m) at key 1.4201579892440324 (about 12,770 km), on the boundary between cells 7218 and 7219. There, for radius 1.2770150274362441E7 the fix returns a key 2 below the dip while 2 later keys have exactly that distance; neither has its low 3 bits clear, so no point can sit on them. Over the 4,514 radii equal to a distance of the 8,193 nearest keys, that is the fix's only miss; stock misclassifies a key with its low 3 bits clear (one a point could have; the probe checks only the bit mask) for 232 of those radii. Measured, not proven. - Inputs: the query constructors reject negative and NaN radii (
LatLonPointDistanceQuery.java:62-64,Circle.java:63-65), butdistanceQuerySortKeyis public and does not check its argument. On a direct call with a negative or NaN radius the fixed loop ends athi = -1: the new assert fails with assertions on, and with them off the method returns NaN, which no key is<=(stock: 0.0 for a negative radius; for NaN its own assert fails, +Infinity with assertions off). A PR may want an explicit argument check. - For the PR (not done here): the threshold also drives BKD cell pruning and acceptance and the grid relations (
GeoUtils.java:147-180,GeoEncodingUtils.java:176-187,:379-385,LatLonPointDistanceQuery.java:197-218,Circle2D.java:421-424); the tests here use one-document indexes, a 400-document harness and random point pairs, so a PR test should also cover cells fully inside and outside the circle. The regression tests do not pin the dip spot or invalid direct inputs.
What the checks validated
- Stock plus the tests: the 2 repro tests and the new
TestGeoUtilstest fail (3 of 10 tests in the two classes), with assertions on (seedDEADBEEF) and off (5EED0573). - With the fix:
org.apache.lucene.geo.*,org.apache.lucene.document.*LatLon*,org.apache.lucene.search.TestLatLon*and the repro, 40 suites, 434 tests, 26 skipped, 0 failed, assertions on and off;:lucene:core:check -x test(format, ecjLint, forbiddenApis) passes; one completelucene/coretest run (JDK 25, aarch64, assertions on, seedDEADBEEF): 749 suites, 8,892 tests, 322 skipped, 0 failed. - The independent reproducer's 3-test class passes with the fix; earlier checks found 0 errors over the finder's 13,320 factory queries, 20,000 random pairs and 8,000 history queries per factory on main and on 6.1.0.
Keep the key test, but for keys within a few steps of the threshold compute haversinMeters(key) <= radius.
Trade-offs
Exact even where haversinMeters dips, given a proven bound on how many keys around the threshold to check; one more branch per document and an asin only for points next to the boundary; more code to review. Not run.
haversinMeters(key) <= radius for every candidate document, as before 6.1.0.
Trade-offs
Exact, but brings back the per-document asin that LUCENE-7215 removed for speed. Not run.
Javadoc only.
Trade-offs
No code change and a legitimate maintainer choice, but the query keeps disagreeing with the distance sort and nearest, contradicts the method's own "exact sortKey" javadoc, and leaves the k-th-hit workflow broken.
Downstream relevance: a distance filter used as a geofence or a MUST_NOT exclusion can decide differently from the distance the application shows, but only for points exactly on the circle or nanometres beyond it (micrometres near the antipode).
A check compared nine distance-query factories with Lucene's own haversinMeters on 400 encoded points and 40 centers, at radii equal to each point's distance and one ulp around it. All nine missed points at radius = distance and matched points one ulp beyond; reading distanceQuerySortKey and a patched copy confirmed the cause, and a public-API JUnit reproduced it.
The method in full192 words
Method: translation validation of the real jar's distance predicates at the radius boundary (semi-formal tier).
- A hunt unit on lat/lon geo queries planned a check of "a radius at the exact distance of a grid point". Its harness ran the 9 public distance-query factories over 400 encoded points and 40 centers with radius 0,
d,nextDown(d)andnextUp(d)(d=SloppyMath.haversinMeters(center, point)), against upstream's own oraclehaversinMeters(center, point) <= radius: 154 of 480 radius-dqueries missed the point and 147 of 480 radius-nextDown(d)queries matched it, for every factory;nextUp(d)and radius 0 were always right (logged per kind forLatLonPoint.newDistanceQuery; the fact check's own run confirms it for all nine). - Reading
GeoUtils.distanceQuerySortKeygave the cause; a patched copy (a search for the last key within the radius) removed every mismatch (0 of 13,320 factory queries), and a public-API JUnit on fixed coordinates reproduced it on the stock jar. - Then an independent reproducer, reading-only reviews by a model from another family (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication and a credibility audit; this doc's runs added a targeted probe of the fix's monotonicity premise.
No prior report found (2026-10-26, re-checked 2026-10-28) in GitHub issues, PRs and commits, all open PRs by changed file, the mailing lists, the JIRA archive, downstream projects and the web. Nearest: LUCENE-7215 (#8270), which introduced the code, and LUCENE-7185 (#8240), the same kind of tie fixed for the distance sort.
Searches5 searches
Verdict new. Nearest, not this bug: #8270 (LUCENE-7215, the introducing optimization: "we just need to compute the exact sort key needed for comparisons", the boundary never discussed), #8110 (LUCENE-7054, the original exact predicate, tested "without leniency") and #8240 (LUCENE-7185, the same kind of key/metre tie fixed for the distance sort, in different code, 6 days before this method existed). The only issues anywhere that name the method are in an outside C++ port that copies it and never mentions the boundary. Keyword search cannot prove absence.
| Where | When | How | Found |
|---|---|---|---|
GitHub issues and PRs: apache/lucene, Elasticsearch, OpenSearch, Solr; distanceQuerySortKey across all of GitHub | 2026-10-26 | the hunt's prior-report tool (28 issue/PR, 4 commit, 3 downstream queries); 35 GraphQL and 22 REST searches | no report; 169 distinct tool hits, none about this defect |
| Open apache/lucene PRs | 2026-10-26, 2026-10-28 | changed files of all 406 open PRs; re-check of the 42 open PRs updated since 2026-10-25 | 6 touch an affected file, none changes the threshold; none of the 42 touches GeoUtils, LatLonPointDistanceQuery, Circle2D or GeoEncodingUtils |
| Mailing lists (dev@, java-user@, issues@, commits@) and the JIRA archive | 2026-10-26 | 20 list and 6 JIRA queries through the tool; 9 list-archive searches; 6 JQL queries and the full threads of 15 JIRA issues | no report |
| The web | 2026-10-26 | 7 queries | generic Q&A pages only |
| Upstream branches | 2026-10-28 | current source of GeoUtils.java on upstream main 20ced71c1e6e, branch_10_6 and branch_10x (GitHub API) | not fixed: the equality branch and the ceil assert are still there |
Introduced by LUCENE-7215 (2335a458d834, backport 87069500fb2e, 2016-04-13), first released in 6.1.0; 6.0.1 is clean. A portable probe fails on 14 releases from 6.1.0 to 10.5.2 and on main with identical counts wherever Circle accepts the radius; the method is unchanged on upstream main, branch_10_6 and branch_10x.
Versions tested7 rows
Introduced by LUCENE-7215 "don't invoke full haversin for LatLonPoint.newDistanceQuery" (#8270, by @rmuir, a JIRA patch, no PR): 2335a458d834 on master and the branch_6x backport 87069500fb2e, both 2016-04-13; first released in 6.1.0 (the 6.0.x branch never got it). It replaced the exact SloppyMath.haversinMeters(h1) <= radiusMeters check with the key comparison. Later commits only moved the method into core GeoUtils (LUCENE-7343: fb3480b8071 on master, the branch_6x backport fc68bd90e6f in 6.2.0) and reformatted its javadoc (LUCENE-9570).
Attribution by execution: the commit itself was not built (2016 ant build); 6.0.1 passes, 6.1.0 fails, and 6.1.0 with only the threshold function replaced by the fix passes, so none of the other geo commits between 6.0.1 and 6.1.0 is needed.
Kinds of runs: the release attribution rests mainly on the portable main(), which runs every factory a release has (4,000 radius-d and 4,000 radius-nextDown(d) queries per factory). The doc's 2-test, three-factory JUnit ran on main, 10.5.2 and 9.12.3; a reduced LatLonPoint-only version ran on 9.0.0, 8.11.4, 7.7.3, 6.6.6 and 6.1.0 (fail) and on 6.0.1 (pass).
Each API from its first release: the LatLonPoint distance query from 6.1.0, the LatLonDocValuesField one from 6.5.0, LatLonShape circles from 8.5.0 (until 8.8.0 Circle rejected radii above 3,185,504 m). LatLonPoint and LatLonDocValuesField were in lucene-sandbox until 7.4.x, in core since 7.5.0.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the doc's test: 2 of 2 fail (gradle and the standalone runner, assertions on and off); the new TestGeoUtils test fails; the independent reproducer's 3 of 3 fail |
| 10.5.2 | affected | the doc's test: 2 of 2 fail (assertions off) |
| 6.1.0, 6.4.2, 6.5.0, 6.6.6, 7.4.0, 7.5.0, 7.7.3, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 | probe | 1023 misses and 897 false matches of 8,000 queries for every factory the release has, identical from 6.1.0 on, except LatLonShape circles on 8.5.0: Circle rejected 7,314 radii (above 3,185,504 m), and the 686 accepted queries gave 81 misses and 26 false matches. The 3-factory JUnit fails on 9.12.3; a reduced LatLonPoint-only JUnit fails on 9.0.0, 8.11.4, 7.7.3, 6.6.6, 6.1.0 |
| 6.0.0, 6.0.1 | not affected | 0 of 8,000 wrong; the JUnit passes on 6.0.1 |
| 6.1.0 + the fix | attribution | 0 of 8,000 wrong |
upstream main 20ced71c1e6e, branch_10_6, branch_10x | read | same method body (GitHub API, 2026-10-28) |
fix on a9779ef3c193 | not affected | the repro and the new test pass; 434 geo and lat/lon tests (assertions on and off) and one complete lucene/core test run passed |
An independent reproducer, an intended-behaviour devil's advocate and an adjudication each reproduced it on stock main (adjudication: confirmed, high). Reading-only reviews by another model family: one agreed on the root cause (its "not real" was procedural), the other called it real. A credibility audit and this doc's runs re-ran it and probed the fix's premise.
Checks and verdicts387 words
Every executing check wrote or re-ran its own test on the stock jar; the checks before this doc ran on main ea57360912ed (identical geo sources).
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | 9 factories x 1,480 boundary queries: 154 misses and 147 false matches each, 0 and 0 with the fix; the 2-test JUnit fails on main (assertions on and off), 10.5.2 and 9.12.3, and a reduced one on 9.0.0, 8.11.2 and 7.7.3. |
| cross-model review, refute lens | procedural "not real" (low) | An independent reviewer from another model family (OpenAI GPT-5.6) ran read-only and could not run Java; it agreed on the root cause, said static review favours the bug, and rated the intended case weak. A separate re-run of the repro: 2 of 2 fail. |
| Independent reproducer | real | Own JUnit from the claim only: 3 of 3 fail on main, 10.5.2 and 9.12.3 (assertions on and off), 15 of 15 over random seeds; the k-th-hit workflow misses the hit in 116 of 400 trials; its own fix passes. |
| Cross-model review, intended lens | real (medium) | Reading only; a separate check over 20,000 random pairs found 5322 misses and 4335 false matches on main, 10.5.2 and 9.12.3, and 0 and 0 with the fix. |
| Intended-behaviour devil's advocate | real | Built the strongest "sloppy by design" case and rejected it from the history, the javadoc and the false negatives; its key probe shows the one-key miss and the ceil-key match. |
| adjudication | confirmed (high) | Re-ran the finder's JUnit (2 of 2 fail) and the independent reproduction's (3 of 3 fail); not a duplicate; security none; asked to pitch it as internal consistency and not to quote one hit rate. |
| credibility audit | keep | Re-ran the JUnit on main with assertions on and off (fail) and with the fix (pass); its own bit-level probe confirmed both branches (5181 and 4202 of 20,000 points). |
| this doc's runs | reproduced on a9779ef3c193; fix validated | The test fails on main (assertions on and off) and 10.5.2 and passes with the fix; one complete lucene/core test run passed with the fix; a scan of 2.07 billion adjacent keys found one 1-ulp dip in the distance, around which the fix misclassified no key with a point key's bit pattern; direct calls with invalid radii were probed. |
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.
- PNG 481 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,715 words
- Registry title
- Lat/lon distance queries can drop a point whose distance equals the radius and match a point one ulp beyond it, because GeoUtils.distanceQuerySortKey does not return the largest sort key within the radius (every release since 6.1.0)
- Area
- core / geo distance queries (GeoUtils.distanceQuerySortKey, shared by LatLonPoint, LatLonDocValuesField and LatLonShape circle queries)
- Priority
- Low, rank 407: Core lat/lon distance queries mis-classify a point only when its distance exactly equals the radius, a boundary band that a radius taken from Lucene's own distance does reach.
- Impact class
- silent wrong results
- Impact note
- Hits every application that runs LatLonPoint.newDistanceQuery, LatLonDocValuesField.newSlowDistanceQuery, their IndexOrDocValuesQuery combination, LatLonShape.newDistanceQuery with a Circle (INTERSECTS, WITHIN, DISJOINT) or a LatLonPoint/LatLonDocValuesField geometry query with a Circle, with the default codec, IndexWriterConfig and searcher, at any index size, with -ea or -da: a point whose Lucene distance (SloppyMath.haversinMeters, the value the distance sort and LatLonPoint.nearest report) equals the radius can be missing, and a point one ulp of the radius (one masked sort-key step) beyond it can match, with no error; typed or random radii essentially never land in that band, but a radius taken from a distance Lucene computed does (the independent reproduction: the k-th distance-sorted hit is missing from a query that uses its own sort distance as the radius in 116 of 400 trials), and the misclassification is nanometre-scale (measured at most about 2e-8 m beyond the radius below 19,000 km, 2.6e-5 m near the antipodal maximum), so the practical severity is low.
- Summary
- You index points with LatLonPoint (or LatLonDocValuesField or LatLonShape) and run a distance query whose radius is exactly a point's distance from the center as Lucene computes it (SloppyMath.haversinMeters, the same value a distance sort or LatLonPoint.nearest reports), and expect that point to match, because the query matches 'points within the specified distance' and Lucene's own test oracle is haversinMeters(center, point) <= radius; with the radius one ulp smaller you expect it not to match. Instead the point is often missing in the first case and often still matches in the second (each in roughly a fifth to a third of random cases, depending on the points): for example, two points that both the distance sort and haversinMeters put 8154434.476304402 m apart are not matched by any of the three distance queries with exactly that radius. The cause is GeoUtils.distanceQuerySortKey, which turns the radius into a sort-key threshold by binary search: it returns the first key it meets whose distance equals the radius, not the largest such key, so a point on a larger key of the same distance fails 'key <= threshold', and when no key has exactly that distance it returns the smallest key whose distance exceeds the radius.
- Introduced
- 2335a458d8340bbb8e4fe8133a74763bd2d37fd1 (master, 2016-04-13, LUCENE-7215 'don't invoke full haversin for LatLonPoint.newDistanceQuery' by @rmuir, migrated as apache/lucene#8270; JIRA patch era, no pull request; CHANGES 'Optimizations') and its branch_6x backport 87069500fb2e56b4ceabadb8bb866be39e4269fb (the one that shipped first, in 6.1.0). It replaced the exact per-document check SloppyMath.haversinMeters(...) <= radiusMeters with haversinSortKey(...) <= sortKey(radiusMeters), where sortKey is the binary search that is still GeoUtils.distanceQuerySortKey. Later commits changed no logic: fb3480b8071 / fc68bd90e6fa (LUCENE-7343, 6.2.0) moved it into core GeoUtils, 2d6ad2fee6d (LUCENE-9570) reformatted the javadoc [read: git log -S 'midRadius == radius', git blame -C -C, git log -L on the body; exec: 6.0.0 and 6.0.1 pass, 6.1.0 fails, and 6.1.0 with only that threshold function replaced by a largest-key-within-radius search passes, so none of the other geo commits between 6.0.1 and 6.1.0 is needed; the commit and its parent were not built from source (2016 ant build)] (2016-04-13)
- First affected version
- 6.1.0 [exec] (last unaffected 6.0.1 [exec]; the 6.0.x maintenance branch never received LUCENE-7215). LatLonPoint was in lucene-sandbox until 7.4.x and is in core since 7.5.0; the LatLonDocValuesField distance query is affected from its first release (6.5.0) and the LatLonShape circle query from its first release (8.5.0) [exec]
- Affected versions
- every release from 6.1.0 through 10.6.0 (10.6.0 by reading), and main ea57360912ed. [exec] version-independent main() program (10 centers x 400 points quantized by each version's own encoder, radius d and nextDown(d), 8,000 queries per factory, -da, seed DEADBEEF): 6.0.0 and 6.0.1 pass 0 of 8,000; 6.1.0, 6.4.2, 6.5.0, 6.6.6, 7.4.0, 7.5.0, 7.7.3, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main fail with 1023 false negatives and 897 false positives for every factory the version has (8.5.0 shape circles: 81 and 26 of 686 run, because Circle rejected radii above 3,185,504 m until 8.8.0), every mismatch explained by the version's own threshold function; 0 and 0 with the fix on 6.1.0 and main. The finder's JUnit fails 2 of 2 on main (-ea and -da), 10.5.2 and 9.12.3; its reduced LatLonPoint-only JUnit fails 2 of 2 on 9.0.0, 8.11.2, 8.11.4, 7.7.3, 6.6.6 and 6.1.0 and passes on 6.0.1 (6.x with the junitcore runner); the independent reproduction's JUnit fails 3 of 3 on main, 10.5.2 and 9.12.3 (-ea and -da). On main the finder's harness also executed IndexOrDocValuesQuery, LatLonShape WITHIN and DISJOINT, LatLonPoint.newGeometryQuery with circles and LatLonDocValuesField.newSlowGeometryQuery with a circle (all affected; in releases only the three main factories were run). [read] the lucene-spatial GeoPointDistanceQuery of 6.2-6.6 used the same method (not executed). Not fixed upstream: the method body is unchanged since 2016 and no open PR touches it. Executed on aarch64 with JDK 25 only (pure Java double arithmetic)
- 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-26, read-only, follow-up prior-report record verdict new): a scripted prior-report search (28 GitHub issue/PR, 4 commit, 3 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, and 6 JIRA queries; 169 distinct hits, 0 high, 12 medium, none about this defect), 35 GitHub GraphQL and 22 REST searches (apache/lucene, Elasticsearch, OpenSearch, Solr, and distanceQuerySortKey across all of GitHub), the changed files of all 406 open apache/lucene PRs (6 touch an affected file: #709, #12036, #12162, #12528, #13988, #16847; none changes the threshold), 6 extra JQL queries plus the full threads of 15 JIRA issues, 9 lists.apache.org searches, 7 web queries and the code history; the finder's run (45 hits) and the intended-behaviour check's (102 hits) were also reviewed. Related items, not reports of this bug: #8270 (LUCENE-7215, the introducing optimization: 'we just need to compute the exact sort key needed for comparisons', boundary never discussed), #8110 (LUCENE-7054, the original exact predicate, tested 'without leniency'), #8240 (LUCENE-7185, precedent: the same kind of sort-key/haversinMeters tie inconsistency fixed for the distance sort, different code, before this method existed). The only issues anywhere naming the method are in an outside C++ port that copies it verbatim and never mentions the boundary. Not fixed upstream: the method body is unchanged since LUCENE-7343 (2016) and no open PR touches it. Search limits: GitHub issue search does not index patch contents; some REST searches hit rate limits and were retried successfully; private channels were not searched
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-28) through gradle in a scratch worktree, stock and with the fix, assertions on and off, and through the standalone JUnit runner; the 10.5.2 release jars (assertions off). Earlier checks (2026-10-26) ran on mainea57360912ed, whosegeoanddocumentpackages andSloppyMathare identical (only the separatedocument/column/package changed), and a portablemain()ran on 16 releases from 6.0.0 to 10.5.2 and on main. The fix was validated in the scratch worktree: the repro, a newTestGeoUtilstest, 40 geo and lat/lon suites (434 tests) with assertions on and off,:lucene:core:check -x test, and one completelucene/coretest run (749 suites, 8,892 tests) with assertions on, seedDEADBEEF. A targeted probe scanned 2.07 billion adjacent sort keys for the fix's monotonicity premise.
- Finding record
lucene/core/src/java/org/apache/lucene/geo/GeoUtils.java:49 (at ea57360912ed) (@lucene.experimental), :110-113 (javadoc 'binary search to find the exact sortKey needed to match the specified radius any sort key lte this is a query match'), :114-141 (distanceQuerySortKey; equality branch returns the first key with haversinMeters == radius at :127-128; not-found branch returns ceil = lo with 'assert haversinMeters(ceil) > radius' at :136-140)lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceQuery.java:62-63 (at ea57360912ed) (radius validation: finite and non-negative), :107-108 ('compute exact sort key: avoid any asin() computations'; distanceQuerySortKey(radiusMeters))lucene/core/src/java/org/apache/lucene/geo/Circle2D.java:415 (at ea57360912ed) (distanceQuerySortKey), :438, :443 ('haversinSortKey(...) <= sortKey'); lucene/core/src/java/org/apache/lucene/geo/GeoEncodingUtils.java:176 (distanceQuerySortKey in createDistancePredicate), :381-383 ('haversinSortKey(...) <= distanceKey')lucene/core/src/java/org/apache/lucene/util/SloppyMath.java:31 (at ea57360912ed) ('Math functions that trade off accuracy for speed'), :38 (error at most 40 cm from the actual haversine distance), :57-59 (haversinMeters(sortKey)), :61-64 (haversinSortKey 'always compares the same' as haversinMeters), :74 (low three bits of the sort key masked)lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:284-296 (at ea57360912ed) (newDistanceQuery: 'matching points within the specified distance', radiusMeters 'maximum distance from the center in meters'), :439 (nearest reports SloppyMath.haversinMeters(hit.distanceSortKey)); LatLonDocValuesField.java:196-210 (newSlowDistanceQuery, same wording); LatLonShape.java:315-318 (newDistanceQuery with Circle); LatLonPointDistanceComparator.java:198, :232-236 (distance-sort value = SloppyMath.haversinMeters of the sort key); geo/Circle.java:47 (@lucene.experimental), :63-64 (radius validation)lucene/test-framework/src/java/org/apache/lucene/tests/geo/BaseGeoPointTestCase.java:1061 (at ea57360912ed) (upstream oracle 'SloppyMath.haversinMeters(centerLat, centerLon, lats[id], lons[id]) <= radiusMeters', no tolerance), :1322 (testRectBoundariesAreInclusive); lucene/core/src/test/org/apache/lucene/geo/TestGeoUtils.java:49 (TODO 'randomly quantize radius too, to provoke exact math errors?')SECURITY.md lines 28, 38, 50 (threat model), as cited in the follow-up the security record
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/geo/GeoUtils.java:110-141; util/SloppyMath.java:47-49, 57-59, 61-75; document/LatLonPointDistanceQuery.java:107-108, 114-115; geo/GeoEncodingUtils.java:176, 381-383; geo/Circle2D.java:415, 438, 443, 579-581; document/LatLonDocValuesField.java:210-213
- Who is affected
lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:79-81, 284-298; util/SloppyMath.java:38lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:439; document/NearestNeighbor.java:173-174; document/LatLonPointDistanceComparator.java:232-236; geo/Circle2D.java:415; geo/GeoUtils.java:165-180
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/geo/GeoUtils.java:49, 110-141; util/SloppyMath.java:31, 38, 61-64, 73-74; document/LatLonPoint.java:284-298; document/LatLonDocValuesField.java:195-213; geo/Circle.java:47lucene/test-framework/src/java/org/apache/lucene/tests/geo/BaseGeoPointTestCase.java:1061; lucene/core/src/test/org/apache/lucene/geo/TestGeoUtils.java:49
- Suggested fix
lucene/core/src/java/org/apache/lucene/geo/GeoUtils.java:114-141; util/SloppyMath.java:144-155, 239-243lucene/core/src/java/org/apache/lucene/util/SloppyMath.java:156-169, 239; document/LatLonPointDistanceQuery.java:62-64, 197-218; geo/Circle.java:63-65; geo/GeoUtils.java:147-180; geo/GeoEncodingUtils.java:176-187, 379-385; geo/Circle2D.java:421-424
- History
a Lucene clone (git merge-base --is-ancestor fc68bd90e6f releases/lucene-solr/6.2.0: yes)
- TestDistanceQueryRadiusBoundary.java (JUnit test):
repro/LFV-573/src/org/apache/lucene/document/TestDistanceQueryRadiusBoundary.java(49 lines)
Made in parallel with this doc from the code at main a9779ef3c193 and an executed probe (keys, distances, the traced binary search and the hit counts); the PNG is the summary still. The alt text describes the intended content.
assets/LFV-573/bug.png(481 KB)
