All findings · Lucene finding · LFV-379 · latlon-distance-feature-bisection-gap

LatLonPoint.newDistanceFeatureQuery can skip a closer document under top-k pruning: its radius search stops up to 1 m short and the box adds only 7 cm

Under top-k pruning, as in the default search(query, k), LatLonPoint.newDistanceFeatureQuery can silently skip a closer, higher-scoring document: its radius search stops up to 1 m short and its box adds only 7 cm. In the repro a document 999.803 m away loses to one 999.999 m away.

Summary

Priority
Low, rank 683A top-k pruning gap in the core, released default searcher silently misranks distance-feature hits, but the error is under a metre and shows in under 1% of queries, so it heads the score-bound pruning rows above the unreleased LFV-203.
Impact
silent wrong results
Since
8.0.0 (LUCENE-8482, 10060a6237cc, 2018-09-19); came with the query, not a regressionintroduced 2018-09-19 · about 8 years
Affected
every release from 8.0.0 to 10.6.0, and main (8.8.2 to 8.11.3 inferred: 8.8.1 and 8.11.4 have identical code)
Status
documented
Found by
code reading, then an SMT solver and an exhaustive check over float thresholds; confirmed by executed tests

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

The finding in 30 seconds

Distance-feature scoring ranks documents by how close their point is to an origin. Under top-k pruning the scorer may skip documents that cannot make the top k, and it does that with a box around the origin.

Background: how distance-feature pruning works

  1. LatLonPoint.newDistanceFeatureQuery(field, weight, lat, lon, pivot) scores each document weight * pivot / (pivot + distance): closer means higher, and a document at the pivot distance scores weight / 2.
  2. In a top-k search, once 1,000 hits are counted, the collector tells the scorer the lowest score that can still enter the top k (setMinCompetitiveScore). The scorer may then skip documents that score below it, and only those.
  3. This scorer converts the score into a radius by binary search, and from then on visits only the documents inside a lat/lon box around the circle of that radius (Rectangle.fromPointDistance, which adds a 7 cm margin).
