All findings · Lucene finding · LFV-740 · vector-normal-distance-assumes-unit-length

geo3d NORMAL and NORMAL_SQUARED point distances are wrong on WGS84 because Vector.normalDistance assumes a unit-length vector

On PlanetModel.WGS84, geo3d's NORMAL distance from a point at latitude 0.3 rad to itself is 0.00165 planet units (10.5 km) instead of 0, and a point 50 m from a circle's centre also reads 10.5 km. Vector.normalDistance projects as if every vector had length 1, true only on the sphere.

Summary

Priority
Low, rank 881Every 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
minor
Since
5.3.0 (LUCENE-6487, 2015)introduced 2015-06-02 · over 11 years
Affected
5.3.0 to 10.6.0, and main
Status
documented
Found by
semi-formal: translation validation against a 50-digit reference, then a JUnit

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Points, planet models and distance styles in geo3d

  1. geo3d (the spatial3d module) works with 3D points. A GeoPoint built 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 for lat and lon onto the planet.
  2. On PlanetModel.SPHERE every surface point is exactly 1 unit from the centre. WGS84 is 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.
  3. 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.
  4. The point of the line through the origin and p nearest to v is p times (p . v) / |p|^2. When |p| = 1 the division can be skipped; otherwise it cannot.
Diagram of the bug: a WGS84 point p at latitude 0.3 rad, slightly more than 1 unit from the planet's centre, and the line from the centre through p. Its NORMAL (perpendicular) distance to that line should be 0, but Vector.normalDistance computes |p·(p·p) − p| without dividing by |p|², which gives 0.00165 planet units, about 10.5 km.
Why a WGS84 point is 10.5 km from itself in the NORMAL style: the projection onto the point's own line is not divided by its squared length, and WGS84 points are not exactly 1 unit long. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You build a point or a circle on PlanetModel.WGS84 at latitude 0.3 rad and ask for a NORMAL distance: p.normalDistance(p), or circle.computeDistance(DistanceStyle.NORMAL, q) for a point q 50 m from the circle's centre.
  2. 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.
  3. You get 0.00165 (10.5 km) for both. The code projects with t = p · q instead 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).
  4. 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.

Who is affected

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 bug (main 645d4fe14a20)

  1. The formula. Vector.normalDistanceSquared(Vector v) (Vector.java:424-430) computes double t = dotProduct(v); (:425), then this * t - v and its squared length; the (x, y, z) overload is the same (:441-447, t at :442). normalDistance is the square root (:456-458, :469-471). There is no division by |this|^2.
  2. What it computes. Write p = |p| u with u of length 1 and split v into its part along u and the perpendicular rest v_perp. Then p * (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 for v = p it is |p| * ||p|^2 - 1|. The error is a floor combined in quadrature, not a constant offset: near p the result is about sqrt(true^2 + floor^2) with floor = |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| = 1 or p . v = 0. From a receiver at latitude 0.3 no WGS84 surface point is closer than 0.00165 (10.5 km).
  3. Who calls it. NormalDistance.computeDistance(point1, x2, y2, z2) returns point1.normalDistance(x2, y2, z2) (NormalDistance.java:33-41), and NormalSquaredDistance the squared form (NormalSquaredDistance.java:33-41); these are the only production callers. Circles call distanceStyle.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, computePathCenterDistance and computeDeltaDistance reach these point-to-point calls; computeOutsideDistance is 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.
  4. Not affected. GeoPoint.arcDistance divides 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| = 1 and the result is exact up to rounding.

The numbers (stock main 645d4fe14a20; the same on 10.6.0)

call (WGS84 unless stated; radians)correctmain
p.normalDistance(p), latitude 0.300.0016507737062451833 (10.5 km)
the same at latitude 0 / 0.6 / 1.2 / 1.5 / the pole00.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.302.7250538292304587E-6
100 m circle at (0.3, 0.2): NORMAL distance of a point 50 m north of the centre7.854520282177453E-60.0016507923676874635
the same circle, NORMAL distance of its centre00.0016507737062451833
meridian path (0,0)-(0.6,0), width 0.01, computePathCenterDistance(NORMAL, ...): A on the centre line vs B 1.63 km off itA 0.0 < B 0.000256A 0.00222 > B 0.000472
CLARKE_1866, p.normalDistance(p), latitude 000.00226644799607095
controls: SPHERE p.normalDistance(p); ARC and LINEAR (p, p) on WGS8400 (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, GeoCircle and GeoPath computeDistance, computeNearestDistance, computePathCenterDistance, ...) with DistanceStyle.NORMAL or NORMAL_SQUARED on 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. computePathCenterDistance is "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 acos rounding); shape membership, Lucene's queries and sorts (Geo3DPointDistanceComparator and Geo3DPointOutsideDistanceComparator use ARC) and the index are unaffected.
  • How likely. Plausible but rare: practically every opted-in call is wrong (all but those with |p| = 1 or orthogonal vectors), yet no Lucene code uses these styles, and the recorded searches found no downstream code using DistanceStyle.NORMAL and no report since 5.3.0 (2015). Registry priority low.

Minimal repro

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.

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

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

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

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 9.12.3: fail; with the recommended fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; plain double arithmetic)
Command (environment)
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)

