geo3d's NORMAL distance style measures how far a point is from the line through the planet's centre and a reference point. The code does this as if every point were exactly 1 unit from the centre, which is true only on the sphere model.
- geo3d (the spatial3d module) works with 3D points. A
GeoPointbuilt from latitude and longitude lies on the surface of a planet model whose mean radius is 1 "planet unit"; on WGS84 one planet unit is 6,371 km, so 0.001 is about 6.4 km. Its latitude is geocentric:GeoPoint(planet, lat, lon)scales the unit direction forlatandlononto the planet. - On
PlanetModel.SPHEREevery surface point is exactly 1 unit from the centre.WGS84is a flattened ellipsoid: points on the equator are 1.0011 units from the centre, points at the poles 0.9978, and points near 35 degrees (geocentric) exactly 1. - Shapes such as circles and paths report distances in five
DistanceStyles: ARC (the angle), LINEAR (the straight line), NORMAL (the perpendicular distance to the line from the centre through a reference point, such as the circle's centre), and the squares of the last two. - The point of the line through the origin and
pnearest tovisptimes(p . v) / |p|^2. When|p| = 1the division can be skipped; otherwise it cannot.
- You build a point or a circle on
PlanetModel.WGS84at latitude 0.3 rad and ask for a NORMAL distance:p.normalDistance(p), orcircle.computeDistance(DistanceStyle.NORMAL, q)for a pointq50 m from the circle's centre. - You expect 0 for the point itself and 7.85e-6 planet units (50 m) for
q: NORMAL is documented as the perpendicular distance to the line from the planet's centre through the point. - You get 0.00165 (10.5 km) for both. The code projects with
t = p · qinstead of(p · q) / |p|², which is right only when |p| = 1, and WGS84 surface points are 0.998 to 1.0011 units long. A point's distance to its own line becomes |p|·||p|² − 1|: 14.3 km at the equator, 28.4 km at the poles, 0 only near 35° (geocentric latitude). - No error is raised. NORMAL_SQUARED is off the same way, and on a WGS84 path
computePathCenterDistance(NORMAL, ...)ranks a point on the centre line (0.00222) behind one 1.63 km off it (0.00047). SPHERE, ARC and LINEAR are correct up to rounding, and no Lucene query or sort uses NORMAL.
Only code that calls the spatial3d geom distance API itself with DistanceStyle.NORMAL or NORMAL_SQUARED on WGS84 or another ellipsoid. Its results never fall below a latitude-dependent error floor (10.5 km at latitude 0.3 rad, up to 28.4 km), so thresholds and comparisons across points or paths go wrong. Lucene's own queries and sorts use ARC.
Mechanism, terms and numbers851 words
Vector.normalDistanceSquared computes the perpendicular distance from a vector v to the line through the origin and the receiver p as |p * (p . v) - v|, without dividing the projection by |p|^2. That is right only when |p| = 1, which holds for every surface point on PlanetModel.SPHERE but not on WGS84, CLARKE_1866 or other ellipsoids, where surface points are 0.998 to 1.0011 units long. So on WGS84 p.normalDistance(p) is |p| * ||p|^2 - 1| instead of 0: 0.0016507737062451833 planet units (10.5 km) at latitude 0.3 rad, 14.3 km at the equator, 28.4 km at the poles, and 0 only near 35 degrees (geocentric latitude), where |p| = 1. DistanceStyle.NORMAL and NORMAL_SQUARED point distances are exactly these calls, and circles and paths measure them from their centre or path points, so a WGS84 circle's NORMAL distance for a point 50 m from its centre is 0.00165 instead of 7.85e-6, and a path-centre comparison ranks a point on the path behind one 1.63 km off it. The error is a floor that combines in quadrature with the true distance, so near distances and small thresholds are hit hardest. Nothing fails loudly. The formula comes from the sphere-only first geo3d code; the 2015 change that added WGS84 (5.3.0) made arc distances magnitude-aware but kept this method's sphere-era formula. Lucene's own queries and sorts use ARC, so only applications that call the geom distance API with NORMAL or NORMAL_SQUARED on an ellipsoid are hit. Dividing t by |p|^2 fixes it.
- The formula.
Vector.normalDistanceSquared(Vector v)(Vector.java:424-430) computesdouble t = dotProduct(v);(:425), thenthis * t - vand its squared length; the(x, y, z)overload is the same (:441-447,tat:442).normalDistanceis the square root (:456-458,:469-471). There is no division by|this|^2. - What it computes. Write
p = |p| uwithuof length 1 and splitvinto its part alonguand the perpendicular restv_perp. Thenp * (p . v) - v = (|p|^2 - 1)(u . v) u - v_perp, so, apart from rounding, the computed square is the true perpendicular distance squared plus((|p|^2 - 1)(u . v))^2. Hence the result is never below the true distance, nor below||p|^2 - 1| * |v|, and forv = pit is|p| * ||p|^2 - 1|. The error is a floor combined in quadrature, not a constant offset: nearpthe result is aboutsqrt(true^2 + floor^2)withfloor = |p| * ||p|^2 - 1|, so the excess shrinks with distance (at latitude 0.3: 10.5 km at 0 and 50 m, 551 m at 100 km, 54 m at 1,000 km). It is zero only when|p| = 1orp . v = 0. From a receiver at latitude 0.3 no WGS84 surface point is closer than 0.00165 (10.5 km). - Who calls it.
NormalDistance.computeDistance(point1, x2, y2, z2)returnspoint1.normalDistance(x2, y2, z2)(NormalDistance.java:33-41), andNormalSquaredDistancethe squared form (NormalSquaredDistance.java:33-41); these are the only production callers. Circles calldistanceStyle.computeDistance(this.center, x, y, z)(GeoStandardCircle.java:158,GeoExactCircle.java:235), and paths use their segment end points and path points as receivers (GeoStandardPath.java:914-948,:1593,:1688,:1745,:1778-1780;GeoDegeneratePath.java:504-536,:663,:713). On paths,computeDistance,computeNearestDistance,computePathCenterDistanceandcomputeDeltaDistancereach these point-to-point calls;computeOutsideDistanceis mixed: segment planes (exact) plus segment corners and end points (affected) (GeoStandardPath.java:1784-1838,:946-949;GeoDegeneratePath.java:866-874,:534-537). Path NORMAL_SQUARED values also carry two separate path bugs, LFV-271 (GeoStandardPath) and LFV-741 (GeoDegeneratePath); the path values below are NORMAL, which those do not touch. - Not affected.
GeoPoint.arcDistancedivides by both magnitudes (GeoPoint.java:230-244); LINEAR is a plain difference; the plane-based NORMAL distances (Plane.java:645-701) do not use this method. On SPHERE|p| = 1and the result is exact up to rounding.
| call (WGS84 unless stated; radians) | correct | main |
|---|---|---|
p.normalDistance(p), latitude 0.3 | 0 | 0.0016507737062451833 (10.5 km) |
| the same at latitude 0 / 0.6 / 1.2 / 1.5 / the pole | 0 | 0.00224 / 0.0000901 / 0.00359 / 0.00443 / 0.00446 (14.3 / 0.57 / 22.9 / 28.2 / 28.4 km) |
NORMAL_SQUARED.computeDistance(p, p), latitude 0.3 | 0 | 2.7250538292304587E-6 |
| 100 m circle at (0.3, 0.2): NORMAL distance of a point 50 m north of the centre | 7.854520282177453E-6 | 0.0016507923676874635 |
| the same circle, NORMAL distance of its centre | 0 | 0.0016507737062451833 |
meridian path (0,0)-(0.6,0), width 0.01, computePathCenterDistance(NORMAL, ...): A on the centre line vs B 1.63 km off it | A 0.0 < B 0.000256 | A 0.00222 > B 0.000472 |
CLARKE_1866, p.normalDistance(p), latitude 0 | 0 | 0.00226644799607095 |
controls: SPHERE p.normalDistance(p); ARC and LINEAR (p, p) on WGS84 | 0 | 0 (ARC up to 2.1e-8 at high latitudes: acos rounding) |
Each self-distance equals |p| * ||p|^2 - 1| to at least 11 significant digits. On WGS84 |p| = 1 at geocentric latitude 0.6143 rad (35.2 degrees), where the error vanishes. All latitudes are geo3d's geocentric latitudes.
Exposure and likelihood321 words
- Who. Application code that calls the geom distance API (
Vector.normalDistance,DistanceStyle.NORMAL.computeDistance,GeoCircleandGeoPathcomputeDistance,computeNearestDistance,computePathCenterDistance, ...) withDistanceStyle.NORMALorNORMAL_SQUAREDon a non-spherical planet model. The caller always picks the style, one of five public constants (DistanceStyle.java:30-46). The API is@lucene.experimental. - Absolute values and thresholds. A point's distance to itself reads up to 28.4 km (at the poles), and a point 50 m from a circle's centre reads 10.5 km at latitude 0.3 rad. Because the computed value is never below the true one, a check such as "NORMAL distance below 1 km" drops points it should keep; from a centre at latitude 0.3 no surface point is closer than 10.5 km, so it is false for every point, the centre included. The excess shrinks with distance (551 m at 100 km), so near distances and small thresholds are hit hardest.
- Comparisons across receivers.
computePathCenterDistanceis "meant to allow comparisons between different paths to find the one that goes closest to a point" (GeoPath.java:60-61); on a WGS84 meridian path a point on the centre line ranks behind one 1.63 km off it. Distances measured from centres at different latitudes carry different floors. - What stays right. Around one fixed circle centre, a verifier's 40-step ray control kept the ARC order (near the centre the floor is almost constant); order around one centre in general was not proved. SPHERE, ARC and LINEAR are correct up to rounding (ARC(p, p) reads up to 2.1e-8 from
acosrounding); shape membership, Lucene's queries and sorts (Geo3DPointDistanceComparatorandGeo3DPointOutsideDistanceComparatoruse ARC) and the index are unaffected. - How likely. Plausible but rare: practically every opted-in call is wrong (all but those with
|p| = 1or orthogonal vectors), yet no Lucene code uses these styles, and the recorded searches found no downstream code usingDistanceStyle.NORMALand no report since 5.3.0 (2015). Registry priority low.
One JUnit class, one method, one assertion, public API only: on WGS84, p.normalDistance(p) must be 0. Pure arithmetic, so the seed does not matter; it fails with assertions on and off.
/*
* 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.spatial3d.geom;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestNormalDistanceOnEllipsoid extends LuceneTestCase {
public void testNormalDistanceOfAPointToItself() {
GeoPoint p = new GeoPoint(PlanetModel.WGS84, 0.3, 0.2);
// perpendicular distance from p to the line through the origin and p: expected 0, actual 0.00165
assertEquals(0.0, p.normalDistance(p), 1e-12);
}
}
cd $LUCENE && ./gradlew :lucene:spatial3d:test --tests org.apache.lucene.spatial3d.geom.TestNormalDistanceOnEllipsoid -Ptests.seed=DEADBEEF
- Expected
p.normalDistance(p)returns 0 for the WGS84 point at latitude 0.3, longitude 0.2 rad.- Observed
expected:<0.0> but was:<0.0016507737062451833>, about 10.5 km, which is |p|·||p|² − 1| for this point.
More files, environment, full output231 words
The class sits in org.apache.lucene.spatial3d.geom only to be next to TestGeoPoint. It compiles on 9.1 and later (org.apache.lucene.tests.util.LuceneTestCase); on 9.0 and older the base class is org.apache.lucene.util.LuceneTestCase (on 9.0.0 the test as written does not compile; on 9.1.0 it fails as the bug), and in 5.3 to 5.5 the package was org.apache.lucene.geo3d.
It shows the self-distance only. The circle, path, CLARKE_1866 and per-latitude values are the executed values in the table above, from a separate value probe, not extra tests.
Regression test: this same test. Its comment line is 101 columns, which google-java-format rejects (./gradlew tidy wraps it).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 9.12.3: fail; with the recommended fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; plain double arithmetic) |
cp $REPRO/TestNormalDistanceOnEllipsoid.java $LUCENE/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/
cd $LUCENE && ./gradlew :lucene:spatial3d:test --tests org.apache.lucene.spatial3d.geom.TestNormalDistanceOnEllipsoid -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false (the recorded runs added --offline --no-daemon)
# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, assertions on
TestNormalDistanceOnEllipsoid > testNormalDistanceOfAPointToItself FAILED
java.lang.AssertionError: expected:<0.0> but was:<0.0016507737062451833>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:A8ED8F369E317E22]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:555)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:685)
at org.apache.lucene.spatial3d.geom.TestNormalDistanceOnEllipsoid.testNormalDistanceOfAPointToItself(TestNormalDistanceOnEllipsoid.java:26)
# assertions off (-Ptests.asserts=false): the same message
# standalone runner, seed DEADBEEF: 1 of 1 failed, same message, on main 645d4fe14a20 (-ea and -da), 10.6.0, 9.12.3
# with the recommended fix: the test passes (assertions on and off)- The javadoc says "normal (perpendicular) distance"; a point's perpendicular distance to its own line is 0, not 10.5 km.
- Upstream
TestGeoCircleasserts the exact values (0 at the centre, sin 0.05 at arc 0.05), but only on SPHERE. - The 2015 WGS84 change made arc distances magnitude-aware but kept this formula, even in the circle methods it edited.
The full argument259 words
- The contract.
Vector.normalDistance: "Compute the normal (perpendicular) distance to a vector described by a vector taken from the origin" (Vector.java:450-451,:461-462).Vectoris "A 3d vector in space, not necessarily going through the origin" (:20), andGeoPoint"a point on the surface of a sphere or ellipsoid" (GeoPoint.java:24). No javadoc restricts NORMAL to SPHERE or to unit vectors. - The author's definition. When the styles were added (LUCENE-6578, #7636, 2015, after WGS84 existed), the geo3d author Karl Wright (@DaddyWri) listed "NormalDistance (perpendicular to a plane/line through the center of the earth)" and said the squared styles "require no square roots, so if you need a fast distance estimate, that's what you'd use": our reading is that the thread ties "estimate" to skipping a square root and states no ellipsoid restriction for NORMAL.
- The tests.
TestGeoCircle.testCircleDistanceasserts a NORMAL distance of 0.0 at the centre and 0.049979 (sin 0.05) at arc 0.05 (TestGeoCircle.java:23-39, asserts:33and:38), on SPHERE only; every upstream NORMAL assertion uses SPHERE, which is why no test caught it. The same centre assertion on WGS84 gives 0.002241464883617787. - History. The formula is from the sphere-only first geo3d code (LUCENE-6196,
bde056c7632, 2015-04-13). The WGS84 change (LUCENE-6487,8c3e6b0ed32, 2015-06-02) madeGeoPoint.arcDistancedivide by both magnitudes but did not changeVector.normalDistance, although it edited theGeoCirclemethods that return it (see the devil's advocate below); its author wrote that on the ellipsoid "distance measures continue to be computed as arc distances, normal distances, or direct (linear) distances".
The devil's-advocate check355 words
The strongest case for "intended" (rated moderate by a cross-model review, weak to moderate by verifiers, weak by the other review): the geom API is @lucene.experimental; GeoDistance says its values "are meant to provide both actual distance values, as well as distance estimates that can be computed more cheaply" (GeoDistance.java:20-22), and the normalDistance javadoc promises only "Monotonically increasing for arc distances up to PI/2"; NORMAL was born on the unit sphere, and parameter docs still say "the point's unit x coordinate" (GeoDistance.java:51); and on WGS84 users should use ARC, since Geo3DPoint.newDistanceQuery already warns that the query "may have up to 0.5% error" (Geo3DPoint.java:112-113). The strongest contrary history evidence, in the refute-lens review's words: the WGS84 commit itself edited GeoCircle.computeNormalDistance and computeSquaredNormalDistance. It removed cutoffNormalDistance, replaced the normalDistance > cutoffNormalDistance checks with if (!isWithin(point)), kept return this.center.normalDistance(point);, and commented in the constructor "In an ellipsoidal world, cutoff distances make no sense, unfortunately. Only membership can be used to make in/out determination."
Why it fails: experimental status is about API stability, not permission to return wrong values. An estimate that gives 10.5 km for a point and itself estimates nothing: the result never drops below that floor, however close the points are. The javadoc's "monotonically increasing" note is naturally read for one receiver, and the path example does not contradict it; it shows that comparisons across receivers fail, which computePathCenterDistance's javadoc invites. The style was defined geometrically after WGS84 existed, and the same WGS84 change made arc distances magnitude-aware. The 0.5% note is about query shapes, not returned distances, and at latitude 0.3 the resulting error exceeds 0.5% of every true distance below about 105 km (the 10.5 km floor itself exceeds 0.5% of distances up to about 2,100 km). The 2015 comment is about the circle's membership cutoff, which the change replaced with isWithin and the circle plane; it says nothing about the values returned, and LUCENE-6578 (committed 2015-07-01) then defined NORMAL as a perpendicular distance. The independent reproducer, the cross-model reviews and the devil's-advocate verifier said real; the adjudication confirmed with high confidence.
Recommended Divide the projection by |this|², with a zero-length guard (recommended, validated)
Divide the projection by the receiver's squared length in both normalDistanceSquared overloads, t = dotProduct(v) / (x*x + y*y + z*z), keeping t = 0 for a zero-length receiver (+6/-2 lines). Validated: the repro passes with assertions on and off, and so do the whole spatial3d test suite and the module's format, ECJ and forbidden-apis checks.
public double normalDistanceSquared(final Vector v) {
// project onto the line through the origin and this vector, which need not be unit length
final double magnitudeSquared = x * x + y * y + z * z;
final double t = magnitudeSquared == 0.0 ? 0.0 : dotProduct(v) / magnitudeSquared;
double deltaX = this.x * t - v.x;
...
// the (x, y, z) overload: the same, with this.x * this.x + ... and dotProduct(x, y, z)All options and trade-offs4 options · 542 words
The recommended fix was validated through gradle in a scratch worktree of 645d4fe14a20; the hunt validated the same division without the guard. The other two options were not built.
Project onto the receiver's direction: t = (this . v) / |this|^2. The guard keeps today's result for a zero-length receiver (t = 0, so the result is |v|^2, the squared distance to the origin) instead of NaN; surface GeoPoints are never zero length.
public double normalDistanceSquared(final Vector v) {
// project onto the line through the origin and this vector, which need not be unit length
final double magnitudeSquared = x * x + y * y + z * z;
final double t = magnitudeSquared == 0.0 ? 0.0 : dotProduct(v) / magnitudeSquared;
double deltaX = this.x * t - v.x;
...
// the (x, y, z) overload: the same, with this.x * this.x + ... and dotProduct(x, y, z)Trade-offs
- Cost. Three multiplications, two additions, a comparison and one division per call; no square root, so the squared style stays root-free. Not benchmarked.
- Changed values. On SPHERE
|p|^2is 1 to within rounding, so values change at most in the last bits; on an ellipsoid NORMAL and NORMAL_SQUARED point distances change to the correct value (except where|p| = 1or the vectors are orthogonal). It is a behaviour change of a public method: callers who tuned thresholds to the old numbers, or who callVector.normalDistanceon their own non-unit vectors, will see different results; a compatibility point, not evidence against the bug. - Reach. Only
NormalDistanceandNormalSquaredDistancecall this method in Lucene, so nothing else changes.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the repro fails with assertions on and off. With the fix: it passes with assertions on and off. :lucene:spatial3d:test, the whole module with the repro in place: 20 suites, 182 tests, 0 failures, 3 skipped (@Nightlytests, not enabled by default).:lucene:spatial3dcheckGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMain,forbiddenApisTest: pass. The fix is format-clean; the repro's comment line is 101 columns and fails the format check, so it was wrapped for this run.- The value probe gives the correct value for every row of the table above: every self-distance 0.0, the 50 m point 7.854520282177453E-6, the path A 0.0 < B 0.000256.
t = dotProduct(v) / (x * x + y * y + z * z) in both overloads, the finder's two-line sketch.
Trade-offs
Returns NaN for a zero-length receiver. Passed every hunt repro, 55 upstream tests in 7 spatial3d classes and the minimal repro on 645d4fe14a20.
GeoPoint caches its magnitude (GeoPoint.java:34-39, :281-288); an override could divide by magnitude() * magnitude().
Trade-offs
Fixes every style call, whose receivers are GeoPoints, but leaves Vector.normalDistance wrong for plain non-unit vectors, whose javadoc makes the same promise.
State that NORMAL and NORMAL_SQUARED are exact only on SPHERE, or for unit vectors.
Trade-offs
Leaves a 10 km self-distance on WGS84 and contradicts "perpendicular distance" in the javadoc.
An application that filters places by NORMAL distance on WGS84, for example "within 5 km of the user", silently drops places it should return, because no computed distance falls below a latitude-dependent floor (10.5 km at latitude 0.3 rad, up to 28.4 km at the poles).
Semi-formal. A hunt unit compared every geo3d distance function with an independent 50-digit reference over a grid of shapes and points; on WGS84 every grid point's NORMAL and NORMAL_SQUARED distance inside a circle deviated, while SPHERE had none. Reading Vector.normalDistanceSquared then gave the cause, and a public-API JUnit on the stock jars confirmed it.
The method in full137 words
- Translation validation. The hunt unit for geo3d distance functions ran the real spatial3d jar against an independent reference that computes the documented perpendicular distance,
|q - p (p . q) / |p|^2|, with 50 significant digits, over circles, exact circles and paths on SPHERE and WGS84. - What it found. On WGS84 all 4,050 inside NORMAL and NORMAL_SQUARED distances per circle type deviated, and more than 25,000 path nearest distances; SPHERE had none. With the fix shadowed, the WGS84 NORMAL deviations disappear except those of other, separately tracked causes.
- Root cause and test. Reading
Vector.normalDistanceSquaredshowed the missing division; a public-API JUnit on the stock main jars failed with assertions on and off, and on 10.5.2 and 9.12.3. - The search was bounded (a fixed grid of shapes and points), not a proof, so the label is semi-formal.
No prior report: 268 recorded queries on 2026-10-27 over apache/lucene issues, PRs and commits (the migrated JIRA archive included), all 410 open PRs, all 342 spatial-labelled items, the mailing lists, JIRA, downstream projects and the web. A recheck on 2026-10-31 found none either.
Searches5 searches
Nothing upstream reports or fixes it. The nearest items are the changes that added the NORMAL styles and WGS84, and fixes of other leftover sphere assumptions.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, migrated JIRA included), commits | 2026-10-27 | two scripted prior-report search runs (identifiers such as NormalDistance, normalDistanceSquared, DistanceStyle.NORMAL; symptoms such as WGS84, ellipsoid, perpendicular distance, unit vector) and 54 manual searches | "DistanceStyle.NORMAL": 0 results; normalDistance: only #7636 and #7733; no report |
| Open PRs, labelled issues, history | 2026-10-27 | changed files of all 410 open PRs; titles of all 342 module:spatial3d / module:spatial items, 42 downloaded with comments; upstream history of the fix-site files on 4 branches | no open PR touches spatial3d/geom; the formula has not changed since 2015 |
| Mailing lists, JIRA, downstream projects, web | 2026-10-27 | dev@, java-user@, issues@, commits@, solr-user@; LUCENE and SOLR JIRA; apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, spatial4j and GitHub code search; 5 web searches | no report; no downstream code that uses DistanceStyle.NORMAL |
| Recheck (GitHub API) | 2026-10-31 | normalDistance, "DistanceStyle.NORMAL", NORMAL_SQUARED, normalDistanceSquared, "geo3d normal distance", "spatial3d WGS84 normal distance"; spatial3d items created since 2026-10-27 | only #7636 and #7733 for the identifiers; no report among the other hits; no new spatial3d items |
| Closest related items (read) | 2026-10-27 | #7636 (LUCENE-6578), #7546 (LUCENE-6487), #7258 (LUCENE-6196), #8991 (LUCENE-7942), #9124, #9410, #9623 | #7636 added the styles and defines NORMAL as a perpendicular distance; #7546 added WGS84 and fixed arcDistance; #8991 (closed 2017) added squared-style path aggregation, which later regressed (LFV-271); #9124 and #9410 fixed other sphere assumptions; #9623 (open) says GeoStandardCircle is accurate only on a sphere and proposes GeoExactCircle for WGS84, without mentioning NORMAL values |
Introduced in 5.3.0 by LUCENE-6487 (8c3e6b0ed32, 2015-06-02), which added WGS84 and made arc distances magnitude-aware but kept the sphere-era normalDistance formula. Every release from 5.3.0 to 10.6.0 and main is affected, and it is not fixed upstream.
Versions tested6 rows
Introduced by LUCENE-6487 "Geo3D with WGS84 option" (#7546; patch by Karl Wright, committed by David Smiley): 8c3e6b0ed32 (branch_5x) and 77de0ed1cf8 (trunk), 2015-06-02, from branch work 560f3a1a010 (2015-05-27). SVN era, no pull request. The formula itself is from the sphere-only first geo3d code, bde056c7632 (LUCENE-6196, 2015-04-13), where every point was a unit vector; the public DistanceStyle.NORMAL and NORMAL_SQUARED came with LUCENE-6578 (688c67f8ff2, 2015-07-01). Both 2015 changes first shipped in 5.3.0. The method body is identical at every release tag from 5.2.0 to 10.5.2 and on main. In 5.2.0 and 5.2.1 the public Vector formula is therefore already wrong for a hand-built non-unit Vector (the follow-up's raw-Vector check on 5.2.1: 0.0022414648883474194 for a receiver of length 1.00112 and itself), but the API of those releases builds no such point; 5.3.0 is the first release in which the described calls are wrong.
Upstream (2026-10-31): main 2f424fd428da, branch_10_6 and branch_9_12 still have double t = dotProduct(v); at Vector.java:425 and :442; the last commit on Vector.java in main (dfd536186fe4, #15889, 2026-04-01, an instanceof refactor) is already in 645d4fe14a20.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (assertions on and off, gradle and the standalone runner); every wrong value of the table reproduced |
| 10.6.0, 9.12.3 | affected | the minimal test fails; on 10.6.0 the value probe gives identical values to main |
| 5.3.0 to 10.5.2 (5.3.0, 5.3.2, 5.4.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.9.0, 8.11.4, 9.0.0, 9.1.0, 9.4.2, 9.12.3, 10.0.0, 10.5.2) | affected | a version-portable program or the finder's JUnit fails on each; 5.3.0 with assertions on and off |
8c3e6b0ed32 (LUCENE-6487) vs its parent | commit level | fails at the introducing commit, passes at its parent and with the fix |
| 5.2.0, 5.2.1 | sphere only | geo3d had no planet model, every point was a unit vector; the check passes (the Vector formula is the same; a hand-built non-unit Vector gets the same kind of wrong value) |
fix (scratch worktree of 645d4fe14a20) | not affected | the minimal test passes; the spatial3d module passes |
An independent reproducer, cross-model reviews (OpenAI GPT-5.6, both executed) and a devil's-advocate verifier said real; the adjudication confirmed with high confidence. Every check found the same root cause and that the division fixes it.
Checks and verdicts313 words
Every executing check ran on stock builds of main (ea57360912ed, a9779ef3c193 or 645d4fe14a20, with identical spatial3d files) or on releases.
| Check | Verdict | What it found |
|---|---|---|
| Finder (translation validation, JUnit) | candidate | 50-digit reference over circles and paths: every WGS84 inside NORMAL distance deviates, SPHERE none; JUnit fails 2 of 3 on main with assertions on and off, and on 10.5.2 and 9.12.3; the fix passes, as do 55 upstream tests. |
| Cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own test fails 2 of 3 with assertions on and off, SPHERE control passes; passes with the fix, as do 35 selected upstream tests. Rated the intended case moderate. |
| Independent reproducer | real | Own 5-test JUnit from the claim: 3 of 5 fail on main, 10.5.2 and 9.12.3, both controls pass; values match |p|·||p|² − 1| at four latitudes; its own fix makes 5 of 5 pass. |
| Cross-model review, intended lens (OpenAI GPT-5.6, executed) | real (high) | Own test fails 4 of 5, 0 with the fix; rated the intended case weak. |
| Devil's advocate | real (high) | Built the intended case and showed why it fails; own 10-test probe: 4 fail on main and 10.5.2, 6 controls pass; found the path-centre inversion and that single-circle order is kept. |
| Adjudication | confirmed (high) | Ran the independent reproduction's, the finder's and its own outside-package probe: 11 tests, 7 fail with assertions on and off, 4 controls pass, 0 fail with the fix; also CLARKE_1866. |
| Follow-up, registry re-run and credibility audit | reproduces; keep | History runs (5.2.x passes, 5.3.0 to main fail, commit-level bisect), the prior-art search (new), fresh-seed re-runs. |
| This doc | reproduced, fix validated | Gradle on 645d4fe14a20; the 10.6.0 and 9.12.3 jars; the value probe on main, 10.6.0 and the fix; the recommended fix passes the repro, the spatial3d module and its lint checks. |
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 470 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,517 words
- Registry title
- geo3d NORMAL and NORMAL_SQUARED point distances are wrong on WGS84 and other ellipsoids because Vector.normalDistance assumes a unit-length vector, so the NORMAL distance from a point to itself is up to about 0.0045 planet units (0.00165, about 10.5 km, at latitude 0.3 rad) instead of 0
- Area
- spatial3d / geom distance styles (Vector.normalDistance[Squared]; DistanceStyle.NORMAL and NORMAL_SQUARED point-to-point distances in GeoCircle and GeoPath)
- Priority
- Low, rank 881: Every NORMAL or NORMAL_SQUARED geo3d distance on WGS84 is silently off by up to about 28 km on ordinary input (10.5 km at latitude 0.3 rad), but no Lucene query or sort uses these styles.
- Impact class
- minor
- Impact note
- Only code that calls the experimental spatial3d geom distance API itself (Vector.normalDistance, DistanceStyle.NORMAL.computeDistance, GeoCircle and GeoPath computeDistance, computeNearestDistance or computePathCenterDistance) with DistanceStyle.NORMAL or NORMAL_SQUARED on WGS84, CLARKE_1866 or another non-spherical planet model gets wrong values, on every call and at any scale: each point-to-point NORMAL distance carries an error floor of |p|*||p|^2-1| (0 near the latitude where |p| = 1, 0.00165 planet units or about 10.5 km at latitude 0.3 rad, 0.0022 at the equator, about 0.0045 near the poles), so absolute values and thresholds are wrong and path-centre comparisons can invert, while SPHERE and the plane-based normal distances are exact and every Lucene query, sort and comparator uses ARC, so search results, sort order and the index are unchanged.
- Summary
- You build a geo3d circle or path on PlanetModel.WGS84 (or call Vector.normalDistance or DistanceStyle.NORMAL.computeDistance on two GeoPoints) and ask for the NORMAL or NORMAL_SQUARED distance, which Vector's javadoc defines as 'the normal (perpendicular) distance to a vector described by a vector taken from the origin', and expect 0 for a point and itself and about 7.85e-6 planet units for a point 50 m from a circle's centre. Instead both come out as 0.00165 (about 10.5 km) at latitude 0.3 rad, because Vector.normalDistanceSquared projects onto the receiver with t = dotProduct(v) and never divides by |this|^2, which is exact only for unit vectors, while WGS84 surface points have lengths between about 0.998 and 1.0011. The same offset enters every NORMAL and NORMAL_SQUARED circle and path distance on non-spherical planet models, with no error; on SPHERE the values are exact.
- Introduced
- 8c3e6b0ed3219e3cd39ad750bfa7a9a6a94b6dbe (branch_5x, svn r1683102) and 77de0ed1cf8 (trunk, svn r1683100), both 2015-06-02: LUCENE-6487 'Geo3D with WGS84 option' (now apache/lucene#7546; patch by Karl Wright, committed by David Smiley; branch work 560f3a1a010 on svn branch lucene6487, 2015-05-27; svn era, no pull request). It added PlanetModel and WGS84, so surface points stopped being unit vectors, and made GeoPoint.arcDistance divide by both magnitudes, but left Vector.normalDistance[Squared] unchanged. The unit-length formula itself dates from the sphere-only first geo3d check-in bde056c7632 (LUCENE-6196, #7258, 2015-04-13; evaluate renamed to dotProduct in c561d376bc4, 2015-05-01), where every point the library built was a unit vector; the public DistanceStyle.NORMAL / NORMAL_SQUARED came with LUCENE-6578 (#7636; 688c67f8ff2 trunk, 780e4040238 branch_5x, 2015-07-01). [read: git blame, git log, git tag --contains in the history clone; exec: 5.2.1 jars with the geo3d package at 8c3e6b0ed32 shadowed fail, with the package at its parent pass, and 8c3e6b0ed32 plus the two-line fix passes] (2015-06-02 (trunk and branch_5x; branch work 2015-05-27; formula 2015-04-13; public NORMAL style 2015-07-01))
- First affected version
- 5.3.0 [exec] (tagged 2015-08-20; the first release with the WGS84 model and with DistanceStyle.NORMAL / NORMAL_SQUARED; it fails with -ea and -da); 5.2.0 and 5.2.1, the releases before, are sphere-only and pass [exec]. If hand-built non-unit Vector receivers count as legal input, the formula is already wrong in 5.2.0 (a raw-Vector run on 5.2.1 fails; informational)
- Affected versions
- every release from 5.3.0 through 10.6.0 and main a9779ef3c193 (unreleased 11.0); never fixed. [exec] The minimal test and the value probe fail on the 10.6.0 jars (2026-10-31). The follow-up's version-portable main() check (normalDistance(p, p) and DistanceStyle.NORMAL.computeDistance(p, p) on WGS84, a WGS84 circle point 50 m from the centre, SPHERE controls) fails on 5.3.0 (-ea and -da), 5.3.2, 5.4.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.7.3, 8.9.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main (-ea and -da), and at the introducing commit 8c3e6b0ed32 (commit-level shadow run); the finder's JUnit fails 2 of 3 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the finder's earlier self-distance main() also fails on 9.4.2; every SPHERE control passes. [read] The method body is identical (whitespace-normalised) at every release tag from 5.2.0 to 10.5.2, on main, upstream main a95c754ee00, branch_10x, branch_10_6 and branch_10_5; releases not executed are affected by that code identity and git tag --contains. Not affected: 5.2.0 and 5.2.1 (sphere-only geo3d, executed); geo3d does not exist before 5.2.0. All runs with JDK 25 on aarch64.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-10-27, read-only; the prior-report record, verdict new: 268 recorded queries, namely two scripted prior-report searches (A: identifiers, B: symptoms and usage) over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive, plus a re-read of the finder's and verifier's runs; 54 manual GitHub issue and PR searches (apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, spatial4j, all of GitHub); 4 GitHub code searches; the changed files of all 410 open apache/lucene PRs; the titles of all 342 issues and PRs labelled module:spatial3d or module:spatial; 16 direct mailing-list searches including solr-user@; 7 JIRA queries (LUCENE, SOLR); 5 web searches; the upstream history of the fix-site files on 4 branches; 42 spatial issues downloaded with all comments and searched for the bug's terms; 18 hits read, none the same bug). Related, not reports: apache/lucene#7636 (LUCENE-6578, closed), which added the public NORMAL / NORMAL_SQUARED styles and defines NormalDistance as 'perpendicular to a plane/line through the center of the earth'; #7546 (LUCENE-6487, closed), which added WGS84 and made arcDistance magnitude-aware but left normalDistance alone; #7258 (LUCENE-6196), the sphere-only origin of the formula; #8991 (LUCENE-7942), squared-style aggregation in GeoStandardPath (LFV-271, a different fix site); #9124 (LUCENE-8076) and #9410 (LUCENE-8363), fixes of other leftover sphere assumptions; #9623 (LUCENE-8577, open), GeoStandardCircle membership accuracy on a sphere only, which does not mention NORMAL values. GitHub code search found no downstream code that uses DistanceStyle.NORMAL. Not fixed upstream: Vector.java was last changed on main by dfd536186fe (2026-04-01, an instanceof refactor), NormalDistance.java and NormalSquaredDistance.java by 2cbf261032d (2021-01-05, reformat), and no open PR touches spatial3d/geom. Gaps: GitHub search returns the first page per query (every relevant query returned fewer than 30 results); the retired 2015 review site was not searched (the issue threads, which carry part of that review traffic, were); web search was throttled but all planned queries ran.
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails through gradle in a scratch worktree (assertions on and off) and through a standalone runner, which also fails it on 10.6.0 and 9.12.3; a value probe gives the same wrong values on main and 10.6.0. With the recommended fix the test passes (assertions on and off), and so do the whole spatial3d test suite and the module's format, ECJ and forbidden-apis checks. Earlier runs: 5.2.0 and 5.2.1 (sphere-only, pass) and 5.3.0 to 10.5.2 (fail), the introducing commit and its parent. The hunt, the verifiers and the adjudication ran maina9779ef3c193(the cross-model reviewsea57360912ed); no file underlucene/spatial3ddiffers between those commits and645d4fe14a20.
- Finding record
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/Vector.java:417-472 (both normalDistanceSquared overloads, t = dotProduct(v) with no division by |this|^2, at :424-447; normalDistance javadoc 'the normal (perpendicular) distance to a vector described by a vector taken from the origin'); Vector.java:22, DistanceStyle.java:22, NormalDistance.java:22 (@lucene.experimental)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/NormalDistance.java:33-41 and NormalSquaredDistance.java:33-41 (point-to-point computeDistance = point1.normalDistance[Squared]); DistanceStyle.java:42,46 (public NORMAL and NORMAL_SQUARED constants)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardCircle.java:158 and GeoExactCircle.java:235 (distanceStyle.computeDistance(this.center, x, y, z)); GeoStandardPath.java:914-948, 1593-1790 and GeoDegeneratePath.java:504-536, 663, 713 (path distances computed from surface-point receivers)lucene (grep of every module's src/java: no production code references DistanceStyle.NORMAL, NORMAL_SQUARED, NormalDistance, NormalSquaredDistance or a Vector normalDistance call outside the style definitions)
- The finding in 30 seconds
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/Vector.java:20, :22, :417-471 (javadoc :418-419, :450-451, :461-462; formula :425, :442) at 645d4fe14a20lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/NormalDistance.java:33-41, NormalSquaredDistance.java:33-41, DistanceStyle.java:30-46 at 645d4fe14a20lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardCircle.java:158, GeoExactCircle.java:235, GeoStandardPath.java:914-948, :1593, :1688, :1745, :1778-1780, GeoDegeneratePath.java:504-536, :663, :713, GeoPoint.java:24, :64-78, :110-111, :230-244, Vector.java:549-556, Plane.java:645-701; path outside distances GeoStandardPath.java:946-949, :1784-1838, GeoDegeneratePath.java:534-537, :866-874 at 645d4fe14a20grep of lucene/spatial3d/src/java at 645d4fe14a20 for normalDistance: the only production callers are NormalDistance.java:34, :40 and NormalSquaredDistance.java:34, :40 (run for this doc)
- Who is affected
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPath.java:60-61, DistanceStyle.java:22, :30-46 at 645d4fe14a20; lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:85, :131, :174 and Geo3DPointOutsideDistanceComparator.java:101, :144 (DistanceStyle.ARC)
- Why it looks unintended
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/Vector.java:20, :22, :450-451, :461-462; GeoPoint.java:24; GeoDistance.java:20-22, :51; lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPoint.java:112-113 at 645d4fe14a20lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestGeoCircle.java:23-39 (asserts :33, :38) at 645d4fe14a20
- Suggested fix
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPoint.java:34-39, :281-288 at 645d4fe14a20
- TestNormalDistanceOnEllipsoid.java (one test class):
repro/LFV-740/src/org/apache/lucene/spatial3d/geom/TestNormalDistanceOnEllipsoid.java(28 lines)
Made in parallel with this doc from the minimal repro and the executed values.
assets/LFV-740/bug.png(470 KB)