Diagram of the minimal repro on a line due north of the origin (0, 0), pivot 1000 m: the binary search returns 999.729 m (interval up to 1000.325 m); the pruning box edge is 7 cm further, at 999.799 m; doc 2001 at 999.803 m (score 0.5000492) is outside the box and never visited; the true cut-off for the threshold 0.5000003 is 999.999 m; doc 0 at 999.999 m (score 0.50000024) is returned as the top-1.
Due north of the origin: the radius the binary search returns, the box edge 7 cm beyond it, the closer document just outside the box (never visited), and the true cut-off beyond it. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You index 2,002 points around the origin (0, 0): doc 0 is 999.999 m due north, 2,000 fillers are thousands of km away, and doc 2001 is 999.803 m due north. You run searcher.search(LatLonPoint.newDistanceFeatureQuery("loc", 1, 0, 0, 1000), 1), which scores 1000 / (1000 + distance).
  2. You expect doc 2001, the closest: it scores 0.5000492, doc 0 scores 0.50000024.
  3. You get doc 0. After 1,000 counted hits the collector tells the scorer it may skip documents scoring below 0.5000003; doc 2001 scores above that and is skipped anyway.
  4. Why: the scorer binary-searches for the largest distance that still scores 0.5000003, stops once the interval [999.729 m, 1000.325 m] is under 1 m wide and keeps the lower end; the true cut-off is 999.999 m. It then visits only a box of 999.729 m + 7 cm = 999.799 m, and doc 2001 is 4.5 mm outside it.
  5. The effect is small but silent: the skipped document lies less than 1 m beyond the box edge (under about 0.53 m on a scorer's first threshold update), near due north, south, east or west of the origin, where the box touches the circle. Every checked release since 8.0.0 has it.

Who is affected

Top-k searches ranked by LatLonPoint.newDistanceFeatureQuery alone (top level, boosted, or next to filters), with default settings, since 8.0.0. Not when it is a SHOULD boost on another scoring query. The skipped document lies less than 1 m beyond the box edge, near a cardinal bearing; on uniform random data 0 of 2,300 single-segment and 3 of 1,300 ten-segment queries.

Mechanism, terms and numbers1,013 words

When a top-k search scored by LatLonPoint.newDistanceFeatureQuery has counted enough hits (1,000 for IndexSearcher#search(query, k)), the collector passes the lowest score that can still enter the top k to the query's scorer, which may then skip only documents scoring below it. The scorer turns that score into a radius with a binary search (computeMaxDistance) that stops once the interval is at most 1 m wide and returns its lower end, although distances up to the upper end may still compete. It then visits only the documents inside a lat/lon box built from that radius with Rectangle.fromPointDistance, which pads it by 7 cm. A competitive document between the box edge and the true cut-off is never visited, and a farther one is returned in its place. Because the box circumscribes the circle, this only happens near due north, south, east or west of the origin, and the skipped document lies in a thin band just beyond the box edge: less than about 0.53 m beyond it on a scorer's first threshold update and 0.93 m on later updates. The javadoc's own example use, a SHOULD boost next to another scoring query, is by code reading not affected. In the repro, search(newDistanceFeatureQuery("loc", 1, 0, 0, 1000), 1) returns a document 999.999 m north of the origin (score 0.50000024) instead of one 999.803 m north (score 0.5000492). Every release from 8.0.0 to 10.6.0 and main is affected (8.8.2 to 8.11.3 by inference); the fix is one line.

The code (main 645d4fe14a20, lucene/core/src/java/org/apache/lucene/)

  • The threshold. IndexSearcher#search(query, n) uses TopScoreDocCollectorManager(n, after, 1000) (search/IndexSearcher.java:100, :597-599). Once more than 1,000 hits are counted, TopScoreDocCollector calls scorer.setMinCompetitiveScore(Math.nextUp(topScore)) (search/TopScoreDocCollector.java:116-119, :154-164); in a later segment setScorer passes on the threshold already reached (:84-91).
  • The radius. DistanceScorer#computeMaxDistance (document/LatLonPointDistanceFeatureQuery.java:283-308): javadoc "... so we binary search the maximum distance. The limit is set to 1 meter." (:284-285); invariant score(min) >= minScore && score(max) < minScore (:295); while (max - min > 1) (:296); assert min == Double.MAX_VALUE || score(min + 1) < minScore; (:306); return min; (:307). The first search starts from EARTH_MEAN_RADIUS_METERS * Math.PI (:256), so after 25 halvings the interval is 0.5965 m wide; later searches start from the previous radius and can end up to 1 m wide.
  • The box. setMinCompetitiveScore (:353-472) builds Rectangle.fromPointDistance(originLat, originLon, maxDistance) (:375), which uses (radiusMeters + 7E-2) / EARTH_MEAN_RADIUS_METERS (geo/Rectangle.java:149-150, comment // LUCENE-7143). When fewer than min(leadCost, it.cost()) / 8 points are estimated in the box (:462-469), it replaces the iterator with the documents inside the box after the current one (:382-460, :470-471).
  • Sampling. After the first 256 calls, setMinCompetitiveScore recomputes the radius and the box only on every 32nd call (:360-364); in between, the previous radius and box stay in place. The repro needs a single call; the sampling affects how often later updates happen, not the size of the gap.
  • The repro's numbers (public Lucene classes on the main jar): threshold nextUp(0.50000024) = 0.5000003 after the 1,001st hit; bisection from 20,015 km in 25 steps to [999.7288 m, 1000.3253 m], returning 999.7288 m; box north edge 999.7988 m (encoded latitude 214543); doc 2001 at 999.8033 m (encoded latitude 214544) is outside; the true cut-off is 999.9989 m. A box built from 1000.3253 m (fix A) or from 999.7288 + 1 m (fix B) contains it.
  • Only near the axes. The box is the circle's circumscribed lat/lon rectangle, so it touches the circle only due N/S/E/W. For r = 1000 m, a document 0.1 / 0.3 / 0.6 m beyond r is outside the box only within +-0.44 / +-1.23 / +-1.86 degrees of a cardinal bearing (outside the north edge when (r + d) cos(bearing) > r + 0.07 m).
  • Scoring is unchanged. score() recomputes the distance from doc values (:311-316, :235-245; the distance is SloppyMath.haversinMeters, not an exact geodesic), so pruned and exhaustive search give every document the same float score; only the visited set differs.
  • Size of the gap. Over every float threshold in [0.5, 1) for pivots 1 m to 1,000 km (58,720,256 values), on a scorer's first update the true radius exceeds the returned one by 0.24-0.30 m on average and at most 0.5965 m, and 80-87% of thresholds leave a competitive distance beyond the 7 cm pad; on later updates the gap can reach 0.9994 m. The first-update bound holds for every threshold: the first search always starts from 20,015,114 m, so its final interval is always 0.5965 m wide; later intervals are at most 1 m wide by the loop condition (:296).
  • Three sizes; keep them apart. (1) How far the returned radius falls short of the true cut-off: under 0.5965 m on a first update, under 1 m later. (2) How far a skipped document lies beyond the box edge: (1) minus the 7 cm pad, so under about 0.53 m and 0.93 m. (3) How much farther the returned k-th hit is than the skipped one: (2) plus up to one float step of the score, because the returned hit scores just below the threshold (nextUp(topScore), search/TopScoreDocCollector.java:160) and the score is a float (:279-281), so its distance can lie anywhere in one float step, about (pivot + distance) x 6e-8 to 1.2e-7 m. By calculation with the scorer's formula and loop (first update, pivots 1 m to 1,000 km), the bound on (3) is under 0.60 m up to 100 km, 0.62-0.76 m at 1,000 km, 0.78-1.00 m at 3,000 km, 1.13-1.46 m at 8,000 km and 1.59-2.43 m at 15,000 km; computed, not a search that was run. In the repro (3) is 0.196 m.
  • The integer sibling is exact. LongDistanceFeatureQuery#computeMaxDistance (document/LongDistanceFeatureQuery.java:273-294) has the same loop and return min on long distances, where max - min <= 1 leaves nothing unresolved (0 mismatches in the same exhaustive check). The double copy inherited it.
Exposure and likelihood449 words
  • Who is hit. Applications that rank top-k results by LatLonPoint.newDistanceFeatureQuery where the distance query alone sets the score: the top-level query, with or without a weight (BoostQuery), or the SHOULD clause next to FILTER clauses (ReqOptSumScorer.java:282-295 forwards the threshold when the required side scores 0), and later segments, which inherit the threshold. By code reading also the only scoring clause of a conjunction (ConjunctionScorer.java:88-92) and DisjunctionMaxQuery with tieBreaker 0 (DisjunctionMaxScorer.java:116-120). By the finder's reading not when another scoring clause is combined with it, which is the javadoc's example use (a SHOULD boost next to a scoring query).
  • Default vs opt-in. Default codec, IndexWriterConfig and IndexSearcher#search(query, k), which prunes after 1,000 counted hits. Using the query is an explicit choice of a public core API.
  • What goes wrong. Silently, a document that scores higher than the k-th returned hit is missing from the top k and a farther one is returned. Returned documents are real matches with the same scores as in an exhaustive search.
  • Size. The skipped document lies less than about 0.53 m (first threshold update) to 0.93 m (later updates) beyond the box edge, near due N/S/E/W of the origin. The hit returned in its place is farther by at most that band plus one float step of the score: by calculation under about 0.6 m up to 100 km from the origin, more at continental distances (about 1.1 to 2.4 m at 8,000 to 15,000 km); 0.196 m in the repro.
  • Scale. No minimum index size beyond the pruning start (the repro has 2,002 documents), but the box must hold fewer than one eighth of the candidates; when it would hold more, for example with dense data around the origin, the scorer keeps its old iterator and nothing is skipped.
  • How often on natural data. Uniform random points (200,000 in a 20 km square or 1,000,000 in a 5 km square at latitude 45, pivot 1 km, k = 10 or 1): one segment 0 of 1,000, 0 of 300 and 0 of 1,000 queries; ten segments 1 of 1,000 and 2 of 300 (0.67%); 0 with the fix. The finder's explanation: a later segment's scorer receives the converged threshold once and bisects from 20,015 km, while within one segment the threshold usually keeps rising past the gap. With one planted near-threshold document at a near-cardinal bearing, 46 of 300 random configurations returned the wrong top-1 (0 with the fix, and 0 for the distance sort and the long sibling).
  • Workaround. A distance sort (LatLonDocValuesField.newDistanceSort), which the randomized check found correct, or no pruning (new TopScoreDocCollectorManager(k, Integer.MAX_VALUE)), at the cost of scoring every match.

Minimal repro

One JUnit test on the public API plus the test framework: one segment, default codec, 2,002 points, search(query, 1). The 2,000 far fillers make pruning start (after 1,000 counted hits) and make the box selective. It fails with assertions on and off on main and 10.6.0, and passes with either fix, so it doubles as the regression test.

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

import org.apache.lucene.geo.GeoEncodingUtils;
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.TopDocs;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestLatLonDistanceFeatureMinCompetitiveGap extends LuceneTestCase {

  private static void addPoint(IndexWriter w, double lat, double lon) throws Exception {
    Document doc = new Document();
    doc.add(new LatLonPoint("loc", lat, lon));
    doc.add(new LatLonDocValuesField("loc", lat, lon));
    w.addDocument(doc);
  }

  public void testClosestDocReturned() throws Exception {
    int numFar = 2000;
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w =
          new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
        addPoint(w, GeoEncodingUtils.decodeLatitude(214586), 0);
        for (int i = 0; i < numFar; i++) {
          addPoint(w, 40 + i * 1e-3, 100);
        }
        addPoint(w, GeoEncodingUtils.decodeLatitude(214544), 0);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        TopDocs top =
            new IndexSearcher(reader)
                .search(LatLonPoint.newDistanceFeatureQuery("loc", 1, 0, 0, 1000), 1);
        // doc 0 is 999.999 m due north, the last doc 999.803 m: expected the last doc, actual doc 0
        assertEquals("top-1 doc", numFar + 1, top.scoreDocs[0].doc);
      }
    }
  }
}
Command
cp $REPRO/TestLatLonDistanceFeatureMinCompetitiveGap.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestLatLonDistanceFeatureMinCompetitiveGap -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and once more with -Ptests.asserts=false: it fails both ways
Expected
search(newDistanceFeatureQuery("loc", 1, 0, 0, 1000), 1) returns doc 2001 (999.803 m, score 0.5000492).
Observed
It returns doc 0 (999.999 m, score 0.50000024): top-1 doc expected:<2001> but was:<0>.

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

More files, environment, full output446 words

Copy the test into lucene/core/src/test/org/apache/lucene/document/ of a Lucene checkout and run the command below. It uses public Lucene APIs plus Lucene's test framework (LuceneTestCase, TestUtil) and no package-private member. Fixed data, one thread, the default codec pinned with TestUtil.getDefaultCodec() (so the random test codec cannot change the point tree), ByteBuffersDirectory and a plain IndexSearcher.

Doc 0 sits at encoded latitude 214586 on longitude 0 (999.9991 m due north of (0, 0)), the 2,000 fillers at latitude 40 to 42 and longitude 100, and the last doc, 2001, at encoded latitude 214544 (999.8033 m due north). The fillers matter twice: search(query, 1) starts pruning only after 1,000 counted hits, and they make the box selective enough to replace the iterator. A sweep of the filler count, on an earlier copy of the test, passed with 500, 998 and 999 fillers and failed with 1,000, 1,500, 2,000, 2,100, 2,500 and 3,000.

It failed in every recorded run: seed DEADBEEF with assertions on and off under gradle and the standalone runner, and seed 5EED1234 5 of 5 times. It passes :lucene:core:checkGoogleJavaFormat.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 9.12.3, 10.0.0 and 10.6.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (the only platform it ran on)
Command (environment)
cp $REPRO/TestLatLonDistanceFeatureMinCompetitiveGap.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestLatLonDistanceFeatureMinCompetitiveGap -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and once more with -Ptests.asserts=false: it fails both ways

Observed output

# gradle, fresh worktree at main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (the same with false)
TestLatLonDistanceFeatureMinCompetitiveGap > testClosestDocReturned FAILED
    java.lang.AssertionError: top-1 doc expected:<2001> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:B2B0C866892F948E]: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.apache.lucene.document.TestLatLonDistanceFeatureMinCompetitiveGap.testClosestDocReturned(TestLatLonDistanceFeatureMinCompetitiveGap.java:55)

