When the queue's weakest hit changes, the XY distance comparator rebuilds its pre-filter box only if the new bottom is below Float.MAX_VALUE and sampling does not skip the call (after 1024 calls it skips 63 of 64). As a later sort key the bottom can grow; the old box is then too small and drops closer tied hits.
- A search for the top n hits by a
Sortkeeps a queue of the best n so far. The worst of them is the bottom. Each new hit is compared with the bottom (compareBottom); if it does not sort before it, it is dropped. - With several SortFields the first one decides. A later SortField is asked only when all earlier ones tie, so it only breaks ties. Its bottom is the bottom of the whole sort, which need not be its own worst value.
- The XY distance comparator speeds up
compareBottomwith a box: a point outside the square of half-width d around the origin is farther than d, so when d is the bottom's distance the point can be rejected without computing its distance.setBottomrebuilds the box when the bottom changes.

- Index 3 documents: doc 0 (category b, point (1, 0)), doc 1 (category a, no point) and doc 2 (category a, point (3, 0)). Sort by category, then by XY distance from (0, 0), and ask for the top 1.
- You expect doc 2: docs 1 and 2 tie on category a, and distance 3 beats doc 1's missing location, which counts as +Infinity and sorts last.
- Doc 0 fills the queue, so the distance comparator builds a box of half-width 1 around (0, 0). Doc 1 replaces doc 0 on category; its distance +Infinity is not below
Float.MAX_VALUE, sosetBottomkeeps the old box. - Doc 2 ties on category. Its x = 3 lies outside the old box, so
compareBottomanswers "not competitive" without computing the distance. You get doc 1, with no error. - The same stale box appears after 1024 bottom updates, when only every 64th update rebuilds it: 1,100 leading documents, then a tie at distance 100 and at 50, and 100 wins.
Applications that sort by another key first and then by XYDocValuesField.newDistanceSort, ask for fewer hits than match, and have documents without a location (or more than 1024 bottom updates). Default config, any index size. Only ties on the earlier keys are broken wrongly; a nearest-first sort is not affected by this bug. Rare: the XY distance sort is little used.
Mechanism, terms and numbers909 words
XYDocValuesField.newDistanceSort sorts by ascending distance, and a document without the field counts as +Infinity and sorts last. To skip far documents cheaply, XYPointDistanceComparator keeps a box around the origin sized to the distance of the queue's weakest hit (the bottom) and rejects any point outside it without computing its distance. setBottom records every new bottom but rebuilds the box only when the bottom is below Float.MAX_VALUE, and, after 1024 calls, only on every 64th call. That is safe while the bottom only gets closer, which holds when the distance is the first SortField. As a later SortField it only breaks ties, the bottom slot changes whenever an earlier key improves, and its distance can grow: the old box is then too small, and a tied document closer than the bottom but outside the old box is silently dropped. With Sort(cat, distance), three documents and top 1, you get the document without a location instead of the one at distance 3. Wrong in every release that was run (8.5.0 to 10.6.0) and on main; a 13-line change in the comparator (+13/-2) fixes it.
- The contract.
newDistanceSort"orders documents by ascending distance from the location", and a document missing the field "is treated as havingDouble#POSITIVE_INFINITYdistance (missing values sort last)" (document/XYDocValuesField.java:136,:139-140).Sort: if the first SortField "produces a tie, then the second SortField is used to break the tie" (search/Sort.java:58-61).LeafFieldComparator: 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)" (search/LeafFieldComparator.java:28-31), andcompareBottom"should return the same result asFieldComparator#compare(int,int)as if bottom were slot1 and the new document were slot 2" (:55-57). - The box. On
setBottomthe comparator recomputes "a bounding box representing competitive distance to the top-N" so thatcompareBottomcan "quickly reject hits based on bounding box alone" (document/XYPointDistanceComparator.java:35-37). It starts as the full encoded range (:55-58);setBottomCountercounts calls ("adversary protection",:60-61). setBottom(:82-99), the fix site.bottom = values[slot]on every call (:83), but the box is rebuilt onlyif (bottom < Float.MAX_VALUE && (setBottomCounter < 1024 || (setBottomCounter & 0x3F) == 0x3F))(:87-88), fromXYRectangle.fromPointDistance(:90), which throws for a non-finite radius (geo/XYRectangle.java:76-78) and pads the radius by one float ulp (:81). Otherwise the box of an earlier bottom stays.compareBottom(:122-161). A point outside the box is skipped (:139-146); only a point inside gets its distance compared (:149-154). It starts atcmp = -1(:134) and returns -1, "not competitive", when no point is inside (:160).- The callers.
MultiLeafFieldComparator.setBottomgives the slot to every comparator (search/MultiLeafFieldComparator.java:45-49), and itscompareBottomasks a later comparator only on a tie of the earlier ones (:52-63).TopFieldCollectordrops a hit whencompareBottom <= 0(search/TopFieldCollector.java:79-80) and callssetBottomwhen the queue first fills (:107-109) and after each competitive hit (:94-98).
- Doc 0 (b, (1, 0)) fills the 1-slot queue: bottom = 1 on call 0, so the box is rebuilt with radius
Math.nextUp(1f)= 1.0000001: x and y in [-1.0000001, 1.0000001]. - Doc 1 (a, no location): "a" beats "b", so the category comparator decides and doc 1 replaces doc 0. Its distance is
+Infinity(sortKey,:190-208).setBottom: bottom =+Infinity;+Infinity < Float.MAX_VALUEis false, so the radius-1 box stays. - Doc 2 (a, (3, 0)): category ties, so the distance comparator decides. x = 3 is outside the box, no point is inside, and
compareBottomreturns -1, althoughcompare(+Infinity, 3)is positive. The collector drops doc 2. Top-1 = doc 1; with n = 3 the same search returns[2, 1, 0].
From call 1024 on, only calls with (counter & 63) == 63 rebuild the box (1087, 1151, ...). The finder's case: 1,100 documents at (1, 0), each with a smaller category than the one before (so each becomes the new bottom), then A (category "0", (100, 0)) and B (category "0", (50, 0)). The last rebuild is at call 1087 with bottom 1. A's call is number 1100 (1100 & 63 = 12): the bottom becomes 100 but the box keeps radius 1, so B at x = 50 is dropped and top-1 is A. With 1,000 leading documents (A's call below 1024) or 1,087 (A's call is a 64th call) B is found.
The guard bottom < Float.MAX_VALUE also skips the rebuild for a finite bottom of at least Float.MAX_VALUE, which needs extreme coordinates. The fact check ran the repro's 3 documents with doc 1 at (-Float.MAX_VALUE, 0) instead of without a location: the same wrong top-1 on main, the right one with fix A.
With the distance as the first SortField, the bottom is the farthest hit in the queue and only gets closer. A box built for an earlier, larger bottom is then too large, which costs speed but no hits. The guard and the 1024/64 rule are correct under that assumption, and only under it.
Exposure and likelihood331 words
- Who is hit. Applications that use
XYDocValuesField.newDistanceSort(cartesian x/y points,lucene-core, public and not@lucene.experimental) as a later SortField, for example category first and distance second, or after any key that has ties, and ask for fewer hits than match. DefaultIndexWriterConfig, codec andIndexSearcher, any index size. - When. (1) Some documents have no XY location (documented as
+Infinity, sorting last): whenever one of them becomes the bottom after a finite bottom, the box stays stale until a finite bottom arrives on a rebuilding call. (2) More than 1024setBottomcalls in one search's comparator, and the bottom grows on a call that does not rebuild. (3) Rarely in practice: points so far from the origin that the bottom's distance reachesFloat.MAX_VALUE. - Size of the error. Only the tie-breaking among documents that tie with the bottom on all earlier keys is wrong: a dropped document is closer than the bottom it should have replaced. Documents that win on an earlier key are unaffected, and which documents match is unchanged. In the finder's 6-document case with n = 2 the result is
[4, 2]instead of[5, 4]. - Silent. No exception and no assertion, with assertions on and off.
- Not affected by this bug. The distance as the first or only SortField (the case upstream tests cover), and n at least the number of hits (the queue is full only after the last hit, if at all, so
compareBottomis never called). This is no claim about other box issues, for example the separate, already known rounding ofXYRectangle.fromPointDistancefor large centre coordinates (LFV-767), which was not assessed here. The LatLon distance sort does not have the missing-value trigger but has the sampling trigger (LFV-1136, a separate fix site). - Likelihood: rare. The XY distance sort is little used (the hunt's GitHub code search found no production caller in Elasticsearch, OpenSearch or Solr), distance sorts are usually nearest-first, and no report was found since 8.5.0 (2020).
One JUnit test for lucene/core, public API only: index the 3 documents, sort by category and then by XY distance from (0, 0), and take the top 1. Deterministic, single thread; it fails with assertions on and off and with 5 of 5 random seeds.
/*
* 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.search.TopDocs;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
public class TestXYDistanceSortAsSecondaryKey extends LuceneTestCase {
public void testMissingValueBottomDoesNotDropCloserTiedHit() throws Exception {
try (Directory dir = newDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new SortedDocValuesField("cat", new BytesRef("b")));
doc.add(new XYDocValuesField("loc", 1f, 0f));
w.addDocument(doc);
doc = new Document();
doc.add(new SortedDocValuesField("cat", new BytesRef("a")));
w.addDocument(doc);
doc = new Document();
doc.add(new SortedDocValuesField("cat", new BytesRef("a")));
doc.add(new XYDocValuesField("loc", 3f, 0f));
w.addDocument(doc);
try (DirectoryReader reader = DirectoryReader.open(w)) {
Sort sort =
new Sort(
new SortField("cat", SortField.Type.STRING),
XYDocValuesField.newDistanceSort("loc", 0f, 0f));
TopDocs top = new IndexSearcher(reader).search(new MatchAllDocsQuery(), 1, sort);
// expected: doc 2 (cat a, distance 3) beats doc 1 (cat a, no location); actual: doc 1
assertEquals(2, top.scoreDocs[0].doc);
}
}
}
}
cp $REPRO/TestXYDistanceSortAsSecondaryKey.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestXYDistanceSortAsSecondaryKey -Ptests.seed=5EED1132 -Ptests.asserts=true
# also with -Ptests.asserts=false; 5 of 5 random-seed iterations (-Ptests.iters=5) failed the same way
- Expected
2: doc 2 (category a, distance 3) sorts before doc 1 (category a, no location).- Observed
expected:<2> but was:<1>: the document without a location is returned, with no error, with assertions on and off.
More files, environment, full output219 words
The test is the hunt's minimal repro, unchanged; it passes :lucene:core:check -x test as is. It uses new IndexWriterConfig() and newDirectory(); LuceneTestCase still randomizes the default codec and the directory, and the result does not depend on them: 5 random seeds fail the same way, and the hunt's main() form with each release's default codec and NIOFSDirectory fails on every release from 8.5.0. The bug is in the comparator's in-memory state, not in the index. Use the same test as the regression test.
Executed: gradle on main 5adff78bb168 in a scratch worktree, seed 5EED1132 with assertions on and off, and 5 iterations with a random root seed, all failing; with the fix, all passing.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (only this platform was run) |
cp $REPRO/TestXYDistanceSortAsSecondaryKey.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestXYDistanceSortAsSecondaryKey -Ptests.seed=5EED1132 -Ptests.asserts=true
# also with -Ptests.asserts=false; 5 of 5 random-seed iterations (-Ptests.iters=5) failed the same way
TestXYDistanceSortAsSecondaryKey > testMissingValueBottomDoesNotDropCloserTiedHit FAILED
java.lang.AssertionError: expected:<2> but was:<1>
at __randomizedtesting.SeedInfo.seed([5EED1132:D8A341B73D36BB55]: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.TestXYDistanceSortAsSecondaryKey.testMissingValueBottomDoesNotDropCloserTiedHit(TestXYDistanceSortAsSecondaryKey.java:54)
:lucene:core:test (FAILURE): 1 test, 1 failure
# assertions off (seed 5EED1132): the same failure
# 5 iterations, random root seed C3E00AB021044CC: 5 tests, 5 failures, each expected:<2> but was:<1>
# with the fix: :lucene:core:test (SUCCESS)LeafFieldComparatornames this case (a comparator that only breaks ties) and requirescompareBottomto equalcompare(bottom, doc); here it says -1 for 3 against+Infinity.- The
Float.MAX_VALUEguard apparently only keeps an infinite radius from the box helper; before it, an infinite bottom built the full box. - The LatLon twin gets the same 3 documents right.
The full argument330 words
- Contract.
newDistanceSortpromises ascending distance with missing values last (XYDocValuesField.java:136-140); neither its javadoc (:133-151) nor the class javadoc limits it to the first SortField, andSortdefines later SortFields as tie-breakers (Sort.java:58-61).LeafFieldComparatornames the non-primary comparator (LeafFieldComparator.java:28-31) and requirescompareBottomto agree withcompare(bottom, doc)(:55-57). The class javadoc says the box represents "competitive distance to the top-N" (XYPointDistanceComparator.java:35-36); a box smaller than the bottom's distance does not. - Tests.
TestXYPointDistanceSortuses the distance only as the first SortField (TestXYPointDistanceSort.java:69,:108,:239-240), where the bottom never grows, so no test sees it. Upstream does use a distance sort as a later key elsewhere:new Sort(SortField.FIELD_SCORE, LatLonDocValuesField.newDistanceSort("foo", lat, lon))(TestLatLonPointDistanceFeatureQuery.java:471). - History. LUCENE-9243 (
c653c04bb17, 2020-03-02, #10283, "Add fudge factor when creating a bounding box of a xycircle") switched toXYRectangle.fromPointDistance, which throws for a non-finite radius, and addedbottom < Float.MAX_VALUE &&in front of the rebuild. Before it, a+Infinitybottom built the full box (Math.max(-Float.MAX_VALUE, x - bottom)and so on). The issue and its PR discuss only the fudge factor. An executed counterfactual: 8.5.0 with the comparator from before the guard returns the right top-1. - The LatLon precedent. In LUCENE-7102 (#8157, 2016), Robert Muir wrote that for a
+Infinitybottom "we can just bound Integer.MIN_VALUE .. Integer.MAX_VALUE for each dimension", because "+Inf means we've filled the priority queue with only missing values, so any possible value competes"; for-Infinity, "only missing values can possibly compete anymore (and only if you have another comparator)". So the use of a distance sort next to another comparator was considered.LatLonPointDistanceComparator.setBottomhas noFloat.MAX_VALUEguard (LatLonPointDistanceComparator.java:88-113), and the same 3-document sort withLatLonDocValuesFieldreturns doc 2. - The sampling is framed as protection of speed: "don't make gobs of bounding boxes if comparator hits a worst case order (e.g. backwards distance order)" (
XYPointDistanceComparator.java:84-86). Nothing documents approximate results.
The devil's-advocate check169 words
The strongest "intended" case (the cross-model reviews, OpenAI GPT-5.6, rated it moderate): the box and its sampling were designed for the shrinking bottom of a nearest-first sort; the comparator is package-private and LeafFieldComparator is @lucene.experimental (LeafFieldComparator.java:41); upstream XY tests use the distance only as the first key.
Why it fails: newDistanceSort is public, not experimental and not limited to the first position, and Sort makes every later SortField a tie-breaker; LeafFieldComparator's own javadoc names the non-primary comparator, and compareBottom's contract is exact; the guard apparently exists to keep a non-finite radius away from the box helper (an inference from the code; its commit and tests do not document a kept box as intended), and before it, as in the LatLon twin, a +Infinity bottom means the full box. The argument explains how the bug arose (an assumption that held for the first key), not why dropped hits would be acceptable. Verdict: not intended, a defect (finder, reproducer, the cross-model reviews, the devil's-advocate verifier, adjudication, audit).
Recommended Rebuild the box whenever the bottom grows; full box for a bottom not below Float.MAX_VALUE (fix A)
In XYPointDistanceComparator.setBottom, remember the bottom the box was built for: reset to the full box when the bottom is not below Float.MAX_VALUE, and always rebuild when the bottom grows; the 1024/64 sampling stays for a shrinking bottom (+13/-2). The repro, the finder's trigger matrix, 699 tests in document.*, TestXY* and TestSort*, and :lucene:core:check -x test pass.
if ((bottom < Float.MAX_VALUE) == false) {
// the bottom is not below Float.MAX_VALUE (+Infinity: no location): use the full box
this.minX = Integer.MIN_VALUE;
this.maxX = Integer.MAX_VALUE;
this.minY = Integer.MIN_VALUE;
this.maxY = Integer.MAX_VALUE;
boxBottom = Double.POSITIVE_INFINITY;
} else if (bottom > boxBottom || setBottomCounter < 1024 || (setBottomCounter & 0x3F) == 0x3F) {
// a box built for a smaller bottom is too small, so always rebuild when the bottom grows
boxBottom = bottom;
XYRectangle rectangle = XYRectangle.fromPointDistance((float) x, (float) y, (float) bottom);
// ... unchanged: encode minX, maxX, minY, maxY from the rectangle
}All options and trade-offs4 options ยท 791 words
One fix was built and validated in a scratch worktree of 5adff78bb168; the alternatives are listed with their trade-offs. The LatLon sibling (LFV-1136) needs the same "rebuild when the bottom grows" rule in LatLonPointDistanceComparator.setBottom, a separate fix site.
Rebuild the box whenever the bottom grows; full box for a bottom not below Float.MAX_VALUE (fix A) (validated by the checks) (recommended)
A new field boxBottom remembers the bottom the current box was built for. A bottom that is not below Float.MAX_VALUE (+Infinity, or a finite distance that large) resets the full box, as the code did before LUCENE-9243 for +Infinity and as the LatLon twin does; it also catches NaN and -Infinity, which valid points and a finite origin cannot produce, so that part is only defensive. A bottom larger than boxBottom always rebuilds. A bottom that shrinks keeps the 1024/64 sampling. It is the finder's patch with the else if condition on one line, as google-java-format wants, and one comment reworded after the fact check (it used to say "no finite box covers this bottom", which is wrong for a finite bottom of at least Float.MAX_VALUE).
if ((bottom < Float.MAX_VALUE) == false) {
// the bottom is not below Float.MAX_VALUE (+Infinity: no location): use the full box
this.minX = Integer.MIN_VALUE;
this.maxX = Integer.MAX_VALUE;
this.minY = Integer.MIN_VALUE;
this.maxY = Integer.MAX_VALUE;
boxBottom = Double.POSITIVE_INFINITY;
} else if (bottom > boxBottom || setBottomCounter < 1024 || (setBottomCounter & 0x3F) == 0x3F) {
// a box built for a smaller bottom is too small, so always rebuild when the bottom grows
boxBottom = bottom;
XYRectangle rectangle = XYRectangle.fromPointDistance((float) x, (float) y, (float) bottom);
// ... unchanged: encode minX, maxX, minY, maxY from the rectangle
}Trade-offs
- A nearest-first sort behaves exactly as today: its bottom never grows, so
bottom > boxBottomis never true after the first box. - A later-key distance sort pays one box rebuild (one
XYRectangleand four encodes, the same work as each of the first 1024 calls) each time its bottom grows, so for growth the 1024/64 throttle no longer applies: an order in which the bottom grows on almost every update rebuilds on almost every call. No benchmark was run. - One
doublefield per comparator; no index, format or API change.
What the checks validated
- Stock main: the repro fails with assertions on and off (seed
5EED1132) and in 5 of 5 random-seed iterations; the finder's trigger matrix fails 3 of 8 (the 3-document case, the 6-document case and the 1,102-document case: 1,100 leading documents, then A and B; its 5 controls pass). - Fixed: the repro, the trigger matrix (8 of 8),
TestXYPointDistanceSortandTestXYDocValuesQueries, 51 tests, 0 failures, 3 nightly tests skipped, assertions on; the repro and the matrix with assertions off (9 tests); the repro andTestXYPointDistanceSortover 5 random-seed iterations (25 tests, 5 nightly skipped); every test inorg.apache.lucene.documentplussearch.TestXY*andsearch.TestSort*: 71 classes, 699 tests, 0 failures, 31 skipped. :lucene:core:check -x test(google-java-format, ecj lint, forbidden APIs, the license check) passes with the fix and the test; eclint was not configured in this checkout and Error Prone was skipped. The finder's original line layout passed every test but failedcheckGoogleJavaFormat, hence the one-line condition.- The fixed-code gradle runs and the
:lucene:core:checkrun above used the old wording of the full-box comment. After the rewording (the only change), the patched file was re-checked: google-java-format 1.37.0 (the version Lucene main pins) leaves it unchanged, and with it shadowed in, the repro (assertions on and off), the trigger matrix (8 of 8) and the fact check's extreme-coordinate probe pass. The gradle suites were not re-run for the comment change. - In the hunt: the repro, the reproducer's own tests (missing-value and sampling), the cross-model reviewers' own tests and upstream
TestXYPointDistanceSortpass with the same logic.
Add an else that resets minX ... maxY to the full range when the bottom is not below Float.MAX_VALUE.
Trade-offs
Behaves like the comparator before LUCENE-9243 for this case, which the history check ran: it fixes the 3-document repro, but the sampling trigger still fails. Not validated as a patch.
Rebuild the box on every call.
Trade-offs
Fixes both triggers, but brings back the cost the 1024/64 rule protects against for a nearest-first sort that sees hits in backwards distance order (LUCENE-7099). Not validated.
A javadoc restriction instead of a code change.
Trade-offs
Legitimises silently wrong results that a small change removes, contradicts LeafFieldComparator's javadoc for non-primary comparators, and the LatLon twin already gets the 3-document case right. Not validated.
No caller of the XY distance sort was found in Elasticsearch, OpenSearch or Solr, so mainly applications that call Lucene directly are affected.
A proof-obligation audit of XYPointDistanceComparator's contract: compareBottom must agree with compare(bottom, doc), so the box must cover every point closer than the bottom. The audit found two setBottom paths that keep a smaller box; a public-API JUnit witnessed the first, and a trigger matrix checked against an independent sort model witnessed both.
The method in full194 words
Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).
- The finder read
XYPointDistanceComparatoragainst the contracts it implements (newDistanceSort's ordering,LeafFieldComparator.setBottomandcompareBottom) and asked what each method must guarantee. ForcompareBottomto agree withcompare(bottom, doc), the box must contain every point closer than the bottom;setBottomkeeps that only if it rebuilds whenever the bottom grows. Two paths do not: a bottom that is not belowFloat.MAX_VALUE, and an unsampled call after 1024. Both matter only when the bottom can grow, which needs the distance as a later SortField. - The finder wrote the one-test JUnit (3 documents, top 1) on the public API and ran it on main with assertions on and off, 10.6.0 and 9.12.3; and an 8-test trigger matrix (both triggers and 5 controls), each case checked against an independent sort model and against the same search with n = maxDoc; and a fix sketch that passes all of it and upstream
TestXYPointDistanceSort. - An independent reproducer, two executed cross-model reviews, a devil's-advocate verifier and an adjudication then reproduced it with their own tests (section "Prior reports, history and checks").
No public report found in a bounded search (2026-11-02): apache/lucene issues, PRs and commits incl. the migrated JIRA archive, the Lucene mailing lists, Solr, Elasticsearch, OpenSearch, Lucene.NET, all of GitHub and the 408 open PRs. Nearest: #10283 (added the guard), #8157 (the LatLon twin's +Infinity rule), #10278 (created the class).
Searches3 searches
None of the hits is the same bug. Other nearest items: #8154 (LUCENE-7099, the box and the sampling), #8158 (LUCENE-7103, a further speed-up: a cheaper sort key that sorts the same way) and #8159 (LUCENE-7104, missing values sort last), all 2016; #12082 (open: setBottom not called before compareBottom after getLeafComparator, another mechanism); open PRs #14775 (inverse sort in XYPointSortField) and #16847 (int casts in TestXYPointDistanceSort), neither touching setBottom. Search limits: GitHub search does not index all review comments; list and JIRA search match words; web coverage is incomplete; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; apache/lucene-solr, Elasticsearch, OpenSearch and its geospatial plugin, Solr, Lucene.NET, all of GitHub) | 2026-11-02 | a scripted prior-report search (45 queries; 129 hits, every high and medium hit read) and 44 manual searches; on top of the finder's 43-query run and the reproducer's, devil's advocate's and judge's own searches | no report; 0 hits for XYPointDistanceComparator anywhere |
| Open PRs and upstream commits | 2026-11-02 | the changed files of all 408 open apache/lucene PRs, the PRs closed since 2026-10-28 and those created in the last 48 hours; git log of the file | none touches XYPointDistanceComparator.java or MultiLeafFieldComparator.java; the file's last commit is a 2020 reformat |
| Mailing lists and JIRA | 2026-11-02 | dev@, java-user@, issues@ and commits@ (18 direct queries), 8 JQL queries (LUCENE, SOLR); 8 web search queries were also listed, but their results were not kept, so they are not counted | no report |
In every release that was run, from the first with the API: 8.5.0 (2020) to 10.6.0, and on main. The sampling rule came with the class (LUCENE-9238, 88dd1c3f3d4); the missing-value trigger with LUCENE-9243 (c653c04bb17), which added the Float.MAX_VALUE guard. Both landed before 8.5.0; a counterfactual run ties the repro to the guard. Not fixed upstream.
Versions tested5 rows
Origin: the class came with 88dd1c3f3d4 (2020-02-21, Ignacio Vera, LUCENE-9238 / #10278, PR apache/lucene-solr#1272; branch_8x 53cb6acf912), with the 1024/64 rebuild rule of LatLonPointDistanceComparator, whose box, sampling and "backwards distance order" comment came with e169050dc3f (2016-03-11, Robert Muir, LUCENE-7099 / #8154); then a +Infinity bottom still built the full box. c653c04bb17 (2020-03-02, Ignacio Vera, LUCENE-9243 / #10283, PR apache/lucene-solr#1278, one "LGTM" review; branch_8x d9787406f89) added the bottom < Float.MAX_VALUE guard; the only commits that add it are c653c04bb17 and its branch_8x copy d9787406f89. Both are in 8.5.0 (tag 2020-03-18), so no release was ever right. The file is one blob in the 10 tags 8.5.0-8.8.1 (the local clones have no 8.8.2-8.11.3 tags; 8.11.4 was run) and another from 9.0.0 to 10.6.0 and on main; its last commit is 2d6ad2fee6d (2020-12-23, reformatting), and upstream main 1c2072f0284, branch_11x and branch_10_6 carry the same blob (checked 2026-11-02). Not new in 10.6.
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | this doc's test fails through gradle with assertions on and off and 5 of 5 random seeds; passes with the fix |
| 10.6.0, 10.5.2, 9.12.3 | affected | released jars: the reproducer's JUnit fails on all three and the finder's on 10.6.0 and 9.12.3, expected:<2> but was:<1> |
| 8.5.0, 8.11.4, 9.0.0, 10.0.0, 10.6.0 (also -da) | affected | the main() form of the repro: top-1 = doc 1 (wrong) |
| 8.5.0 with the comparator from before the guard | counterfactual | the repro is right (top-1 = doc 2); the sampling program still fails, with and without the guard |
| 8.4.1 | API absent | does not compile: no XYDocValuesField |
An independent reproducer, two executed cross-model reviews (OpenAI GPT-5.6, refute and intended lenses), a devil's-advocate verifier, an adjudication, an audit and a final repro check reproduced it with their own tests and agree on the root cause. This doc's gradle runs reproduced it on main and validated the fix.
Checks and verdicts483 words
Every executed check saw the dropped hit come from the box that setBottom did not rebuild, and every one that ran the fix saw it pass. The cross-model reviews ran on an earlier main checkout, ea57360912ed, whose XYPointDistanceComparator.java is the same blob as at 5adff78bb168; a separate re-run repeated the refute reviewer's probe on 5adff78bb168.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | The repro fails on main (assertions on and off), 10.6.0 and 9.12.3; the trigger matrix fails 3 of 8 with its 5 controls passing; with the fix sketch all pass, and upstream TestXYPointDistanceSort passes. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own 5-test probe (both triggers and 3 controls: a contender inside the kept box, n = maxDoc, growth on the sampled call 1087) fails 2 of 5 with assertions on and off and passes 5 of 5 with the fix; intended argument rated moderate. |
| independent reproducer | real | Own test (other data, an in-test n = maxDoc oracle) fails on main (on and off), 10.6.0, 10.5.2 and 9.12.3; its own fix passes it; its sampling test fails, and a control with 1,000 leading documents passes, which isolates the 1024/64 rule. |
| cross-model review, intended lens (OpenAI GPT-5.6, executed) | real (high) | Own suite fails 2 of 5 (assertions on) and passes with the fix; the strongest works-as-designed case was rated moderate and rejected. |
| devil's-advocate verifier | real (high) | Re-ran the repro on main (on and off), 10.6.0 (off) and 9.12.3; every intended, documented, misuse and unreachable argument fails; its probe shows LatLon right and XY wrong for the same 3 documents. |
| adjudication | confirmed (high), Medium | Re-ran the finder's test (off) and the reproducer's (on): both fail; the finder's with the fix: passes. |
| separate re-run, audit and final repro check | confirmed | The repro fails on main with assertions on and off; the refute reviewer's probe fails 2 of 5 (off), one failure per trigger; with the fix the repro passes. |
| brief validation run | reproduced; fix validated | Gradle on main: the repro fails with assertions on and off and 5 of 5 random seeds; fix A passes the repro, the trigger matrix, the closest suites and :lucene:core:check -x test. |
| fact check | real as described; 4 low-severity errors, fixed | Re-ran the repro on main (assertions on and off, 5 of 5 random-seed iterations: fails) and the trigger matrix (3 of 8 fail; 8 of 8 pass with the fix); found and ran the extreme-coordinate path (fails on main, passes with the fix); every file:line, commit and count re-checked. |
| cross-model fact check (OpenAI GPT-5.6) | defect real; corrections needed | Reviewed the brief against the code, the history and the logs: the defect is real and fix A semantically safe; its confirmed corrections (the extreme-coordinate path, the document count, the unsaved web searches, tighter wording) are applied here. |
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 556 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,574 words
- Registry title
- XY distance sort used as a later Sort key drops closer tied hits, because XYPointDistanceComparator.setBottom keeps an old, too-small bounding box when the bottom grows (a missing-value bottom of +Infinity, or growth after 1024 bottom updates)
- Area
- core / document: XY distance sort (XYDocValuesField.newDistanceSort, XYPointDistanceComparator)
- Priority
- Medium, rank 199: The XY distance sort used as a later sort key silently drops closer tied hits from the top-N, with default settings in every release since 8.5.0. This happens every time a document without a location becomes the queue bottom, and can also happen after more than 1024 bottom updates. It sits low in Medium because the cartesian XY sort is little used and only its use as a later sort key is affected, not the usual nearest-first sort.
- Impact class
- silent wrong results
- Impact note
- Applications that sort with XYDocValuesField.newDistanceSort as a later SortField (for example Sort(category, distance)) and ask for fewer hits than match get a silently wrong top-N, with the default IndexWriterConfig, codec and searcher, at any index size and with -ea or -da: a document that ties on the earlier keys and is closer than the queue bottom is dropped whenever the bottom's distance has grown since the comparator last built its bounding box, which happens deterministically when a document without a location (distance +Infinity) becomes the bottom after a finite bottom (3 documents and n=1 suffice), and also after 1024 bottom updates when the bottom grows on one of the 63 of 64 updates that skip the rebuild; which documents match is unchanged, only which of them make the returned top n; a nearest-first (primary) distance sort is not affected because its bottom only shrinks; released since 8.5.0.
- Summary
- You sort three documents, (cat b, distance 1), (cat a, no location) and (cat a, distance 3), with Sort(new SortField("cat", STRING), XYDocValuesField.newDistanceSort("loc", 0, 0)) and ask for the top 1; you expect the (a, 3) document, because ties on cat are broken by ascending distance and a missing location counts as +Infinity and sorts last. Instead you get the (a, no location) document: when it becomes the queue bottom, setBottom skips rebuilding the comparator's bounding box because +Infinity is not below Float.MAX_VALUE, so the box built for the earlier bottom at distance 1 stays, and compareBottom rejects the (a, 3) document as outside it. The same stale box arises after 1024 bottom updates, when only every 64th update rebuilds it and the bottom grows on another one; a nearest-first distance sort is not affected because its bottom only shrinks.
- Introduced
- c653c04bb1717f9813e50d2fd2cef1d323ee6036 (master, now main; branch_8x copy d9787406f895d166e0d13eb5ce8a98865f1f3e39), 'LUCENE-9243: Add fudge factor when creating a bounding box of a xycircle (#1278)' by iverase (migrated issue apache/lucene#10283, PR apache/lucene-solr#1278, one 'LGTM' review), for the missing-value trigger of the minimal repro: it added 'bottom < Float.MAX_VALUE &&' to the rebuild condition in setBottom because the new XYRectangle.fromPointDistance throws on a non-finite radius, where the code before it built the full box for an infinite bottom (Math.max(-Float.MAX_VALUE, x - bottom) and so on) [exec: 8.5.0 with the comparator as it was before the guard returns the right top-1 for the minimal repro; read: git log -S 'bottom < Float.MAX_VALUE' finds only c653c04bb17 and d9787406f89]. The stale box as such (rebuild only while setBottomCounter < 1024 or on every 64th call) came with the class in 88dd1c3f3d4eb37c522b9b00a5ccce979e6efad6 (master; branch_8x copy 53cb6acf912f54a07b870fe6726bfee329b62d06), 'LUCENE-9238: Add new XYPointField, queries and sorting capabilities (#1272)' by iverase (migrated issue apache/lucene#10278, PR apache/lucene-solr#1272, 'Adaptation of LatLonPoint to generic cartesian space'), whose rebuild rule comes from LatLonPointDistanceComparator (LUCENE-7099, 2016) [exec: the sampling-trigger program fails on 8.5.0 with and without the guard]. Both landed before the first release with the API, so no release was ever correct (2020-03-02 (author date of c653c04bb17, the missing-value trigger); 2020-02-21 (author date of 88dd1c3f3d4, the class and its sampling rule))
- First affected version
- 8.5.0 [exec: the cross-version form of the minimal repro gives the wrong top-1 on 8.5.0; 8.4.1 has no XY point API (the program fails to compile at XYDocValuesField)]
- Affected versions
- every release from 8.5.0 through 10.6.0, and main 5adff78bb168 (unreleased 12.0.0-SNAPSHOT) [exec: the cross-version form of the minimal repro (same 3 documents, same Sort, same expected top-1) gives the wrong top-1 on 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.6.0 (-ea and -da) and main (follow-up); the finder's JUnit fails on main 5adff78bb168 (-ea and -da), 10.6.0 and 9.12.3; the independent reproduction's JUnit fails on main (-ea and -da), 10.6.0, 10.5.2 and 9.12.3; the intended-behaviour check re-ran the finder's on 10.6.0 (-da) and 9.12.3; read: XYPointDistanceComparator.java is blob 0b20764235 in the 8.5.0 to 8.8.1 tags and blob 3ab9a8fde9 in every tag from 9.0.0 to 10.6.0 and on main; the history clone's 8.x tags stop at 8.8.1, and 8.11.4 was executed]. Not affected: 8.4.1 and earlier (API absent) [exec]. Not new in 10.6. Not fixed upstream: upstream main 1c2072f0284 (2026-11-02), branch_11x and branch_10_6 carry the same blob, and the file's last upstream commit is still 2d6ad2fee6d (2020-12-23, reformatting) on 2026-11-02. All runs on aarch64 Linux with JDK 25
- 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): a scripted prior-report search (45 queries over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 129 distinct hits, every high and medium hit read, none about this bug), 44 GitHub searches (apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch and its geospatial plugin, Solr, Lucene.NET, all of GitHub; 0 hits for XYPointDistanceComparator anywhere), the changed files of all 408 open apache/lucene PRs (none touches XYPointDistanceComparator.java or MultiLeafFieldComparator.java), the PRs closed since 2026-10-28 and created in the last 48 h (none in this area), 18 list and 8 JQL queries and 8 web searches; the finder's run (43 queries) and the independent reproduction's and the intended-behaviour check's own searches found nothing either; re-check 2026-11-02: no issue or PR mentions XYPointDistanceComparator, no issue or PR about distance sort updated since 2026-10-31, the newest PR is #16965 (unrelated), and the file is unchanged upstream. Context, not this bug: #10283 (LUCENE-9243, the change that added the Float.MAX_VALUE guard; it discusses only the fudge factor), #8157 (LUCENE-7102, 2016: in the LatLon twin an infinite bottom was made to mean the full box, the precedent for the intended behaviour), #10278 (LUCENE-9238, created the class), #8154, #8158 and #8159 (origin of the box, the sampling and the missing-last rule), #12082 (open: setBottom not called before compareBottom after getLeafComparator; another mechanism), and open PRs #14775 (inverse sort in XYPointSortField) and #16847 (int casts in TestXYPointDistanceSort), neither touching setBottom. Gaps: GitHub search does not index all review comments, list search matches words, and private security reports are not visible
- Lucene commit tested
- main
5adff78bb168(2026-11-02) through gradle in a scratch worktree, with assertions on and off and 5 random-seed iterations, without and with the fix (the repro, the finder's 8-test trigger matrix,TestXYPointDistanceSort,TestXYDocValuesQueries, every test inorg.apache.lucene.document,search.TestXY*andsearch.TestSort*, and:lucene:core:check -x test). The hunt ran the repro or itsmain()form on 8.4.1 (API absent), 8.5.0 (also with the comparator from before the guard), 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main. Only Linux aarch64 with OpenJDK 25.0.3 was run.
- Finding record
lucene/core/src/java/org/apache/lucene/document/XYPointDistanceComparator.java:52-58 (at 5adff78bb168) (the initial box is the full int range), :60-61 (setBottomCounter, 'adversary protection'), :82-99 (setBottom: bottom = values[slot] on every call, but the box is rebuilt only if bottom < Float.MAX_VALUE (:87) and (setBottomCounter < 1024 || (setBottomCounter & 0x3F) == 0x3F)), :122-161 (compareBottom: a value outside the box is skipped, -1 when no value is inside)lucene/core/src/java/org/apache/lucene/document/XYDocValuesField.java:133-151 (at 5adff78bb168) (newDistanceSort javadoc: ascending distance; a missing document is treated as Double.POSITIVE_INFINITY, 'missing values sort last' at :140; no restriction to the first SortField; factory at :151)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') and :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/MultiLeafFieldComparator.java:45-64 (at 5adff78bb168) (setBottom goes to every comparator; a later comparator's compareBottom runs on every tie of the earlier ones), lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:79-91 (thresholdCheck drops the hit when compareBottom <= 0), lucene/core/src/java/org/apache/lucene/search/Sort.java:58-61 (later SortFields break ties)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/document/XYPointDistanceComparator.java:35-37, (at 5adff78bb168) :55-61, :82-99 (fix site; :83, :87-88, :90), :122-161 (:134, :139-146, :149-154, :160), :190-208lucene/core/src/java/org/apache/lucene/document/XYDocValuesField.java:133-151; (at 5adff78bb168) XYPointSortField.java:29 (missing value +Infinity); lucene/core/src/java/org/apache/lucene/geo/XYRectangle.java:70-87lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java:28-31, (at 5adff78bb168) :55-65; MultiLeafFieldComparator.java:45-63; TopFieldCollector.java:79-80, :94-98, :102-110; Sort.java:58-61
- Who is affected
lucene/core/src/java/org/apache/lucene/document/XYDocValuesField.java:32-55, (at 5adff78bb168) :133-151 (no @lucene.experimental)lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:102-110, (at 5adff78bb168) :150-170 (setBottom and compareBottom only once the queue is full)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/document/XYDocValuesField.java:133-151; (at 5adff78bb168) XYPointDistanceComparator.java:35-36, :84-86; LatLonPointDistanceComparator.java:88-113lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java:28-31, (at 5adff78bb168) :41, :55-57; Sort.java:58-61lucene/core/src/test/org/apache/lucene/search/TestXYPointDistanceSort.java:69, (at 5adff78bb168) :108, :239-240; lucene/core/src/test/org/apache/lucene/document/TestLatLonPointDistanceFeatureQuery.java:471github.com/apache/lucene/issues/8157 (LUCENE-7102, Robert Muir's comment with the patch; read 2026-11-02)
- TestXYDistanceSortAsSecondaryKey.java (lucene/core, public API):
repro/LFV-1132/TestXYDistanceSortAsSecondaryKey.java(58 lines)
A summary still (PNG), drawn from the repro's three documents.
assets/LFV-1132/bug.png(556 KB)
