All findings · Lucene finding · LFV-544 · geo3d-distance-sort-searchafter-comparetop-unit-mismatch

Geo3D distance sorts drop hits from later pages of searchAfter: compareTop compares meters with planet units

Page 2 of a Geo3D distance sort comes back empty with searchAfter: the cursor is the last hit's distance in meters (444,778), but compareTop compares it with distances on a planet of radius 1 (0.0873), so every remaining document looks already seen. All seven Geo3D sorts are affected, since 6.1.0.

Summary

Priority
Medium, rank 192On 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
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 and main, on WGS84 and CLARKE_1866
Status
documented
Found by
a metamorphic paging test (searchAfter pages must add up to the full sort)

AI-assisted page: this finding was first signalled by code reading or testing 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

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.

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].
Page 2 of a Geo3D distance sort: the cursor is in meters, the documents' distances inside the comparator are in planet units, so every document compares below the cursor and is skipped; with the fix both sides are in meters. Select the still to open it at full size. Generated with AI, checked against the code.
  1. 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.
  2. Page 1 (n = 2) holds documents 0 and 1. The last hit's FieldDoc value is 444,778.37, in meters, as the javadoc promises.
  3. You call searchAfter(lastHit, query, 6, sort) and expect the other four documents, [2, 3, 4, 5].
  4. Instead you get []. compareTop compares 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.
  5. totalHits still says 6 and nothing fails. On PlanetModel.SPHERE (mean radius 1, so meters and planet units are the same number) the same paging is correct.

Who is affected

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.

Terms

  • Geo3D sorts: Geo3DDocValuesField (module lucene/spatial3d) has seven sort factories: newDistanceSort, newPathSort, newOutsideDistanceSort, newOutsideBoxSort, newOutsidePolygonSort, newOutsideLargePolygonSort and newOutsidePathSort. 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 use Geo3DPointDistanceComparator, the five outside sorts Geo3DPointOutsideDistanceComparator.
  • 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 for PlanetModel.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 to setTopValue (TopFieldCollector.java:249-254) and skips every document whose compareTop is positive as "Already collected on a previous page" (:275-284).

The bug

  1. copy(slot, doc) stores values[slot] = computeMinimumDistance(doc), an arc distance in planet units (Geo3DPointDistanceComparator.java:137-139, :163-182).
  2. value(slot) returns values[slot] * planetModel.getMeanRadius(), meters, as documented (:152-156).
  3. setTopValue stores that meters value unchanged (:91-94).
  4. compareTop(doc) returns Double.compare(topValue, computeMinimumDistance(doc)) (:158-161): meters on the left, planet units on the right. Geo3DPointOutsideDistanceComparator has the same four methods (:69-72, :106-109, :122-126, :128-131).
  5. 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.

The numbers

Six documents at latitudes 3-8, longitude 0, newDistanceSort("p", 0, 0, 2_000_000, WGS84):

document012345
distance, meters (FieldDoc value)333,586444,778555,976667,170778,364889,558
distance, planet units (what compareTop sees)0.05240.06980.08730.10470.12220.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 with searchAfter from the previous page's FieldDoc, on WGS84 (the default planet of Geo3DPoint) 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 before compareTop), 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 and TopDocs.merge, which never call compareTop; 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.newDistanceSort pages 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 seen totalHits hits: that keeps it from running on, but does not bring the lost hits back.

Minimal repro

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.

TestGeo3DDistanceSortSearchAfter.java (JUnit test)59 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.spatial3d;

import org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.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);
      }
    }
  }
}
Command
# 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 -ea and -da.

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

More files, environment, full output37 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: 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)

Why it looks unintended

  • Every factory documents the FieldDoc value in meters, and searchAfter says to pass that hit back as the cursor.
  • compareTop must agree with compare; a hit's own value fed back gives 1, not 0.
  • The LatLon twin converts in compareTop on 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-168 and the six others); the class is public and has no @lucene.experimental tag (: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 type value(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 own value(slot) back through setTopValue and got compareTop = 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()'. TestLatLonPointDistanceSort pages with searchAfter (:250-261). The Geo3D port converts in value() 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 changed value() in both comparators, not compareTop.
  • Never tested. No test source references Geo3DDocValuesField or its sort fields, and the 49 comments of #8267 never mention searchAfter, 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.

Suggested fix

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 compareTop call, only on searchAfter pages, 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/spatial3d test 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 -ea and -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.

Convert the cursor once in setTopValue

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).

Keep values in meters inside the comparator

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.

Document that cursors must be divided by the mean radius

Rejected.

Trade-offs

Contradicts the factories' meters javadoc and the comparator contract, and every caller would have to know about it.

Downstream note

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.

How it was found

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 newDistanceSort five 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 and compareTop not 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).

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories, dev@ / java-user@ / issues@ / commits@ lists, JIRA archive, web2026-10-26two 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-2658 manual REST and GraphQL searches; upstream path history and contents of main and branch_10xonly #8267 names the comparator; compareTop still unconverted on main
JIRA archive (LUCENE, SOLR) and lists.apache.org2026-10-269 JIRA queries and 5 issue reads; 23 list queriesLUCENE-7212 notifications and one 2018 API-organisation thread
Open PRs2026-10-26the changed files of all 407 open apache/lucene PRsnone touches either comparator; #14775 touches the Geo3D sort fields (inverseSort only)
GitHub (apache/lucene)2026-10-27issues and PRs updated since 2026-10-26 matching Geo3D, geo3d sort, searchAfter distance, compareTop, spatial3dnone

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe new JUnit 8 of 9 fail (gradle and the standalone runner, assertions on and off); the original JUnit 5 of 6
main ea57360912ed affectedprobe: 8 of 8 Earth-model sorts lose page 2; the original and the independent JUnits fail (assertions on and off)
10.5.2 affectedthe new JUnit 8 of 9 fail (assertions on and off); probe 8 of 8
9.12.3 affectedthe new JUnit 8 of 9 fail; probe 8 of 8
8.5.0, 8.11.4 affectedprobe: 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 affectedpre-8.5 probe: 7 of 7 sorts lose page 2; the hand-divided cursor pages correctly
7.0.0 affectedthe original probe (distance sort only) loses page 2
6.0.1 API absentno Geo3DDocValuesField; the probe does not compile
main + fix not affectedthe new JUnit 9 of 9; the whole lucene/spatial3d test task, 190 tests, 0 failed

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateRandom 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 onlyAn 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 realOwn 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 weakOpenAI 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, securityAll 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 keepFresh 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 validatedA 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.

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

Finding record

LFV-544, snapshot of 2026-11-06

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 main ea57360912ed, 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 whole lucene/spatial3d test task (190 tests) with assertions on and off, and :lucene:spatial3d:check -x test.

Lucene and upstream sources

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-182
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideDistanceComparator.java:69-72, (at a9779ef3c193) :106-109, :122-126, :128-131
  • lucene/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-271
  • lucene/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-686
  • lucene/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-686
  • lucene/core/src/test/org/apache/lucene/document/TestLatLonPointDistanceSort.java:250-261; (at a9779ef3c193) lucene/CHANGES.txt:5882, :5980, :6012
  • lucene/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

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 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.

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.