All findings · Lucene finding · LFV-1132 · xy-distance-sort-secondary-key-stale-box-drops-hits

XY distance sort as a later sort key drops closer tied hits, because its comparator keeps a too-small bounding box when the queue bottom gets farther away

Sort by a category, then by XYDocValuesField.newDistanceSort, and the top hit can be a tied document with no location instead of one at distance 3. The distance comparator keeps a pre-filter box built for an earlier, closer bottom; every release that was run, from 8.5.0 on, has it.

Summary

Priority
Medium, rank 199The 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
silent wrong results
Since
8.5.0 (2020; the first release with the XY distance sort)introduced 2020-03-02 · about 6 years
Affected
8.5.0 to 10.6.0 and main (7 releases run; the comparator file is the same in the other tags checked); not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of XYPointDistanceComparator's contract, witnessed by an executable 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

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.

Background: how a sorted top-n search skips documents

  1. A search for the top n hits by a Sort keeps 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.
  2. 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.
  3. The XY distance comparator speeds up compareBottom with 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. setBottom rebuilds the box when the bottom changes.
A three-box flow of a sorted top-1 search over three documents: matching documents go into a comparison with the bottom (first by cat, on a tie by distance, where XYPointDistanceComparator skips any point outside a box around the origin), and better hits go into the TopFieldCollector queue whose weakest entry is the bottom; a red arrow back from the queue marks the step where setBottom should resize the box and does not when the bottom gets farther (1 to +infinity); below, an orange-headed panel shows the expected whole-plane box that lets doc 2 at distance 3 win, and a blue-headed panel shows the actual small box for distance 1 that skips doc 2.
A distance sort skips points outside a box sized to the queue's bottom, so when a later sort key makes the bottom farther and the box is not rebuilt, a closer tied document is dropped. Generated with AI, checked against the code.
Diagram of the bug: three documents sorted by category, then by XY distance from the origin, top 1. Doc 0 (category b, point (1, 0)) fills the queue and the comparator builds a box of half-width 1 around the origin. Doc 1 (category a, no location, distance +Infinity) replaces it, and setBottom keeps the old box because +Infinity is not below Float.MAX_VALUE. Doc 2 (category a, point (3, 0)) ties on category, lies outside the old box and is rejected without its distance being computed, so the top hit is doc 1 instead of doc 2. With the fix the box is reset to the full range and doc 2 is returned.
The pre-filter box built for the old bottom at distance 1, and doc 2 at distance 3 outside it: rejected without its distance being computed, although it beats the new bottom, the document without a location (+Infinity). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. 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.
  2. 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.
  3. 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, so setBottom keeps the old box.
  4. Doc 2 ties on category. Its x = 3 lies outside the old box, so compareBottom answers "not competitive" without computing the distance. You get doc 1, with no error.
  5. 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.

Who is affected

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 mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/...)

  1. The contract. newDistanceSort "orders documents by ascending distance from the location", and a document missing the field "is treated as having Double#POSITIVE_INFINITY distance (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), and compareBottom "should return the same result as FieldComparator#compare(int,int) as if bottom were slot1 and the new document were slot 2" (:55-57).
  2. The box. On setBottom the comparator recomputes "a bounding box representing competitive distance to the top-N" so that compareBottom can "quickly reject hits based on bounding box alone" (document/XYPointDistanceComparator.java:35-37). It starts as the full encoded range (:55-58); setBottomCounter counts calls ("adversary protection", :60-61).
  3. setBottom (:82-99), the fix site. bottom = values[slot] on every call (:83), but the box is rebuilt only if (bottom < Float.MAX_VALUE && (setBottomCounter < 1024 || (setBottomCounter & 0x3F) == 0x3F)) (:87-88), from XYRectangle.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.
  4. compareBottom (:122-161). A point outside the box is skipped (:139-146); only a point inside gets its distance compared (:149-154). It starts at cmp = -1 (:134) and returns -1, "not competitive", when no point is inside (:160).
  5. The callers. MultiLeafFieldComparator.setBottom gives the slot to every comparator (search/MultiLeafFieldComparator.java:45-49), and its compareBottom asks a later comparator only on a tie of the earlier ones (:52-63). TopFieldCollector drops a hit when compareBottom <= 0 (search/TopFieldCollector.java:79-80) and calls setBottom when the queue first fills (:107-109) and after each competitive hit (:94-98).

The worked example (the repro)

  • 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_VALUE is 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 compareBottom returns -1, although compare(+Infinity, 3) is positive. The collector drops doc 2. Top-1 = doc 1; with n = 3 the same search returns [2, 1, 0].

