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.
- 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). - With several sort keys, the second key is compared only when the first key ties, the third only when both tie, and so on.
newDistanceSortsorts 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.

- 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 smallercatthan the one before and lie 1.1 km away; docs 1024 and 1025 sharecat1000 and lie 2,002 km and 1,501 km away. - You expect doc 1025: distance breaks the tie on
cat, and 1,501 km is closer than 2,002 km. - You get doc 1024, with no error, with assertions on and off.
- 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.
- 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.
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.
- Every competitive hit updates the bottom.
TopFieldCollectorcallssetBottomafter each hit that enters a full queue (search/TopFieldCollector.java:94-100, and:102-112when the queue first fills).MultiLeafFieldComparator.setBottompasses the bottom slot to every comparator, the later ones too (search/MultiLeafFieldComparator.java:45-49). - The sampled rebuild (fix site).
LatLonPointDistanceComparator.setBottomalways setsbottom = values[slot](:89) but rebuilds the box only ifsetBottomCounter < 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) andgetLeafComparatorreturns the same object for every segment (:185-194), so the count is per collector, not per segment: one collector for all segments with the defaultIndexSearcher(search/IndexSearcher.java:226-231), one per slice with an executor (:760-772). - 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 oncat: 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 ofgeo/Rectangle.java:150). Doc 1024 wins oncat(1000 < 3977) without a distance check; its update has counter 1024,1024 & 0x3Fis 0, sobottombecomes about 2,002 km while the 1.1 km box stays. - The drop. Doc 1025 ties on
cat, soMultiLeafFieldComparator.compareBottomasks the distance comparator (:52-64). Latitude 13.5 is outside the box, socompareBottomreturns its initialcmp = -1without computing the distance (LatLonPointDistanceComparator.java:148-160,:176), andthresholdCheckdrops the hit (search/TopFieldCollector.java:79-92). Its distance, about 1,501 km, would have won.searchAfteruses 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. - 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
LeafFieldComparatorstates.
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.newDistanceSortas the second or laterSortField: "newest first, then nearest", "category, then nearest", "rating, then nearest". Public API only; defaultIndexWriterConfig, 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 anIntFieldfirst key sorted bySortedNumericSortField(desc), where the numeric comparator can skip documents, 6,000 documents still gave 11/20 wrong. - Effect. Silent: no exception,
totalHitsthe same as with the fix and the match set unchanged, the same with-eaand-da(a check probe prints both). A closer tied document is replaced by a farther one in the top N or on asearchAfterpage. - 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.
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.
/*
* 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);
}
}
}
}
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.
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,
-eaand-da) and gradle (seed DEADBEEF, codecAsserting(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 printssegments=1), and the counter spans segments anyway. MatchAllDocsQuery.INSTANCEdoes not exist on 10.0.0 and older; usenew MatchAllDocsQuery()there.checkGoogleJavaFormatpasses with the file in the tree. As a regression test, the same method fits inTestLatLonPointDistanceSort.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 (only this platform was run) |
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
# 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)Sortsays a later SortField breaks ties of the earlier ones;newDistanceSortdocuments ascending distance, with no primary-only note.LeafFieldComparatorwarns that a non-primary comparator's bottom may not be its weakest value, andcompareBottommust matchcompare(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
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-151builds comparators for later sort fields as usual;document/LatLonPointSortField.java:41-43neither rejects nor special-cases that position.
TestLatLonPointDistanceSortalways puts distance first (Sort(distanceSort, id),:241).TestLatLonPointDistanceFeatureQuerysorts 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.
- 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
6f124e10017removed 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.
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.
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
+Infinitybottom (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.*plusTestNearest, 504 tests, 26 skipped, 0 failures;TestLatLonPointDistanceSortandTestNearestwith-Ptests.nightly=true, 9 tests (testRandomHugeincluded);checkGoogleJavaFormatpasses. - 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/coresuite.
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.
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).
No code change.
Trade-offs
Contradicts Sort's tie-break contract and LeafFieldComparator's non-primary note; users cannot see the limit coming.
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.
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)equalscompare(bottom, doc)(LeafFieldComparator.java:55-57), including for a comparator that is not the primary one (:28-31). - Discharging it for
LatLonPointDistanceComparatorneeds "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-eaand-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).
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs, commits; the JIRA archive; dev@, java-user@, issues@, commits@; downstream repositories; the web (automated, two runs) | 2026-11-02 | 16 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 read | no report of this defect |
| GitHub (manual): apache/lucene, apache/solr, apache/lucene-solr, elastic/elasticsearch, opensearch-project/OpenSearch, GitHub-wide | 2026-11-02 | 51 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 Overflow | 2026-11-02 | 10 JIRA queries, 12 list queries, 10 web queries | SOLR-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, #8157 | speed discussions of the guard, comparator API redesign and other setBottom defects; none reports wrong results |
| Re-check before this doc | 2026-11-02 | gh search on apache/lucene updated since 2026-11-01 for five terms; the PRs updated that day | nothing new; the comparator file last changed in 2020 |
| Independent fact check re-check | 2026-11-02 | commits on the comparator file; apache/lucene issues and PRs updated since a recent cutoff | nothing new |
| Re-check before publishing | 2026-11-02 | commits 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 day | nothing new |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | top 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 | affected | the 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 | affected | the same wrong top hit (the main() form, -ea; 7.5.0: first lucene-core release) |
| 7.4.0, 6.6.6, 6.2.0 | affected | the same wrong top hit (the main() form, -ea; lucene-sandbox) |
| 6.1.0 | first release | the 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 | counterfactual | correct top hit, doc 1025 |
| 6.0.0, 6.0.1 | API absent | no LatLonDocValuesField or distance comparator (the program does not compile) |
| main + fix A | fix | the JUnit passes, asserts on and off |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | The JUnit fails on main (-ea, -da) and 10.6.0 (-da) and passes with the fix; mechanism check and likelihood probe. |
| independent reproducer | real | Own 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, high | Own 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, high | Rated the speed-guard defence weak; its 1023-update and distance-first controls pass; showed the XY analogue (LFV-1132). |
| devil's-advocate check | real, high | Re-ran on main and 10.6.0; the guard was described upstream as a speed shortcut. |
| adjudication | confirmed, Medium | Re-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 | confirmed | Re-run on main fails (-ea, -da); probe with an IntField first key still 11/20 wrong; one minimal test. |
| this doc | real as described | Gradle: 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 described | Re-ran the repro, the gradle command and fix A, and ran a missing-location probe; their wording fixes are applied. |
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 411 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,686 words
- 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 (amain()form of the test; 6.0.0 and 6.0.1 lack the API); fix A in a scratch worktree of5adff78bb168(JDK 25.0.3, Linux aarch64); a check probe (segment count,totalHits, asearchAfterpage) on main and with fix A, assertions on and off. The cross-model reviews ran on mainea57360912ed, whereLatLonPointDistanceComparator.java,LatLonPointSortField.java,LatLonDocValuesField.javaandMultiLeafFieldComparator.javaare byte-identical to5adff78bb168's.
- 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, :228lucene/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-57lucene/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-160lucene/core/src/java/org/apache/lucene/search/FieldValueHitQueue.java:146-151; (at 5adff78bb168) document/LatLonPointSortField.java:41-43lucene/core/src/test/org/apache/lucene/document/TestLatLonPointDistanceSort.java:241; (at 5adff78bb168) TestLatLonPointDistanceFeatureQuery.java:440-480https://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
- TestLatLonDistanceSortSecondaryKey.java (lucene/core, public API):
repro/LFV-1136/TestLatLonDistanceSortSecondaryKey.java(59 lines)
A summary still (PNG).
assets/LFV-1136/bug.png(411 KB)
