Each hit of a Geo3D sort carries its distance in meters, and searchAfter hands that number back as the cursor for the next page. Inside, the comparator keeps distances on a planet of radius 1 and forgets to convert when it compares a document with the cursor.
- Index six points 3 to 8 degrees north of (0, 0) on WGS84 and sort by
Geo3DDocValuesField.newDistanceSort("p", 0, 0, 2_000_000, PlanetModel.WGS84): the distances are 333,586 m, 444,778 m, 555,976 m and so on up to 889,558 m. - Page 1 (n = 2) holds documents 0 and 1. The last hit's
FieldDocvalue is 444,778.37, in meters, as the javadoc promises. - You call
searchAfter(lastHit, query, 6, sort)and expect the other four documents,[2, 3, 4, 5]. - Instead you get
[].compareTopcompares 444,778.37 with document 2's distance in planet units, 555,976 / 6,371,009 = 0.0873, finds the cursor bigger and skips the document as already collected; the same happens to documents 3, 4 and 5. totalHitsstill says 6 and nothing fails. OnPlanetModel.SPHERE(mean radius 1, so meters and planet units are the same number) the same paging is correct.
Applications that page any of the seven Geo3DDocValuesField sorts (opt-in spatial3d module) with IndexSearcher.searchAfter on WGS84 or CLARKE_1866: every later page once the cursor is beyond a few meters, at any index size, with the default codec and settings. Plain top-N search, TopDocs.merge, PlanetModel.SPHERE and the LatLon distance sort are not affected.
Mechanism, terms and numbers687 words
Geo3DDocValuesField.newDistanceSort and its six sibling factories (path, outside distance, outside box, outside polygon, outside large polygon, outside path) document that each hit's FieldDoc value is "a Double instance with the distance in meters", and IndexSearcher.searchAfter tells you to pass the previous page's bottom hit back as the cursor. Inside, the two comparators keep distances in planet units (distances on a planet scaled to mean radius 1): value(slot) multiplies by planetModel.getMeanRadius() to report meters, but compareTop compares the meters cursor with the raw planet-unit distance (Geo3DPointDistanceComparator.java:158-161, Geo3DPointOutsideDistanceComparator.java:128-131). On WGS84 and CLARKE_1866 (mean radius about 6,371 km) a cursor beyond a few meters (about 3.2 m for circle and outside sorts; a long path sort can need more) is larger than every finite planet-unit distance, so the paging collector skips every such document as "Already collected on a previous page". Every later page is then empty or holds only documents whose sort value is +Infinity; a smaller positive cursor gives a partial wrong page. The bug does not change totalHits, and nothing fails. PlanetModel.SPHERE pages correctly. The mismatch came with the feature (LUCENE-7212, 6.1.0) and is in every release through 10.5.2 and in main (executed on nine releases; compareTop and setTopValue unchanged in all 81 release tags scanned). Converting the document distance in compareTop exactly as value() does fixes it.
- Geo3D sorts:
Geo3DDocValuesField(modulelucene/spatial3d) has seven sort factories:newDistanceSort,newPathSort,newOutsideDistanceSort,newOutsideBoxSort,newOutsidePolygonSort,newOutsideLargePolygonSortandnewOutsidePathSort. Each says "The value returned in FieldDoc for the hits contains a Double instance with the distance in meters" (Geo3DDocValuesField.java:167-168,:197-199,:233-234,:264-265,:297-298,:323-324,:347-348). The first two useGeo3DPointDistanceComparator, the five outside sortsGeo3DPointOutsideDistanceComparator. - Planet units: spatial3d computes on a planet scaled to mean radius 1; a radius in meters becomes
radiusMeters / planetModel.getMeanRadius()(Geo3DUtil.java:159). The mean radius is 6,371,008.77 m for WGS84 (PlanetModel.java:35,:142) and 1.0 forPlanetModel.SPHERE(:31). - searchAfter: "Finds the top n hits for query where all results are after a previous result"; "By passing the bottom result from a previous page as after, this method can be used for efficient 'deep-paging'" (
IndexSearcher.java:682-686). The collector gives the cursor's sort values tosetTopValue(TopFieldCollector.java:249-254) and skips every document whosecompareTopis positive as "Already collected on a previous page" (:275-284).
copy(slot, doc)storesvalues[slot] = computeMinimumDistance(doc), an arc distance in planet units (Geo3DPointDistanceComparator.java:137-139,:163-182).value(slot)returnsvalues[slot] * planetModel.getMeanRadius(), meters, as documented (:152-156).setTopValuestores that meters value unchanged (:91-94).compareTop(doc)returnsDouble.compare(topValue, computeMinimumDistance(doc))(:158-161): meters on the left, planet units on the right.Geo3DPointOutsideDistanceComparatorhas the same four methods (:69-72,:106-109,:122-126,:128-131).- A document is skipped whenever its planet-unit distance is below the cursor's number of meters. For circle and outside sorts planet-unit distances are at most about pi, so a cursor beyond about 3.2 m skips every document with a finite distance; a path sort's distance includes the length of the path before the segment it is measured on (
GeoStandardPath.java:869-885,:1538-1549), so a long path raises that threshold. Documents whose sort value is +Infinity (no location, which sorts last, or outside a distance or path sort's shape) still compare correctly and survive. A cursor of exactly 0.0 or +Infinity happens to be right; a smaller positive cursor gives a partial wrong page.
Six documents at latitudes 3-8, longitude 0, newDistanceSort("p", 0, 0, 2_000_000, WGS84):
| document | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
distance, meters (FieldDoc value) | 333,586 | 444,778 | 555,976 | 667,170 | 778,364 | 889,558 |
distance, planet units (what compareTop sees) | 0.0524 | 0.0698 | 0.0873 | 0.1047 | 0.1222 | 0.1396 |
Page 1 is [0, 1]; the cursor is 444,778.37. compareTop compares 444,778.37 with 0.0873, 0.1047, 0.1222 and 0.1396: always positive, so page 2 is [] instead of [2, 3, 4, 5]. The core twin, LatLonPointDistanceComparator, applies the same conversion in value() and in compareTop (LatLonPointDistanceComparator.java:196-204).
Exposure and likelihood301 words
- Who is exposed. Users of the seven Geo3D sort factories (module
lucene/spatial3d, opt-in) who page withsearchAfterfrom the previous page'sFieldDoc, on WGS84 (the default planet ofGeo3DPoint) or CLARKE_1866. Before 8.5.0 the factories took no planet argument and always used WGS84. - No special setup. Default codec and
IndexWriterConfig; one segment or several (an independent repro used 2 segments); single-threaded or concurrent search (4 slices in the security probe); any index size. - Silent. No exception, and the bug does not change
totalHits(countHit()runs beforecompareTop), so an application shows "21 hits" and an empty or short page 2. Across 14 sort and query combinations of one probe, 198 expected hits went missing while every returned hit was correct. - Deterministic. Every later page, every time, once the cursor is beyond a few meters (for circle and outside sorts; a long path sort can need more).
- Not affected.
PlanetModel.SPHERE; plain top-N search andTopDocs.merge, which never callcompareTop;LatLonDocValuesField.newDistanceSort. - How likely. Plausible: no upstream test sorts with these fields, and a code search of the default branches of Elasticsearch, OpenSearch and Solr found no use (code search can miss uses); who uses them is otherwise unknown. An application that does page them loses hits on later pages.
- Workaround until fixed. For a point-distance sort,
LatLonDocValuesField.newDistanceSortpages correctly; it has no path or outside-shape sorts and measures haversine distance on a sphere, not on the Geo3D ellipsoid. Otherwise fetch a larger top-N instead of paging. Dividing the cursor by the mean radius works on today's code but breaks once the fix lands. A paging loop should stop on an empty page, not when it has seentotalHitshits: that keeps it from running on, but does not bring the lost hits back.
The test shows that paging a Geo3D distance sort (Geo3DDocValuesField.newDistanceSort, WGS84) with IndexSearcher.searchAfter loses hits: the cursor is in meters but compareTop compares it with the planet-unit distance.
/*
* 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.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.Sort;
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 TestGeo3DDistanceSortSearchAfter extends LuceneTestCase {
public void testSearchAfterReturnsNextHit() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
for (int lat = 1; lat <= 2; lat++) {
Document doc = new Document();
doc.add(
new Geo3DDocValuesField(
"p", new GeoPoint(PlanetModel.WGS84, Math.toRadians(lat), 0), PlanetModel.WGS84));
w.addDocument(doc);
}
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IndexSearcher searcher = new IndexSearcher(reader);
Sort sort =
new Sort(Geo3DDocValuesField.newDistanceSort("p", 0, 0, 1_000_000, PlanetModel.WGS84));
ScoreDoc last = searcher.search(MatchAllDocsQuery.INSTANCE, 1, sort).scoreDocs[0];
ScoreDoc[] page2 =
searcher.searchAfter(last, MatchAllDocsQuery.INSTANCE, 1, sort).scoreDocs;
// expected page 2 = [doc 1] after the doc-0 cursor (in meters); actual: no hits
assertEquals("hits on page 2", 1, page2.length);
assertEquals(1, page2[0].doc);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-544/, 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.TestGeo3DDistanceSortSearchAfter
- Expected vs actual
- Two points, page size 1: expected page 2 = [doc 1]; actual: no hits (
hits on page 2 expected:<1> but was:<0>), with-eaand-da.
More files, environment, full output37 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: hits on page 2 expected:<1> but was:<0>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.spatial3d.TestGeo3DDistanceSortSearchAfter.testSearchAfterReturnsNextHit(TestGeo3DDistanceSortSearchAfter.java:54)- Every factory documents the
FieldDocvalue in meters, andsearchAftersays to pass that hit back as the cursor. compareTopmust agree withcompare; a hit's own value fed back gives 1, not 0.- The LatLon twin converts in
compareTopon purpose, and a unit error in these comparators was fixed as a bug before.
The full argument271 words
- The documented unit is meters. All seven factories say so (
Geo3DDocValuesField.java:167-168and the six others); the class is public and has no@lucene.experimentaltag (:51). - searchAfter takes that value back. "By passing the bottom result from a previous page as after, this method can be used for efficient 'deep-paging'" (
IndexSearcher.java:685-686). - The comparator contract.
FieldComparator<T>.setTopValue(T)takes the typevalue(slot)returns and is "only called for searches that use searchAfter (deep paging)" (FieldComparator.java:70-83).LeafFieldComparator.compareTop"should return the same result as FieldComparator#compare(int,int) as if topValue were slot1 and the new document were slot 2" (LeafFieldComparator.java:69-80). An independent test fed a document's ownvalue(slot)back throughsetTopValueand gotcompareTop= 1 instead of 0. - The twin converts by design. LUCENE-7212 is titled "Add Geo3DPoint equivalents of LatLonPointDistanceComparator and LatLonPointSortField". For the twin, LUCENE-7103 (#8158,
1660b5630ae) introduced an internal sort key, and @rmuir wrote: 'We "do the rest" of the haversin calculation only when we need the final value: value() and compareTop()'.TestLatLonPointDistanceSortpages withsearchAfter(:250-261). The Geo3D port converts invalue()only. - Precedent. LUCENE-9263, "Fix wrong transformation of distance in meters to radians in Geo3DPoint", is listed under 8.5.0 Bug Fixes (
lucene/CHANGES.txt:5980,:6012) and changedvalue()in both comparators, notcompareTop. - Never tested. No test source references
Geo3DDocValuesFieldor its sort fields, and the 49 comments of #8267 never mentionsearchAfter,compareTop, paging or units. - Effect size. Page 2 holds 0 of 4 expected hits; with 13 documents in 2 segments and page size 3, paging returns
[0, 1, 2, 12]instead of[0..12].
The devil's-advocate check191 words
The strongest case for "not a bug": LUCENE-7212 (#8267) is still open, and its author's last comment lists tests as still needed; there is no CHANGES entry and no test, so the sorts could be called an unfinished, unannounced feature. FieldComparator is @lucene.experimental (FieldComparator.java:56), and so is PlanetModel (PlanetModel.java:26), which every factory takes from 8.5.0 on. Planet units are a deliberate hot-path choice, and a cursor divided by the mean radius by hand pages correctly, so one could claim compareTop is meant to take planet units.
Why it fails: the factories are public, documented in meters and shipped in every release since 6.1.0 (before 8.5.0 they took no PlanetModel and used WGS84); nothing documents a cursor conversion or a searchAfter restriction; the comparator contract requires a cursor taken from value() to compare equal to its own document; the twin converts on purpose; and a unit error in the same comparators was already fixed as a bug. Planet units inside the comparator are fine; only the missing conversion in compareTop is the defect. Verdict: not intended, a defect. LUCENE-7212 is still open and the sorts have no tests.
Recommended Convert the document distance in compareTop, as value() does
In both comparators, compareTop multiplies the document's distance by planetModel.getMeanRadius(), exactly as value() does, so a cursor compares equal to its own hit. Two lines; the repro passes 9 of 9 and the whole spatial3d test task (190 tests) passes with assertions on and off.
@Override
public int compareTop(int doc) throws IOException {
- return Double.compare(topValue, computeMinimumDistance(doc));
+ // topValue comes from value(slot), in meters: convert the document's distance the same way
+ return Double.compare(topValue, computeMinimumDistance(doc) * planetModel.getMeanRadius());
}All options and trade-offs4 options · 533 words
One fix was built and validated. It changes results for anyone who divided cursors by the mean radius by hand to work around the bug (undocumented, unlikely), which is worth a CHANGES line. It fixes the units only: multi-valued documents are still read twice per competitive hit, a separate bug in the same comparators with its own fix. Until a fix ships, LatLonDocValuesField.newDistanceSort pages correctly.
Convert the document distance in compareTop, as value() does (validated by the checks) (recommended)
One line in each of Geo3DPointDistanceComparator.compareTop and Geo3DPointOutsideDistanceComparator.compareTop (+4/-2 with a comment).
@Override
public int compareTop(int doc) throws IOException {
- return Double.compare(topValue, computeMinimumDistance(doc));
+ // topValue comes from value(slot), in meters: convert the document's distance the same way
+ return Double.compare(topValue, computeMinimumDistance(doc) * planetModel.getMeanRadius());
}Trade-offs
- Applies exactly the multiplication
value(slot)applies, so the cursor's own document compares equal (0) and the collector's tie rule (doc <= afterDoc,TopFieldCollector.java:276) keeps working for documents at the same distance; +Infinity stays +Infinity. - One multiplication per
compareTopcall, only onsearchAfterpages, next to a doc-values decode and a distance computation. Not measured. - Changes results for code that divided cursors by the mean radius by hand (undocumented).
- Units only: the separate multi-valued re-read bug in the same comparators needs its own fix; the hunt's random paging harness (seed 3, 1,100 trials, up to 40 documents) finds 0 mismatches in 7,248 checks with both fixes and 2,453 with only the other one; this fix alone leaves 1,070 mismatches and 853 exceptions.
What the checks validated
- Scratch worktree of main
a9779ef3c193, gradle, JDK 25: stock + the new test, 8 of 9 fail with assertions on and off; + fix, the new test passes 9 of 9 with assertions on and off. - + fix, the whole
lucene/spatial3dtest task: 20 suites, 190 tests (181 existing + 9 new), 3 skipped, 0 failures, with assertions on and off;:lucene:spatial3d:check -x test(ecjLint, forbiddenApis, checkGoogleJavaFormat) passes. - Standalone runner with the fix shadowed: 9 of 9 with
-eaand-da. - Earlier: the original repro 6 of 6, the independent reproducer's tests 10 of 10, the security probe drops 0 hits.
- No existing upstream test sorts with these fields, so the new test is the coverage.
topValue = value.doubleValue() / planetModel.getMeanRadius(); saves the per-document multiplication. Not built.
Trade-offs
Division does not exactly undo multiplication: with the WGS84 mean radius, (x * R) / R != x for 144,779 of 1,000,000 random distances in [0, 3.2]. For those the page-1 bottom hit no longer compares equal to its own cursor, so it could come back on page 2, or a document at the same distance with a higher doc id could be skipped (reasoned, not run in Lucene).
Multiply in copy() and compare meters everywhere. Not built.
Trade-offs
setBottom and compareBottom work in planet units (bounding boxes from getDistanceBounds(..., bottomDistance), Geo3DPointDistanceComparator.java:77-134), so they would all need the conversion: a bigger change for the same result.
Rejected.
Trade-offs
Contradicts the factories' meters javadoc and the comparator contract, and every caller would have to know about it.
A read-only code search of the default branches of Elasticsearch, OpenSearch and Solr found no use of these sorts (code search can miss uses). Affected are applications that page a Geo3D sort with searchAfter; who they are is otherwise unknown.
A metamorphic test: pages fetched with searchAfter must add up to the full sorted result. A random harness found mismatches only on Earth-shaped planet models; reading the comparator showed the missing unit conversion, and a public-API JUnit confirmed it. An earlier probe of another Geo3D bug had already seen an empty page 2.
The method in full161 words
Method: testing (metamorphic relations), then code reading.
- First signal. While probing a different Geo3D bug (LFV-274, outside distances of small circles), a control that paged a plain WGS84
newDistanceSortfive hits at a time returned 5 of 36 documents and then an empty page. It was recorded as an untriaged side finding. - Triage. A later hunt unit on sort-field pagination built a random harness with metamorphic relations, among them R1 (searchAfter pages concatenate to the full sorted result) and R4 (the same with concurrent search). The Geo3D sorts broke R1 and R4 on WGS84 only. Reading the comparator showed
value()converting to meters andcompareTopnot converting; with both Geo3D comparator fixes (this one and the separate multi-valued re-read fix) the harness found 0 mismatches in 7,248 checks (seed 3, 1,100 trials). - An AI finder then reduced it to a 6-test public-API JUnit, and independent AI tools and reviewers from another model family reproduced, reviewed and judged it (see the checks).
No prior report found (2026-10-26, rechecked 2026-10-27): two scripted prior-report search runs and 98 manual queries over GitHub, JIRA, the mailing lists, downstream projects and the web, plus the files of all 407 open PRs. The closest items created the comparators (LUCENE-7212) or changed their value() but not compareTop (LUCENE-9150, LUCENE-9263).
Searches5 searches
No issue, PR, JIRA ticket, list thread, commit, downstream issue or web page describes the Geo3D comparators comparing a meters cursor with a planet-unit distance, or any searchAfter failure of the Geo3D sorts. Upstream main f7bcc0abd816 (2026-10-27) still has the unconverted compareTop in both comparators; the last commit to either file is the 2021 reformat 2cbf261032dc.
Closest, not this bug: #8267 / LUCENE-7212 (open; created the comparators; no paging or units discussion in 49 comments), apache/lucene-solr#1318 / #10303 / LUCENE-9263 (a different meters-to-radians error; in the comparators it changed only value()), #10190 / LUCENE-9150 (added the planet argument and changed the value() multiplier), #8158 / LUCENE-7103 and #8154 / LUCENE-7099 (the LatLon twin converts in compareTop and has a searchAfter test), open PRs #16857 ("Consolidate Sort pruning logic", a generic refactor in lucene/core) and #14775 (inverseSort on the Geo3D sort fields), neither touching the Geo3D comparators.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream repositories, dev@ / java-user@ / issues@ / commits@ lists, JIRA archive, web | 2026-10-26 | two runs of a scripted prior-report search: 68 queries (class, method and symptom terms) and 51 (distance sort with compareTop, setTopValue, searchAfter; the LatLon twin) | #8267 and generic comparator-API items; none about this |
| GitHub (apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, Lucene.NET, all of GitHub) | 2026-10-26 | 58 manual REST and GraphQL searches; upstream path history and contents of main and branch_10x | only #8267 names the comparator; compareTop still unconverted on main |
| JIRA archive (LUCENE, SOLR) and lists.apache.org | 2026-10-26 | 9 JIRA queries and 5 issue reads; 23 list queries | LUCENE-7212 notifications and one 2018 API-organisation thread |
| Open PRs | 2026-10-26 | the changed files of all 407 open apache/lucene PRs | none touches either comparator; #14775 touches the Geo3D sort fields (inverseSort only) |
| GitHub (apache/lucene) | 2026-10-27 | issues and PRs updated since 2026-10-26 matching Geo3D, geo3d sort, searchAfter distance, compareTop, spatial3d | none |
Introduced with the feature in LUCENE-7212 (07af00d8e7bc and cc263413457c, May 2016), first released in 6.1.0; compareTop and setTopValue never changed since. Executed on 6.1.0, 6.6.6, 7.0.0, 7.7.3, 8.4.1, 8.5.0, 8.11.4, 9.12.3, 10.5.2 and main: page 2 is lost everywhere.
Versions tested9 rows
Introduced by LUCENE-7212 "Add Geo3D sorted document fields" (07af00d8e7bc, 2016-05-17: distance and path sorts) and "Add outside distance classes and methods" (cc263413457c, 2016-05-18: outside sorts), committed from JIRA without a PR; the issue is now #8267 and still open. In the first commit value() already returned values[slot] * PlanetModel.WGS84_MEAN while compareTop compared the raw distance, and the meters javadoc came in the same commit, so it is not a regression. The branch_6x copies 8a407f0399c6 and aa339a2f0756 shipped in 6.1.0. LUCENE-9150 (authored 2020-02-07, committed 2020-03-02; master a6e80d004d8, in 8.5.0 as ab6fb77c63d) and LUCENE-9263 (master 03c25576819, in 8.5.0 as f4737e5974d) changed the value() multiplier and left setTopValue and compareTop untouched; LUCENE-9150 also gave the comparators a PlanetModel field and constructor argument and planet-specific doc-value decoding. A scan of 81 release tags (162 comparator file versions) finds compareTop and setTopValue unchanged from 6.1.0 to 10.5.2. Not fixed on upstream main.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the new JUnit 8 of 9 fail (gradle and the standalone runner, assertions on and off); the original JUnit 5 of 6 |
main ea57360912ed | affected | probe: 8 of 8 Earth-model sorts lose page 2; the original and the independent JUnits fail (assertions on and off) |
| 10.5.2 | affected | the new JUnit 8 of 9 fail (assertions on and off); probe 8 of 8 |
| 9.12.3 | affected | the new JUnit 8 of 9 fail; probe 8 of 8 |
| 8.5.0, 8.11.4 | affected | probe: 8 of 8 Earth-model sorts lose page 2; SPHERE and hand-divided-cursor controls pass |
| 6.1.0, 6.6.6, 7.7.3, 8.4.1 | affected | pre-8.5 probe: 7 of 7 sorts lose page 2; the hand-divided cursor pages correctly |
| 7.0.0 | affected | the original probe (distance sort only) loses page 2 |
| 6.0.1 | API absent | no Geo3DDocValuesField; the probe does not compile |
| main + fix | not affected | the new JUnit 9 of 9; the whole lucene/spatial3d test task, 190 tests, 0 failed |
An independent reproducer and a devil's advocate reproduced it and passed with the fix, and reviews by another model family, which could not run code, agreed on the cause (one of them also on the fix); the adjudication confirmed it (high) after re-running the independent tests, and a registry re-run and an audit reproduced it with fresh seeds.
Checks and verdicts412 words
Each check that ran code used the stock jars with assertions on and off. The reviewers from another model family had read-only tools; separate runs re-ran the original JUnit.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Random metamorphic harness: paging mismatches on WGS84 only, 0 with both Geo3D fixes; 6-test public-API JUnit fails 5 of 6 on main (-ea, -da), 10.5.2 and 9.12.3; probes fail on 6.1.0 to 8.11.4. |
| cross-model reviews before the candidate | real (high); refute lens: not real (low), procedural only | An independent reviewer from another model family (OpenAI GPT-5.6) agreed with the root cause and the fix; the refute-lens run agreed on the root cause and returned not real only because it could not run code itself, leaving the fix unconfirmed; a separate run re-ran the JUnit (5 of 6 fail). |
| Independent reproducer | real | Own JUnits (13 documents, 2 segments, page size 3; a round trip of a hit's own value) fail 7 of 10 on main (-ea, -da), 10.5.2 and 9.12.3; 3 controls pass; own fix 10 of 10; added the outside polygon sort. |
| Cross-model review, works-as-designed lens | real (high); intended argument weak | OpenAI GPT-5.6: root cause agrees, inputs in contract; the reviewer could not run code and left the fix verdict open; separate runs fail 5 of 6 on main and 10.5.2 and pass with the fix. |
| Devil's advocate | real (high) | Built the strongest not-a-bug case (open feature issue, no tests, experimental comparator API, hand-divided cursor works) and rejected it; own probe with a Geo3DPoint distance query gives an empty page for every cursor; controls pass. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's JUnits with a new seed: 7 of 10 fail on main (-ea, -da) and 10.5.2; 10 of 10 with the fix. |
| Follow-up | history, prior art, security | All seven sorts on 9 versions from 6.1.0 to main; 217 prior-art queries, verdict new; security probe: 198 dropped hits, 0 filtered, deleted or duplicate hits returned. |
| registry re-run and audit | keep | Fresh seeds on main, 10.5.2 and a9779ef3c193; the audit's own 20-document probe pages [0, 1, 2, 3] instead of [0..19], and passes with the fix. |
| this doc's brief | reproduced, fix validated | A 9-test JUnit covering all seven sorts fails 8 of 9 on main, 10.5.2 and 9.12.3; with the fix it passes, and so does the whole spatial3d test task. |
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 435 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,472 words
- Registry title
- Geo3D distance, path and outside-distance sorts (Geo3DDocValuesField) drop hits under IndexSearcher.searchAfter: compareTop compares the cursor, which is in meters, with the document's distance in planet units, so on WGS84 every page after the first loses all finite-distance hits (released since 6.1.0)
- Area
- spatial3d / Geo3DDocValuesField sort comparators (Geo3DPointDistanceComparator, Geo3DPointOutsideDistanceComparator: compareTop under searchAfter paging)
- Priority
- Medium, rank 192: On WGS84, searchAfter paging of Geo3D sorts drops every finite-distance hit after page one, deterministic and silent but needing the little-used sort plus paging.
- Impact class
- silent wrong results
- Impact note
- Applications that sort with any of the seven Geo3DDocValuesField sort factories of the opt-in spatial3d module (newDistanceSort, newPathSort, newOutsideDistanceSort, newOutsideBoxSort, newOutsidePolygonSort, newOutsideLargePolygonSort, newOutsidePathSort; no upstream test sorts with them and a read-only code search found no use in Elasticsearch, OpenSearch or Solr) and page with IndexSearcher.searchAfter from the previous page's FieldDoc get silently incomplete pages with the default WGS84 planet model (or CLARKE_1866), codec and IndexWriterConfig, at any index size: once the cursor is more than a few meters away, every later page holds only documents whose sort value is +Infinity (no location, or outside a distance sort's shape) and all other matching hits are never returned; totalHits stays the full count, no exception, nothing persisted; PlanetModel.SPHERE (mean radius 1), plain top-N search and TopDocs.merge are unaffected; every release from 6.1.0 to 10.5.2 and main.
- Summary
- You sort by Geo3DDocValuesField.newDistanceSort (or the path or outside-distance sorts) on the default WGS84 planet model, take the last FieldDoc of page 1 and call IndexSearcher.searchAfter(last, query, n, sort), expecting the next hits in distance order, as searchAfter documents. Instead the comparator's value() returns the distance in meters (as the factories' javadoc says), but compareTop compares that meters cursor with the document's distance in planet units (distances on a planet of mean radius 1), so every document with a finite distance looks 'already collected on a previous page' and is skipped. With six documents 3 to 8 degrees north of the origin and page size 2, page 2 should be [2, 3, 4, 5] and is []; on PlanetModel.SPHERE, where meters and planet units coincide, the same paging is correct.
- Introduced
- 07af00d8e7bc4ce2820973e2ab511bfe536654c6 (LUCENE-7212 'Add Geo3D sorted document fields', now apache/lucene#8267, still open; distance and path sorts) and cc263413457c826914c3f3c1c26f885330a931aa (LUCENE-7212 'Add outside distance classes and methods'; outside sorts); the branch_6x copies 8a407f0399c6 and aa339a2f0756 are the commits in 6.1.0; no PR (committed from JIRA) [read: in 07af00d8e7b value() already multiplied by PlanetModel.WGS84_MEAN while compareTop compared raw; git blame on main puts the compareTop and setTopValue lines of both comparators on these two commits; LUCENE-9150 (a6e80d004d8) and LUCENE-9263 (03c25576819, apache/lucene-solr#1318) later changed only the value() multiplier]. Not a regression: the meters javadoc and the mismatch arrived in the same commit (2016-05-17 (distance and path comparator); 2016-05-18 (outside-distance comparator))
- First affected version
- 6.1.0 [exec] (first release with the Geo3D sort API; 6.0.1 [exec] has no Geo3DDocValuesField, the probe does not compile)
- Affected versions
- Every release from 6.1.0 through 10.5.2, and main ea57360912ed and a9779ef3c193; never fixed (upstream main still has the unconverted compareTop at Geo3DPointDistanceComparator.java:159-161, and the last upstream commit to either comparator is the 2021 LUCENE-9570 reformat, both checked read-only on 2026-10-26). [exec] the follow-up's version-portable main() probes (all seven factories; from 8.5.0 also CLARKE_1866 and a SPHERE control) lose every page-2 hit on 6.1.0, 6.6.6, 7.7.3, 8.4.1, 8.5.0, 8.11.4, 9.12.3, 10.5.2 and main, while a cursor hand-divided by the mean radius pages correctly on all of them; the finder's main() probes also fail on 7.0.0; the finder's JUnit fails 5 of 6 (SPHERE control passes) on main (-ea and -da), 10.5.2 and 9.12.3; the independent reproduction's JUnits fail 7 of 10 (3 controls pass) on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's probe fails 2 of 5 (3 controls pass) on main (-ea and -da), 10.5.2 and 9.12.3; re-runs on main, 10.5.2 and a9779ef3c193 agree. [read] a scan of 81 release tags (162 comparator file versions, 6.1.0-10.5.2) finds compareTop and setTopValue unchanged in every one; the history clone lacks the 8.8.2-8.11.4 tags (8.11.4 executed). Releases older than 9.12.3 were executed with main() probes, not the JUnit
- 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
- non-formal
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record verdict new: 217 query records (212 distinct), namely the scripted prior-report search in two runs (68 and 51 records over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web) plus 98 manual records (GitHub REST and GraphQL searches over apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet and all of GitHub; JIRA LUCENE and SOLR queries and issue reads; lists.apache.org queries, 2 of which hit a JSON parse error and were re-run; upstream path history and contents of main and branch_10x; the changed files of all 407 open apache/lucene PRs); also the finder's earlier 51-query run and the intended-behaviour check's 43-query run; 20 hits read, none the same bug). Closest items, not reports: #8267 / LUCENE-7212 (the open feature issue that created the comparators; 49 comments, no paging or units discussion), apache/lucene-solr#1318 / LUCENE-9263 (#10303; fixed a different meters-to-radians error and changed only value()), #10190 / LUCENE-9150 (changed the value() multiplier), #8158 / LUCENE-7103 and #8154 / LUCENE-7099 (the LatLon twin converts in compareTop and got a searchAfter test), #16857 (merged 2026-10-29 as 70a428b5ba9 on main and 203699279f5 on branch_11x, not on branch_10_6; generic sort pruning refactor, no spatial3d file) and open PR #14775 (inverseSort on the Geo3D sort fields), neither touching compareTop
- 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 (assertions on and off) and 9.12.3 (assertions on) release jars. Earlier, the hunt and its checks ran on mainea57360912ed, whose spatial3d comparators are identical, and version-portable probes ran on 6.0.1 (API absent), 6.1.0, 6.6.6, 7.0.0, 7.7.3, 8.4.1, 8.5.0, 8.11.4, 9.12.3 and 10.5.2. The fix was validated in the scratch worktree: the new 9-test JUnit with assertions on and off, the wholelucene/spatial3dtest task (190 tests) with assertions on and off, and:lucene:spatial3d:check -x test.
- Finding record
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:92-94 (at ea57360912ed) (setTopValue stores the FieldDoc value as given), :138 (copy: values[slot] = computeMinimumDistance(doc), planet units), :153-156 (value(slot) = values[slot] * planetModel.getMeanRadius(), meters), :159-161 (compareTop = Double.compare(topValue, computeMinimumDistance(doc)): meters vs planet units)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideDistanceComparator.java:70-72, (at ea57360912ed) :108, :123-126, :129-131 (the same four methods, the same mismatch)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:165-168, (at ea57360912ed) :195-198, :230-234, :261-265, :294-298, :320-324, :344-348 (each sort factory: 'The value returned in FieldDoc for the hits contains a Double instance with the distance in meters'), :183, :216, :249, :281, :311, :337, :365 (the seven public factories)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DUtil.java:159 (at ea57360912ed) (radiusMeters / planetModel.getMeanRadius(): meters to planet units on input)lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:250-254 (at ea57360912ed) (setTopValue(after.fields[i]) unchanged), :268-291 (countHit, then compareTop > 0 => 'Already collected on a previous page', skipped); lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:682-686 (searchAfter: 'all results are after a previous result', 'By passing the bottom result from a previous page as after')lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceComparator.java:202-203 (at ea57360912ed) (the twin: compareTop = Double.compare(topValue, haversin2(sortKey(doc))), the same conversion as value())
- The finding in 30 seconds
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:91-94, (at a9779ef3c193) :137-139, :152-156, :158-161, :163-182lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideDistanceComparator.java:69-72, (at a9779ef3c193) :106-109, :122-126, :128-131lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:167-168, (at a9779ef3c193) :197-199, :233-234, :264-265, :297-298, :323-324, :347-348 (meters javadoc); :191, :224 (Geo3DPointSortField), :257, :290, :314, :340, :373 (Geo3DPointOutsideSortField)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointSortField.java:45 (at a9779ef3c193) and Geo3DPointOutsideSortField.java:45 (which comparator each sort field builds)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DUtil.java:159; (at a9779ef3c193) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/PlanetModel.java:31, :35, :39, :142, :268-271lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:249-254, (at a9779ef3c193) :269, :275-284; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:682-686lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceComparator.java:196-204 (at a9779ef3c193)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardPath.java:869-885, (at a9779ef3c193) :1538-1549 (a path distance adds the length of the path before the segment; +Infinity outside the path)
- Who is affected
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:59 (at a9779ef3c193) (default WGS84 PlanetModel)lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:269, (at a9779ef3c193) :275 (countHit before compareTop); lucene/core/src/java/org/apache/lucene/util/SloppyMath.java:57-58 (haversine)
- Why it looks unintended
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:51, (at a9779ef3c193) :167-168 (and the six other javadoc blocks)lucene/core/src/java/org/apache/lucene/search/FieldComparator.java:56, (at a9779ef3c193) :70-83; LeafFieldComparator.java:69-80; IndexSearcher.java:682-686lucene/core/src/test/org/apache/lucene/document/TestLatLonPointDistanceSort.java:250-261; (at a9779ef3c193) lucene/CHANGES.txt:5882, :5980, :6012lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/PlanetModel.java:26 (at a9779ef3c193) (@lucene.experimental), :139 (public constructor)
- Suggested fix
lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:276; (at a9779ef3c193) lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:77-134
- TestGeo3DDistanceSortSearchAfter.java (JUnit test):
repro/LFV-544/src/org/apache/lucene/spatial3d/TestGeo3DDistanceSortSearchAfter.java(59 lines)
A step-by-step picture of the repro's distance-sort case, made in parallel with this doc from an executed probe on main a9779ef3c193 and the code; the PNG is the final summary card. The alt text describes the intended content.
assets/LFV-544/bug.png(435 KB)
![Diagram of the Geo3D searchAfter unit bug: six documents 333,586 m to 889,558 m from the origin; page 1 returns documents 0 and 1 and the cursor 444,778 m. For page 2, compareTop compares 444,778 with the documents' planet-unit distances 0.0873 to 0.1396, finds the cursor larger every time and skips all four, so page 2 is empty while totalHits says 6. With the fix the documents' distances are converted to meters (555,976 m and up) and page 2 is [2, 3, 4, 5].](../assets/LFV-544/bug.png)