All findings · Lucene finding · LFV-247 · geo3d-docvalues-distance-sort-small-radius-off-surface

Geo3D doc-values distance sorts with a small radius treat about half of the nearby documents as missing

With Geo3DDocValuesField.newDistanceSort and a 1 km radius, about half of the documents inside the circle, even near the centre, get distance +Infinity and sort after documents 3 km away. Each point is read back metres off the ground, and for a small circle that height decides in or out.

Summary

Priority
Medium, rank 197Geo3D doc-values distance sorts with an ordinary radius under 10 km silently mis-sort about half the in-radius documents, which matches LFV-209's spatial3d small-radius profile, but the field is little used.
Impact
silent wrong results
Since
6.1.0 (LUCENE-7212, 2016)introduced 2016-05-17 · over 10 years
Affected
6.1.0 to 10.5.2 (13 releases run) and main
Status
documented
Found by
a solver-checked bound from a sibling bug, applied to the doc values, then a JUnit

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

Geo3DDocValuesField stores each point in 21 bits per coordinate, so a point is read back a few metres off the ground. Before it measures a distance, newDistanceSort asks the circle whether the point is inside, and answers +Infinity, the value of a missing field, when it is not.

Diagram of the Geo3D doc-values distance-sort bug: a side view of the planet surface with a 1 km circle whose inside test is a flat plane only 8 cm below the surface at the centre. Stored points are read back a few metres above or below the surface (up to 4.14 m in this example). Points read back below the plane, even at the centre, get distance +Infinity and sort last; points read back above it count as inside out to several kilometres. In the repro, 17 of 36 documents 500 m from the centre get +Infinity and 14 of 36 documents 3 km away get finite distances. Projecting each point back onto the surface fixes it.
Points around a 1 km circle, each read back slightly above or below the ground: the circle's inside test is a plane 8 cm below the surface, so points read back below the plane fall outside it even at the centre, while points read back well above it pass kilometres away. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 72 points with Geo3DDocValuesField on WGS84: 36 at 500 m and 36 at 3 km from (40, -74), one every 10 degrees. Sort all hits by newDistanceSort("point", 40, -74, 1000, WGS84), a 1 km maximum radius.
  2. You expect the 36 documents at 500 m first, each with its distance, about 500 m. The sort's javadoc gives +Infinity only to a document without the field.
  3. Instead only 19 come before every 3 km document: 17 get +Infinity and sort after the 14 documents 3 km away that got finite distances (with ties broken by doc ID, 22 of the first 36 hits are 500 m documents). newOutsideDistanceSort gives the same 17 an outside distance of up to 722 m instead of 0.0.
  4. Why: each stored point is read back a few metres (measured up to 4.5 m) above or below the ground. The circle's inside test is a flat plane only 8 cm below the ground at the centre, so a point read back more than 8 cm below the ground counts as outside, even at the centre.
  5. No error, every release since 6.1.0. It fades with the radius: from 20 km, documents well inside the circle sorted right in every sample, but a band near the rim (about 1.4 km wide at 20 km) still flips. Projecting the point back onto the surface before the test fixes it.

Who is affected

Applications that sort Geo3DDocValuesField hits (opt-in spatial3d module) by distance with a maximum radius below about 10 km; larger radii still mis-sort documents near the rim. Defaults and ordinary data suffice, and nothing fails. The field is rarely used. Workaround: a radius of 20 km or more, cutting in the application at no more than 0.9 x that radius.

Mechanism, terms and numbers919 words

Geo3DDocValuesField.newDistanceSort(field, lat, lon, maxRadiusMeters, planetModel) promises ascending distance in metres and +Infinity only for documents missing the field; newOutsideDistanceSort promises 0.0 for points inside the circle. Both comparators decode each doc value (21 bits per coordinate, a grid of about 6 m) to a point a few metres off the planet surface (measured up to 4.48 m) and pass it, unprojected, to the circle's membership test, a single plane that for a 1 km circle lies only about 8 cm below the surface at the centre. So whether the read-back point lands above or below that plane, not its distance, decides in or out: points read back more than about 8 cm below the surface get +Infinity (or a non-zero outside distance) even at the centre, and points read back well above it can be accepted kilometres beyond the rim. With WGS84 and a 1 km radius, 276 of 576 documents inside the circle get +Infinity while Geo3DPoint.newDistanceQuery over the same circle matches all 576; the outside sort gives inside documents up to 988 m. The sampled finite distances were within 4.5 m. For documents well inside the circle the error fades with the radius (125 of 576 at 5 km, 1 at 10 km, 0 at 20 km), but a band near the rim keeps flipping (at 20 km, 83 of 360 documents at 0.98 r get +Infinity), and has been in every release since 6.1.0 (LUCENE-7212, 2016). Projecting the decoded point onto the surface with PlanetModel.createSurfacePoint in the two comparators fixes it; the fix was validated with the repro, a wider independent test and the whole spatial3d module.