Observed output

# 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)

Why it looks unintended

  • The javadoc says "normal (perpendicular) distance"; a point's perpendicular distance to its own line is 0, not 10.5 km.
  • Upstream TestGeoCircle asserts 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). Vector is "A 3d vector in space, not necessarily going through the origin" (:20), and GeoPoint "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.testCircleDistance asserts a NORMAL distance of 0.0 at the centre and 0.049979 (sin 0.05) at arc 0.05 (TestGeoCircle.java:23-39, asserts :33 and :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) made GeoPoint.arcDistance divide by both magnitudes but did not change Vector.normalDistance, although it edited the GeoCircle methods 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.

Suggested fix

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.

Divide the projection by |this|², with a zero-length guard (recommended, validated)

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|^2 is 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| = 1 or the vectors are orthogonal). It is a behaviour change of a public method: callers who tuned thresholds to the old numbers, or who call Vector.normalDistance on their own non-unit vectors, will see different results; a compatibility point, not evidence against the bug.
  • Reach. Only NormalDistance and NormalSquaredDistance call 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 (@Nightly tests, not enabled by default).
  • :lucene:spatial3d checkGoogleJavaFormat, 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.

The same division without the guard (validated by the hunt)

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.

Override in GeoPoint with the cached magnitude (not built)

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.

Document the restriction (not recommended)

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.

Downstream note

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

How it was found

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.normalDistanceSquared showed 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, migrated JIRA included), commits2026-10-27two 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, history2026-10-27changed 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 branchesno open PR touches spatial3d/geom; the formula has not changed since 2015
Mailing lists, JIRA, downstream projects, web2026-10-27dev@, java-user@, issues@, commits@, solr-user@; LUCENE and SOLR JIRA; apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, spatial4j and GitHub code search; 5 web searchesno report; no downstream code that uses DistanceStyle.NORMAL
Recheck (GitHub API)2026-10-31normalDistance, "DistanceStyle.NORMAL", NORMAL_SQUARED, normalDistanceSquared, "geo3d normal distance", "spatial3d WGS84 normal distance"; spatial3d items created since 2026-10-27only #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

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe 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 affectedthe 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) affecteda 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 levelfails at the introducing commit, passes at its parent and with the fix
5.2.0, 5.2.1 sphere onlygeo3d 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 affectedthe minimal test passes; the spatial3d module passes

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (translation validation, JUnit) candidate50-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 realOwn 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; keepHistory 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 validatedGradle 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,517 words

Finding record

LFV-740, snapshot of 2026-11-06

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 main a9779ef3c193 (the cross-model reviews ea57360912ed); no file under lucene/spatial3d differs between those commits and 645d4fe14a20.

Lucene and upstream sources

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 645d4fe14a20
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/NormalDistance.java:33-41, NormalSquaredDistance.java:33-41, DistanceStyle.java:30-46 at 645d4fe14a20
  • lucene/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 645d4fe14a20
  • grep 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 645d4fe14a20
  • lucene/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

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

Repro files

About the diagrams

Made in parallel with this doc from the minimal repro and the executed values.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.