zScaling should follow the query shape's size. Lucene estimates it from the distance between two opposite bounding-box corners: exact on a plane and inside the tested tolerance for geo boxes up to 180 degrees wide, but on a globe a wider box's corners are closer the other way round, through the longitudes outside the box.
rect(-170, 170, -10, 10) on the globe, seen from above the dateline: Lucene measures between the corners A and B the short way, across the 20-degree gap outside the box (28.2 degrees), so zScaling is 1.41 instead of 17.0 and a hit at (170, 0) scores 0.0082 instead of 0.091. Select the still to open it at full size. Generated with AI, checked against the code.- You score a geo query by distance with
strategy.makeRecipDistanceValueSource(queryShape). Its javadoc: score = zScaling / (d + zScaling), where zScaling is one tenth of the distance from the centre to the farthest edge, so 1 at the centre and ~0.1 at the edge. - Your query box is
rect(-170, 170, -10, 10): 340 degrees of longitude around (0, 0). The point (170, 0) on its east edge is 170 degrees away, so you expect zScaling 17.0 and a score of 17 / 187 = 0.091 there. - You get zScaling 1.41 and a score of 0.0082, on all four strategies. Lucene takes half the distance between the corners (-170, -10) and (170, 10), and on the globe those are only 28.2 degrees apart, the short way across the dateline.
- So a box W degrees wide is scaled like a box 360 - W wide. The band of all longitudes
rect(-180, 180, -5, 5)gets zScaling 0.5 instead of 17.5: its edge point (180, 5) scores 0.0028. - No error and the same hits, but distance swamps any combined score: with
FunctionScoreQuery.boostByValueon the band, a hit with inner score 10 at 170 degrees ranks below a hit with inner score 1 at 10 degrees (0.029 against 0.048).
Applications that boost or rank by makeRecipDistanceValueSource in a geo context with a query shape wider than 180 degrees (wide boxes, latitude bands, hemispheres; mildly, pole circles), on every strategy with a distance source. Scores leave the tested tolerance for thin boxes from about 327 degrees wide; the hits and sorting by the value alone do not change.
Mechanism, terms and numbers821 words
SpatialStrategy.makeRecipDistanceValueSource(queryShape) returns zScaling / (d + zScaling), and its javadoc says zScaling is "one tenth the distance to the farthest edge from the center", so scores are 1 at the centre and "as low as ~0.1 at its furthest edges". The code takes one tenth of half the distance between the bounding box's corners (minX, minY) and (maxX, maxY). In a geo context that is a great-circle distance, which always takes the shorter way round the globe; for a box wider than 180 degrees the shorter way runs outside the box, across the dateline, so a box W degrees wide gets the zScaling of a box 360 - W wide.
For rect(-170, 170, -10, 10) zScaling is 1.41 instead of 17.0 and the edge point (170, 0) scores 0.0082 instead of 0.091; for the band rect(-180, 180, -5, 5) it is 0.5 and 0.0028, on every strategy with a distance source (PointVector, BBox, SerializedDV and both prefix-tree strategies; the method is final, so they share the zScaling). Nothing fails and the hits do not change, but any combined score over-weights distance. The code is unchanged since the method was added in 4.0.0 (2012); LUCENE-4550 (4.2.0) fixed the identical computation in SpatialArgs.calcDistanceFromErrPct and left this copy. A guarded fix that changes only geo shapes wider than 180 degrees passes the repro and the module's tests.
SpatialStrategy.java:126-132(javadoc): "The formula iszScaling/(d + zScaling)where 'd' is the distance and 'zScaling' is one tenth the distance to the farthest edge from the center. Thus the scores will be 1 for indexed points at the center of the query shape and as low as ~0.1 at its furthest edges.":133the method ispublic final, so every strategy shares its zScaling; the distance comes from each strategy'smakeDistanceValueSource(:143), whichCompositeSpatialStrategyandNumberRangePrefixTreeStrategydo not support (they throw). The class is@lucene.experimental(:53;:51: "however it is quite stable").:134-141diagonalDist= distance from corner A = (minX, minY) to corner B = (maxX, maxY) ofqueryShape.getBoundingBox();distToEdge = diagonalDist * 0.5.:142-144c = distToEdge * 0.1, wrapped asReciprocalDoubleValuesSource(c, distance from the centre), which returnsc / (d + c)(util/ReciprocalDoubleValuesSource.java:61-63).
- Plane (non-geo context): half the diagonal is the centre-to-corner distance, the farthest point of a box: the javadoc holds.
- Geo box up to 180 degrees wide: the great circle from A to B stays within the box's longitudes; half of it is 0.45 to 1.00 of the distance to the farthest edge point: 1.00 near the equator, lower for high-latitude boxes, whose corner geodesic bulges towards the pole (10x10 at latitude 60: 0.983; 60x20 at latitude 60: 0.863;
rect(-60, 60, 40, 80): 0.668; lowest 0.447 in a grid of 13,319 boxes 10 to 180 degrees wide). Every edge score in that grid stays inside the upstream tolerance (lowest 0.043). - Geo box wider than 180 degrees: the great circle from A to B takes the shorter way, through the longitudes outside the box: the 360 - W degree gap. For
rect(-170, 170, -10, 10), A = (-170, -10) and B = (170, 10) are 28.2 degrees apart, so zScaling = 0.1 x 28.2 / 2 = 1.41: exactly the value of the 20-degree boxrect(-10, 10, -10, 10). For a full-longitude band A and B are on the same meridian and only the height counts (10 degrees for 5 S to 5 N; about 7e-16 for the equator linerect(-180, 180, 0, 0)).
Score at the shape's farthest edge (documented 0.0909; the upstream test tolerance is 0.10 +- 0.09). A full-longitude rectangle wraps around the globe, so its only edges are its two latitude lines.
| geo query shape | width | farthest edge | zScaling: code / documented | edge score |
|---|---|---|---|---|
rect(-60, 60, -10, 10) (control) | 120 | 60.5 | 6.05 / 6.05 | 0.0909 |
rect(-90.5, 90.5, -5, 5) | 181 | 90.5 | 8.95 / 9.05 | 0.0900 |
rect(80, -80, -5, 5) (crosses the dateline) | 200 | 100 | 8.00 / 10.0 | 0.0741 |
rect(-135, 135, -10, 10) | 270 | 135 | 4.59 / 13.5 | 0.0329 |
rect(-170, 170, -10, 10) | 340 | 170 | 1.41 / 17.0 | 0.0082 |
rect(-180, 180, -5, 5) (band) | 360 | 175 | 0.50 / 17.5 | 0.0028 |
rect(-180, 180, 0, 0) (equator line) | 360 | 180 | ~7e-16 / 18.0 | 0 |
rect(-180, 180, 0, 90) (northern hemisphere) | 360 | 135 | 4.50 / 13.5 | 0.0323 |
| circle r = 20 at (0, 80) (contains the pole) | 360 | 20 | 1.50 / 2.00 | 0.0698 |
circle of TestDistanceStrategy.testRecipScore (r = 4.27) | 8.5 | 4.27 | 0.60 / 0.43 | 0.124 |
Bold rows are outside the tolerance. A grid of 1016 generated shapes on four strategies found 124 generated shapes (91 distinct rectangles) outside it, all 340 to 360 degrees wide; that grid sampled no widths between 270 and 340, and a finer sweep finds thin boxes outside the tolerance from about 327 degrees wide (see Why it matters).
Exposure and likelihood272 words
- Who. Opt-in spatial-extras scoring API; no other Lucene code calls it. Downstream, Solr's spatial field types expose it as
score=recipDistance(read, not run). - What changes. Only the size of the recip values. The value stays monotone in distance, so the hit set and a sort by the value alone are unchanged. A combined score changes: with
FunctionScoreQuery.boostByValueon the band, "far" (inner score 10, at 170 degrees) scores 0.0293 and "near" (inner score 1, at 10 degrees) 0.0476, so near ranks first; with the documented zScaling far ranks first (0.933 against 0.636). - How far off. Outside the upstream tolerance (0.01 to 0.19) for thin boxes from about 327 degrees wide up to 360: the first width out of it is 327 to 333 at the equator (heights 0 to 20), 333 to 341 at latitude 30 (heights 0 to 20) and 336 to 343 at latitude 60 (heights 0 to 10); boxes 40 degrees tall, or 20 at latitude 60, never left it in that sweep. Below those widths, low but inside it (200: 0.074; 270: 0.033);
rect(-165, 165, -5, 5), 330 wide, already scores 0.0095. Circles whose bounding box spans all longitudes (they contain a pole) get 0.50 to 0.96 of the documented zScaling, inside the tolerance. - Not affected in practice. Non-geo contexts. Geo shapes whose bounding box is at most 180 degrees wide stay inside the tolerance, although high-latitude ones get as little as 0.45 of the documented zScaling (edge scores down to 0.043 in a 13,319-box grid); the fix does not change them.
- Likelihood. Rare, since most geo queries are much narrower; when it happens it is silent.
The test shows that SpatialStrategy#makeRecipDistanceValueSource for the geo box rect(-170, 170, -10, 10) (wider than 180 degrees) scores the point (170, 0) on its east edge far below the documented value.
/*
* 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.spatial;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.DoubleValues;
import org.apache.lucene.spatial.vector.PointVectorStrategy;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.shape.Point;
import org.locationtech.spatial4j.shape.Rectangle;
public class TestRecipDistanceBoxWiderThan180 extends LuceneTestCase {
public void testScoreAtFurthestEdge() throws Exception {
SpatialContext ctx = SpatialContext.GEO;
SpatialStrategy strategy = PointVectorStrategy.newInstance(ctx, "pv");
Rectangle box = ctx.getShapeFactory().rect(-170, 170, -10, 10);
Point edge = ctx.getShapeFactory().pointXY(170, 0);
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
for (Field f : strategy.createIndexableFields(edge)) {
doc.add(f);
}
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
DoubleValues v =
strategy.makeRecipDistanceValueSource(box).getValues(reader.leaves().get(0), null);
assertTrue(v.advanceExact(0));
// Expected ~0.1 (documented; 17 / (170 + 17) = 0.091), within 0.10 +- 0.09; actual 0.0082
assertEquals("score at the furthest edge", 0.10, v.doubleValue(), 0.09);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-778/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/spatial-extras/build/libs/lucene-spatial-extras-$V.jar
CP=$CP:$LUCENE/lucene/spatial3d/build/libs/lucene-spatial3d-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.spatial.TestRecipDistanceBoxWiderThan180
# the module(s) spatial-extras also need their third-party runtime jars
- Expected vs actual
- Expected: ~0.1 at the furthest edge (javadoc; 17 / 187 = 0.091, upstream tolerance 0.10 +- 0.09); actual (-ea and -da): 0.00823, because zScaling is taken from the corner-to-corner great circle, which goes the short way round the globe.
More files, environment, full output38 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: score at the furthest edge expected:<0.1> but was:<0.00822938792500614>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:555)
org.apache.lucene.spatial.TestRecipDistanceBoxWiderThan180.testScoreAtFurthestEdge(TestRecipDistanceBoxWiderThan180.java:53)- The javadoc gives the formula and the ~0.1 edge score with no width limit; the 340-degree box's edges are 166-170 degrees away, yet zScaling is 1.41.
- LUCENE-4550 fixed the identical computation in
SpatialArgs.calcDistanceFromErrPctas "a bug nonetheless" (4.2.0). - Up to 180 degrees wide the code stays inside the upstream tolerance; past 180 it collapses (12 times too small at 340).
The full argument269 words
- Explicit, unqualified contract.
SpatialStrategy.java:126-132states the formula, what zScaling is and the resulting ~0.1 edge score; it has no exception for geo contexts or wide shapes, and the method accepts anyShape. - Stated intent. LUCENE-4208 (#5280), which added the method: "'c' is roughly 1/10th of the distance to the query shape edge", and "how quickly the score curve slides ... doesn't vary based on the overall query shape; I think it should". The shape-relative scale is the point of the method.
- The half-diagonal is a proxy. On a plane it is the centre-to-corner distance; for geo boxes up to 180 degrees wide it is 0.45-1.00 of the distance to the farthest edge, always inside the upstream tolerance in a 13,319-box grid. It collapses once the box is wider than 180 degrees, because the shorter corner geodesic then runs through the 360 - W degrees of longitude outside the box instead of through the box's own W.
- Precedent. LUCENE-4550 (#5616,
6ba9d0b6418, 4.2.0, by the same author) replaced this exact computation inSpatialArgs.calcDistanceFromErrPct: "If the shape presented is extremely wide, > 180 degrees, then the internal calculations in SpatialArgs.calcDistanceFromErrPct(...) will wrongly measure the shape's size as having width < 180 degrees ... I am flagging this as "minor", but a bug nonetheless." Its issue and patch never mention this method. - Tests.
TestSpatialArgsasserts thatmakeRectangle(-180, 180, 0, 0)is 180 degrees from its centre to its corner: a full-longitude rectangle is a normal shape for the module. The only test of this method,TestDistanceStrategy.testRecipScore, uses a circle of radius 4.27 degrees, too small to reach the problem.
The devil's-advocate check223 words
The strongest case for "intended": the API is @lucene.experimental, "~0.1" signals a heuristic and the upstream tolerance is wide; in LUCENE-4550 the same maintainer called shapes wider than 180 degrees "unrealistic shape sizes" and said the report came from "someone using lucene-spatial incorrectly"; and for the full-longitude band one can argue that its edges are the 5 S and 5 N lines, 5 degrees from the centre, which gives exactly the code's 0.5. And in LUCENE-4208 the author first proposed c as "1/10th the distance to the nearest query shape edge" (2012-09-12); under that reading the 340-degree box would get about 1.0, close to the code's 1.41.
Why it fails: the 340-degree box has real east and west edges 165.9 to 170 degrees from its centre, so the documented zScaling is 16.6 to 17.0 under any reading, against 1.41, a factor of 12. Even for the band the javadoc's second sentence fails: (180, 5) is on its north edge and scores 0.0028. Two days after the "nearest edge" comment the author proposed the "farthest edge" javadoc that was committed (2012-09-14), and under it both shapes score far below "~0.1 at its furthest edges". And the same maintainer called the identical computation "a bug nonetheless" and fixed it. A committer may still rate it minor, as LUCENE-4550 was, or prefer a javadoc qualification.
Recommended Measure geo shapes wider than 180 degrees from the centre; leave everything else alone
When the context is geo and the bounding box is wider than 180 degrees, take zScaling from the circle's radius, or else from the centre's distance to the farthest bounding-box corner; keep the old formula otherwise (+30/-8 in SpatialStrategy.java). The repro and all 121 spatial-extras tests pass; no probed shape up to 180 degrees wide changes.
All options and trade-offs4 options · 1,020 words
One fix was built and validated through gradle; a variant and the hunt's earlier sketch were compared with a probe over 21 shapes.
Measure geo shapes wider than 180 degrees from the centre; leave everything else alone (validated by the checks) (recommended)
In makeRecipDistanceValueSource, when ctx.isGeo() and the bounding box is wider than 180 degrees, take distToEdge from the circle's radius, or else from the centre's distance to the farthest bounding-box corner; keep the half-diagonal otherwise. For a box wider than 180 degrees the corners lie on its edges, and a full-longitude box's corners lie on its two latitude lines, its only edges; a circle's farthest edge is its radius. The guard is exactly the condition under which the shorter corner geodesic runs through the 360 - W degrees of longitude outside the box instead of through the box's own W; on the 21 probe shapes the result matches the intended scale (0.976-1.000), which is not a proof for every shape.
diff --git a/lucene/spatial-extras/src/java/org/apache/lucene/spatial/SpatialStrategy.java b/lucene/spatial-extras/src/java/org/apache/lucene/spatial/SpatialStrategy.java
index 903547b18bc..3afb307d3e6 100644
--- a/lucene/spatial-extras/src/java/org/apache/lucene/spatial/SpatialStrategy.java
+++ b/lucene/spatial-extras/src/java/org/apache/lucene/spatial/SpatialStrategy.java
@@ -22,6 +22,7 @@ import org.apache.lucene.search.Query;
import org.apache.lucene.spatial.query.SpatialArgs;
import org.apache.lucene.spatial.util.ReciprocalDoubleValuesSource;
import org.locationtech.spatial4j.context.SpatialContext;
+import org.locationtech.spatial4j.shape.Circle;
import org.locationtech.spatial4j.shape.Point;
import org.locationtech.spatial4j.shape.Rectangle;
import org.locationtech.spatial4j.shape.Shape;
@@ -132,18 +133,39 @@ public abstract class SpatialStrategy {
*/
public final DoubleValuesSource makeRecipDistanceValueSource(Shape queryShape) {
Rectangle bbox = queryShape.getBoundingBox();
- double diagonalDist =
- ctx.getDistCalc()
- .distance(
- ctx.getShapeFactory().pointXY(bbox.getMinX(), bbox.getMinY()),
- bbox.getMaxX(),
- bbox.getMaxY());
- double distToEdge = diagonalDist * 0.5;
+ Point center = queryShape.getCenter();
+ double distToEdge;
+ if (ctx.isGeo() && bbox.getWidth() > 180) {
+ // Half the distance between two opposite corners would go the short way around the globe,
+ // so measure from the center instead.
+ distToEdge =
+ queryShape instanceof Circle circle
+ ? circle.getRadius()
+ : farthestCornerDistance(bbox, center);
+ } else {
+ double diagonalDist =
+ ctx.getDistCalc()
+ .distance(
+ ctx.getShapeFactory().pointXY(bbox.getMinX(), bbox.getMinY()),
+ bbox.getMaxX(),
+ bbox.getMaxY());
+ distToEdge = diagonalDist * 0.5;
+ }
float c = (float) distToEdge * 0.1f; // one tenth
- DoubleValuesSource distance = makeDistanceValueSource(queryShape.getCenter(), 1.0);
+ DoubleValuesSource distance = makeDistanceValueSource(center, 1.0);
return new ReciprocalDoubleValuesSource(c, distance);
}
+ private double farthestCornerDistance(Rectangle bbox, Point center) {
+ double max = 0;
+ for (double x : new double[] {bbox.getMinX(), bbox.getMaxX()}) {
+ for (double y : new double[] {bbox.getMinY(), bbox.getMaxY()}) {
+ max = Math.max(max, ctx.getDistCalc().distance(center, x, y));
+ }
+ }
+ return max;
+ }
+
@Override
public String toString() {
return getClass().getSimpleName() + " field:" + fieldName + " ctx=" + ctx;Trade-offs
- No change for non-geo contexts or for any shape whose bounding box is at most 180 degrees wide: identical zScaling for all 7 such probe shapes (small circles, high-latitude boxes) and identical output for all 13,319 boxes of a fact-check grid, including high-latitude boxes whose corner geodesic bulges out of the box.
- Wide geo shapes change, by design: wide boxes go from 0 to 0.99 of the documented zScaling to 0.976-1.000; circles that contain a pole from 0.50-0.96 to 1.000.
- A step at 180 degrees: the 180-degree
rect(-90, 90, 80, 90)keeps zScaling 0.5 (about 0.45 of the documented value), while the 180.0001-degreerect(-90.00005, 90.00005, 80, 90)gets 1.117. - Full-longitude boxes are measured to their latitude edges, so a point beyond the farthest edge (for example (180, 0) inside
rect(-180, 180, -85, 85), 180 degrees away while the edges are at most 95) scores below 0.1 (0.050), as "farthest edge" implies. - Other wide shapes (polygons, collections) are approximated by their bounding-box corners, which can lie far outside the shape; no polygon was tested, and neither was the Geo3d context. Cost: up to four distance computations once per query, only for affected non-circle shapes (none for circles); no API, index or format change.
What the checks validated
- gradle, clean worktree at
a9779ef3c193(2026-10-28): stock, the repro fails 2 of 4 with assertions on and off; with the fix it passes 4 of 4 with assertions on and off.TestDistanceStrategy,TestSpatialArgsandTestPortedSolr3pass before and after (18 tests). All of:lucene:spatial-extras:testwith the repro class added passes (27 classes, 121 tests, 0 failures, 5 skipped).ecjLintMain,ecjLintTestandcheckGoogleJavaFormatpass;fix.diffapplies cleanly to a clean tree. - classpath runner, fix shadowed onto the main jars: this doc's JUnit 4 of 4 (-ea and -da); the hunt's JUnit 3 of 3, including the boostByValue order (3 of 3 fail without it); the independent reproducer's JUnit 3 of 3.
- probe over 21 shapes: identical zScaling to stock for all 7 shapes up to 180 degrees wide; 0.976-1.000 of the documented zScaling for all 14 wider ones (boxes, bands, hemispheres, pole circles).
zScaling = 0.1 x the largest distance from the centre to the bounding box's corners and edge midpoints, with no width guard.
Trade-offs
- Fixes every box (the hunt's 1016 shapes: 0 out of tolerance, not independently re-run; its pole circles came close: r = 45 at (0, 60) scores 0.189 with it), but changes ordinary shapes too (10x10 at latitude 60: +1.75%; 60x20 at latitude 60: +16%); a downstream test pins recip-distance scores at 1e-3 (read, not run).
- Over-corrects circles that contain a pole: their bounding box reaches round the far side of the pole, so the circle r = 6 at (0, 85) gets zScaling 1.6 instead of 0.6 and edge score 0.21, and r = 1.1 at (0, 89) 0.22, both above the tolerance; this doc's pole control fails with it (1 of 4).
Consistent with SpatialArgs.calcDistanceFromErrPct.
Trade-offs
That rule deliberately takes the nearer corner (for precision), so it would still under-measure some wide shapes (by hand: the northern hemisphere rect(-180, 180, 0, 90) would stay at 4.5 instead of 13.5), and it shifts ordinary geo shapes slightly too.
Say that zScaling is estimated from the bounding-box diagonal and is too small for geo shapes wider than 180 degrees.
Trade-offs
Cheapest and defensible for an experimental API, but the scores of those queries stay wrong when a small guarded fix exists; LUCENE-4550 chose the code fix for the same computation.
Solr's spatial field types expose this method as score=recipDistance, so their users get the same scores for very wide geo boxes (read, not run).
Code reading while planning a bug hunt over spatial-extras' scoring code flagged the half corner-to-corner distance as wrong on a sphere past 180 degrees. A grid of 1016 generated query shapes on four strategies, checked against an independent great-circle reference, then found every out-of-tolerance case at its sampled widths 340-360 degrees.
The method in full160 words
- Lead (code reading). The hunt's planning step read
SpatialStrategy.java:127-144and named the lead: zScaling from half the corner-to-corner distance, the short way round on a sphere, too small for boxes wider than 180 degrees. So it is not a blind discovery. - Quantified (translation validation on a bounded grid). The finder generated 1016 query shapes (rectangles with centres at latitudes 0, +-60, +-89 and longitudes 0, +-179.5, 180, widths 1, 10, 90, 179, 180, 181, 200, 270, 340, 359 and 360 including dateline-crossing ones, heights 0 to 180; circles with radii 0.5 to 179), indexed the centre and the farthest boundary point with PointVector, BBox, SerializedDV and RecursivePrefixTree strategies on the stock jar, and compared the scores against an independent great-circle reference (haversine and atan2 forms cross-checked). 124 generated shapes (91 distinct rectangles) were outside the upstream tolerance on every strategy, all at the sampled widths 340, 359 and 360.
- Witnessed by a public-API JUnit, then reproduced independently (see the checks).
No prior report found (2026-10-27) in GitHub issues, PRs and commits, the JIRA archive, the Lucene and Solr lists, downstream projects or the web, nor in the 408 open PRs. Closest: LUCENE-4550 (#5616), which fixed the same computation in SpatialArgs.calcDistanceFromErrPct in 4.2.0 and never mentions this method.
Searches3 searches
Verdict new. LUCENE-4550 shares the mechanism at a different fix site and is treated as a fixed sibling.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits (incl. migrated JIRA); apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet, locationtech/spatial4j; dev@, java-user@, issues@, commits@ lucene, the old and new Solr lists; the JIRA archive; the web | 2026-10-27 | the hunt's prior-report tool in two runs (59 and 51 queries) plus the finder's 51-query run; manual GitHub, JIRA and list searches (method names, recipDistance, distToEdge, diagonalDist, superwide, wider than 180, LUCENE-4208, LUCENE-4550) | no report of this defect |
| Closest items (read) | 2026-10-27 | issue bodies, comments and patches | #5616 (LUCENE-4550: the same computation, fixed in SpatialArgs.calcDistanceFromErrPct only); #5280 (LUCENE-4208: added the method, no discussion of wide shapes); #5681 (LUCENE-4616, open: what makeQuery's score means); SOLR-3304, SOLR-4255 (score=recipDistance, no defect report) |
| Open PRs | 2026-10-27 | changed-file lists of all 408 open apache/lucene PRs | none touches SpatialStrategy.java or ReciprocalDoubleValuesSource.java |
There since the method was added: LUCENE-4208 (577e25c7ffc, 2012-09-15, first released in 4.0.0). Executed on 10 releases from 4.0.0 to 10.5.2 and on main, all with identical values; not fixed upstream.
Versions tested4 rows
Introduced by LUCENE-4208 (#5280) "Spatial distance relevancy should use score of 1/distance", trunk commit 577e25c7ffc (SVN r1385074, no pull request) and branch_4x backport 11e5aca556b (r1385122), by GitHub dsmiley. Later commits (LUCENE-5118, -7015, -7060, -7737, -9394, -9570) changed only the API and formatting around the computation. The same half corner-to-corner pattern came from LUCENE-4342 and LUCENE-4186 (distErrPct) and was fixed there by LUCENE-4550 (6ba9d0b6418, 4.2.0). Not a regression.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's JUnit fails 2 of 4 through gradle (assertions on and off) and the classpath runner (-ea, -da); passes with the fix |
| 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.5.2 | affected | the history probe gives identical values: control 0.09160 in tolerance, rect(-170,170,-10,10) 0.00823, band 0.00285; the hunt's JUnit fails 3 of 3 on 9.12.3 and 10.5.2, this doc's 2 of 4 on 10.5.2 and 9.12.3 |
| 4.0.0-BETA | no such method | javap of the released jar: no makeRecipDistanceValueSource |
| main with the recommended fix | fix | all three JUnits pass; probe: no shape up to 180 degrees wide changes |
An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests and probes; a reviewer from another model family (OpenAI GPT-5.6) agreed on the root cause without running code; an adjudication, an audit, this doc's gradle runs and an independent fact check re-ran it on a9779ef3c193; fact checks corrected three over-generalisations.
Checks and verdicts505 words
Every executing check gave the same values to five decimals.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Grid comparison of 1016 generated shapes on 4 strategies (124 out of tolerance, all at its sampled widths 340-360), then a 3-test public-API JUnit: fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3. |
| cross-model review, refute lens (OpenAI GPT-5.6, reading only) | not real, low (procedural) | It cannot vote real without running its own test. It agreed on the root cause and that the inputs are in contract; its own arithmetic gave 1.41 and 0.00823. |
| independent reproducer | real | Own JUnit with its own haversine: fails 2 of 3 on main (-ea, -da), 10.5.2 and 9.12.3; the control passes; implied zScaling = 0.05 x the corner-to-corner geodesic. |
| cross-model adversary, intended lens (OpenAI GPT-5.6, reading only) | real, medium | Intended-behaviour argument moderate, defeated by the unqualified javadoc, the module's full-longitude test rectangles and LUCENE-4550; it did not run code. (The width check 200 -> 0.074, 270 -> 0.033, 340 -> 0.0082 is a separate run.) |
| intended-behaviour devil's advocate | real, high | Re-ran the repro on main (-ea, -da) and 10.5.2; probes: 0.86-1.00 of the documented zScaling on its four boxes under 180 degrees (a later 13,319-box grid goes down to 0.45, all inside the tolerance), 0.083 at 340; the 340-degree box fails under both readings of "farthest edge". |
| adjudication | confirmed, high | Re-ran the independent JUnit (2 of 3 fail, -ea and -da) and the finder's (3 of 3); duplicate of none. |
| registry re-runs and audit | keep | New seeds with -ea and -da: the same failures; the hunt's sketch passes; a tall whole-world box stays inside the tolerance. |
| this doc | reproduced; fix validated | This doc's JUnit (4 tests, 2 controls) fails 2 of 4 through gradle on a clean a9779ef3c193 worktree with assertions on and off, and through the classpath runner on main (-ea, -da) and 10.5.2 (-da); the hunt's JUnit fails 3 of 3 again. With the recommended fix all three JUnits and the whole spatial-extras module pass; the hunt's sketch fails the new pole-circle control. |
| independent fact check | reproduced; 3 over-generalisations corrected | Re-ran this doc's gradle command on a clean extraction of a9779ef3c193 (2 of 4 fail with assertions on and off; the module 121 tests, only the repro fails), the fix (4 of 4; the module 0 failures; lint and format pass) and the classpath runner on main, 10.5.2 and 9.12.3. Two new probes (13,319 boxes up to 180 degrees wide; the first out-of-tolerance width per height and latitude) corrected the bound under 180 degrees, the guard's rationale and the width threshold; those corrections are in this doc. |
| cross-model fact check (OpenAI GPT-5.6, reading only) | real; wording corrected | Confirmed the formula, every row of the width table, the scores, the ranking flip, the history and the fix results from the saved runs; flagged the same over-generalisations, the strategy list and the blame line. Corrections are in this doc; its novelty reading is answered. |
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 476 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,227 words
- Registry title
- SpatialStrategy.makeRecipDistanceValueSource takes zScaling from half the corner-to-corner great-circle distance of the query's bounding box, which goes the short way round the globe once a geo box is wider than 180 degrees of longitude, so edge scores fall far below the documented ~0.1 (0.0082 for rect(-170,170,-10,10), 0.0028 for a 5S-5N band of all longitudes; released since 4.0.0)
- Area
- spatial-extras / SpatialStrategy recip-distance scoring (every strategy with a distance value source)
- Priority
- Low, rank 436: Only recip-distance scores change, and only for geo boxes wider than 180 degrees, which is the scoring-only profile of LFV-675 and LFV-369, and it affects every strategy, so it goes between them.
- Impact class
- silent wrong results
- Impact note
- Applications that score or boost by SpatialStrategy.makeRecipDistanceValueSource in a geo context with a query box wider than 180 degrees of longitude get edge scores below the documented ~0.1 with no error, on every strategy (PointVector, BBox, SerializedDV, RecursivePrefixTree); the scores leave the upstream test tolerance (0.10 +- 0.09) only for thin boxes about 340-360 degrees wide and are low but inside it from 181 to 339 degrees, the hit set never changes and sorting by the value alone keeps its order, so only rankings that combine it with another score (for example FunctionScoreQuery.boostByValue) change; geo boxes under 180 degrees and non-geo contexts keep the documented approximation, and circles stay inside the tolerance.
- Summary
- You score a geo spatial query with makeRecipDistanceValueSource, whose javadoc says zScaling is one tenth of the distance from the centre to the farthest edge, so scores run from 1 at the centre down to about 0.1 at the furthest edge. For a box wider than 180 degrees of longitude the method instead takes half the great-circle distance between the box's two opposite corners, and that distance goes the short way round the globe: for rect(-170,170,-10,10) zScaling is 1.41 instead of 17.0, and the edge point (170,0) scores 0.0082 instead of about 0.091 (for the band rect(-180,180,-5,5), 0.0028). Nothing fails; distance is over-weighted, so with FunctionScoreQuery.boostByValue a hit with inner score 10 at 170 degrees ranks below a hit with inner score 1 at 10 degrees on the band query, the reverse of the documented scaling.
- Introduced
- 577e25c7ffc11a3dfd64252bc43cde6b62e3fada (LUCENE-4208 'Spatial distance relevancy should use score of 1/distance', now GitHub apache/lucene#5280; SVN trunk r1385074, shipped through the branch_4x backport 11e5aca556b58bae04854493e0b687a1b7767e64, r1385122; SVN era, no PR; by GitHub dsmiley) [read: git blame on main gives this commit for the diagonalDist, distToEdge and c lines; git log -S diagonalDist; later commits (LUCENE-5118, -7015, -7060, -7737, -9394, -9570) changed only the API and formatting around them] [exec: 4.0.0, the first release with the method, fails with the same values as main; 4.0.0-BETA has no such method (javap)]. The defect came with the method, so it is not a regression. The same half corner-to-corner computation was fixed in the sibling SpatialArgs.calcDistanceFromErrPct by LUCENE-4550 (6ba9d0b6418, 4.2.0) and left unchanged here (2012-09-15)
- First affected version
- 4.0.0 [exec] (the first release with SpatialStrategy.makeRecipDistanceValueSource; 4.0.0-BETA, the release before, does not have the method [exec: javap of the released jar])
- Affected versions
- Every release from 4.0.0 to 10.5.2 and main a9779ef3c193; not fixed upstream (SpatialStrategy.java last changed in 2cbf261032d, a 2021 reformat, on main and branch_10x; no open PR touches it, 2026-10-27). [exec] a probe (one source variant per API generation) fails with identical values (control rect(-60,60,-10,10) edge 0.09160 in band; rect(-170,170,-10,10) 0.00823; rect(-180,180,-5,5) 0.00285) on 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main a9779ef3c193; the finder's JUnit fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 2 of 3 (the control passes) on main (-ea, -da), 10.5.2 and 9.12.3. [read] the zScaling lines are the same computation at every release tag 4.0.0..10.5.2 in the history clone (8.9.0-8.11.3 are not tagged there; bracketed by 8.11.4 [exec]). All runs on JDK 25.0.3
- 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
- non-formal: code reading; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-27, read-only; the prior-report record, verdict new: two scripted prior-report searches (24 + 22 GitHub, 3 + 3 commit, 2 + 2 downstream, 20 + 20 mailing-list and 4 + 4 JIRA-archive queries, plus web queries; 160 and 238 distinct hits, the high hits read and unrelated) plus the finder's 51-query run, manual GitHub searches on apache/lucene, apache/solr and ports, JIRA LUCENE and SOLR text searches, lists.apache.org over the Lucene and Solr lists, web searches, the LUCENE-4550 patch, and the changed-file lists of all 408 open apache/lucene PRs; the finder's, the independent reproduction's and the intended-behaviour check's searches agree). Context, not reports: LUCENE-4550 / #5616 (closed, fixed in 4.2.0 by 6ba9d0b6418; GitHub dsmiley): the same half corner-to-corner computation, called a bug for shapes wider than 180 degrees and fixed only in SpatialArgs.calcDistanceFromErrPct (prefix-tree precision); its issue, comments and patch never mention makeRecipDistanceValueSource; LUCENE-4208 / #5280 (introduced the method; no discussion of wide boxes); LUCENE-4616 / #5681 (open; what makeQuery's score means); LUCENE-4342 / #5409 and LUCENE-4186 / #5258 (origin of the pattern for distErrPct); SOLR-3304 and SOLR-4255 (Solr's score=recipDistance mode; no defect report). No open PR touches SpatialStrategy.java or ReciprocalDoubleValuesSource.java. Gaps: keyword-based searches, list snippets only, web results capped at 10 per query, private reports not visible
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26): this doc's JUnit through gradle in a clean worktree, stock and with the fix, assertions on and off, plusTestDistanceStrategy,TestSpatialArgs,TestPortedSolr3and all oflucene/spatial-extraswith the fix (2026-10-28); through the hunt's classpath runner, the same JUnit on main (-ea, -da, fix shadowed, the hunt's fix sketch shadowed) and on 10.5.2 (-da), the hunt's and an independent reproducer's JUnits with and without the fix, and a zScaling probe over 21 shapes (stock, sketch, fix). History runs on 4.0.0, 4.10.4, 5.5.5, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main. OpenJDK 25.0.3, Linux aarch64.
- Finding record
lucene/spatial-extras/src/java/org/apache/lucene/spatial/SpatialStrategy.java:126-132 (javadoc: zScaling 'one tenth the distance to the farthest edge from the center', scores 'as low as ~0.1 at its furthest edges'), :133-145 (makeRecipDistanceValueSource, final; :134-141 diagonalDist between (minX,minY) and (maxX,maxY), distToEdge = diagonalDist * 0.5; :142 c = distToEdge * 0.1)lucene/spatial-extras/src/java/org/apache/lucene/spatial/util/ReciprocalDoubleValuesSource.java:28 ('v = k / (v + k)'), :61-63 (recip)lucene/spatial-extras/src/java/org/apache/lucene/spatial/query/SpatialArgs.java calcDistanceFromErrPct (centre-to-corner distance since LUCENE-4550) and lucene/spatial-extras/src/test/org/apache/lucene/spatial/TestSpatialArgs.java (rect(-180,180,0,0) asserted as 180 * distErrPct), as cited by the cross-model review and the intended-behaviour check; lucene/spatial-extras/src/test/org/apache/lucene/spatial/TestDistanceStrategy.java testRecipScore (small circle, edge tolerance 0.10 +- 0.09)
- The finding in 30 seconds
lucene/spatial-extras/src/java/org/apache/lucene/spatial/SpatialStrategy.java:53, 126-145 (a9779ef3c193)lucene/spatial-extras/src/java/org/apache/lucene/spatial/util/ReciprocalDoubleValuesSource.java:28, 61-63, 105 (a9779ef3c193)
- Who is affected
grep -rn makeRecipDistanceValueSource lucene/*/src/java (only SpatialStrategy.java)
- Why it looks unintended
lucene/spatial-extras/src/java/org/apache/lucene/spatial/SpatialStrategy.java:126-132 (javadoc), :53 (@lucene.experimental), :51 ('however it is quite stable')lucene/spatial-extras/src/test/org/apache/lucene/spatial/TestSpatialArgs.java:30-50; lucene/spatial-extras/src/test/org/apache/lucene/spatial/TestDistanceStrategy.java:92-104
- Suggested fix
lucene/spatial-extras/src/java/org/apache/lucene/spatial/query/SpatialArgs.java:55-71 (centre-to-corner since LUCENE-4550)
- TestRecipDistanceBoxWiderThan180.java (JUnit test):
repro/LFV-778/src/org/apache/lucene/spatial/TestRecipDistanceBoxWiderThan180.java(57 lines)
The caption and alt text follow the summary still as scripted.
assets/LFV-778/bug.png(476 KB)