Terms

  • Geo3DDocValuesField (lucene/spatial3d): a per-document location for sorting. It stores x, y and z "21 bits each" in one long (Geo3DDocValuesField.java:57-58); one grid step is about 9.5e-7 planet radii, about 6 m (PlanetModel.java:644-653).
  • newDistanceSort: "orders documents by ascending distance from the location", the value is "the distance in meters", and "If a document is missing the field, then by default it is treated as having Double#POSITIVE_INFINITY distance" (Geo3DDocValuesField.java:167-171). maxRadiusMeters "is the maximum radius in meters" (:179).
  • newOutsideDistanceSort: "Points within the circle have distance 0.0" (:232-233).
  • Membership gate: a shape's distance is +Infinity unless isWithin(x, y, z) (GeoBaseDistanceShape.java:47-53); its outside distance is 0.0 only if isWithin (GeoBaseMembershipShape.java:47-53). For a circle, isWithin is one plane test (GeoStandardCircle.java:174-181).

The bug

  1. Both comparators decode the stored long into x, y, z with planetModel.getDocValueEncoder() and use that point as is: Geo3DPointDistanceComparator.java:114-116 and :127-131 (compareBottom), :172-177 (computeMinimumDistance, used by copy and compareTop); Geo3DPointOutsideDistanceComparator.java:92-101 and :142-147.
  2. Rounding each coordinate to its grid moves the point off the ellipsoid: up to 4.48 m radially and 4.94 m along the surface (measured); half a grid step per axis bounds the move at about 5.3 m.
  3. The plane of a cap of radius r lies R(1 - cos r) below the surface at the centre: about 7.8 cm for 1 km, 2.0 m for 5 km, 31 m for 20 km. A point read back e below the surface is inside only within about sqrt(r^2 - 2e) of the centre, which for a 1 km circle and an e of metres is nowhere; a point read back e above the surface passes out to about sqrt(r^2 + 2e), about 7.6 km for e = 4.5 m. (Small-angle, locally spherical estimates in planet radii; on WGS84 the circle is a plane-defined ellipse.)
  4. So whether each document's read-back point lands above or below the plane, fixed by its stored cell, decides in or out (a point read back only slightly below the surface can stay inside). Of 10,000 points well inside 1 km circles at 200 centres, 4,805 decoded points were rejected, all of them below the ellipsoid, and 0 after projecting them onto the surface.
  5. The geom package requires the opposite. The comparators call the (x, y, z) overload of computeDistance, documented as the distance "to the GeoPoint" (GeoDistance.java:41-46); a GeoPoint "represents a point on the surface of a sphere or ellipsoid" (GeoPoint.java:24), and its (x, y, z) tuple "must be on the desired ellipsoid" (:202-203). The user's points are on it; Lucene's own decode moves them off.

The numbers

Repro (WGS84, centre 40N 74W, 1 km): of 36 documents at 500 m, 19 sort before every 3 km document and 17 get +Infinity and sort after the 14 of 36 documents at 3 km that get finite distances (with ties broken by doc ID, 22 of the first 36 hits are 500 m documents); the outside sort gives the same 17 an outside distance above 1 m, the worst 722 m. 4 centres, 576 documents at most 700 m from the centre: 276 get +Infinity, 123 documents 2-3 km away get finite values, the worst outside distance is 988 m. The sampled finite values were within 4.5 m of the true distance (per-radius maxima 3.9-4.5 m). Near the rim the flips persist at every radius tested: at 20 km, 11, 83 and 120 of 360 documents at 0.95, 0.98 and 0.99 r get +Infinity and 88 of 360 at 1.02 r get finite distances; at 50 km, 92 of 360 at 0.998 r; with the fix, every ring tested is right. Documents exactly at the centre get +Infinity at 1 of 6, 1 of 6 and 2 of 6 centres on a true sphere, WGS84 and CLARKE_1866.

