All findings · Lucene finding · LFV-746 · geo3d-distance-sort-outside-radius-tie-skips-secondary-sort

Geo3D distance and path sorts skip the secondary sort for hits outside the shape's bounding box

Geo3D distance sorts give every hit outside the radius distance +Infinity, so a second sort key must order them. Once they fill the page, a bounding-box shortcut rejects later out-of-box ones without asking that key: for 5 far-away documents the top 2 by tie are [1, 0], not [4, 3].

Summary

Priority
Low, rank 345Needs the little-used Geo3D distance or path sort with a secondary sort over a query wider than the radius.
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 (all 90 releases run) and main
Status
documented
Found by
bounded-exhaustive comparison of index layouts, then code reading

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

newDistanceSort orders hits by distance from a point; a hit outside maxRadiusMeters, like a document without the field, gets distance +Infinity. To save work, the comparator rejects a hit early when its point lies outside a bounding box built from the weakest distance in the top-n queue.

Diagram of the Geo3D distance-sort bug: a 1,000 km circle around (0, 0) and its bounding box, five documents far outside at latitude 60 with tie values 0 to 4, all at distance +Infinity. A top-2 page sorted by distance and then tie descending should hold documents 4 and 3; after documents 0 and 1 fill it, the bounding-box shortcut rejects documents 2, 3 and 4 as worse instead of tied, so the page holds documents 1 and 0. A document without the field, also at +Infinity, is compared on tie and can enter.
Five far-away documents sorted by distance, then by tie: once two fill the page, the bounding-box shortcut turns the rest away without comparing tie, so the page keeps documents 1 and 0 instead of 4 and 3. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 5 documents at latitude 60, about 6,700 km from (0, 0), with a second key tie = 0 to 4. Search MatchAllDocsQuery sorted by newDistanceSort around (0, 0) with a 1,000 km radius, then by tie descending.
  2. All 5 are outside the radius, so each gets distance +Infinity, the value a document without the field gets, and tie decides. The full result is [4, 3, 2, 1, 0], so you expect the top 2 to be [4, 3].
  3. Instead search(q, 2, sort) returns [1, 0]: the first two documents indexed, here the two with the lowest tie. No error, with assertions on or off.
  4. Why: once 2 hits fill the queue, its weakest distance is +Infinity. For each later hit whose point lies outside the circle's bounding box, the comparator answers "worse" (-1) instead of "tie" (0), so tie is never compared.
  5. A document without the field is tie-broken (add one with tie 5: [5, 1] instead of [5, 4]), hits inside the radius are compared normally, and newPathSort fails the same way. Every release since 6.1.0 has it.

Who is affected

Applications that use the Geo3D distance or path sort (lucene/spatial3d, opt-in) with a second sort key, over a query that also matches documents outside the radius: say "within 10 km nearest first, the rest newest first". Default settings, 5 documents are enough, no error. Pairing the sort with the matching distance query normally hides it.

Mechanism, terms and numbers732 words

Geo3DDocValuesField.newDistanceSort(field, lat, lon, maxRadiusMeters, planet) and newPathSort sort hits by ascending distance; a document without the field, and any hit outside the circle or path, gets distance +Infinity, so in new Sort(distanceSort, secondary) the secondary SortField must order all of them (Sort: "if it produces a tie, then the second SortField is used to break the tie"). Geo3DPointDistanceComparator keeps a bounding box built from the queue's weakest distance and lets compareBottom reject hits outside it without computing their distance. When that weakest distance is +Infinity, the box is the shape's own box, and compareBottom starts at -1 and skips every value outside it, so a later out-of-box hit returns -1 ("not competitive") although it ties at +Infinity. The secondary SortField is never asked, so earlier out-of-radius hits in doc order keep their places. Five documents at latitude 60 sorted around (0, 0) with a 1,000 km radius and a tie key descending give [1, 0] for the top 2, while the full order is [4, 3, 2, 1, 0]. Documents without the field and out-of-radius points inside the box are tie-broken correctly, and hits inside the radius (by their decoded doc-value point) are compared normally. The code is unchanged since the sorts were added in 6.1.0 (LUCENE-7212, 2016). Starting cmp at 0 when the bottom is +Infinity fixes it (a one-line change, validated with the repro and the whole spatial3d module).

