All findings · Lucene finding · LFV-1136 · latlon-distance-sort-secondary-key-stale-box-drops-hits

LatLon distance sort used as a tie-breaker drops closer tied hits after 1024 queue updates

When LatLonDocValuesField.newDistanceSort only breaks ties of an earlier sort key, a search with more than 1024 queue updates can silently drop a closer tied hit: in the repro the top hit is doc 1024 (2,002 km away) instead of doc 1025 (1,501 km). Every release since 6.1.0 has it.

Summary

Priority
Medium, rank 29Silently wrong top-N hits or searchAfter pages from the core LatLon distance sort used as a later sort key, with default settings in every release since 6.1.0, on an ordinary sort shape such as newest first, then nearest. It needs more than 1024 bottom updates and ties on the earlier keys, and no wrong data is stored, so it stays in Medium.
Impact
silent wrong results
Since
6.1.0 (LUCENE-7099, 2016)introduced 2016-03-11 · over 10 years
Affected
6.1.0 to 10.6.0 and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of the comparator's contract, witnessed by a one-test repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

The comparator rebuilds its box on each of the first 1024 bottom updates, then only on every 64th. An old box is fine while the bottom distance shrinks. It is too small once the bottom moves farther away, which can only happen when distance is not the first sort key.

Background: how a sorted top-N search uses the bottom

  1. A sorted search keeps the N best hits in a queue. The worst of them is the bottom. A new hit gets in only if it sorts before the bottom; then the bottom is updated (setBottom).
  2. With several sort keys, the second key is compared only when the first key ties, the third only when both tie, and so on.
  3. newDistanceSort sorts by distance from a point. To avoid computing a distance for every hit, it keeps a latitude/longitude box around the bottom's distance and rejects hits outside the box at once.