The second trigger: sampling

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.

A third, extreme path

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.

Why a nearest-first sort is safe from this bug

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. Default IndexWriterConfig, codec and IndexSearcher, 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 1024 setBottom calls 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 reaches Float.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 compareBottom is never called). This is no claim about other box issues, for example the separate, already known rounding of XYRectangle.fromPointDistance for 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).

Minimal repro

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.

TestXYDistanceSortAsSecondaryKey.java (lucene/core, public API)58 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.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);
      }
    }
  }
}
Command
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.

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

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.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (only this platform was run)
Command (environment)
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

Observed output

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)

Why it looks unintended

  • LeafFieldComparator names this case (a comparator that only breaks ties) and requires compareBottom to equal compare(bottom, doc); here it says -1 for 3 against +Infinity.
  • The Float.MAX_VALUE guard 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. newDistanceSort promises 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, and Sort defines later SortFields as tie-breakers (Sort.java:58-61). LeafFieldComparator names the non-primary comparator (LeafFieldComparator.java:28-31) and requires compareBottom to agree with compare(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. TestXYPointDistanceSort uses 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 to XYRectangle.fromPointDistance, which throws for a non-finite radius, and added bottom < Float.MAX_VALUE && in front of the rebuild. Before it, a +Infinity bottom 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 +Infinity bottom "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.setBottom has no Float.MAX_VALUE guard (LatLonPointDistanceComparator.java:88-113), and the same 3-document sort with LatLonDocValuesField returns 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).

Suggested fix

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 > boxBottom is never true after the first box.
  • A later-key distance sort pays one box rebuild (one XYRectangle and 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 double field 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), TestXYPointDistanceSort and TestXYDocValuesQueries, 51 tests, 0 failures, 3 nightly tests skipped, assertions on; the repro and the matrix with assertions off (9 tests); the repro and TestXYPointDistanceSort over 5 random-seed iterations (25 tests, 5 nightly skipped); every test in org.apache.lucene.document plus search.TestXY* and search.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 failed checkGoogleJavaFormat, hence the one-line condition.
  • The fixed-code gradle runs and the :lucene:core:check run 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 TestXYPointDistanceSort pass with the same logic.

Only restore the full box for a bottom not below Float.MAX_VALUE (partial)

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.

Drop the sampling and rebuild on every setBottom (not recommended)

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.

Document that newDistanceSort must be the first SortField (not recommended)

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.

Downstream note

No caller of the XY distance sort was found in Elasticsearch, OpenSearch or Solr, so mainly applications that call Lucene directly are affected.

How it was found

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 XYPointDistanceComparator against the contracts it implements (newDistanceSort's ordering, LeafFieldComparator.setBottom and compareBottom) and asked what each method must guarantee. For compareBottom to agree with compare(bottom, doc), the box must contain every point closer than the bottom; setBottom keeps that only if it rebuilds whenever the bottom grows. Two paths do not: a bottom that is not below Float.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").

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
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-02a 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 searchesno report; 0 hits for XYPointDistanceComparator anywhere
Open PRs and upstream commits2026-11-02the 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 filenone touches XYPointDistanceComparator.java or MultiLeafFieldComparator.java; the file's last commit is a 2020 reformat
Mailing lists and JIRA2026-11-02dev@, 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 countedno report

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedthis 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 affectedreleased 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) affectedthe main() form of the repro: top-1 = doc 1 (wrong)
8.5.0 with the comparator from before the guard counterfactualthe repro is right (top-1 = doc 2); the sampling program still fails, with and without the guard
8.4.1 API absentdoes not compile: no XYDocValuesField

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedThe 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 realOwn 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), MediumRe-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 confirmedThe 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 validatedGradle 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, fixedRe-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 neededReviewed 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.

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

Finding record

LFV-1132, snapshot of 2026-11-06

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 in org.apache.lucene.document, search.TestXY* and search.TestSort*, and :lucene:core:check -x test). The hunt ran the repro or its main() 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.

Lucene and upstream sources

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-208
  • lucene/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-87
  • lucene/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-113
  • lucene/core/src/java/org/apache/lucene/search/LeafFieldComparator.java:28-31, (at 5adff78bb168) :41, :55-57; Sort.java:58-61
  • lucene/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:471
  • github.com/apache/lucene/issues/8157 (LUCENE-7102, Robert Muir's comment with the patch; read 2026-11-02)

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), drawn from the repro's three documents.

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.