Terms

  • Geo3D distance sort: Geo3DDocValuesField.newDistanceSort(field, latitude, longitude, maxRadiusMeters, planetModel) (lucene/spatial3d) "orders documents by ascending distance from the location"; "If a document is missing the field, then by default it is treated as having Double#POSITIVE_INFINITY distance (missing values sort last)" (Geo3DDocValuesField.java:164-192). newPathSort does the same for a path (:194-225); both build a Geo3DPointSortField whose comparator is Geo3DPointDistanceComparator (Geo3DPointSortField.java:44-46).
  • Outside the shape: the distance of a point outside the circle (or path) is +Infinity (GeoBaseDistanceShape.java:47-53), the same value as a missing document. FieldDoc.fields[0] reports Infinity for both.
  • Ties: new Sort(a, b) checks a first, "but if it produces a tie, then the second SortField is used to break the tie" (Sort.java:58-62).
  • The queue bottom: a top-n search keeps n hits in a queue; the weakest one is the bottom. compareBottom(doc) answers whether a new hit beats it: positive, 0 for a tie (then the next SortField decides), negative for worse (LeafFieldComparator.java:54-67).
  • The bounding-box shortcut: "When the least competitive item on the priority queue changes (setBottom), we recompute a bounding box representing competitive distance to the top-N. Then in compareBottom, we can quickly reject hits based on bounding box alone" (Geo3DPointDistanceComparator.java:33-40).

The bug

  1. The box for an +Infinity bottom. setBottom (Geo3DPointDistanceComparator.java:77-89) calls getDistanceBounds(bounds, ARC, bottomDistance); for +Infinity it returns the shape's own XYZ box (GeoBaseDistanceShape.java:80-87). A radius below the minimum angular resolution gives a GeoDegeneratePoint, which is not a GeoBaseDistanceShape but behaves the same way (GeoCircleFactory.java:43-46, GeoDegeneratePoint.java:174-187).
  2. The rejection. compareBottom (:97-134) starts with cmp = -1 (:108) and skips (continue) every value outside the box (:118-125). A hit whose points are all outside the box returns -1, although its distance, +Infinity, ties with the bottom. For a finite bottom the shortcut is right: such a point is outside the shape, so +Infinity is worse.
  3. The secondary sort is skipped. MultiLeafFieldComparator.compareBottom returns the first non-zero result (:52-64), and the collector drops the hit (TopFieldCollector.java:92-96).
  4. The inconsistency. A document without the field returns Double.compare(bottomDistance, +Infinity) = 0 (:101-103) and is tie-broken; an out-of-radius point inside the box computes its distance, +Infinity, also gets 0 and is tie-broken. Only out-of-box points are rejected.

The example, step by step

Docs 0-4 at (60, 0) with tie 0-4, top 2 by distance (1,000 km circle at (0, 0)), then tie descending. Docs 0 and 1 fill the queue; the bottom is doc 0 (+Infinity, tie 0). Docs 2, 3 and 4 lie outside the circle's box, so each returns -1 and is dropped. The page is [1, 0]; the full order, [4, 3, 2, 1, 0], starts with [4, 3].

Not affected: hits inside the radius (inside the box, finite distance), documents without the field, doc-id tie-breaking (the earliest documents win anyway), and the outside-distance sorts, whose comparator has no box.

Exposure and likelihood355 words
  • Who is exposed. Users of Geo3DDocValuesField.newDistanceSort or newPathSort (lucene/spatial3d, opt-in) with a secondary SortField whose query also matches documents outside the radius or path. No upstream test uses either factory.
  • When it shows. The comparator drops a hit it should keep when all three hold: (1) the queue is full and its bottom is +Infinity (fewer than n of the hits collected so far lie inside the radius); (2) a later hit has all its decoded points outside the shape's XYZ bounding box, which holds for most far-away points; (3) the later SortFields would rank that hit ahead of the bottom. The page is then wrong unless later hits would have pushed that hit out anyway. (3) never holds when the secondary key follows doc-id order; it does for a key that runs against doc order, such as "newest first" in an index built in time order. An index sort or a reordered index changes which hits arrive first.
  • The usual pairing hides it. With the matching Geo3DPoint.newDistanceQuery, out-of-radius hits normally do not match (read, not run; the query reads the indexed points and the sort the coarser doc values, so a hit right at the edge can still sort as +Infinity).
  • Worst case is ordinary. In an index built in time order, "then newest first" can keep old out-of-radius documents that came first and drop newer ones whose points lie outside the box (documents without the field, in-box points and in-radius hits are still compared and can displace the old ones). A small radius or a deep page makes an +Infinity bottom more likely.
  • Silent. No exception; totalHits and the hits inside the radius are right. Default IndexWriterConfig, codec and searcher; 5 documents in one segment are enough; assertions on or off; the JUnit runs got two different random codecs with the same result.
  • Layout-dependent. It was first seen as top-n results of an index-sorted index (1,200 documents per segment) that differ from a force-merged single-segment copy of the same documents.
  • How likely. Plausible: a little-used API, hidden by the usual pairing and by doc-id tie-breaking, and no report since 6.1.0 (2016).

Minimal repro

The test shows that Geo3DDocValuesField.newDistanceSort plus a secondary SortField returns the wrong top-N when all hits are outside the radius (all +Infinity): 5 WGS84 docs at latitude 60, radius 1,000 km around (0,0), secondary tie descending.