Exposure and likelihood415 words
  • Who is exposed. Users of the Geo3D doc-values sorts with an ordinary "nearest within a few km" radius. Defaults are enough: WGS84, the default codec and IndexWriterConfig, no special data and no index-size threshold (each document's flip depends only on its own stored cell; its rank also depends on doc IDs and the other hits). newPathSort end caps probably fail the same way (not executed).
  • What they see. No error. At 1 km, about half of the documents inside the radius sort as if they had no location; documents outside the radius take their places with finite distances; the outside sort reports inside documents as hundreds of metres outside. In one probe a 1 km top-50 page missed 27 of the 50 nearest documents (it held no document 1.5 km or more away: the order inside the radius degrades).
  • Scale by radius (576 documents at most 0.7 r from 4 centres, WGS84; documents near the rim are not in this sample):
maximum radiusinside docs with +Infinitydocs at 2r or more with a finite distance
250 m277150
1 km276123 (up to 3 km)
2 km25266 (up to 6 km)
5 km1250
10 km10
20 km00
  • Near the rim, at any radius. A rim probe (5 centres x 72 bearings per ring) found +Infinity for 11, 83 and 120 of 360 documents at 0.95, 0.98 and 0.99 r of a 20 km circle and finite distances for 88 of 360 at 1.02 r; at 50 km 92 of 360 at 0.998 r, at 100 km 5 of 360 at 0.998 r. The band is about e R / r wide (e the decode error, R the planet radius): about 1.4 km at 20 km.
  • Workaround until fixed. A maximum radius of 20 km or more with the application cutting by distance at no more than 0.9 x that radius (rim probe at 20 km: 0 of 360 wrong at 0.9 r, 11 of 360 at 0.95 r), or LatLonDocValuesField sorts.
  • Not affected. The hit set (a sort only orders the hits the query chose), stored data (the fix is query-time only, no reindex), LatLonDocValuesField sorts (all 36 near documents first on the same points).
  • How widely used. Rarely: no upstream test sorts with Geo3DDocValuesField, and a read-only code search found no use in Elasticsearch, OpenSearch or Solr. No report was found in the searched sources, so the likelihood is rated plausible.

Minimal repro

The test shows that Geo3DDocValuesField.newDistanceSort("point", 40, -74, 1000, WGS84) gives distance +Infinity to a document stored exactly at the sort centre, because its 21-bit decoded point lies below the surface and is tested against the circle's nearly tangent plane without being projected back.

TestGeo3DDistanceSortCentreDocument.java (JUnit test)55 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.spatial3d;

import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.FieldDoc;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.search.TopFieldDocs;
import org.apache.lucene.spatial3d.geom.GeoPoint;
import org.apache.lucene.spatial3d.geom.PlanetModel;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestGeo3DDistanceSortCentreDocument extends LuceneTestCase {

  public void testDocumentAtCentreGetsFiniteDistance() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      GeoPoint centre = new GeoPoint(PlanetModel.WGS84, Math.toRadians(40), Math.toRadians(-74));
      doc.add(new Geo3DDocValuesField("point", centre, PlanetModel.WGS84));
      w.addDocument(doc);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        SortField sortField =
            Geo3DDocValuesField.newDistanceSort("point", 40, -74, 1000, PlanetModel.WGS84);
        TopFieldDocs top =
            new IndexSearcher(reader).search(new MatchAllDocsQuery(), 1, new Sort(sortField));
        double distance = (Double) ((FieldDoc) top.scoreDocs[0]).fields[0];
        // expected: a finite distance (the document is at the centre); actual: +Infinity
        assertTrue("expected a finite distance near 0 m, but was " + distance, distance < 1000);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-247/, 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:$LUCENE/lucene/spatial3d/build/libs/lucene-spatial3d-$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.spatial3d.TestGeo3DDistanceSortCentreDocument
Expected vs actual
Expected: a finite distance near 0 m (the document is at the centre of the 1 km circle). Actual: Infinity, with -ea and -da.

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

More files, environment, full output39 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected a finite distance near 0 m, but was Infinity
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.spatial3d.TestGeo3DDistanceSortCentreDocument.testDocumentAtCentreGetsFiniteDistance(TestGeo3DDistanceSortCentreDocument.java:51)

Why it looks unintended

  • The javadoc promises ascending distance in metres, +Infinity only for a missing field, and 0.0 for "Points within the circle".
  • Geo3DPoint.newDistanceQuery over the same 1 km circle matches all 576 inside documents; the sort treats 276 as missing.
  • In 2016 the feature's author warned that small-circle in/out tests need full resolution; the shipped sort runs them on 21-bit points.
The full argument301 words
  • Contract. Geo3DDocValuesField.java:167-171: "This sort orders documents by ascending distance from the location. The value returned in FieldDoc for the hits contains a Double instance with the distance in meters." and "If a document is missing the field, then by default it is treated as having Double#POSITIVE_INFINITY distance (missing values sort last)." :179: "maxRadiusMeters is the maximum radius in meters." :232-233: "Points within the circle have distance 0.0." GeoDistance.java:28-35: distances are for points "expected to be INSIDE the shape", +Infinity "for points outside of the shape". GeoPoint.java:24 and :202-203: a point "on the surface", whose tuple "must be on the desired ellipsoid".
  • The sort disagrees with the matching query. The class javadoc pairs the field with a Geo3DPoint for queries (:43); over the same circle the query matches 576 of 576 inside documents and the sort gives 276 of them +Infinity.
  • No test. No upstream test calls newDistanceSort, newOutsideDistanceSort or newPathSort on Geo3DDocValuesField; TestGeo3DDocValues checks only the encoding, with a 0.02 tolerance (TestGeo3DDocValues.java:56).
  • History. In #8267 (LUCENE-7212, 2016-04-27) mikemccand asked: "Will the geo3d distance checking APIs be upset about the heavy quantization (21 bits per value, instead of 32 bits per value points is using)? I know we've had issues with "the point is not on the earth's surface" already with 32 bits per x/y/z." The author answered: "the resolution is quite important for determination if in-set/out-of-set for shapes like small circles. However, for distance measurement/ordering, I can't see any need to be that precise, so if the docvalues is only used for distance computation then 64 bits should be OK." The comparators committed three weeks later (2016-05-17/18) gate every distance on that in/out test. The same author proposed projecting quantized points onto the surface for the query tests (#8223, #8280).
The devil's-advocate check178 words

The checkers built the strongest "works as designed" case: the class WARNING admits "some loss of precision" (Geo3DDocValuesField.java:45-47); +Infinity is the documented result outside the shape and maxRadiusMeters bounds the work; for Geo3DPoint queries the quantized point is deliberately the truth (LUCENE-7168, LUCENE-7225); and LUCENE-7212 is still open with "tests" listed as still needed.

Why it fails: the WARNING's figures (4.19e-8 and 8.38e-8, millimetres if read as degrees) were copied from the 32-bit lat/lon fields, while the real error is about 5 m, and the failure is not imprecision but an in/out flip for documents hundreds of metres inside the circle, even at its centre, with finite values still right to a few metres. +Infinity is meant for documents beyond the radius, not inside it. The quantized-point rule keeps BKD cell relations consistent for queries; a per-document sort has no such constraint, and it disagrees with that very query. An open feature issue explains why nothing caught it, not a decision to accept wrong results in an API shipped since 6.1.0. Verdict: not intended, a defect.

Suggested fix

Recommended A: project the decoded point onto the surface in both comparators

Project each decoded point onto the surface with PlanetModel.createSurfacePoint before the bounds check and the distance call, in both comparators (a private helper, +38/-16 in two files, no index change). The repro, a wider 4-centre test (0 errors from 250 m to 50 km) and the whole spatial3d module (184 tests) pass with assertions on and off. Not benchmarked.

// compareBottom, before the bounds check:
final GeoPoint point = decodeSurfacePoint(encoded);
// computeMinimumDistance (likewise computeOutsideDistance):
distanceShape.computeDistance(DistanceStyle.ARC, decodeSurfacePoint(encoded));

private GeoPoint decodeSurfacePoint(long encoded) {
  final PlanetModel.DocValueEncoder encoder = planetModel.getDocValueEncoder();
  return planetModel.createSurfacePoint(
      encoder.decodeXValue(encoded),
      encoder.decodeYValue(encoded),
      encoder.decodeZValue(encoded));
}
All options and trade-offs4 options · 685 words

Option A was built and validated in a scratch worktree of a9779ef3c193; B, C and D come from the brief and the checkers and were not built.

A: project the decoded point onto the surface in both comparators (validated by the checks) (recommended)

Add a private decodeSurfacePoint(long) to Geo3DPointDistanceComparator and Geo3DPointOutsideDistanceComparator and use it in compareBottom (before the bounds check and the distance call) and in computeMinimumDistance. PlanetModel.createSurfacePoint (PlanetModel.java:417-431) scales the point along its ray onto the ellipsoid, which is what the geom package expects.

// compareBottom, before the bounds check:
final GeoPoint point = decodeSurfacePoint(encoded);
// computeMinimumDistance (likewise computeOutsideDistance):
distanceShape.computeDistance(DistanceStyle.ARC, decodeSurfacePoint(encoded));

private GeoPoint decodeSurfacePoint(long encoded) {
  final PlanetModel.DocValueEncoder encoder = planetModel.getDocValueEncoder();
  return planetModel.createSurfacePoint(
      encoder.decodeXValue(encoded),
      encoder.decodeYValue(encoded),
      encoder.decodeZValue(encoded));
}

Trade-offs

  • One square root, a few multiplications and one GeoPoint allocation per doc value read, on the sort's hot path: once the queue is full, compareBottom runs for every collected hit (TopFieldCollector.java:92-93, :203-209) and projects before its bounding-box rejection, and a competitive hit projects again in copy. Not benchmarked; an allocation-free variant scales x, y, z inline by the same factor (not built).
  • Finite distances move by at most a few metres; documents beyond the radius now get +Infinity as intended.
  • Fixes the doc-values sorts only; the same kind of error on the Geo3DPoint query path (32-bit points, tracked separately, likely behind #13056) needs its own fix, and near the rim the query and the sort can still disagree.
  • The two comparators also serve newPathSort and the outside box, polygon, large-polygon and path sorts (Geo3DDocValuesField.java:216-224, :281-373); only the circle sorts were tested directly. It does not fix the separate outside-distance bug for points outside a small circle on WGS84 (LFV-274).

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: stock + the new test, 2 of 3 fail with assertions on and off; + the fix, the new test with TestGeo3DDocValues and TestGeo3DPoint (3 suites, 21 tests, 2 skipped) pass, and with assertions off the new test with TestGeo3DDocValues (2 suites, 4 tests), the whole lucene/spatial3d module (20 suites, 184 tests, 3 skipped) passes with assertions on and off, and :lucene:spatial3d:check -x test (google-java-format, ecjLint, forbiddenApis) passes.
  • Standalone runner: the new test 3 of 3 with assertions on and off; the independent 4-centre test 5 of 5, with 0 wrong documents at every radius from 250 m to 50 km; the finder's test passes.
  • The finder's equivalent fix passed in every recorded run that applied it: the independent reproducer's run, the adjudication's run, the security probe and 6.1.0. A separate probe showed that projecting the points removes all 17 +Infinity values; the devil's advocate did not run a fix.

B: make the geom membership test robust to off-surface points

Project inside GeoStandardCircle.isWithin (or a shared helper), or give the plane test a tolerance scaled to the point's height above or below the surface.

Trade-offs

Could also fix the query path, but changes query semantics and must stay consistent with the shapes' bounds and getRelationship used by the BKD visitor. A change in GeoStandardCircle.isWithin reaches every circle user, queries included; a shared helper or a general plane tolerance reaches every shape, and a tolerance can admit points beyond the intended rim. Needs the full spatial3d random tests and review. Projecting at index time instead does not work: the per-axis quantization that follows moves the point off the surface again.

C: compare the arc distance from the centre with the radius

Skip the plane test for circles in the comparators.

Trade-offs

The comparators accept any GeoDistanceShape (paths too), so it needs a special case per shape, and on WGS84 a plain arc-distance test would not keep the plane-defined ellipse GeoStandardCircle uses; A is simpler.

D: document the small-radius and rim limits

Say that a small maxRadiusMeters gives unreliable results on WGS84 and that results near the rim are unreliable at any radius (for example: use at least 20 km and cut at no more than 0.9 x maxRadius).

Trade-offs

Not a fix; the radius is the user's choice and the javadoc gives no hint today.

Downstream note

The hit set never changes (filters, deny clauses and deletions still apply), so only an application that reads sort values as a membership test, such as "a finite distance means within the radius", would make wrong decisions from them.

How it was found

While modelling a sibling bug on the Geo3D query path, a solver-checked bound showed that a point read back e off the surface moves a small circle's edge to about sqrt(r^2 + 2e). For the much coarser 21-bit doc values it predicted kilometre-scale sort errors; a JUnit and a radius sweep on the stock jar confirmed them.

The method in full148 words

Method: a hypothesis from a model, then witnessed by a tool (semi-formal).

  • In the spatial3d hunt unit, a sibling candidate on the Geo3DPoint query path had an exact-rational solver model of a small cap tested on a quantized point; it bounds the effective rim at about sqrt(r^2 + 2e) for a radial error e.
  • The finder applied that bound to the doc-values grid (step about 9.5e-7 planet radii, radial error up to about 8e-7), which predicts a rim shift up to about sqrt(2 x 8e-7) = 1.3e-3 rad, about 8 km. The 21-bit prediction is a closed-form extrapolation of that model, not a separate solver run.
  • A radius sweep on the stock main jar (100 m to 20 km) and a public-API JUnit then showed the effect: 57-58 of 108 inside documents with +Infinity at 100 m and 1 km, 23 at 5 km, 0 at 20 km.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): 154 recorded queries over GitHub, the mailing lists, JIRA, downstream projects and the web, plus the files of all 413 open PRs. Closest is the 2016 feature issue #8267, where the author warned that small-circle in/out tests need full resolution.

Searches4 searches

No issue, PR, JIRA ticket, list thread or downstream report describes Geo3D doc-values sorts giving documents inside a small circle +Infinity or a non-zero outside distance, or the comparators testing the off-surface decoded point. 18 hits were read and classified; none is this bug.

Closest, not this bug: #8267 (LUCENE-7212, open): the 2016 design exchange quoted above, a warning made before the code existed (mikemccand's question there also notes earlier "the point is not on the earth's surface" issues with 32-bit points); if read as partial overlap the label would become known, which the cross-model fact check recommends. #13056 (open, 2024): a TestGeo3DPoint.testRandomBig failure, likely the same off-surface mechanism on the 32-bit query path. #8483 (LUCENE-7430): doc-values-resolution slop for the query visitor's bounding box only. #8223, #8280, #7817: off-surface quantized points and surface projection for queries and tests. #8343 (LUCENE-7288): defines +Infinity outside the shape. #10303 (LUCENE-9263): radius conversion, fixed in 2020; the bug reproduces after it. #14775 (open PR): adds inverseSort only.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), Elasticsearch, OpenSearch, Solr, dev@ / java-user@ / issues@ / commits@ lists, JIRA archive, web2026-10-26a scripted prior-report search: 51 queries (408 distinct hits), then a 24-query lists and JIRA pass (317 hits)#8267 (high) and unrelated items
GitHub (apache/lucene, solr, lucene-solr, elasticsearch, OpenSearch, all of GitHub)2026-10-2666 manual GraphQL searches#8267, #8366 and items judged different
JIRA (LUCENE, SOLR) and mailing lists2026-10-2610 JQL queries and 24 list queriesthe LUCENE-7212 and LUCENE-7312 notifications, unrelated threads
Open PRs2026-10-26the changed files of all 413 open apache/lucene PRsnone touches the comparators, Geo3DDocValuesField, GeoStandardCircle or PlanetModel; #14775 touches the sort fields only

History

Introduced with the sorts by LUCENE-7212 (07af00d8e7b, 2016-05-17; outside sort cc263413457, 2016-05-18), first released in 6.1.0; not a regression. A portable repro fails with identical counts on 13 releases from 6.1.0 to 10.5.2 and on main; 6.0.1 has no such field.

Versions tested6 rows

Introduced by LUCENE-7212 (@DaddyWri): 07af00d8e7bc "Add Geo3D sorted document fields." (newDistanceSort, Geo3DPointDistanceComparator) and cc263413457c "Add outside distance classes and methods." (newOutsideDistanceSort and its comparator), both decoding then testing from their first version; 6.1.0 shipped the branch_6x backports 8a407f0399c6 and aa339a2f0756, byte-identical. The 21-bit encoding (351878223ddb / 0e9ec477d76d, 2016-05-12) is also new in 6.1.0. A JIRA patch committed directly, no PR; #8267 is still open.

Later changes, none of which changed what reaches computeDistance: LUCENE-7430 (6.3.0, slop for the query visitor's box), LUCENE-7407 (iterator API), LUCENE-6228 (Scorable), LUCENE-9150 (getDocValueEncoder()), LUCENE-9263 (metres to radians) and LUCENE-9570 (formatting). Not fixed upstream. Every run used JDK 25.0.3; releases between the executed ones are inferred.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe brief's test 2 of 3 fail (gradle and the standalone runner, assertions on and off); the finder's and the reproducer's tests fail
10.5.2 affectedthe brief's test 2 of 3 fail; the finder's and the reproducer's tests fail; the portable main() fails, the 50 km control passes
9.12.3 affectedthe finder's and the reproducer's tests fail; the portable main() fails
6.1.0, 6.3.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 10.0.0 affectedthe portable main(): 17 of 36 at 500 m get +Infinity, 14 of 36 at 3 km finite, 17 of 36 outside distances above 1 m (worst 722 m)
6.1.0 + projection fix not affected0 wrong values; the 50 km control also passes on 6.1.0
6.0.1 no APIno Geo3DDocValuesField (compile error)

Independent checks

An independent reproducer, a devil's advocate and reviews by another model family agree on the cause. The reproducer reproduced it on WGS84 with its own test, the devil's advocate on three planet models. The adjudication confirmed it (high); a re-run on the newer main and a fact check reproduced it, the latter widening the scope near the rim.

Checks and verdicts431 words

Every executed check except a separate re-run (assertions on only) ran with assertions on and off. The reviews by another model family were reading only (they could not run Java); separate runs re-ran the tests.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateThe radius sweep and a public-API JUnit fail on main (20 of 20 seeds), 10.5.2 and 9.12.3; the projection fix passes.
cross-model review, refute lens procedural "not real" (low)An independent reviewer from another model family (OpenAI GPT-5.6) read the code but could not run Java, so it voted not real on procedure only; it agreed with the root cause, found the inputs in contract and rated the intended case weak.
Independent reproducer realOwn JUnit with an exact-surface-point oracle at 4 centres: 3 of 5 fail on main, 10.5.2 and 9.12.3, 30 of 30 derived seeds; all 4,805 rejected decoded points lie below the ellipsoid; the fix passes 5 of 5.
Cross-model review, intended lens real (medium)Reading only; root cause agreed. A separate probe: 17 of 36 documents at 500 m get +Infinity, all decoded below the surface; LatLonDocValuesField sorts all 36 first.
Devil's advocate real (high)Probe on a true sphere, WGS84 and CLARKE_1866: documents at the centre get +Infinity at some centres, 256 of 666 within 500 m; measured the decode error; rebutted each intended reading.
adjudication confirmed (high)Re-ran the reproducer's test with a new seed (3 of 5 fail, 5 of 5 with the fix) and the finder's test.
main refresh still reproducesSame failures on a9779ef3c193 as on ea57360912ed; no component file changed.
brief reproduced, fix validatedNew 3-test JUnit through gradle and the standalone runner; the fix with the module suite and static checks.
fact check reproduced; 4 correctionsRe-ran the documented gradle command (assertions on and off) and the standalone runner on main, 10.5.2 and 9.12.3, and the fix; a full-order probe (19 finite near, 14 finite far, 17 +Infinity near, 22 +Infinity far) and a rim probe showing that a band near the rim flips at 20, 50 and 100 km. Its four corrections (the 22, the 20 km bound, the assertions sentence, the fix runs) are applied.
cross-model fact check core bug real; 6 errorsAn independent reviewer from another model family (OpenAI GPT-5.6, reading only) confirmed the root cause and the recorded runs. Its corrections to this doc (the 22, the hot-path cost, the in/out wording, several overclaims) are applied; the novelty question and two registry fields are left open.

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,609 words

Finding record

LFV-247, snapshot of 2026-11-06

Registry title
Geo3DDocValuesField.newDistanceSort and newOutsideDistanceSort with a maximum radius below about 10 km test each document's 21-bit decoded point, which lies metres off the planet surface, against the circle's nearly tangent plane: about half the documents inside the circle, even at its centre, get distance +Infinity and sort last, documents up to about 8 km away get finite distances, and inside documents get outside distances of up to the radius instead of 0.0
Area
spatial3d / Geo3DDocValuesField distance sorts (Geo3DPointDistanceComparator, Geo3DPointOutsideDistanceComparator; GeoStandardCircle membership test on the decoded doc value)
Priority
Medium, rank 197: Geo3D doc-values distance sorts with an ordinary radius under 10 km silently mis-sort about half the in-radius documents, which matches LFV-209's spatial3d small-radius profile, but the field is little used.
Impact class
silent wrong results
Impact note
Applications that store locations in Geo3DDocValuesField (spatial3d module; a rarely used field: no upstream test sorts with it and a read-only code search found no use in Elasticsearch, OpenSearch or Solr) and sort with newDistanceSort or newOutsideDistanceSort and a maximum radius below about 10 km get a silently wrong order with the default WGS84 planet model, codec and IndexWriterConfig, ordinary data and any index size: at 1 km about half of the documents inside the radius (276 of 576) get +Infinity and sort as if they had no location, documents 2-3 km away get finite distances, and inside documents get outside distances of up to 988 m instead of 0.0 (125 of 576 still wrong at 5 km, 1 at 10 km, none at 20 km); the finite distances returned are accurate to a few metres, the hit set is never changed, nothing is persisted wrongly, and every release since 6.1.0 is affected.
Summary
You store locations with Geo3DDocValuesField on the default WGS84 model and sort hits with Geo3DDocValuesField.newDistanceSort(field, lat, lon, 1000, WGS84), expecting ascending distance from the centre with the distance in metres (and, with newOutsideDistanceSort, 0.0 for points inside the circle). Instead about half of the documents inside the 1 km radius, even ones 10 m from the centre, get +Infinity and sort last as if they had no location, some documents 2-3 km away get finite distances and sort ahead of them, and inside documents get outside distances of up to 988 m, while LatLonDocValuesField's distance sort orders the same points correctly. The cause: each doc value decodes to a point up to about 4.5 m off the planet surface, and the comparators pass that point unprojected to the circle's membership test, a single plane that for a 1 km circle lies only about 8 cm below the surface at the centre, so the decode error, not the distance, decides in or out.
Introduced
07af00d8e7bc4ce2820973e2ab511bfe536654c6 (master, 2016-05-17, 'LUCENE-7212: Add Geo3D sorted document fields.' by DaddyWri: Geo3DPointDistanceComparator, Geo3DPointSortField and newDistanceSort) and cc263413457c826914c3f3c1c26f885330a931aa (2016-05-18, 'LUCENE-7212: Add outside distance classes and methods.': Geo3DPointOutsideDistanceComparator and newOutsideDistanceSort); 6.1.0 shipped their branch_6x backports 8a407f0399c6 and aa339a2f0756; both comparators passed the raw decoded x,y,z to computeDistance/computeOutsideDistance from their first version; precondition 351878223ddb / backport 0e9ec477d76d (2016-05-12, 'LUCENE-7212: Add geo3d doc values field', the 21-bit-per-dimension encoding, also new in 6.1.0); committed from the JIRA patch, no pull request; LUCENE-7212 is apache/lucene#8267, still open [read: git log --follow, git blame and git show in the full-history clone; 6.1.0's two comparator files are byte-identical to the two commits; exec: 6.0.1 has no API (compile error), 6.1.0 fails] (2016-05-17 (newDistanceSort, Geo3DPointDistanceComparator); 2016-05-18 (newOutsideDistanceSort, Geo3DPointOutsideDistanceComparator))
First affected version
6.1.0 [exec] (release tag 2016-06-12; the first release with Geo3DDocValuesField and its sorts; fails with -ea and -da, the 50 km control passes and a shadowed surface-projection fix passes); 6.0.1 and earlier have no Geo3DDocValuesField [exec: compile error]
Affected versions
every release from 6.1.0 through 10.5.2 (the last release before 10.6.0) and main ea57360912ed. [exec] a version-neutral public-API main() (two API-era variants that differ in 3 calls) fails with identical counts on 6.1.0 (-ea and -da), 6.3.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (17 of 36 documents 500 m from the centre get +Infinity under a 1 km sort, 14 of 36 documents 3 km away get finite distances, 17 of 36 inside documents get an outside distance above 1 m); the finder's JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3, the independent reproduction's JUnit and the intended-behaviour check's probe on main (-ea, -da), 10.5.2 and 9.12.3. [read] the decode-then-test code in both comparators is unchanged since the 2016 commits: the later changes (LUCENE-7430 bounds slop, LUCENE-7407 iterator API, LUCENE-6228 Scorable, LUCENE-9150 PlanetModel API, LUCENE-9263 metres-to-radians, LUCENE-9570 formatting) did not change what reaches computeDistance, and the executed releases on both sides of each change fail the same way; unexecuted intermediate releases are inferred. Not fixed on main; no open PR touches the comparators (#14775 only adds inverseSort to the sort fields). Not affected: 6.0.1 and earlier (API absent) [exec]. All runs with JDK 25.0.3 on an aarch64 host.
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
Upstream and prior reports
none found (2026-10-26, the prior-report record, verdict new: 154 recorded queries, namely a scripted prior-report search over apache/lucene issues, PRs and commits (16 + 3), downstream repositories (2), the dev@, java-user@, issues@ and commits@ lists (20), the JIRA archive (4) and the web, plus 66 manual GitHub searches (apache/lucene, apache/solr, apache/lucene-solr, elastic/elasticsearch, opensearch-project/OpenSearch, all of GitHub), 10 JIRA queries (LUCENE, SOLR), 24 list queries, the changed files of all 413 open apache/lucene PRs and local history; 18 hits read and classified, none the same bug). Related, not reports: apache/lucene#8267 (LUCENE-7212, the open feature issue): in 2016, before the code existed, mikemccand asked whether the geo3d distance APIs would be 'upset about the heavy quantization (21 bits per value ...)' and DaddyWri answered that 'the resolution is quite important for determination if in-set/out-of-set for shapes like small circles' but that 21 bits are fine 'if the docvalues is only used for distance computation'; the shipped comparators make that in/out decision; a design-time remark, not a report. #13056 (open, 2024, TestGeo3DPoint.testRandomBig failure): the same off-surface mechanism on the 32-bit query path (registry LFV-209); a comparator fix would not touch it. #8483 (LUCENE-7430, 2016): added doc-values-resolution slop to the comparators' bounds pre-check only, not to the membership test. #8223, #8280, #7817 (2015-2016): off-surface decoded points and surface projection discussed for Geo3DPoint queries only. #8343 (LUCENE-7288): defines +Infinity for points outside the shape. #10303 (LUCENE-9263, radius conversion, fixed 2020): different; the bug reproduces after it. Not fixed upstream: the comparators are unchanged on main ea57360912ed and the only open PR touching the sort fields, #14775, leaves them alone. Gaps: list search is keyword-based; private security reports cannot be searched.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 release jars. Earlier runs on main ea57360912ed (identical spatial3d sources): the finder's, the verifiers' and the adjudication's tests, and a portable main() on 13 releases from 6.1.0 to 10.5.2. The fix was validated in the scratch worktree with the new test, the independent 4-centre test, the whole lucene/spatial3d module (184 tests, assertions on and off) and :lucene:spatial3d:check -x test. After the fact check corrected the test's failure message (same counts), the gradle and standalone runs on main and 10.5.2, with assertions on and off, and the fix runs were repeated on 2026-10-27.

Lucene and upstream sources

Finding record
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:97-134 (at ea57360912ed) (compareBottom: decodes x,y,z with planetModel.getDocValueEncoder() at :114-116, bounds pre-check, then distanceShape.computeDistance(DistanceStyle.ARC, x, y, z)), :163-182 (computeMinimumDistance: computeDistance on the raw decoded values, used by copy() and compareTop())
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideDistanceComparator.java:75-104 (at ea57360912ed) (compareBottom: decode at :92-94, computeOutsideDistance at :101), :133-152 (computeMinimumDistance: computeOutsideDistance on the raw decoded values at :143-147)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseDistanceShape.java:47-53 (at ea57360912ed) (computeDistance returns Double.POSITIVE_INFINITY when !isWithin(x, y, z))
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseMembershipShape.java:47-53 (at ea57360912ed) (computeOutsideDistance returns 0.0 only when isWithin(x, y, z))
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardCircle.java:175-181 (at ea57360912ed) (isWithin: a single circlePlane.isWithin test)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/PlanetModel.java:404-417 (at ea57360912ed) (createSurfacePoint, used by the fix)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:43 (at ea57360912ed) ('If you also need query operations, you should add a separate Geo3DPoint'), :45-47 (precision WARNING, 4.19e-8 / 8.38e-8), :56-57 (21 bits per dimension), :167-168 ('This sort orders documents by ascending distance from the location', distance in meters), :170-171 (+Infinity for documents missing the field), :179 ('maxRadiusMeters is the maximum radius in meters'), :232-234 (newOutsideDistanceSort: 'Points within the circle have distance 0.0')
The finding in 30 seconds
  • Lucene main a9779ef3c193 lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:97-134, 163-182
  • Lucene main a9779ef3c193 lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideDistanceComparator.java:75-104, 133-152
  • Lucene main a9779ef3c193 lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseDistanceShape.java:47-53; geom/GeoBaseMembershipShape.java:47-53; geom/GeoStandardCircle.java:174-181; geom/GeoPoint.java:202-203; geom/PlanetModel.java:644-653
  • Lucene main a9779ef3c193 lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:57-58, 167-171, 179, 232-233
  • Lucene main a9779ef3c193 lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoDistance.java:41-46; geom/GeoPoint.java:24
Why it looks unintended
  • Lucene main a9779ef3c193 lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:43, 45-47, 167-171, 179, 232-233; geom/GeoDistance.java:28-35; geom/GeoPoint.java:202-203
  • Lucene main a9779ef3c193 lucene/spatial3d/src/test/org/apache/lucene/spatial3d/TestGeo3DDocValues.java:56 (and a grep of lucene/ for the sort factories)
Suggested fix
  • Lucene main a9779ef3c193 lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/PlanetModel.java:417-431 (createSurfacePoint)
  • Lucene main a9779ef3c193 lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:92-93, 203-209; lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:216-224, 281-373

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

Repro files

About the diagrams

A picture of the mechanism and of the repro's numbers, made in parallel with this doc from the executed repro on main a9779ef3c193 and the code; the PNG is the summary card. The alt text describes the intended content.

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.