# standalone runner, the same test (tests_run=1 failures=1 each, seed DEADBEEF)
main 645d4fe14a20    -ea and -da   top-1 doc expected:<2001> but was:<0>
10.6.0               -ea and -da   the same
10.0.0, 9.12.3       -da           the same

# the numbers behind it (public Lucene classes on the main jar)
doc 0     lat bits 214586 lat 0.008993195  distance 999.9991 m  score 0.50000024
last doc  lat bits 214544 lat 0.008991435  distance 999.8033 m  score 0.5000492
minCompetitiveScore = nextUp(score(doc 0)) = 0.5000003  last doc competes: true
bisection: 25 steps, min 999.7288 m, max 1000.3253 m (width 0.5965 m); returns min
true competitive radius 999.9989 m
box for radius 999.7288 m (+7 cm pad): maxLat bits 214543 -> last doc (214544) inside: false, doc 0 (214586) inside: false
box for radius 1000.3253 m (+7 cm pad): maxLat bits 214671 -> last doc (214544) inside: true, doc 0 (214586) inside: true
box for radius 1000.7288 m (+7 cm pad): maxLat bits 214757 -> last doc (214544) inside: true, doc 0 (214586) inside: true
north edge of box(min) = 999.7988 m from the origin

Why it looks unintended

  • Scorable#setMinCompetitiveScore allows skipping only documents that score below the threshold; the skipped one scores 0.5000492 against 0.5000003.
  • Lucene's own test runs CheckHits.checkTopScores on this query: pruned and exhaustive top-k must agree.
  • The method's own assert, score(min + 1) < minScore, puts the cut-off up to 1 m above the returned radius; the box allows 7 cm.
