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.
LatLonPoint.newDistanceFeatureQuery(field, weight, lat, lon, pivot)scores each documentweight * pivot / (pivot + distance): closer means higher, and a document at the pivot distance scoresweight / 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. - 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).
- 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 scores1000 / (1000 + distance). - You expect doc 2001, the closest: it scores 0.5000492, doc 0 scores 0.50000024.
- 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.
- 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.
- 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.
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 threshold.
IndexSearcher#search(query, n)usesTopScoreDocCollectorManager(n, after, 1000)(search/IndexSearcher.java:100,:597-599). Once more than 1,000 hits are counted,TopScoreDocCollectorcallsscorer.setMinCompetitiveScore(Math.nextUp(topScore))(search/TopScoreDocCollector.java:116-119,:154-164); in a later segmentsetScorerpasses 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); invariantscore(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 fromEARTH_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) buildsRectangle.fromPointDistance(originLat, originLon, maxDistance)(:375), which uses(radiusMeters + 7E-2) / EARTH_MEAN_RADIUS_METERS(geo/Rectangle.java:149-150, comment// LUCENE-7143). When fewer thanmin(leadCost, it.cost()) / 8points 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,
setMinCompetitiveScorerecomputes 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 isSloppyMath.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 afloat(: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 andreturn minonlongdistances, wheremax - min <= 1leaves 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.newDistanceFeatureQuerywhere 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-295forwards 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) andDisjunctionMaxQuerywith 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,
IndexWriterConfigandIndexSearcher#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.
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.
/*
* 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);
}
}
}
}
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>.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 9.12.3, 10.0.0 and 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (the only platform it ran on) |
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
# 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 originScorable#setMinCompetitiveScoreallows skipping only documents that score below the threshold; the skipped one scores 0.5000492 against 0.5000003.- Lucene's own test runs
CheckHits.checkTopScoreson 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 givenminScore" (search/Scorable.java:45-52). The skipped document scores about 800 float ulps above the threshold. - Test.
TestLatLonPointDistanceFeatureQuery#testRandomrunsCheckHits.checkTopScores(random(), q, searcher)(:396-437,:431), which requires TOP_SCORES and COMPLETE top-k to agree and assertss1.score() < minScorefor 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#newDistanceFeatureQuerygives 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 inRectangle.fromPointDistanceis 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.
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 returnedmaxalways 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=trueandfalse, and passes with it both ways; aftergit apply -Rit fails again; TestLatLonPointDistanceFeatureQuerywith-Ptests.iters=20: 160 tests, 0 failures;--tests 'org.apache.lucene.document.*': 60 suites, 499 tests, 26 skipped, 0 failures (among themTestLatLonPointDistanceFeatureQuery,TestLongDistanceFeatureQuery,TestLatLonPointDistanceSortand 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:checkGoogleJavaFormatpasses 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
-eaand-da, and the independent reproducer's 6 of 6 with-ea(and with-dain 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.
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.
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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| Search tool (GitHub issues, PRs and commits incl. the migrated JIRA archive; dev@, java-user@, issues@, commits@, builds@; downstream trackers; web) | 2026-10-26 | 56 queries: class, method and test names (computeMaxDistance, LatLonPointDistanceFeatureQuery, newDistanceFeatureQuery, setMinCompetitiveScore), symptoms; 996 distinct hits, the medium ones read | no report of this bug |
| GitHub GraphQL search (apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, all of GitHub) | 2026-10-26 | 110 searches over issues and PRs, open and closed; JIRA keys LUCENE-8482, LUCENE-8340, LUCENE-7143; 3 code searches for copies of computeMaxDistance | only related items (#9528, #9387, #8198, #11245, es#39770, luxir#135) |
| lists.apache.org, JIRA archive, web | 2026-10-26 | 22 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 PRs | 2026-10-26 | the changed files of all 410 open PRs | none changes computeMaxDistance or the box (#16847 and #14475 touch other lines) |
| GitHub, recheck for this doc | 2026-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 lines | nothing new; upstream main 2f424fd428da still has return min; at :307 and the box at :375 |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (gradle and the standalone runner, -ea and -da) |
| 10.6.0 (release jar) | affected | the minimal test fails (-ea and -da) |
| 10.0.0, 9.12.3 (release jars) | affected | the 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 port | the 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 check | the 8.x port passes |
| 7.4.0, 7.5.0, 7.7.1, 7.7.3 | no API | newDistanceFeatureQuery does not exist |
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).
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | cvc5 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), procedural | Could 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 | real | Own 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 | keep | History: 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 validated | Gradle 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 464 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,461 words
- 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 upstreamTestLatLonPointDistanceFeatureQuerypasses 160 of 160 runs over 20 iterations and the wholeorg.apache.lucene.documentpackage 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 mainea57360912ed, 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.
- 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-472lucene/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-164lucene/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, :748lucene/core/src/java/org/apache/lucene/document/LatLonPoint.java:357-377; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/SloppyMath.java:38lucene/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)
- TestLatLonDistanceFeatureMinCompetitiveGap.java (lucene/core, package org.apache.lucene.document, 1 test):
repro/LFV-379/src/org/apache/lucene/document/TestLatLonDistanceFeatureMinCompetitiveGap.java(59 lines)
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).
assets/LFV-379/bug.png(464 KB)