A flow of four boxes shows how a sorted top-N search handles each matching hit (the hits arrive in document order, the top-N queue keeps the best N with its worst kept hit called the bottom, a new hit is compared with the bottom on key 1 and only on a tie on key 2, the distance, and a better hit replaces the bottom and every key's comparator is told the new bottom); below it the distance comparator's shortcut, a box around the query point that holds every point closer than the bottom, is fed by a red, crossed-out 'resize the box' arrow and a red panel explains the bug: after 1024 bottom updates the box is rebuilt only on every 64th update, so when a hit that wins on key 1 moves the bottom farther away the old box is too small; two small panels at the bottom compare the expected case, where the box grows with the bottom and the closer tied hit inside it replaces the farther one, with the actual case, where the small old box leaves the closer tied hit outside, it is skipped and the farther hit stays, with no error.
When distance only breaks ties, the comparator's shortcut box can stay sized for an old, closer bottom, so a closer tied hit is skipped without its distance being checked. Generated with AI, checked against the code.
Diagram of the bug: Sort by cat, then distance from (0, 0), top 1. Doc 0 fills the top spot and docs 1 to 1023 each take it; the competitive box is rebuilt at about 1.1 km each time. Doc 1024 (cat 1000, 2,002 km) takes the top spot on the 1025th update, which keeps the old 1.1 km box. Doc 1025 (cat 1000, 1,501 km) ties on cat, lies outside the stale box and is rejected without its distance being computed, so the result is doc 1024 instead of doc 1025.
The top-1 queue as the 1026 documents arrive: the box is rebuilt for each of the first 1024 updates, kept when doc 1024 moves the bottom to 2,002 km, and then rejects doc 1025 at 1,501 km. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Sort 1026 documents by cat, then by distance from (0, 0), top 1: new Sort(new SortField("cat", SortField.Type.INT), LatLonDocValuesField.newDistanceSort("loc", 0, 0)). Docs 0-1023 each have a smaller cat than the one before and lie 1.1 km away; docs 1024 and 1025 share cat 1000 and lie 2,002 km and 1,501 km away.
  2. You expect doc 1025: distance breaks the tie on cat, and 1,501 km is closer than 2,002 km.
  3. You get doc 1024, with no error, with assertions on and off.
  4. Why: the comparator rejects any hit outside a box around the queue's worst distance, and after 1024 queue updates it rebuilds that box only every 64th time. Doc 1024 moves the worst distance to 2,002 km, the box stays at 1.1 km, and doc 1025 is rejected without its distance being computed.
  5. Keeping an old box is safe when distance is the first key, because the worst distance then only shrinks. As a tie-breaker it can grow: sorting newest first, then nearest, over 3,600-6,000 documents gave a wrong top 10 in about half of random trials.

Who is affected

Anyone who sorts by another field first and by newDistanceSort after it, for example newest first, then nearest, with default settings. It needs more than 1024 queue updates in one collector (by default, one per search) and ties on the earlier keys. In a newest-then-nearest probe over 3,600-6,000 documents, about half the random trials gave a wrong top 10.

Mechanism, terms and numbers632 words

LatLonDocValuesField.newDistanceSort speeds up distance sorting with a bounding box around the distance of the queue's worst hit (the bottom): compareBottom rejects any point outside the box without computing its distance. setBottom rebuilds the box on each of the first 1024 bottom updates, then only on every 64th. That is safe while the bottom distance only shrinks, which holds when distance is the first sort key. When distance only breaks ties of an earlier key, a hit that wins on that key can move the bottom farther away; the old, smaller box then rejects a tied hit that is closer than the new bottom, and the top N (or a searchAfter page) is silently wrong. One test with 1026 documents and default settings shows it on main; a main() form of an earlier version of this test gives the same wrong top hit on 13 releases from 6.1.0 to 10.6.0, and every release in between has the same guard line. A one-condition fix (also rebuild when the bottom grows) passes the repro and the existing distance-sort tests.

The mechanism (main 5adff78bb168)

  1. Every competitive hit updates the bottom. TopFieldCollector calls setBottom after each hit that enters a full queue (search/TopFieldCollector.java:94-100, and :102-112 when the queue first fills). MultiLeafFieldComparator.setBottom passes the bottom slot to every comparator, the later ones too (search/MultiLeafFieldComparator.java:45-49).
  2. The sampled rebuild (fix site). LatLonPointDistanceComparator.setBottom always sets bottom = values[slot] (:89) but rebuilds the box only if setBottomCounter < 1024 || (setBottomCounter & 0x3F) == 0x3F (:93), "if comparator hits a worst case order (e.g. backwards distance order)" (:90-92). The counter is never reset (:66, :112) and getLeafComparator returns the same object for every segment (:185-194), so the count is per collector, not per segment: one collector for all segments with the default IndexSearcher (search/IndexSearcher.java:226-231), one per slice with an executor (:760-772).
  3. The repro, step by step. Doc 0 fills the empty queue (search/TopFieldCollector.java:102-112) and docs 1..1023 each replace the top-1 hit on cat: updates with counter 0..1023, each rebuilding a box of about 1.1 km radius (0.01 degrees of latitude plus the 7 cm pad of geo/Rectangle.java:150). Doc 1024 wins on cat (1000 < 3977) without a distance check; its update has counter 1024, 1024 & 0x3F is 0, so bottom becomes about 2,002 km while the 1.1 km box stays.
  4. The drop. Doc 1025 ties on cat, so MultiLeafFieldComparator.compareBottom asks the distance comparator (:52-64). Latitude 13.5 is outside the box, so compareBottom returns its initial cmp = -1 without computing the distance (LatLonPointDistanceComparator.java:148-160, :176), and thresholdCheck drops the hit (search/TopFieldCollector.java:79-92). Its distance, about 1,501 km, would have won. searchAfter uses the same check (:186, :228); a two-page check probe returns the farther tied document as page 2 on main and the closer one with fix A.
  5. Why the first key is safe. As the first key, a new bottom only arrives with a closer hit, so an old box is always large enough. A later key has no such guarantee, as LeafFieldComparator states.

Mechanism check (N filler documents; run in the earlier test's layout, re-run by the fact check in the repro's own layout with the same table): wrong exactly when the far document's update skips the rebuild (N = 1024, 1086, 1088, 2048), right when it rebuilds (N = 1000, 1023, 1087, 2047).

Also with a missing location (found by reading, then run by the fact check): after 1024 updates, a bottom hit without a location (sort key +Infinity, :212) is another way the bottom grows; main returns that tied document instead of the closer one, and fix A returns the closer one.

Exposure and likelihood262 words
  • Who. Any search with LatLonDocValuesField.newDistanceSort as the second or later SortField: "newest first, then nearest", "category, then nearest", "rating, then nearest". Public API only; default IndexWriterConfig, codec and searcher.
  • When. More than 1024 bottom updates in one collector, which can happen when the earlier key's best values arrive late in doc-id order (for example newest first over documents indexed in date order). Then the bottom distance must grow on one of the 63 of 64 updates that keep the box, and a document tied on the earlier keys must lie between the stale box and the new bottom distance.
  • How often (a probe, not the repro). Sort(day INT desc, distance), top 10, documents indexed in day order, locations uniform in a 2 by 2 degree area, 20 random trials per size, compared with a brute-force sort: a wrong top 10 in 11/20 trials with 6,000 documents, 11/20 with 5,000 and 10/20 with 3,600; 0/20 with 600 documents (fewer than 1024 updates); 0/20 for the three larger sizes with the fix. With an IntField first key sorted by SortedNumericSortField (desc), where the numeric comparator can skip documents, 6,000 documents still gave 11/20 wrong.
  • Effect. Silent: no exception, totalHits the same as with the fix and the match set unchanged, the same with -ea and -da (a check probe prints both). A closer tied document is replaced by a farther one in the top N or on a searchAfter page.
  • Versions. Every release from 6.1.0 (2016) to 10.6.0 (13 of them run, the rest have the same guard line), and main.

Minimal repro

One JUnit test, public API only, default IndexWriterConfig, codec and searcher, 1026 documents. It sorts by cat, then by distance from (0, 0), top 1, and expects doc 1025. Copy it into the lucene/core test tree and run it with gradle.

TestLatLonDistanceSortSecondaryKey.java (lucene/core, public API)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.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.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestLatLonDistanceSortSecondaryKey extends LuceneTestCase {

  private static void add(IndexWriter w, int cat, double lat) throws Exception {
    Document doc = new Document();
    doc.add(new NumericDocValuesField("cat", cat));
    doc.add(new LatLonDocValuesField("loc", lat, 0));
    w.addDocument(doc);
  }

  public void testCloserTiedDocIsNotDropped() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (int i = 0; i < 1024; i++) {
          add(w, 5000 - i, 0.01);
        }
        add(w, 1000, 18);
        add(w, 1000, 13.5);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Sort sort =
            new Sort(
                new SortField("cat", SortField.Type.INT),
                LatLonDocValuesField.newDistanceSort("loc", 0, 0));
        IndexSearcher searcher = new IndexSearcher(reader);
        // expected: doc 1025 (same cat as doc 1024 but closer); actual: doc 1024
        assertEquals(1025, searcher.search(MatchAllDocsQuery.INSTANCE, 1, sort).scoreDocs[0].doc);
      }
    }
  }
}
Command
cp $REPRO/TestLatLonDistanceSortSecondaryKey.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test \
  --tests org.apache.lucene.document.TestLatLonDistanceSortSecondaryKey -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false: same failure