The full argument210 words
  • Contract. Scorable#setMinCompetitiveScore: "Tell the scorer that its iterator may safely ignore all documents whose score is less than the given minScore" (search/Scorable.java:45-52). The skipped document scores about 800 float ulps above the threshold.
  • Test. TestLatLonPointDistanceFeatureQuery#testRandom runs CheckHits.checkTopScores(random(), q, searcher) (:396-437, :431), which requires TOP_SCORES and COMPLETE top-k to agree and asserts s1.score() < minScore for every skipped document (CheckHits.java:690-697, :748). Its random points are spread over the whole globe, so they are unlikely to land in the sub-metre band.
  • Public javadoc. LatLonPoint#newDistanceFeatureQuery gives the exact score formula and mentions no approximation (document/LatLonPoint.java:357-368).
  • The original review allowed over-inclusion only. In LUCENE-8482 (#9528), Adrien Grand (jpountz) wrote that when updating the iterator "it's fine if some documents still have a score that is less than the minimum competitive score" and that with a box "we will only fail to exclude documents that are within the bounding box of the circle but not in the circle itself" (comment of 2018-09-07).
  • History. The loop was copied from LongDistanceFeatureQuery (LUCENE-8340, a9acdfdb544, 2018-09-04), where it is exact; the 7 cm pad in Rectangle.fromPointDistance is older (045659533cd, LUCENE-7147, 2016-03-29) and was sized for tiny-radius boxes (LUCENE-7143, #8198), not for a search tolerance.
The devil's-advocate check174 words

The case (built by the intended-behaviour devil's advocate; every reviewer rated it weak): the method's javadoc says "The limit is set to 1 meter", so a 1 m pruning tolerance is documented; in #9528 the author, Ignacio Vera, and the reviewer both called a rounding effect "expected"; SloppyMath.haversinMeters is documented as off by up to 40 cm (util/SloppyMath.java:38), so a misranking inside a sub-metre band is within the score's precision; and the effect is small and rare.

Why it fails: the javadoc is on a private method and says when the search stops, not that pruning may drop competitive documents, and the next assert shows the author knew the cut-off lies above min. The "expected" in #9528 is about two very close points whose float scores tie in a boost-vs-sort test; here the skipped score is strictly higher. Both scoring modes compute the same score from the same encoded values, so distance accuracy cannot explain a disagreement between them. Small and rare is a question of priority (Low), not of intent.

Suggested fix

Recommended Return max, the upper end of the final interval (computeMaxDistance)

Return the upper end of the final 1 m interval from computeMaxDistance (return max;): its score is below the threshold, so every competitive document is closer and inside the box. The minimal test, the upstream test (160 of 160 over 20 iterations) and the whole org.apache.lucene.document package (499 tests) pass.

      assert score(min) >= minScore;
-      assert min == Double.MAX_VALUE || score(min + 1) < minScore;
-      return min;
+      assert score(max) < minScore;
+      return max;
All options and trade-offs2 options · 653 words

Two one-line options, both validated with gradle in a fresh scratch worktree at 645d4fe14a20 (JDK 25.0.3, seed DEADBEEF, 2026-10-30): the minimal test fails without a fix and passes with either one, with assertions on and off. Both remove the reproduced gap and passed the suites listed; neither is proven for every coordinate (poles, dateline, large radii) beyond the randomized checks. A is the finder's fix and the one most checked, B is an independent reproducer's fix and changes less behaviour. Neither was benchmarked. Not recommended: a larger pad in Rectangle.fromPointDistance, which LatLonPointDistanceQuery, the distance sort comparator, NearestNeighbor, GeoEncodingUtils and Circle2D share; it would widen all their boxes and would not name the cause.

Return max, the upper end of the final interval (computeMaxDistance) (validated by the checks) (recommended)

In computeMaxDistance, return max; instead of return min;, with assert score(max) < minScore; replacing assert min == Double.MAX_VALUE || score(min + 1) < minScore; and one javadoc sentence. The loop invariant gives score(max) < minScore and the score never rises with distance, so every document that scores at least the threshold is closer than max and inside the box.

      assert score(min) >= minScore;
-      assert min == Double.MAX_VALUE || score(min + 1) < minScore;
-      return min;
+      assert score(max) < minScore;
+      return max;

Trade-offs

  • The early return at :289 (score(previousMaxDistance) >= minScore) can no longer fire after a scorer's first bisection, because the returned max always scores below its threshold, so every sampled call re-runs the bisection (at most 25 score evaluations). By code reading the box is still rebuilt only when the result changes (:368-371). Not benchmarked.
  • The box radius is now up to 0.6-1 m above the true cut-off instead of up to 1 m below it: a few more candidates at the edge, none skipped.

What the checks validated

Gradle, fresh scratch worktree at 645d4fe14a20, -Ptests.seed=DEADBEEF, 2026-10-30:

  • the minimal test fails without the fix with -Ptests.asserts=true and false, and passes with it both ways; after git apply -R it fails again;
  • TestLatLonPointDistanceFeatureQuery with -Ptests.iters=20: 160 tests, 0 failures;
  • --tests 'org.apache.lucene.document.*': 60 suites, 499 tests, 26 skipped, 0 failures (among them TestLatLonPointDistanceFeatureQuery, TestLongDistanceFeatureQuery, TestLatLonPointDistanceSort and the minimal test); this run and the 20-iteration run did not set -Ptests.asserts, so each ran only in the build's seed-derived default assertion mode;
  • :lucene:core:checkGoogleJavaFormat passes with the fix and the test.
  • Earlier, in the hunt, with the change shadowed onto the stock jars: the finder's test passes 6 of 6 with -ea and -da, and the independent reproducer's 6 of 6 with -ea (and with -da in the fact check's run); the randomized differential goes from 46 of 300 wrong top-1 to 0, the ten-segment natural-data runs to 0 and the scorer-level contract test to 0; the same line changed in the 8.0.0 source makes 8.0.0 pass.

Keep min, build the box from maxDistance + 1 (setMinCompetitiveScore) (validated by the checks)

Rectangle.fromPointDistance(originLat, originLon, maxDistance + 1) at :375, with a comment; the independent reproducer's fix. The loop exits with max - min <= 1, so every competitive distance is below min + 1, as the existing assert at :306 says.

Trade-offs

The bisection, its early return and how often the box is rebuilt stay as they are; the box reaches up to 1.07 m beyond min, slightly larger than with A; the call site now depends on the loop's 1 m tolerance, so a later change of one must update the other.

What the checks validated

Gradle, same worktree and seed: the minimal test passes with assertions on and off; TestLatLonPointDistanceFeatureQuery with 20 iterations: 160 tests, 0 failures; the org.apache.lucene.document package: 60 suites, 499 tests, 26 skipped, 0 failures (these two in the default assertion mode only); the format check passes. Earlier the reproducer's own 6-method test passed 6 of 6 with it.

Downstream note

By code reading only, Elasticsearch and OpenSearch build this query for distance_feature on geo_point fields; whether their query shapes pass a top-k threshold to this scorer was not established, and nothing was tested there.

How it was found

Code reading in the hunt plan flagged the mismatch: a 1 m radius search next to a 7 cm box margin. An SMT solver (cvc5, floating-point theory) then produced concrete thresholds where a competitive distance falls outside the box, an exhaustive check of 58.7 million float thresholds measured the gap, and a public-API test confirmed it on the stock jar.

The method in full187 words

Method: semi-formal (a hypothesis from code reading, witnessed by a tool). The hunt plan for the distance-feature pruning area pointed at the 1 m bisection that keeps the lower end while the box pads only 7 cm.

  • Solver. With cvc5 1.4.2 (QF_FP), query q1, a proof attempt that the loop invariant and the exit test imply score(r + 0.075) < minScore, came back SAT in 20 s; q2, the real bisection unrolled 11 steps from a reachable previous radius of 2,048 m (pivot 1000, boost 1, a symbolic float threshold), came back SAT in about 3 minutes. Both models were replayed on the JVM.
  • Exhaustive check. A JVM transcription of the search over every float threshold in [0.5, 1) for 7 pivots (58,720,256 thresholds) measured the gap (0.24-0.30 m on average, at most 0.5965 m on a first update) and found 0 mismatches for the integer sibling.
  • Tests. A public-API JUnit on the stock main jar failed; randomized and natural-data probes measured how often it happens. The adjudication's credibility audit and the registration check re-ran it; it was later reduced to the single test shown here.

Prior reports, history and checks

Prior reports

No earlier report found: 215 queries over GitHub issues and PRs, the JIRA archive, the mailing lists and the web on 2026-10-26, rechecked on 2026-10-30. Closest: #9528, the issue that introduced the query, where the box was meant to over-include only.

Searches5 searches

Related, not this bug: #9528 / LUCENE-8482 (introduced the query); #9387 / LUCENE-8340 (the exact integer sibling); #8198 / LUCENE-7143 (origin of the 7 cm pad, about tiny-radius boxes); #11245 / LUCENE-10208 (the only upstream failure of TestLatLonPointDistanceFeatureQuery, a collector bug fixed by #431); elastic/elasticsearch#39770 (over-inclusive ranges in the long sibling, a performance remark); luxir-search/luxir#135 (another engine's reimplementation that adds slack, with no mention of a Lucene defect). Open PRs #16847 and #14475 touch other lines of the file. Limits: keyword search only; GitHub search does not index PR review comments (LUCENE-8482 was reviewed in the issue thread, read in full); private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
Search tool (GitHub issues, PRs and commits incl. the migrated JIRA archive; dev@, java-user@, issues@, commits@, builds@; downstream trackers; web)2026-10-2656 queries: class, method and test names (computeMaxDistance, LatLonPointDistanceFeatureQuery, newDistanceFeatureQuery, setMinCompetitiveScore), symptoms; 996 distinct hits, the medium ones readno report of this bug
GitHub GraphQL search (apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, all of GitHub)2026-10-26110 searches over issues and PRs, open and closed; JIRA keys LUCENE-8482, LUCENE-8340, LUCENE-7143; 3 code searches for copies of computeMaxDistanceonly related items (#9528, #9387, #8198, #11245, es#39770, luxir#135)
lists.apache.org, JIRA archive, web2026-10-2622 list searches plus the full bodies of 228 CI mails naming the test classes and 9 java-user mails; 9 JQL searches; 11 web searches (2 throttled)no report; the CI mails list the test only among passing tests
Open apache/lucene PRs2026-10-26the changed files of all 410 open PRsnone changes computeMaxDistance or the box (#16847 and #14475 touch other lines)
GitHub, recheck for this doc2026-10-30 (saved)read-only gh calls: the five search terms, issues and PRs updated since 2026-10-26 mentioning distance, open PRs updated since 2026-10-26 by changed file, upstream main head and the file's blob and linesnothing new; upstream main 2f424fd428da still has return min; at :307 and the box at :375

History

Came with the query in 8.0.0 (LUCENE-8482, 10060a6237cc, 2018-09-19); the radius search and the box it feeds are unchanged since. Fails on 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main; 8.0.0 with return max passes. Not fixed upstream.

Versions tested6 rows

Introduced by 10060a6237cc "LUCENE-8482: Added feature query in LatLonPoint to boost results by distance" (Ignacio Vera, 2018-09-19), a JIRA patch reviewed by Adrien Grand in the issue thread (now #9528), with no pull request; the archived final patch already has while (max - min > 1), return min; and the box from maxDistance. The loop was adapted from LongDistanceFeatureQuery (LUCENE-8340, a9acdfdb544, 2018-09-04); the 7 cm pad is from 045659533cd (LUCENE-7147, 2016-03-29). git log -L287,308 on the query at 645d4fe14a20 lists only the introducing commit; later commits to setMinCompetitiveScore and Rectangle change neither the radius, nor the returned end, nor the north/south extent of the box. All 51 release tags in the history clone from 8.0.0 to 10.5.2 have the same method text; 8.8.2 to 8.11.3 are not in the clone and were not checked one by one (8.8.1 and 8.11.4 bracket them with identical code), so they are affected by inference. Upstream main 2f424fd428da (2026-10-30) has the file unchanged (same blob as 645d4fe14a20).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle and the standalone runner, -ea and -da)
10.6.0 (release jar) affectedthe minimal test fails (-ea and -da)
10.0.0, 9.12.3 (release jars) affectedthe minimal test fails (-da); the hunt's 6-method test fails 5 of 6 on 9.12.3, 10.0.0, 10.5.2 and main ea57360912ed
8.0.0, 8.11.4, 9.0.0 (release jars) 8.x portthe hunt's 8.x port of the test fails: TOP_SCORES top-1 0.50000024 vs COMPLETE 0.5000492
8.0.0 with return max fix checkthe 8.x port passes
7.4.0, 7.5.0, 7.7.1, 7.7.3 no APInewDistanceFeatureQuery does not exist

Independent checks

An independent reproducer with its own geometry, an intended-behaviour devil's advocate, read-only reviews by an independent reviewer from another model family (OpenAI GPT-5.6), an adjudication, a security screen and a credibility audit all found it real and not intended; this doc re-ran the test on main and three releases and validated both fixes with gradle.

Checks and verdicts433 words

Not done: a cross-model review that executes code (both ran read-only; separate runs ran the repro); an independent test on 8.x (only the finder's 8.x port ran there); origins at the poles and across the dateline only in the randomized differential (5 and 11 of 100 configurations wrong).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatecvc5 q1 and q2 SAT, replayed on the JVM; exhaustive gap check over 58.7M thresholds; randomized differential 46 of 300 wrong top-1 (0 with the fix); natural-data exposure; a 6-method public-API JUnit fails 5 of 6 on main (-ea, -da), 10.5.2 and 9.12.3, and its 8.x port on 8.11.4 and 8.0.0; passes with the fix.
refute review (OpenAI GPT-5.6, reading only) not real (low), proceduralCould not run Java, so it voted not real on procedure only; its reading agreed with the root cause, found the inputs in contract and rated the intended argument weak; a separate re-run of the repro failed.
Independent reproducer realOwn JUnit with another geometry (origin (37, -122), pivot 300 m, 20,000 fillers, the near documents due east and due south) fails 4 of 6 on main, 10.5.2 and 9.12.3, 40 of 40 over 10 seeds; controls inside the box and on a diagonal pass; own fix (option B) passes.
Intended review (OpenAI GPT-5.6, reading only) real (medium)Intended argument rated weak; a separate run re-ran the repro (fails on main and 10.5.2, passes with the fix) and, per the adjudication's record (the hunt's record), recomputed the bisection numbers.
Devil's advocate real (high)Four intended readings refuted; a scorer-level contract test skips documents scoring at least the threshold for 345 to 355 of 401 thresholds at every cardinal bearing, 0 on a diagonal, 0 for the long sibling, 0 with the fix; size bound corrected to 0.9994 m on later updates.
adjudication confirmed (high)Re-ran both JUnits on main with new seeds (-ea, -da) and the independent reproduction's with the fix; not a duplicate; suggested a lower impact on severity grounds.
history, security, audit, minimizer keepHistory: since 8.0.0, not a regression. Security screen: none (12,000 pruned searches, no unsafe result). Credibility audit and registration check re-ran the tests with fresh seeds. The minimizer reduced the repro to one test and swept the filler count.
this doc reproduced; both fixes validatedGradle in a fresh 645d4fe14a20 worktree without and with each fix; the minimal test on main, 10.6.0, 10.0.0 and 9.12.3; the repro's numbers recomputed from the public classes; upstream recheck on 2026-10-30.

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

Finding record

LFV-379, snapshot of 2026-11-03

Registry title
LatLonPoint.newDistanceFeatureQuery can skip a competitive document under top-k pruning: computeMaxDistance returns the lower end of its 1 m binary search, and the pruning box built from that radius adds only a 7 cm pad
Area
core / document: LatLonPoint distance feature query (dynamic pruning through setMinCompetitiveScore)
Priority
Low, rank 683: A top-k pruning gap in the core, released default searcher silently misranks distance-feature hits, but the error is under a metre and shows in under 1% of queries, so it heads the score-bound pruning rows above the unreleased LFV-203.
Impact class
silent wrong results
Impact note
Top-k searches scored by LatLonPoint.newDistanceFeatureQuery with pruning on (the default IndexSearcher#search(query, k), default codec, IndexWriterConfig and searcher, any index size) can silently return a farther document instead of a closer, higher-scoring one whenever the minimum competitive score reaches the distance scorer (executed: the top-level query with or without a weight, the SHOULD clause next to a FILTER clause, a later segment; by code reading also the only scoring clause among FILTERs and DisjunctionMaxQuery with tieBreaker 0; not when another scoring clause is combined with it), in every release since 8.0.0; the missed document is at most about 0.6 m (first threshold update) to 1 m (later updates) closer than the returned k-th hit and lies near due north, south, east or west of the origin, and on dense uniform random data it showed up in 0 to 0.7% of queries, only on 10-segment indexes.
Summary
You index points with LatLonPoint plus LatLonDocValuesField and run searcher.search(LatLonPoint.newDistanceFeatureQuery("loc", 1, 0, 0, 1000), 1), expecting the closest document, because Scorable#setMinCompetitiveScore lets a scorer skip only documents that score below the threshold and Lucene's own TestLatLonPointDistanceFeatureQuery asserts that pruned (TOP_SCORES) and unpruned (COMPLETE) top-k agree for this query. Instead, once a document 999.999 m due north (score 0.50000024) fills the queue, a document 999.803 m due north (score 0.5000492) is never visited and the farther one is returned. The scorer turns the threshold into a radius with a binary search that stops when the interval is 1 m wide and keeps the lower end (999.729 m, although every distance up to 999.999 m still competes), and the box it then searches adds only 7 cm to that radius, so competitive documents just beyond the box edge are dropped.
Introduced
10060a6237ccd2785f6cbe248ca7254028f8eb04 'LUCENE-8482: Added feature query in LatLonPoint to boost results by distance' by GitHub iverase (author and committer; a JIRA patch reviewed in the issue thread, migrated to GitHub issue #9528, no pull request) [read: git log --follow, git log -L on computeMaxDistance and setMinCompetitiveScore and git blame -w in the history clone; the archived final LUCENE-8482.patch already has 'while (max - min > 1)', 'return min;' and the box from maxDistance; exec: 7.4.0, 7.5.0, 7.7.1 and 7.7.3 lack the API, 8.0.0 fails]. computeMaxDistance was adapted from LongDistanceFeatureQuery (LUCENE-8340, a9acdfdb544, 2018-09-04), where the same loop is exact on longs; the 7 cm pad in Rectangle.fromPointDistance is older (045659533cd, LUCENE-7147, 2016-03-29, with a 'LUCENE-7143' comment). Not a regression (2018-09-19)
First affected version
8.0.0 [exec] (2019-03-13, the first release with LatLonPoint#newDistanceFeatureQuery; every 7.x release lacks the API [exec: the 7.x probe fails to compile only on the missing method on 7.4.0, 7.5.0, 7.7.1 and 7.7.3])
Affected versions
Every release from 8.0.0 to 10.6.0 (10.6.0 by reading) and main ea57360912ed; not fixed upstream. [exec] the finder's JUnit fails 5 of 6 (control passes) on main (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and 9.12.3 (-ea and -da); its 8.x port (top-level path only) fails 1 of 1 on 8.0.0, 8.11.4 and 9.0.0 (-ea; 8.0.0 and 8.11.4 also -da) and passes on 8.0.0 with the one-line fix shadowed; the independent reproduction's independent JUnit fails 4 of 6 (both controls pass) on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's scorer-level contract test fails on main (-ea and -da) and 10.5.2 (follow-up). [read] the computeMaxDistance text is identical (whitespace-normalised) in all 51 release tags of the history clone from 8.0.0 to 10.5.2, 'return min' (dload 4; dreturn) is in the 8.0.0, 8.11.4, 9.12.3, 10.5.2 and main jars, and 8.8.2-8.11.3 were not checked one by one; upstream main cf398bfb142c (2026-10-26) has the file byte-identical, branch_10x and branch_10_6 differ only by 'final' modifiers (10.6.0, released 2026-10-29, is affected by reading, not executed). All runs on aarch64 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 could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by cvc5, exhaustive check
Upstream and prior reports
none found (2026-10-26, read-only, the prior-report record, verdict new: 215 logged queries: a scripted prior-report search (16 GitHub issue and PR, 3 commit, 2 downstream, 25 mailing-list, 4 JIRA and 6 web queries), 110 GitHub GraphQL searches over issues and PRs, 22 lists.apache.org searches plus one full-body read, 9 JIRA searches, 11 web searches, 3 GitHub code searches, a scan of the changed files of all 410 open apache/lucene PRs, an upstream blob check and the local git history; plus the finder's (45 queries) and the intended-behaviour check's (36 queries) scripted prior-report searches); no hit is the same bug. Not fixed upstream: LatLonPointDistanceFeatureQuery.java is byte-identical on upstream main cf398bfb142c (2026-10-26) and ea57360912ed, branch_10x 1309534296e3 and branch_10_6 2763db11b0a7 differ only by 'final' modifiers, and Rectangle.java is identical on all three; no open PR changes computeMaxDistance or the box (#16847 and #14475 touch other lines of the file). Related items, not reports of this bug: #9528 / LUCENE-8482 (introduced the query; the review allowed the box to over-include only), #9387 / LUCENE-8340 (the exact integer sibling), #11245 / LUCENE-10208 (the only upstream failure of TestLatLonPointDistanceFeatureQuery, a collector root cause fixed by #431), #8198 / LUCENE-7143 (origin of the 7 cm pad, about boxes for tiny radii), elastic/elasticsearch#39770 (over-inclusive ranges in the long sibling), luxir-search/luxir#135 (another engine's reimplementation that adds slack; no mention of a Lucene defect).
Lucene commit tested
main 645d4fe14a20 (11.0.0-SNAPSHOT), 2026-10-30: the minimal test with gradle in a fresh scratch worktree (fails with assertions on and off; passes with fix A and with fix B; with fix A the upstream TestLatLonPointDistanceFeatureQuery passes 160 of 160 runs over 20 iterations and the whole org.apache.lucene.document package passes, 60 suites and 499 tests; with fix B the upstream test passes 160 of 160; the core format check passes with both) and with the standalone runner (-ea, -da); the 10.6.0 (-ea, -da), 10.0.0 and 9.12.3 (-da) release jars. The hunt ran on main ea57360912ed, where the query, Rectangle, Scorable, the collector and the upstream test are identical, and on 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2. JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceFeatureQuery.java:58-60 (pivotDistance > 0), :256 (maxDistance starts at EARTH_MEAN_RADIUS_METERS * PI), :283-308 (computeMaxDistance: 'The limit is set to 1 meter', loop at :296 stops at max - min <= 1, assert score(min + 1) < minScore at :306, return min at :307), :354-375 (setMinCompetitiveScore: sampling at :362, box from maxDistance at :375), :462-471 (box materialized only below cost/8, then the iterator is replaced)
  • lucene/core/src/java/org/apache/lucene/geo/Rectangle.java:143-150 (fromPointDistance: '// LUCENE-7143', radius padded by 7E-2 m)
  • lucene/core/src/java/org/apache/lucene/search/Scorable.java:44-52 (the iterator 'may safely ignore all documents whose score is less than the given minScore')
  • lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:357-377 (newDistanceFeatureQuery javadoc: exact score formula, no approximation; weight != 1 wraps in a BoostQuery)
  • lucene/core/src/test/org/apache/lucene/document/TestLatLonPointDistanceFeatureQuery.java:431 (CheckHits.checkTopScores on this query)
  • lucene/core/src/java/org/apache/lucene/search/ReqOptSumScorer.java:282-295 (the threshold is forwarded to the optional clause when the required side scores 0)
  • lucene/core/src/java/org/apache/lucene/document/LongDistanceFeatureQuery.java:273-293 (the integer sibling: same loop and return min, exact on longs)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceFeatureQuery.java:235-245, (at 645d4fe14a20) :256, :279-281, :283-308, :311-316, :353-472
  • lucene/core/src/java/org/apache/lucene/geo/Rectangle.java:142-150 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:96-100, (at 645d4fe14a20) :587-601; lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:84-91, :113-128, :154-164
  • lucene/core/src/java/org/apache/lucene/document/LongDistanceFeatureQuery.java:269-294 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceFeatureQuery.java:360-364 (at 645d4fe14a20) (sampling); lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:160
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/ReqOptSumScorer.java:281-297; (at 645d4fe14a20) ConjunctionScorer.java:87-93; DisjunctionMaxScorer.java:112-122; TopScoreDocCollectorManager.java:93
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Scorable.java:45-52 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/document/TestLatLonPointDistanceFeatureQuery.java:396-437; (at 645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:690-711, :748
  • lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:357-377; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/SloppyMath.java:38
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceFeatureQuery.java:283-308 (at 645d4fe14a20) (javadoc :284-285, assert :306)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/document/LatLonPointDistanceFeatureQuery.java:287-308, (at 645d4fe14a20) :366-375; lucene/core/src/java/org/apache/lucene/geo/Rectangle.java:150; grep of Rectangle.fromPointDistance callers (LatLonPointDistanceQuery.java:83, LatLonPointDistanceComparator.java:94, NearestNeighbor.java:131, GeoEncodingUtils.java:174, Circle2D.java:417)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and executed probes on the stock main jars (-ea, -da, and with the fix).

Built 2026-11-03 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.