TestGeo3DDistanceSortOutsideRadiusTie.java (JUnit test)65 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 java.util.Arrays;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.spatial3d.geom.GeoPoint;
import org.apache.lucene.spatial3d.geom.PlanetModel;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestGeo3DDistanceSortOutsideRadiusTie extends LuceneTestCase {

  public void testTopNOutsideRadiusUsesSecondarySort() throws Exception {
    PlanetModel pm = PlanetModel.WGS84;
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (int i = 0; i < 5; i++) {
          Document doc = new Document();
          doc.add(new Geo3DDocValuesField("loc", new GeoPoint(pm, Math.toRadians(60), 0), pm));
          doc.add(new NumericDocValuesField("tie", i));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        Sort sort =
            new Sort(
                Geo3DDocValuesField.newDistanceSort("loc", 0, 0, 1_000_000, pm),
                new SortField("tie", SortField.Type.LONG, true));
        TopDocs full = searcher.search(MatchAllDocsQuery.INSTANCE, 100, sort);
        TopDocs top2 = searcher.search(MatchAllDocsQuery.INSTANCE, 2, sort);
        // all 5 docs are outside the radius (distance +Infinity): expected [4, 3], actual [1, 0]
        assertEquals(docs(full, 2), docs(top2, 2));
      }
    }
  }

  private static String docs(TopDocs td, int n) {
    return Arrays.toString(Arrays.stream(td.scoreDocs).limit(n).mapToInt(sd -> sd.doc).toArray());
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-746/, 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.TestGeo3DDistanceSortOutsideRadiusTie
Expected vs actual
Expected (Sort breaks the +Infinity tie with the second SortField): search(q, 2, sort) = first 2 hits of search(q, 100, sort) = [4, 3]. Actual: [1, 0] (expected:<[[4, 3]]> but was:<[[1, 0]]>).

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

More files, environment, full output34 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
    => org.junit.ComparisonFailure: expected:<[[4, 3]]> but was:<[[1, 0]]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.spatial3d.TestGeo3DDistanceSortOutsideRadiusTie.testTopNOutsideRadiusUsesSecondarySort(TestGeo3DDistanceSortOutsideRadiusTie.java:57)

Why it looks unintended

  • Sort promises that the second SortField breaks ties, and compareBottom must return 0 for equal values; the comparator's own compare calls these hits equal.
  • A document without the field, also +Infinity, is tie-broken; one outside the radius is not.
  • Out-of-radius points inside the circle's bounding box are tie-broken, others are not: an internal shortcut decides.
The full argument289 words
  • The Sort contract. "the first SortField is checked first, but if it produces a tie, then the second SortField is used to break the tie" (Sort.java:58-62). The full search orders out-of-radius hits by the secondary key, so the same Sort returns different documents for different n.
  • The comparator contract. compareBottom "should return the same result as FieldComparator#compare(int,int)", 0 if equal (LeafFieldComparator.java:54-67); compare uses Double.compare of the stored distances (Geo3DPointDistanceComparator.java:72-74), which gives 0 for two +Infinity values.
  • Two kinds of +Infinity. A document without the field returns 0 (:101-103); an out-of-radius document reports the same Infinity in FieldDoc.fields[0] but gets -1.
  • It depends on a box. In the repro, an out-of-circle point inside the circle's XYZ box competes on tie, and a far point with a better tie does not: [3, 1] instead of [2, 3] (executed). No intended rule could give that split.
  • The box is documented as a shortcut to "quickly reject hits" (:33-40); LUCENE-7212 describes the LatLon box it copies as rejecting "hits that won't compete anyway". A shortcut must not change results.
  • The LatLon twin already treats an +Infinity bottom as "any possible value competes". In LUCENE-7102 (#8157, fixed 2016-03; its trigger was missing-first sorting, and LatLon has no radius), @rmuir wrote "+Inf means we've filled the priority queue with only missing values, so any possible value competes", and the LatLon box was made unbounded for that case.
  • No precondition in the javadoc. "maxRadiusMeters is the maximum radius in meters" (Geo3DDocValuesField.java:179); nothing says out-of-radius hits come back unordered or that the query must stay inside the radius. No upstream test covers the Geo3D sorts; LUCENE-7212's last comment still lists "tests" as needed.
The devil's-advocate check248 words

The strongest case for "works as designed" (rated weak by the devil's advocate and by a reviewer from another model family): the sort was meant to run with the matching query. In LUCENE-7212 (#8267) its author explains that an unbounded Geo3D distance sort "would not perform reasonably", so "If you introduced an upper bound to the circle radius as an additional argument, then it would work fine without additional geo3d code", and "The arguments would have to correspond exactly, though, to \"newXXXQuery\", including distance bounds". The javadoc begins "Creates a SortField for sorting by distance within a circle" (Geo3DDocValuesField.java:165). On that reading, out-of-radius hits were never meant to be there. A maintainer could add that GeoBaseDistanceShape and LeafFieldComparator are marked @lucene.experimental.

Why it fails: the requirement lives in a 2016 design comment, not in the API docs, and the code does not enforce it. Any query is accepted, and out-of-radius hits get a defined value, +Infinity, the same as documents without the field, which the comparator does tie-break. Whether such a hit reaches the secondary key depends on an internal box and on doc order, so the result for one Sort changes with n and with the segment layout. Experimental status allows API changes; it does not lift the Sort and compareBottom contracts of the public factories. The fix costs one comparison and changes nothing inside the radius. A maintainer may still prefer to document the pairing; the two kinds of +Infinity would stay inconsistent.

Suggested fix

Recommended Start cmp at 0 when the bottom is +Infinity

In compareBottom, start cmp at 0 instead of -1 when the bottom distance is +Infinity: nothing can sort after such a bottom, and a value inside the shape is still compared and wins. A one-line change; the repro and the whole spatial3d module (188 tests) pass with assertions on and off. Not benchmarked.

     int numValues = currentDocs.docValueCount();
     assert numValues > 0;
 
-    int cmp = -1;
+    // Nothing sorts after an +Infinity bottom: the document ties with it (0), unless one of its
+    // values lies inside the shape (and so inside the bounds) and has a finite distance (1).
+    int cmp = bottomDistance == Double.POSITIVE_INFINITY ? 0 : -1;
     for (int i = 0; i < numValues; i++) {
       long encoded = currentDocs.nextValue();
 
All options and trade-offs4 options · 829 words

The shortcut is only wrong when the bottom is +Infinity, so both validated fixes return 0 instead of -1 in that case and leave everything else unchanged; they give identical results. Starting cmp at 0 is the shortest and tests the bottom once per document; the finder's version adds the same rule next to the shortcut and tests it once per out-of-box value. Skipping the box while the bottom is +Infinity (as the LatLon comparator does) would also work but computes every distance. Documenting "use with the matching distance query" changes nothing in the code and leaves the inconsistency; until a fix ships, that pairing is the workaround.

Start cmp at 0 when the bottom is +Infinity (validated by the checks) (recommended)

In Geo3DPointDistanceComparator.compareBottom, replace int cmp = -1; by a start value of 0 when bottomDistance is Double.POSITIVE_INFINITY (+3/-1 in one file). Every distance is at most +Infinity, so the document ties or wins; a value inside the shape is inside the shape's box, so it is still compared and gives 1. The finite-bottom path is unchanged.

     int numValues = currentDocs.docValueCount();
     assert numValues > 0;
 
-    int cmp = -1;
+    // Nothing sorts after an +Infinity bottom: the document ties with it (0), unless one of its
+    // values lies inside the shape (and so inside the bounds) and has a finite distance (1).
+    int cmp = bottomDistance == Double.POSITIVE_INFINITY ? 0 : -1;
     for (int i = 0; i < numValues; i++) {
       long encoded = currentDocs.nextValue();
 

Trade-offs

  • While the bottom is +Infinity, an out-of-box hit now reaches the secondary comparator instead of being rejected; if it wins, copy computes its distance and setBottom runs, which rebuilds the box on its first 1,024 calls and then on every 64th. A document without the field takes the same path today, minus the distance computation. Not benchmarked.
  • Results change only for out-of-radius hits, which were wrong.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: stock + the new test, 6 of 7 fail with assertions on and off; + the fix, the new test and TestGeo3DDocValues (8 tests) pass with assertions on and off, the whole lucene/spatial3d module (20 suites, 188 tests, 3 skipped) passes with assertions on and off, and :lucene:spatial3d:check -x test (ecjLint, forbiddenApis, checkGoogleJavaFormat) passes.
  • Standalone runner: 7 of 7 with assertions on and off.
  • No upstream test exercises a Geo3D sort, so the module run shows only that nothing else changes.

Treat an out-of-box value as a tie when the bottom is +Infinity (the finder's fix) (validated by the checks)

In the out-of-bounds branch, before continue: if (bottomDistance == Double.POSITIVE_INFINITY) { cmp = Math.max(cmp, 0); } (+5 lines: a 2-line comment and a 3-line branch). Same results as the first option, since a document has at least one value and every in-box value gives 0 or 1 against an +Infinity bottom.

           || y < priorityQueueBounds.getMinimumY()
           || z > priorityQueueBounds.getMaximumZ()
           || z < priorityQueueBounds.getMinimumZ()) {
+        // outside the bounds the distance is greater than bottomDistance, or +Infinity (outside
+        // the shape) when bottomDistance is itself +Infinity: then the document ties with it
+        if (bottomDistance == Double.POSITIVE_INFINITY) {
+          cmp = Math.max(cmp, 0);
+        }
         continue;
       }
 

Trade-offs

Same results as the first option, with +5 lines instead of +3/-1; it tests the bottom once per out-of-box value instead of once per document, but the rule sits next to the shortcut it corrects. The finder's original second comment line is 102 columns and fails checkGoogleJavaFormat; the version above rewords the comment to fit 100 columns ("the circle" became "the shape", "ties with the bottom" became "ties with it"); the code is unchanged.

What the checks validated

  • The new test 7 of 7 with assertions on and off; the whole lucene/spatial3d module (188 tests) with assertions on; :lucene:spatial3d:check -x test with the reworded comment.
  • Earlier: the finder's 4 tests (alone and on top of the LFV-551 and LFV-544 fixes), an independent reproducer's 7 tests with its equivalent fix, and a reviewer's 5-test box probe.

Skip the box while the bottom is +Infinity

In setBottom, use an unbounded box (or a flag) when bottomDistance is +Infinity, as the LatLon comparator does since LUCENE-7102. Not built.

Trade-offs

Same results, but every hit computes its distance while the bottom is +Infinity, which costs more than the first two options.

Document the pairing with the distance query

Add to newDistanceSort and newPathSort that hits outside the radius (path) are not ordered by later SortFields and that the sort should be used with the matching Geo3DPoint.newDistanceQuery (newPathQuery). Not built.

Trade-offs

No behaviour change; but compareBottom stays inconsistent with compare and with documents that lack the field, and results still depend on doc order and the segment layout. The pairing normally keeps out-of-radius hits out (read, not run); hits right at the edge can still sort as +Infinity because the query and the sort read differently encoded points.

How it was found

A bounded-exhaustive check indexed the same documents in many segment layouts and compared every sorted search with a force-merged single-segment copy. With an index sort, a Geo3D distance sort with a radius plus a tie-break returned different top hits; reading compareBottom gave the cause, and a 5-document test reproduced it.

The method in full115 words

Method: bounded-exhaustive layout translation validation on the stock main jars, then code reading.

  • The hunt unit on documented absent-field behaviour built a harness (AbsentFieldLayoutTV) that indexes the same documents in every segment layout it enumerates, plus an index-sorted variant, and compares each search with a force-merged single-segment reference.
  • Its probe Sort(distance with maxRadius, id) differed only in index-sorted layouts at 1,200 documents per segment, where doc order differs from the tie-break order (11 mismatching layouts).
  • An AI finder read compareBottom, found the bounding-box rejection under an +Infinity bottom, and reduced it to a 5-document single-segment JUnit without an index sort; independent AI tools then reproduced, reviewed and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): five scripted prior-report search runs over GitHub, the mailing lists, JIRA and downstream projects, 40 GitHub GraphQL searches, 9 JQL queries, 21 list queries and the files of all 408 open PRs. The closest items are the feature issue and same-class fixes in the LatLon and XY comparators.

Searches4 searches

No issue, PR, JIRA ticket, list thread or downstream report discusses Geo3D distance or path sorts rejecting out-of-radius hits under an +Infinity bottom, skipping the secondary sort, or treating out-of-radius and missing documents differently. The comparator on upstream main f7bcc0abd816 is byte-identical to the tested commit, and no open PR touches it or GeoBaseDistanceShape.java.

Closest, not this bug: #8267 (LUCENE-7212, open; created the comparator; 49 comments, none about ties at +Infinity), #8157 (LUCENE-7102, fixed 2016: the LatLon precedent, "any possible value competes"), #8240 (LUCENE-7185, a LatLon tie-breaker fix with a different cause), #8198 (LUCENE-7143, still open) and #10283 (LUCENE-9243, fixed in 8.5.0): the same failure class in the LatLon and XY boxes, neither mentions Geo3D; #8343 (LUCENE-7288, made outside distances +Infinity); #14775 (open; adds inverseSort() overrides to the Geo3D sort fields, not the comparator).

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-27five runs of a scripted prior-report search: the finder's (283 hits), identifiers (178), symptoms across the Geo3D, LatLon and XY comparators (534), feature history (272, #8267 at rank 1) and a web run (15)only #8267 and unrelated items
GitHub (apache/lucene, lucene-solr, solr, elasticsearch, OpenSearch, lucenenet, all of GitHub)2026-10-2740 GraphQL searches and 5 gh CLI searches#8267, #8157, #8240, #8198, #10283, #8343 and others judged different
Open PRs2026-10-27the changed files of all 408 open apache/lucene PRsnone touches the comparator or GeoBaseDistanceShape; #14775 touches the Geo3D sort fields only
JIRA (LUCENE, SOLR) and lists.apache.org2026-10-279 JQL queries; 21 list queries; the 49 comments of LUCENE-7212no report

History

Introduced with the Geo3D sorts by LUCENE-7212 (07af00d8e7b, branch_6x 8a407f0399c, 2016-05-17), first released in 6.1.0; not a regression. A portable repro fails on all 90 releases from 6.1.0 to 10.5.2 and on main, and passes with the fix.

Versions tested6 rows

Introduced by LUCENE-7212 "Add Geo3D sorted document fields." (@DaddyWri; 07af00d8e7b on master and 8a407f0399c on branch_6x, 2016-05-17; a JIRA patch committed directly, GitHub mirror #8267, still open). The commit added newDistanceSort (a circle overload and a path overload; the path one was renamed newPathSort the next day, before 6.1.0), Geo3DPointSortField and the comparator, with cmp = -1 and the continue from the start. It relies on 23422908165 (2016-04-19), which added getDistanceBounds returning the shape's box for the outside sentinel; LUCENE-7288 (a911eb85619, 2016-05-18; branch_6x twin ace1f7e3bde in 6.1.0) changed that sentinel from Double.MAX_VALUE to +Infinity, and the bug reproduces before that change too. Later commits changed the comparator but not its cmp/bounds logic (LUCENE-7407, LUCENE-6228, LUCENE-9150, LUCENE-9263, the LUCENE-9570 reformat in 2021). Not fixed upstream.

Caveats: every run used JDK 25.0.3; the commit-level runs paired the commit's spatial3d sources with the 6.1.0 core jar; the path sort was executed on 8.11.4 and later only.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe brief's test 6 of 7 fail (gradle and the standalone runner, assertions on and off); the portable main() fails with assertions on and off
10.5.2 affectedthe brief's test 6 of 7 fail (assertions on and off); the finder's and the independent reproducer's tests fail
6.1.0 to 10.5.2 (all 90 releases) affectedthe portable main(): top 2 [1, 0] instead of [4, 3], [5, 1] instead of [5, 4]; controls match
07af00d8e7b, 8a407f0399c (LUCENE-7212) introducingthe portable main() reproduces the bug (before LUCENE-7288 the sentinel is MAX_VALUE; still [1, 0])
6.0.0, 6.0.1; ba7698e4e70 (parent) no APIno Geo3D sort: compile error (6.0.x) or no newDistanceSort (parent)
main + fix not affectedthe repro passes; the whole lucene/spatial3d module, 188 tests, 0 failed

Independent checks

An independent reproducer, a devil's advocate and reviews by another model family agree on the cause; the reproducer and the devil's advocate reproduced it with their own tests, including the path sort and the box case. The adjudication confirmed it (high), and a registry re-run and an audit reproduced it.

Checks and verdicts421 words

Every executed check ran its main repro with assertions on and off, except a separate re-run (-ea only); a few extra runs were -ea only (the intended-lens stage's box probe, the devil's advocate on 10.5.2). The cross-model reviews ran read-only on the previous main ea57360912ed, whose spatial3d sources are identical; separate runs then re-ran the tests.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateThe layout harness flagged the probe; the 4-test public-API JUnit fails 3 of 4 on main (-ea, -da), 10.5.2 and 9.12.3 and passes with the fix; the LFV-551 and LFV-544 fixes alone do not help; a path probe gives [1, 0].
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, 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. A separate run re-ran the finder's JUnit with -ea only: 3 of 4 fail.
Independent reproducer realOwn JUnit from the claim only, outside org.apache.lucene: 6 of 7 fail on main (-ea, -da), 10.5.2, 9.12.3 and 8.11.4, including the path sort and the box case; its fix passes 7 of 7.
Cross-model review, intended lens real (medium)OpenAI GPT-5.6, read-only; a separate run then ran the finder's JUnit (3 of 4 fail, -ea, -da; 4 of 4 with the fix) and its own box probe (2 of 5 fail; 5 of 5 with the fix).
Devil's advocate realBuilt the "designed for the matching query" case and rejected it; own probe on main (-ea, -da) and 10.5.2: out-of-radius points inside the box are tie-broken, those outside it are not.
adjudication confirmed (high)Re-ran the independent reproduction's JUnit on main: 6 of 7 fail with -ea and -da; 7 of 7 pass with the independent reproduction's fix.
registry re-run and audit keepThe finder's JUnit fails 3 of 4 with -ea and -da (three seeds) and passes 4 of 4 with the fix; the independent reproduction's fails 6 of 7 with -da.
this doc's brief reproduced, two fixes validatedA new 7-test JUnit fails 6 of 7 on main (gradle and the standalone runner, -ea, -da) and 10.5.2; both fixes pass it, and the recommended one passes the whole spatial3d module with assertions on and off.

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

Finding record

LFV-746, snapshot of 2026-11-06

Registry title
Geo3D distance and path sorts (Geo3DDocValuesField.newDistanceSort, newPathSort) skip the secondary SortField for hits outside the shape's bounding box once the queue bottom is +Infinity, so top-N keeps the earliest out-of-radius documents in doc order instead of the first N of the sorted order (released since 6.1.0)
Area
spatial3d / Geo3D distance sort comparator (Geo3DPointDistanceComparator.compareBottom bounds shortcut; Geo3DDocValuesField.newDistanceSort and newPathSort)
Priority
Low, rank 345: Needs the little-used Geo3D distance or path sort with a secondary sort over a query wider than the radius.
Impact class
silent wrong results
Impact note
Applications that sort with Geo3DDocValuesField.newDistanceSort or newPathSort (spatial3d module, a little-used API: no upstream test uses any Geo3D sort factory) get a silently wrong page, with the default IndexWriterConfig, codec and searcher, any index size and with -ea or -da, when (1) the query also matches documents outside maxRadiusMeters (or outside the path), (2) a secondary SortField disagrees with doc-id order (a descending key, an index sort or a reordered index), (3) more such hits remain than free top-N slots and (4) their decoded points lie outside the shape's axis-aligned XYZ bounds (most far-away points; a point just outside a circle but inside its box is tie-broken correctly): the earliest out-of-radius documents in doc order are returned instead of the ones the secondary sort ranks first; hits inside the radius are always ordered correctly, and pairing the sort with the matching distance query hides the bug.
Summary
You sort a query with Sort(Geo3DDocValuesField.newDistanceSort(field, lat, lon, maxRadiusMeters, planet), secondary SortField) and expect search(q, n, sort) to return the first n hits of the sorted order: every hit outside the radius reports distance +Infinity (like a document without the field), so the secondary field should order them. Instead, five documents at latitude 60 sorted around (0,0) with a 1,000 km radius and a secondary 'tie' key descending give top 2 = [1, 0] although the full order is [4, 3, 2, 1, 0] (WGS84; the same on SPHERE with a 0.2 planet-radius circle; with a sixth document missing the field, [5, 1] instead of [5, 4]), and newPathSort does the same. The cause: once the queue bottom is +Infinity, compareBottom rejects (-1) every later document whose points lie outside the shape's bounding box instead of reporting a tie (0), so the secondary SortField is never consulted.
Introduced
07af00d8e7bc4ce2820973e2ab511bfe536654c6 on master and its branch_6x twin 8a407f0399c6575d6f4bb087f1d9fdc7d112e5d2, 'LUCENE-7212: Add Geo3D sorted document fields.' (JIRA LUCENE-7212, now apache/lucene#8267, still open; pre-GitHub patch on the JIRA issue, no pull request; committer GitHub DaddyWri). It added newDistanceSort, newPathSort, Geo3DPointSortField and Geo3DPointDistanceComparator, whose compareBottom starts at cmp = -1 and skips ('continue') every value outside priorityQueueBounds from the first version on. Preconditions from earlier commits: 23422908165f (LUCENE-7212, 2016-04-19) added getDistanceBounds, which returns the shape's own bounds for the outside sentinel; 688c67f8ff22 (LUCENE-6578, 2015) made computeDistance return that sentinel (then Double.MAX_VALUE), and a911eb856196 (LUCENE-7288, 2016-05-18) changed it to +Infinity. [read: git log --follow, git blame -w -M -C -C on setBottom/compareBottom and getDistanceBounds, git log -S in the history clone; exec: the bug reproduces at 07af00d8e7b and 8a407f0399c, before LUCENE-7288, and the branch_6x parent ba7698e4e70 has no newDistanceSort] (2016-05-17 (master and branch_6x))
First affected version
6.1.0 [exec] (tag commit 4726c5b2d2e, 2016-06-12), the first release with the Geo3D sorts; 6.0.0 and 6.0.1 have no Geo3DDocValuesField [exec: compile error], so no earlier release is affected
Affected versions
every lucene-spatial3d release from 6.1.0 through 10.5.2 (all 90 releases >= 6.1.0 in the Maven Central metadata) and main a9779ef3c193. Executed (JDK 25): the follow-up's portable main() fails on all 90 releases (-ea) and on main (-ea and -da), and passes on main with the fix shadowed; the finder's JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit (including the newPathSort case) fails on main and 10.5.2 (-ea, -da), 9.12.3 and 8.11.4 (a copy differing only in the LuceneTestCase import); the intended-behaviour check's probe fails on main and 10.5.2. Not fixed upstream: Geo3DPointDistanceComparator.java on upstream main f7bcc0abd816 is byte-identical to the tested commit (last commit 2cbf261032dc, 2021, a reformat), the same on branch_10x, and no open PR touches it or GeoBaseDistanceShape.java [read]. Caveats: all runs used JDK 25; the commit-level runs paired the commit's spatial3d sources with the 6.1.0 core jar; the path sort was executed on 8.11.4 and later only, older releases by reading.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-27 finder run and follow-up, read-only; the prior-report record, verdict new): five scripted prior-report searches (finder 51 queries and 283 hits; identifiers 51 and 178; symptoms across the Geo3D, LatLon and XY comparators 51 and 534; feature history 51 and 272, with #8267 found at rank 1; web ingest 15 hits) over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 40 GitHub GraphQL and 5 gh CLI searches; 9 JQL queries; 21 lists.apache.org queries; 5 web searches; the changed files of all 408 open apache/lucene PRs. Related, not reports: #8267 (LUCENE-7212, open; created the comparator and getDistanceBounds; 49 comments, the bounding box described as rejecting 'hits that won't compete anyway', no discussion of ties at +Infinity or of the secondary sort), #8157 (LUCENE-7102, fixed 2016-03: in the LatLon twin an +Infinity bottom was made to mean 'any possible value competes', a precedent), #8240 (LUCENE-7185, a LatLon tie-breaker fix with a different cause), #8198 (LUCENE-7143) and #10283 (LUCENE-9243), the same failure class in the LatLon and XY boxes, and #8343 (LUCENE-7288, made outside distances +Infinity); none mentions Geo3D, maxRadius or ties at +Infinity. Not fixed upstream: the comparator on upstream main f7bcc0abd816 is byte-identical to the tested commit and no open PR touches it or GeoBaseDistanceShape.java (#14775 only adds inverseSort overrides to the sort fields). Gaps: GitHub search does not index PR review comments or attachments; web search was throttled on 2 of 7 attempts (one retried); list search matches words, not phrases.
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 (assertions on and off). A portable main() ran on all 90 lucene-spatial3d releases from 6.1.0 to 10.5.2 and on the introducing commits; earlier JUnit tests ran on 9.12.3 and 8.11.4. Two fixes were validated in the scratch worktree: the new test, TestGeo3DDocValues, the whole lucene/spatial3d module (188 tests) and :lucene:spatial3d:check -x test.

Lucene and upstream sources

Finding record
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:33-40 (at a9779ef3c193) (class javadoc: setBottom recomputes a bounding box so compareBottom can 'quickly reject hits based on bounding box alone'), :77-89 (setBottom: priorityQueueBounds = distanceShape.getDistanceBounds(bounds, ARC, bottomDistance)), :97-133 (compareBottom: :102 a document without the field returns Double.compare(bottomDistance, +Infinity), i.e. 0 for an +Infinity bottom; :108 cmp = -1; :118-125 a value outside priorityQueueBounds is skipped with continue; :133 return cmp), :163-182 (computeMinimumDistance: +Infinity outside the shape)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseDistanceShape.java:47-53 (at a9779ef3c193) (computeDistance returns +Infinity when !isWithin) and :80-87 (getDistanceBounds: for distanceValue == +Infinity it returns the shape's own bounds)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardCircle.java:161-166 (at a9779ef3c193) (distanceBounds ignores the distance, 'TBD', and returns the shape's bounds)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoDistanceShape.java:27-39 (at a9779ef3c193) (getDistanceBounds may return bounds larger than the distance indicates, never smaller)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:164-192 (at a9779ef3c193) (newDistanceSort javadoc: ascending distance in meters in FieldDoc, missing documents +Infinity and sort last, maxRadiusMeters 'is the maximum radius', no other precondition) and :194-225 (newPathSort, same comparator)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointSortField.java:44-45 (at a9779ef3c193) (getComparator returns Geo3DPointDistanceComparator)
  • lucene/core/src/java/org/apache/lucene/search/Sort.java:58-63 (at a9779ef3c193) (Sort(SortField...): 'if it produces a tie, then the second SortField is used to break the tie')
  • lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java:54-67 (at a9779ef3c193) (compareBottom 'should return the same result as FieldComparator#compare(int,int)', 0 if equal)
  • lucene/core/src/java/org/apache/lucene/search/MultiLeafFieldComparator.java:52-62 (at a9779ef3c193) (a non-zero first comparator result returns at once; later comparators are not consulted)
The finding in 30 seconds
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:33-40, (at a9779ef3c193) :72-74, :77-89, :97-134 (:101-103, :108, :118-125), :137-139, :163-182
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseDistanceShape.java:47-53, (at a9779ef3c193) :80-87
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:164-192, (at a9779ef3c193) :194-225; Geo3DPointSortField.java:44-46; Geo3DPointOutsideDistanceComparator.java (setBottom stores the distance only; compareBottom computes every value)
  • lucene/core/src/java/org/apache/lucene/search/Sort.java:58-62, (at a9779ef3c193) LeafFieldComparator.java:54-67, MultiLeafFieldComparator.java:52-64, TopFieldCollector.java:92-96
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoCircleFactory.java:43-46, (at a9779ef3c193) GeoDegeneratePoint.java:174-187, GeoPathFactory.java:40-43 (degenerate shapes)
Who is affected
  • lucene/spatial3d/src/test/ (at a9779ef3c193) (no file mentions newDistanceSort, newPathSort or Geo3DPointSortField; grep); lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:122-128, :53 (points: Integer.BYTES per dimension), geom/PlanetModel.java:644-651 (doc values: 21 bits per dimension)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Sort.java:58-62, (at a9779ef3c193) LeafFieldComparator.java:54-67
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:33-40, (at a9779ef3c193) :72-74, :101-103; Geo3DDocValuesField.java:164-182
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseDistanceShape.java:23, (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java:41 (@lucene.experimental)
Suggested fix
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:82 (at a9779ef3c193) (setBottom sampling), :106 (assert numValues > 0); lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceComparator.java:88-113, :232-237

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

Repro files

About the diagrams

A step-by-step picture of the repro's first case, made in parallel with this doc from the executed repro on main a9779ef3c193 and the code; the PNG is the final 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.