Expected
Doc 1025: it ties doc 1024 on cat = 1000 and is closer (1,501 km against 2,002 km).
Observed
Doc 1024 (expected:<1025> but was:<1024>), with no exception, with assertions on and off.

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

More files, environment, full output247 words
  • Deterministic: the test has no random input; the test framework still randomizes its usual environment from the seed (codec, similarity, locale, time zone, some writer settings). It failed in every run: the standalone runner (seed DEADBEEF, -ea and -da) and gradle (seed DEADBEEF, codec Asserting(Lucene104), asserts on and off).
  • 1024 filler documents is the smallest count that shows it: with 1023, doc 1024's update still rebuilds the box and the result is correct.
  • The test does not force one segment (no NoMergePolicy, no leaf-count assertion); with the default settings the 1026 documents end in one segment (a check probe prints segments=1), and the counter spans segments anyway.
  • MatchAllDocsQuery.INSTANCE does not exist on 10.0.0 and older; use new MatchAllDocsQuery() there.
  • checkGoogleJavaFormat passes with the file in the tree. As a regression test, the same method fits in TestLatLonPointDistanceSort.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); a `main()` form of an earlier version of the test also on 13 releases from 6.1.0 to 10.6.0
jdkOpenJDK 25.0.3
osLinux, aarch64 (only this platform was run)
Command (environment)
cp $REPRO/TestLatLonDistanceSortSecondaryKey.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test \
  --tests org.apache.lucene.document.TestLatLonDistanceSortSecondaryKey -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false: same failure

Observed output

# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF (same with -Ptests.asserts=false)
TestLatLonDistanceSortSecondaryKey > testCloserTiedDocIsNotDropped FAILED
    java.lang.AssertionError: expected:<1025> but was:<1024>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:6FA20F4851EBD307]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.junit.Assert.assertEquals(Assert.java:633)
        at org.apache.lucene.document.TestLatLonDistanceSortSecondaryKey.testCloserTiedDocIsNotDropped(TestLatLonDistanceSortSecondaryKey.java:55)
:lucene:core:test (FAILURE): 1 test, 1  failure

# with fix A: :lucene:core:test (SUCCESS): 1 test (asserts on and off)

Why it looks unintended

  • Sort says a later SortField breaks ties of the earlier ones; newDistanceSort documents ascending distance, with no primary-only note.
  • LeafFieldComparator warns that a non-primary comparator's bottom may not be its weakest value, and compareBottom must match compare(bottom, doc).
  • The guard came in as a speed shortcut (LUCENE-7099, 2016), described upstream as rebuilding the box "less often", never as dropping hits.
The full argument259 words

Contract

  • search/LeafFieldComparator.java:28-31: the bottom slot "may not hold the weakest value according to your comparator, in cases where your comparator is not the primary one (ie, is only used to break ties from the comparators before it)". The note dates from LUCENE-1779 (2009), before this comparator existed.
  • search/LeafFieldComparator.java:55-57: compareBottom "should return the same result as FieldComparator#compare(int,int) as if bottom were slot1 and the new document were slot 2".
  • search/Sort.java:59-61: "if it produces a tie, then the second SortField is used to break the tie".
  • document/LatLonDocValuesField.java:143: "This sort orders documents by ascending distance from the location." The class javadoc says "Sorting by distance is efficient" (:40).
  • search/FieldValueHitQueue.java:146-151 builds comparators for later sort fields as usual; document/LatLonPointSortField.java:41-43 neither rejects nor special-cases that position.

Tests

  • TestLatLonPointDistanceSort always puts distance first (Sort(distanceSort, id), :241).
  • TestLatLonPointDistanceFeatureQuery sorts by score, then distance (:471), but that score falls with the same distance, so the two keys agree and the bottom distance still only shrinks.

