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.
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.- Index 5 documents at latitude 60, about 6,700 km from (0, 0), with a second key
tie= 0 to 4. SearchMatchAllDocsQuerysorted bynewDistanceSortaround (0, 0) with a 1,000 km radius, then bytiedescending. - All 5 are outside the radius, so each gets distance +Infinity, the value a document without the field gets, and
tiedecides. The full result is[4, 3, 2, 1, 0], so you expect the top 2 to be[4, 3]. - Instead
search(q, 2, sort)returns[1, 0]: the first two documents indexed, here the two with the lowesttie. No error, with assertions on or off. - 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
tieis never compared. - A document without the field is tie-broken (add one with
tie5:[5, 1]instead of[5, 4]), hits inside the radius are compared normally, andnewPathSortfails the same way. Every release since 6.1.0 has it.
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).
- 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 havingDouble#POSITIVE_INFINITYdistance (missing values sort last)" (Geo3DDocValuesField.java:164-192).newPathSortdoes the same for a path (:194-225); both build aGeo3DPointSortFieldwhose comparator isGeo3DPointDistanceComparator(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]reportsInfinityfor both. - Ties:
new Sort(a, b)checksafirst, "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 box for an +Infinity bottom.
setBottom(Geo3DPointDistanceComparator.java:77-89) callsgetDistanceBounds(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 aGeoDegeneratePoint, which is not aGeoBaseDistanceShapebut behaves the same way (GeoCircleFactory.java:43-46,GeoDegeneratePoint.java:174-187). - The rejection.
compareBottom(:97-134) starts withcmp = -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. - The secondary sort is skipped.
MultiLeafFieldComparator.compareBottomreturns the first non-zero result (:52-64), and the collector drops the hit (TopFieldCollector.java:92-96). - 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.
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.newDistanceSortornewPathSort(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;
totalHitsand the hits inside the radius are right. DefaultIndexWriterConfig, 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).
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.
/*
* 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());
}
}
# 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 ofsearch(q, 100, sort)= [4, 3]. Actual: [1, 0] (expected:<[[4, 3]]> but was:<[[1, 0]]>).
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> 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)Sortpromises that the second SortField breaks ties, andcompareBottommust return 0 for equal values; the comparator's owncomparecalls 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 asFieldComparator#compare(int,int)", 0 if equal (LeafFieldComparator.java:54-67);compareusesDouble.compareof 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 sameInfinityinFieldDoc.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 bettertiedoes 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.
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.
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,
copycomputes its distance andsetBottomruns, 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 andTestGeo3DDocValues(8 tests) pass with assertions on and off, the wholelucene/spatial3dmodule (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/spatial3dmodule (188 tests) with assertions on;:lucene:spatial3d:check -x testwith 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.
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.
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.
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).
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).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), Elasticsearch, OpenSearch, Solr, dev@ / java-user@ / issues@ / commits@ lists, JIRA archive, web | 2026-10-27 | five 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-27 | 40 GraphQL searches and 5 gh CLI searches | #8267, #8157, #8240, #8198, #10283, #8343 and others judged different |
| Open PRs | 2026-10-27 | the changed files of all 408 open apache/lucene PRs | none touches the comparator or GeoBaseDistanceShape; #14775 touches the Geo3D sort fields only |
| JIRA (LUCENE, SOLR) and lists.apache.org | 2026-10-27 | 9 JQL queries; 21 list queries; the 49 comments of LUCENE-7212 | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the 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 | affected | the 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) | affected | the portable main(): top 2 [1, 0] instead of [4, 3], [5, 1] instead of [5, 4]; controls match |
07af00d8e7b, 8a407f0399c (LUCENE-7212) | introducing | the 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 API | no Geo3D sort: compile error (6.0.x) or no newDistanceSort (parent) |
| main + fix | not affected | the repro passes; the whole lucene/spatial3d module, 188 tests, 0 failed |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | The 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 | real | Own 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 | real | Built 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 | keep | The 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 validated | A 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 462 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,536 words
- 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 portablemain()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 wholelucene/spatial3dmodule (188 tests) and:lucene:spatial3d:check -x test.
- 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-182lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseDistanceShape.java:47-53, (at a9779ef3c193) :80-87lucene/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-96lucene/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-67lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:33-40, (at a9779ef3c193) :72-74, :101-103; Geo3DDocValuesField.java:164-182lucene/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
- TestGeo3DDistanceSortOutsideRadiusTie.java (JUnit test):
repro/LFV-746/src/org/apache/lucene/spatial3d/TestGeo3DDistanceSortOutsideRadiusTie.java(65 lines)
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.
assets/LFV-746/bug.png(462 KB)