History

  • The guard came with the box in e169050dc3f (LUCENE-7099, 2016-03-11); its parent computed exact distances.
  • LUCENE-7103 (#8158) treats the worst case, in which the distance function "could still get called many times", as a speed problem only; on LUCENE-7212 (#8267) the author described the box as rejecting "hits that won't compete anyway", with "a guard in the code to start doing it less often (only every Nth update)".
  • Five days later 6f124e10017 removed the javadoc warning that distance sorting may be expensive, because "this sort makes bounding boxes for you".
The devil's-advocate check173 words

The case. The sampling is a deliberate, ten-year-old speed guard that is sound for the use it was designed and tested for (distance first); the same guard was copied into the XY, Geo3D and nearest-neighbour code; and LeafFieldComparator is @lucene.experimental, so users should put distance first or live with it.

Why it fails. The user touches only public, non-experimental API (Sort, SortField, newDistanceSort). Sort makes tie-breaking keys a normal feature, LeafFieldComparator names the non-primary case, and newDistanceSort documents an ascending distance order; the comparator cannot even tell its position. Upstream called the guard a way to rebuild the box less often, never a licence to drop hits, and copying it only spreads the same unstated assumption. Two separately prompted cross-model reviews (both OpenAI GPT-5.6, the same model) and a devil's-advocate check built this case; all three found the bug real and the case weak. The nearest-neighbour search (NearestNeighbor) has the same guard line but is not a sort comparator; it is not part of this finding.

Suggested fix

Recommended A. Rebuild the box whenever the bottom grows (recommended)

Remember the bottom the box was built for, and also rebuild the box whenever the new bottom is larger: one extra condition in setBottom, +8/-2 lines. Distance-first sorts keep the sampling as today. The repro passes, and so do 504 document-package tests and the nightly distance-sort tests.

  // the bottom value the current bounding box(es) were built for (infinite: no box yet)
  double boxBottom = Double.POSITIVE_INFINITY;

  public void setBottom(int slot) {
    bottom = values[slot];
    // Keeping an old box is only safe if the bottom did not grow, which can happen when this
    // comparator only breaks ties of an earlier sort field: always rebuild the box then.
    if (setBottomCounter < 1024 || (setBottomCounter & 0x3F) == 0x3F || bottom > boxBottom) {
      boxBottom = bottom;
      Rectangle box = Rectangle.fromPointDistance(latitude, longitude, haversin2(bottom));
      ...
All options and trade-offs4 options ยท 495 words

Option A was built and tested in a scratch worktree of 5adff78bb168; B and D were not built; C was run only as a control.

A. Rebuild the box whenever the bottom grows (recommended) (validated by the checks)

Add a field boxBottom (initially +Infinity, which matches the initial unbounded box), set it whenever the box is rebuilt, and add || bottom > boxBottom to the rebuild condition.

  // the bottom value the current bounding box(es) were built for (infinite: no box yet)
  double boxBottom = Double.POSITIVE_INFINITY;

  public void setBottom(int slot) {
    bottom = values[slot];
    // Keeping an old box is only safe if the bottom did not grow, which can happen when this
    // comparator only breaks ties of an earlier sort field: always rebuild the box then.
    if (setBottomCounter < 1024 || (setBottomCounter & 0x3F) == 0x3F || bottom > boxBottom) {
      boxBottom = bottom;
      Rectangle box = Rectangle.fromPointDistance(latitude, longitude, haversin2(bottom));
      ...

Trade-offs

  • No change when distance is the first key: the bottom only shrinks, so the new condition is never true.
  • As a later key, the box is rebuilt on every update that grows the bottom: the same per-update cost as the first 1024 updates; if the bottom grows on nearly every update (an adversarial order), that is a rebuild on nearly every update, the cost the sampling avoids today. No benchmark was run.
  • Also covers a +Infinity bottom (the rebuilt box is then the whole world; the fact check's missing-location probe passes with fix A) and any other way the bottom can grow.

What the checks validated

  • Gradle, scratch worktree at 5adff78bb168, JDK 25.0.3, -Ptests.seed=DEADBEEF: the repro fails without the fix and passes with it, asserts on and off.
  • With the fix: org.apache.lucene.document.* plus TestNearest, 504 tests, 26 skipped, 0 failures; TestLatLonPointDistanceSort and TestNearest with -Ptests.nightly=true, 9 tests (testRandomHuge included); checkGoogleJavaFormat passes.
  • The finder's version of the same condition removes every wrong top 10 of the probe (0/20 for 3,600, 5,000 and 6,000 documents). Not run: the full lucene/core suite.

B. Turn the sampling off when the comparator is not primary (not built)

LatLonPointSortField.getComparator already receives Pruning.NONE for every later sort field (search/FieldValueHitQueue.java:146-151) but ignores it (document/LatLonPointSortField.java:41-43); it could tell the comparator to rebuild on every update.

Trade-offs

Other callers pass Pruning.NONE too (the grouping collectors, NonPruningSortField) and would lose the sampling where it is safe; it does not cover a bottom that grows for another reason.

C. Remove the sampling (control only)

Always rebuild the box. A comparator with only this change passes an independent version of the test, and so does 6.1.0 with the same change.

Trade-offs

Brings back the worst-case cost LUCENE-7099 and LUCENE-7103 guarded against (data in decreasing distance order, a huge top N).

D. Document that distance must be the first sort key (not recommended)

No code change.

Trade-offs

Contradicts Sort's tie-break contract and LeafFieldComparator's non-primary note; users cannot see the limit coming.

Downstream note

Downstream servers that map a geo-distance sort clause onto newDistanceSort (by reading, not run: Solr's geodist(), the Elasticsearch and OpenSearch _geo_distance sort in metres) return the same wrong pages when that clause is not the first sort key.

How it was found

A proof-obligation audit of LatLonPointDistanceComparator against the LeafFieldComparator contract: compareBottom must give the same answer as compare(bottom, doc), also when the comparator only breaks ties. The sampled box rebuild meets that obligation only if the bottom never grows, which a tie-breaker cannot promise; a one-test JUnit on the stock main jars witnessed it.

The method in full196 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The obligation: for every document, compareBottom(doc) equals compare(bottom, doc) (LeafFieldComparator.java:55-57), including for a comparator that is not the primary one (:28-31).
  • Discharging it for LatLonPointDistanceComparator needs "every point outside the current box is farther than the current bottom". A rebuilt box gives that; a kept box gives it only if the bottom did not grow since the box was built. Distance as the first key guarantees that; a later key does not.
  • The finder then built a one-test JUnit (public API; an earlier form of the repro shown here, with NoMergePolicy, the default codec set explicitly and fillers at 0.009 degrees) that fails on main with -ea and -da, checked that it passes with the fix, mapped exactly which filler counts fail (the mechanism check) and measured the newest-then-nearest probe. The repro on this page is a later, minimal form of that test with plain default settings.
  • An independent reproducer, cross-model reviews (OpenAI GPT-5.6), a devil's-advocate check, an adjudication, a history and prior-art follow-up, a credibility audit and a final repro check followed (see the checks).

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, the JIRA archive, four Lucene mailing lists, downstream trackers and the web (keyword searches; last re-check 2026-11-02). The nearest items, LUCENE-7099, -7103 and -7212, treat the guard as a speed guard for a distance-first sort.

Searches7 searches

No public report was found saying that newDistanceSort as a later SortField drops competitive hits, or that the setBottomCounter sampling is unsound when the bottom grows. Limits: keyword search only; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs, commits; the JIRA archive; dev@, java-user@, issues@, commits@; downstream repositories; the web (automated, two runs)2026-11-0216 GitHub issue and PR queries, 3 commit queries, 20 list queries, 4 JIRA queries, 2 downstream and 10 web queries; 274 and 654 distinct hits, every high and medium hit readno report of this defect
GitHub (manual): apache/lucene, apache/solr, apache/lucene-solr, elastic/elasticsearch, opensearch-project/OpenSearch, GitHub-wide2026-11-0251 queries (34 on apache/lucene), for example LatLonPointDistanceComparator, newDistanceSort, setBottomCounter, "distance sort secondary"; the file lists of all 408 open apache/lucene PRs; git log -S'setBottomCounter'no report; open PR #14775 adds a throwing inverseSort() and does not touch the comparators
Apache JIRA, lists.apache.org, forums and Stack Overflow2026-11-0210 JIRA queries, 12 list queries, 10 web queriesSOLR-18006 (a geodist() desc exception), a 2023 OpenSearch forum thread (score, then distance in miles, another code path), a 2019 Elasticsearch forum thread (explains the sampling for a distance-first sort): none is this bug
Related Lucene items (read)2026-11-02#8154, #8158, #8267, #9921, #9993, #12082, #8157speed discussions of the guard, comparator API redesign and other setBottom defects; none reports wrong results
Re-check before this doc2026-11-02gh search on apache/lucene updated since 2026-11-01 for five terms; the PRs updated that daynothing new; the comparator file last changed in 2020
Independent fact check re-check2026-11-02commits on the comparator file; apache/lucene issues and PRs updated since a recent cutoffnothing new
Re-check before publishing2026-11-02commits on the comparator file (last 2d6ad2fee6d, 2020-12-23); main at 1c2072f02843; the four open PRs updated late that day and their file lists; issue and PR searches for LatLonPointDistanceComparator, newDistanceSort, setBottomCounter, "distance sort" and XYPointDistanceComparator updated that daynothing new

History

Introduced in 6.1.0 by LUCENE-7099 (e169050dc3f, 2016-03-11), which added the box and the sampling guard together. Wrong on all 13 releases run from 6.1.0 to 10.6.0 and on main; not fixed upstream.

Versions tested8 rows

Introduced by e169050dc3f (main) and its branch_6x copy a4722845e30, "LUCENE-7099: speed up embarrasingly slow sandbox/ distance sort" (Robert Muir, 2016-03-11; no PR; JIRA LUCENE-7099 = #8154). git blame gives the guard line and its comment to this commit; its parent b3ee746a8b1 computed exact distances with no box and was never released.

The class moved from lucene-sandbox to lucene-core in 7.5.0 (LUCENE-7314). Later edits (LUCENE-7103, -7104, -7123, -7184, -7240, -7407, -7314 and the 2020 reformat 2d6ad2fee6d) kept the guard. Upstream main 1c2072f02843 (2026-11-02) still has it. None of the 408 open apache/lucene PRs whose file lists were read on 2026-11-02 touched the file, and a re-check found nothing new.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedtop hit doc 1024 instead of 1025; the JUnit fails with -ea and -da (gradle and standalone)
10.6.0, 10.5.2, 10.0.0, 9.12.3 affectedthe same wrong top hit (a main() form of an earlier version of the test, -ea; an independent JUnit, -da)
9.0.0, 8.11.4, 8.0.0, 7.7.3, 7.5.0 affectedthe same wrong top hit (the main() form, -ea; 7.5.0: first lucene-core release)
7.4.0, 6.6.6, 6.2.0 affectedthe same wrong top hit (the main() form, -ea; lucene-sandbox)
6.1.0 first releasethe same wrong top hit (the main() form, -ea); the first release with the API
6.1.0 with only the sampling condition replaced by true counterfactualcorrect top hit, doc 1025
6.0.0, 6.0.1 API absentno LatLonDocValuesField or distance comparator (the program does not compile)
main + fix A fixthe JUnit passes, asserts on and off

Independent checks

An independent reproducer, cross-model reviews (OpenAI GPT-5.6), a devil's-advocate check, an adjudication, a credibility audit and a final repro check re-ran it on main and releases; all found it real. This doc's gradle run confirms the failure and the fix.

Checks and verdicts275 words

Each check ran its own test or re-ran the repro; the cross-model reviews ran on main ea57360912ed, where the four relevant files are byte-identical to 5adff78bb168's. The cross-model reviews used the same model, prompted separately with different lenses.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedThe JUnit fails on main (-ea, -da) and 10.6.0 (-da) and passes with the fix; mechanism check and likelihood probe.
independent reproducer realOwn test (rank desc first, then distance) fails on main (-ea, -da), 10.6.0, 10.5.2, 10.0.0 and 9.12.3; a comparator that always rebuilds the box passes.
cross-model review, refute lens (OpenAI GPT-5.6) real, highOwn test fails (-ea, -da), its 1023-update control passes; the fix passes it and TestLatLonPointDistanceSort.
cross-model review, intended lens (OpenAI GPT-5.6) real, highRated the speed-guard defence weak; its 1023-update and distance-first controls pass; showed the XY analogue (LFV-1132).
devil's-advocate check real, highRe-ran on main and 10.6.0; the guard was described upstream as a speed shortcut.
adjudication confirmed, MediumRe-ran two tests (fail) and the fixed comparator (pass); LFV-550 has the same box but a different cause.
credibility audit and final repro check confirmedRe-run on main fails (-ea, -da); probe with an IntField first key still 11/20 wrong; one minimal test.
this doc real as describedGradle: fails without the fix, passes with fix A; 504 document-package tests and the nightly distance tests pass.
fact checks of this doc (one by OpenAI GPT-5.6) real as describedRe-ran the repro, the gradle command and fix A, and ran a missing-location probe; their wording fixes are applied.

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

Finding record

LFV-1136, snapshot of 2026-11-06

Registry title
LatLon distance sort used as a later Sort key drops closer tied hits after 1024 bottom updates, because LatLonPointDistanceComparator keeps a stale bounding box when the bottom grows (every release since 6.1.0)
Area
core document / LatLon distance sort (LatLonPointDistanceComparator.setBottom: sampled competitive bounding box)
Priority
Medium, rank 29: Silently wrong top-N hits or searchAfter pages from the core LatLon distance sort used as a later sort key, with default settings in every release since 6.1.0, on an ordinary sort shape such as newest first, then nearest. It needs more than 1024 bottom updates and ties on the earlier keys, and no wrong data is stored, so it stays in Medium.
Impact class
silent wrong results
Impact note
Any search sorted with LatLonDocValuesField.newDistanceSort as a later SortField (for example newest first, then nearest) can return a silently wrong top-N or searchAfter page once the search has made more than 1024 bottom updates (any earlier key whose order runs against index order makes every hit competitive), whenever the bottom distance grows on one of the 63 of 64 updates that keep the old box and a hit tied on the earlier keys lies between the old box radius and the new bottom distance; default IndexWriterConfig, codec and searcher, the same with -ea and -da, 1026 documents suffice, and every release from 6.1.0 through 10.6.0 is affected [exec]; in the finder's likelihood probe (newest first, then nearest, documents indexed in day order, top-10) about half of the random trials with 3,600-6,000 documents gave a wrong top-10, and none with the fix.
Summary
You search with Sort(cat INT, LatLonDocValuesField.newDistanceSort("loc", 0, 0)), so distance only breaks ties of cat, over 1026 documents: docs 0..1023 each have a smaller cat than the one before (so each becomes the new top hit) and lie about 1 km from the origin, and docs 1024 and 1025 share the smallest cat, with doc 1025 (about 1500 km away) closer than doc 1024 (about 2000 km). You expect doc 1025 as the top hit, because Sort says a later SortField breaks ties and the distance sort orders by ascending distance. You get doc 1024, with no error: after 1024 bottom updates the comparator rebuilds its competitive bounding box only on every 64th update, so it keeps the 1 km box built for the old bottom, and compareBottom rejects doc 1025 as outside that box without computing its distance.
Introduced
e169050dc3fe3418947b88948827141dcb3911c2 ('LUCENE-7099: speed up embarrasingly slow sandbox/ distance sort', Robert Muir (GitHub rmuir), master) and its branch_6x copy a4722845e3080dac06c59ca9837a602cf85f3e17 (the one in 6.1.0); no PR (JIRA LUCENE-7099, migrated as apache/lucene#8154): it added the bounding-box rejection in compareBottom together with the sampling guard 'if (setBottomCounter < 1024 || (setBottomCounter & 0x3F) == 0x3F)' in setBottom, commented as protection against 'a worst case order (e.g. backwards distance order)' [read: git blame at 7cecf795236 (file byte-identical to 5adff78bb168) gives the guard line, main line 93, to e169050dc3f; its parent b3ee746a8b1 (same day, never released) computed the exact distance with no box; exec: 6.1.0 with only the sampling condition removed returns the right hit]. Later edits (LUCENE-7103, -7104, -7123, -7184, -7240, -7407, -7314 and the 2020 reformat 2d6ad2fee6d) kept the guard. Not a regression: wrong since the first release with the API (2016-03-11 (author date of e169050dc3f and a4722845e30))
First affected version
6.1.0 [exec] (the first release with any LatLon distance sort, then in lucene-sandbox; in lucene-core from 7.5.0 [exec], after LUCENE-7314 (#8368) moved the class); 6.0.0 and 6.0.1 have no LatLonDocValuesField or LatLonPointDistanceComparator (the history program does not compile there) [exec], so there is no earlier correct release
Affected versions
every release from 6.1.0 through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT). Executed: the finder's one-test JUnit, an earlier form of the minimal repro (fillers at 0.009 degrees, NoMergePolicy and the default codec set explicitly), fails on main (-ea and -da; finder, the independent reproduction, the intended-behaviour check, the adjudication and a separate re-run) and on 10.6.0 (-da; finder, the intended-behaviour check); the minimal repro fails on main (-ea and -da) [exec]; the independent reproduction's own test fails on main (-ea and -da) and on 10.6.0, 10.5.2, 10.0.0 and 9.12.3 (-da); the follow-up's main() form of that earlier test (same 1026 documents, sort and expected top hit) fails on 6.1.0, 6.2.0, 6.6.6, 7.4.0, 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main [exec]; the releases in between by code identity (every releases/* tag from 6.1.0 to 10.6.0 in the history clone contains e169050dc3f or a4722845e30, and the guard line is unchanged) [read]. Not affected: 6.0.x (API absent) [exec]. Not new in 10.6 (10.5.2 and older fail). Not fixed upstream: the last upstream commit on LatLonPointDistanceComparator.java is still 2d6ad2fee6d (author date 2020-12-23, reformatting), with upstream main at 1c2072f02843 (author date 2026-11-02), and no open PR touches it [read: GitHub API, re-check 2026-11-02]. Executed on Linux aarch64 with JDK 25.0.3 only.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-02, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): a scripted prior-report search in two runs (the finder's: 16 GitHub issue and PR queries on apache/lucene, 3 commit queries, 20 mailing-list queries over dev@, java-user@, issues@ and commits@ and 4 JIRA-archive queries, 274 distinct hits; the follow-up's: the same sources plus 2 downstream-repository and 10 web queries, 654 distinct hits, every high and medium hit read), 51 manual GitHub searches (34 on apache/lucene, 3 GitHub-wide, 14 on apache/solr, apache/lucene-solr, elastic/elasticsearch and opensearch-project/OpenSearch), 10 Apache JIRA queries, 12 lists.apache.org queries, 10 web queries (incl. the Elasticsearch and OpenSearch forums and Stack Overflow), the file lists of all 408 open apache/lucene PRs, the upstream commits on the fix-site files and git log -S'setBottomCounter': no report that newDistanceSort as a later SortField drops competitive hits, or that the setBottomCounter sampling is unsound when the bottom grows. A same-day recheck found no apache/lucene issue or PR updated since 2026-11-01 for LatLonPointDistanceComparator, newDistanceSort, setBottomCounter, XYPointDistanceComparator or "distance sort secondary"; none of the 23 apache/lucene PRs updated on 2026-11-02 touches LatLonPointDistanceComparator.java, XYPointDistanceComparator.java, Geo3DPointDistanceComparator.java, MultiLeafFieldComparator.java or LatLonPointSortField.java; the last upstream commit on LatLonPointDistanceComparator.java is still 2d6ad2fee6d (2020-12-23), with main at 1c2072f02843. Related, not reports: #8154 (LUCENE-7099, added the comparator; speed only), #8158 (LUCENE-7103, many setBottom calls as a speed problem), #8267 (LUCENE-7212, open; the guard described as a speed guard, copied into the Geo3D comparator), #9921 and #9993 (comparator API redesign; mention the sampled box, not this case), #12082 (a different setBottom defect), #8157 (sort missing first, fixed 2016), open PR #14775 (adds a throwing inverseSort() to LatLonPointSortField/XYPointSortField; does not touch the comparators), SOLR-18006 / apache/solr#3923 (a geodist() desc exception, fixed 2025-12; no box discussion), a 2023 OpenSearch forum thread (score then geo distance in miles, a path that does not reach newDistanceSort; not reproduced there) and a 2019 Elasticsearch forum thread (explains the sampling for a primary distance sort).
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT) through gradle and a standalone runner, assertions on and off; release jars 6.1.0, 6.2.0, 6.6.6, 7.4.0, 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (a main() form of the test; 6.0.0 and 6.0.1 lack the API); fix A in a scratch worktree of 5adff78bb168 (JDK 25.0.3, Linux aarch64); a check probe (segment count, totalHits, a searchAfter page) on main and with fix A, assertions on and off. The cross-model reviews ran on main ea57360912ed, where LatLonPointDistanceComparator.java, LatLonPointSortField.java, LatLonDocValuesField.java and MultiLeafFieldComparator.java are byte-identical to 5adff78bb168's.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceComparator.java:44 (at 5adff78bb168) (implements LeafFieldComparator), :66 (int setBottomCounter, never reset), :88-113 (setBottom: bottom = values[slot] always; the box is rebuilt only if setBottomCounter < 1024 || (setBottomCounter & 0x3F) == 0x3F, line 93, comment :90-92; fix site), :136-176 (compareBottom: a value outside the box is skipped and cmp stays -1, no distance computed), :185-193 (getLeafComparator returns this, so the counter carries across segments of a search)
  • lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java:28-31 (at 5adff78bb168) (the bottom slot 'may not hold the weakest value according to your comparator, in cases where your comparator is not the primary one'), :55-57 (compareBottom 'should return the same result as FieldComparator#compare(int,int) as if bottom were slot1')
  • lucene/core/src/java/org/apache/lucene/search/Sort.java:59-61 (at 5adff78bb168) ('if it produces a tie, then the second SortField is used to break the tie')
  • lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesField.java:140-160 (at 5adff78bb168) (newDistanceSort: 'orders documents by ascending distance', no approximation or primary-only note)
  • lucene/core/src/java/org/apache/lucene/search/MultiLeafFieldComparator.java:45-64 (at 5adff78bb168) (setBottom passed to every comparator; compareBottom returns the first non-zero comparison) and TopFieldCollector.java:79-98 (thresholdCheck drops a hit when reverseMul * compareBottom(doc) <= 0; each competitive hit calls setBottom)
  • https://github.com/apache/lucene/issues/8154, /8158, /8267, /9921, /9993, /12082, /8157, https://github.com/apache/lucene/pull/14775, https://issues.apache.org/jira/browse/SOLR-18006, https://github.com/apache/solr/pull/3923 (read 2026-11-02); GitHub API commits on LatLonPointDistanceComparator.java and file lists of the apache/lucene PRs updated on 2026-11-02, read 2026-11-02
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceComparator.java:37-43 (at 5adff78bb168) (class javadoc), :66, :88-113 (setBottom; guard :93), :136-177 (compareBottom), :185-194 (getLeafComparator returns this), :208-227 (sortKey, +Infinity for a missing value)
  • lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:79-112, (at 5adff78bb168) :186, :228
  • lucene/core/src/java/org/apache/lucene/search/MultiLeafFieldComparator.java:45-64 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/geo/Rectangle.java:143-150 (at 5adff78bb168) (7 cm pad)
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:226-231 (at 5adff78bb168) (one slice without an executor), :760-772 (one collector per slice)
Who is affected
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceComparator.java:66, (at 5adff78bb168) :112, :185-194 (counter per collector)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java:28-31, (at 5adff78bb168) :55-57
  • lucene/core/src/java/org/apache/lucene/search/Sort.java:59-61 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/document/LatLonDocValuesField.java:40, (at 5adff78bb168) :140-160
  • lucene/core/src/java/org/apache/lucene/search/FieldValueHitQueue.java:146-151; (at 5adff78bb168) document/LatLonPointSortField.java:41-43
  • lucene/core/src/test/org/apache/lucene/document/TestLatLonPointDistanceSort.java:241; (at 5adff78bb168) TestLatLonPointDistanceFeatureQuery.java:440-480
  • https://github.com/apache/lucene/issues/8158 and /8267 (read 2026-11-02)
  • https://github.com/apache/lucene/issues/8158 (body, read 2026-11-02: "could still get called many times")
  • lucene/core/src/java/org/apache/lucene/document/NearestNeighbor.java:128 (at 5adff78bb168) (same guard line, not a FieldComparator)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/FieldValueHitQueue.java:146-151; (at 5adff78bb168) search/NonPruningSortField.java:56; document/LatLonPointSortField.java:41-43
How it was found
  • lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java:28-31, (at 5adff78bb168) :55-57

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

Repro files

About the diagrams

A summary still (PNG).

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.