All findings · Lucene finding · LFV-741 · geodegeneratepath-squared-styles-aggregation-form

On a zero-width geo3d path the squared distance styles are not squared: path-center distance comes back as d, outside distance near the path as d^4

On a geo3d path built with width 0, computePathCenterDistance(LINEAR_SQUARED, p) returns the plain distance (0.0100 where 0.0001 is expected) and computeOutsideDistance(LINEAR_SQUARED, p) its fourth power (1e-8). A 2022 refactor left out two conversions; it ships in every release since 9.5.0, and Lucene's own queries and sorts are not affected.

Summary

Priority
Low, rank 883The experimental squared distance styles return a wrong value only to direct callers, and it also needs a zero-width GeoDegeneratePath, so it is narrower.
Impact
minor
Since
9.5.0 (a 2022 refactor of the geo3d paths)introduced 2022-11-24 · about 3 years
Affected
9.5.0 to 10.6.0 and main
Status
documented
Found by
a check of geo3d path distances against an independent 50-digit reference

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 distance methods take a distance style, and the squared styles must return the square of the plain distance. On a zero-width path one method forgets to square at the end and another squares twice.

Background in 3 steps

  1. lucene/spatial3d (geo3d) models shapes on a sphere or ellipsoid. A GeoPath is a line through points with a width; GeoPathFactory.makeGeoPath picks a special zero-width class when the width is (almost) 0. Zero-width paths exist since 7.1.0.
  2. Distance methods take a DistanceStyle: ARC (angle), LINEAR (straight-line chord), NORMAL, and the squared variants LINEAR_SQUARED and NORMAL_SQUARED, the squares of LINEAR and NORMAL.
  3. A path's distance is combined from its pieces (segments and end points). Each style has an "aggregation form" for that: the value itself for most styles, the square root for the squared ones. The rule: convert each piece in, combine, convert back once.
Diagram of the minimal repro. A zero-width path on the sphere runs from (0, 0) to (0, 0.1); the query point (0.01, 0.05) lies 0.01 rad beside its middle, a LINEAR distance of 0.0099999583. LINEAR_SQUARED should be its square, about 1e-4. computePathCenterDistance returns about 1e-2 because it never converts back from the square-root aggregation form, and computeOutsideDistance returns about 1e-8 because a plain squared value is squared again.
The minimal repro in one picture: a zero-width path, a point 0.01 rad beside it, and what LINEAR_SQUARED should return (about 1e-4) against what the two methods return (about 1e-2, not squared, and 1e-8, squared twice). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build a path with GeoPathFactory.makeGeoPath(PlanetModel.SPHERE, 0, points) from (0, 0) to (0, 0.1). A width of 0 (anything below about 3e-12 rad) gives the zero-width path class; a width of 1e-6 gives the regular one.
  2. Take the point (lat 0.01, lon 0.05), 0.01 rad beside the middle of the path. Its LINEAR (straight-line) distance is 0.0099999583, so LINEAR_SQUARED should give 0.000099999 (9.9999e-5). The width-1e-6 path does.
  3. Instead the zero-width path returns 0.0099999583 from computePathCenterDistance(LINEAR_SQUARED, q), the unsquared value, and 9.9998e-9 from computeOutsideDistance(LINEAR_SQUARED, q), the fourth power. NORMAL_SQUARED is wrong the same way; ARC, LINEAR and NORMAL are right.
  4. Why: a squared style combines a path's pieces as square roots and must square once at the end. Since 9.5.0 the pieces return square roots, but computePathCenterDistance never squares back, and one piece of the outside distance still returns the plain square, which is then squared again.
  5. No error is raised. Asking "which path is closest?" under LINEAR_SQUARED can pick a wider path 0.0408 rad away over a zero-width path 0.0100 rad away (0.0017 vs 0.0100). Nothing in Lucene uses the squared styles.

Who is affected

Only code that calls GeoPath.computePathCenterDistance or computeOutsideDistance itself with LINEAR_SQUARED or NORMAL_SQUARED on a zero-width path; for it the values are wrong, silently, at every point off the path (outside distance: every such point closer than a chord of 1). Lucene's own queries and distance sorts use ARC and are unaffected. The geom API is @lucene.experimental.

Mechanism, terms and numbers847 words

GeoPathFactory.makeGeoPath turns a path whose width is below Vector.MINIMUM_ANGULAR_RESOLUTION (about 3e-12 rad, for example 0) into a GeoDegeneratePath. For the squared distance styles LINEAR_SQUARED and NORMAL_SQUARED, which combine partial results in an "aggregation form" (the square root) and must convert back once (square) for the final result, this class gets two of its distance methods wrong: computePathCenterDistance returns the plain distance d instead of d^2 at every point off the path, because it never converts back, and computeOutsideDistance returns d^4 at every point off the path closer than a chord of 1 (the planet radius), because one of its pieces returns the plain d^2 into a minimum of aggregation-form values that is then squared. For a point 0.01 rad beside a sphere path that is 0.0099999583 and 9.9998e-9 where 9.9999e-5 is expected; the same path with width 1e-6 returns 9.9999e-5.

The cause is an incomplete edit in commit 839dfb5a2dc (2022-11-24, part of the #11965 spatial3d refactor; branch_9x cherry-pick f69fb6ed99b), first released in 9.5.0: it converted the pieces, not the two places that combine them. 9.4.2 returns d^2. Affected: 9.5.0 to 10.6.0 and main; not fixed upstream. ARC, LINEAR and NORMAL are unaffected, and no Lucene query, sort or comparator uses the squared styles, so the impact is limited to code that calls these GeoPath methods itself. Fix: two lines (validated with the whole lucene/spatial3d test suite).

The mechanism (main 645d4fe14a20, files in lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/)

  1. The factory picks the class from the width alone. makeGeoPath returns new GeoDegeneratePath(...) when maxCutoffAngle < Vector.MINIMUM_ANGULAR_RESOLUTION (GeoPathFactory.java:40-41), else a GeoStandardPath (:43). GeoDegeneratePath is package-private and @lucene.internal (GeoDegeneratePath.java:33-35); callers only see a GeoPath.
  2. The squared styles' aggregation form is the square root. toAggregationForm returns Math.sqrt(distance) and fromAggregationForm squares (LinearSquaredDistance.java:63-71, NormalSquaredDistance.java:63-71). The defaults are the identity (DistanceStyle.java:116-118, :146-148), so for ARC, LINEAR and NORMAL a missing conversion changes nothing.
  3. Path-center distance: converted in, never converted out. computePathCenterDistance (GeoDegeneratePath.java:160-180) takes the minimum of PathSegment.pathCenterDistance and SegmentEndpoint.pathCenterDistance, which both return toAggregationForm(...) (:739, :522), and ends with return closestDistance; (:179). For a squared style that is sqrt(d^2) = d.
  4. Outside distance: one piece left plain, then converted out. outsideDistance (:270-287) takes the minimum of SegmentEndpoint.outsideDistance (toAggregationForm(...), :534-537) and PathSegment.outsideDistance, which returns the plain value Math.min(Math.min(startDistance, endDistance), distance) (:866-874, :873), i.e. d^2, and then applies fromAggregationForm (:286). For LINEAR_SQUARED the result is min(e, d^2)^2, with d the true LINEAR distance and e the chord to the nearest path vertex. Below d = 1 (geo3d units: the sphere's radius is 1) that is always (d^2)^2 = d^4, beyond the path end too. Above 1 it is d^2 when the nearest path point is a vertex, d^4 while d^2 < e, and e^2, too large, otherwise (executed on the sphere: 1.6084 for d^2 = 1.2753 beside a 2-rad segment). NORMAL_SQUARED never exceeds 1 on the sphere, so it gets NORMAL^4 at every point off the path. computeOutsideDistance reaches it through GeoBaseMembershipShape.java:46-53 (0 for points on the path).
  5. The width > 0 sibling does these two methods right. GeoStandardPath returns fromAggregationForm(...) from both (GeoStandardPath.java:278-285, :326-333). Its computeDistance has a separate defect (LFV-271).
  6. A third plain return, masked. The pole branch of PathSegment.pathCenterDistance returns computeDistance(start, ...) without toAggregationForm (GeoDegeneratePath.java:712-714; same at GeoStandardPath.java:1592-1594). In the one pole probe run, the end points win the minimum, so the result is right once the two lines are fixed.
  7. Scope: only these two methods. On the same zero-width path, computeDistance (a point on the path) and computeNearestDistance (a point on and a point off the path) return the square with LINEAR_SQUARED and NORMAL_SQUARED (executed), and points on the path get 0 from the two affected methods, which is right. At the pole of the path's great circle (the north pole, an equatorial path), computeOutsideDistance throws a NullPointerException from Plane.linearDistance or Plane.arcDistance with ARC, LINEAR and LINEAR_SQUARED alike, on 9.4.2 too: a separate behaviour, not part of this bug.

The executed forms

input (sphere unless noted)expectedactual
path (0, 0)-(0, 0.1), width 0, point (0.01, 0.05): computePathCenterDistance(LINEAR_SQUARED)9.999916666944443E-50.009999958333385416 (= LINEAR)
same: computeOutsideDistance(LINEAR_SQUARED)9.999916666944447E-59.999833334583335E-9 (= LINEAR^4)
same path with width 1e-6 (GeoStandardPath), path-center9.9999e-5correct
3-point path lon 0 -> 0.2 -> 0.4, point beyond the end (0, 0.45): outside0.0024994792100675076.2473963215596976E-6 (= LINEAR^4)
WGS84 path (an independent reviewer's test): center / outside1.7910505946914878E-4 / 1.7910505946914875E-40.013383013841028066 / 3.207862232744731E-8
path (0, 0)-(0, 2.0), point (1.2, 1.0), d = 1.1293 (above 1): outside1.2752844910466531.608434539414104 (= e^2, the squared chord to the nearest vertex)
zero-width path (ARC 0.0100) vs a width-1e-6 path (ARC 0.0408), path-center under LINEAR_SQUAREDzero-width path nearerwider path reported nearer (0.0099999583 vs 0.0016611674)
GeoCircle vs the zero-width path, outside LINEAR_SQUAREDboth d^2circle 8.4094e-4 = 0.0289990^2; path 9.9998e-9 for LINEAR 0.0100

Lucene 9.4.2 returns the squares for the zero-width path; every release from 9.5.0 returns the values above.

Exposure and likelihood274 words
  • Silent, wide. With a squared style on a zero-width path, computePathCenterDistance is wrong at every point off the path, and computeOutsideDistance at every point off the path closer than a chord of 1 (with NORMAL_SQUARED on the sphere, at every point off the path) and at many farther ones. No exception, no special input; points on the path get 0, which is right.
  • Who is exposed. Code that calls computePathCenterDistance or computeOutsideDistance with LINEAR_SQUARED or NORMAL_SQUARED on a path whose width is below Vector.MINIMUM_ANGULAR_RESOLUTION (for example 0). Both choices are opt-in, on the @lucene.experimental geom API (GeoPath, GeoPathFactory, DistanceStyle, GeoOutsideDistance).
  • Lucene's own results are not affected. LINEAR_SQUARED and NORMAL_SQUARED appear nowhere in lucene/ outside DistanceStyle.java; nothing in Lucene calls computePathCenterDistance; the in-tree distance sorts, including the outside-path sort of Geo3DDocValuesField, pass DistanceStyle.ARC.
  • Mixed widths can rank wrongly. The GeoPath javadoc says the path-center distance "is meant to allow comparisons between different paths to find the one that goes closest to a point". The class follows from the width, so a zero-width path compared with a wider path under a squared style can get the order wrong: it flips when the zero-width path's unsquared d is larger than the other path's squared value (executed; with the same two paths at q = (0.001, 0.1) the order is still right). Outside distances of a zero-width path cannot be compared with those of a GeoCircle either (d^4 against d^2).
  • Likelihood: plausible. Zero-width paths are a documented, tested feature, but the squared styles have no in-tree caller and no report of the bug was found (see the prior reports).

Minimal repro

One JUnit test in lucene/spatial3d, public API only: a two-point zero-width path on the sphere and one point beside it. It asserts that each LINEAR_SQUARED result is the square of the LINEAR result, one assertion per fix site. No randomness; fails with assertions on and off, no build change needed.

TestGeoDegeneratePathSquaredStyles.java (1 test)43 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 TestGeoDegeneratePathSquaredStyles extends LuceneTestCase {

  public void testZeroWidthPathLinearSquared() {
    PlanetModel pm = PlanetModel.SPHERE;
    GeoPath path =
        GeoPathFactory.makeGeoPath(
            pm, 0, new GeoPoint[] {new GeoPoint(pm, 0, 0), new GeoPoint(pm, 0, 0.1)});
    GeoPoint q = new GeoPoint(pm, 0.01, 0.05);
    // expected LINEAR^2 (~1e-4) from both calls; actual LINEAR (~1e-2), then LINEAR^4 (~1e-8)
    double center = path.computePathCenterDistance(DistanceStyle.LINEAR, q);
    assertEquals(
        "computePathCenterDistance(LINEAR_SQUARED)",
        center * center,
        path.computePathCenterDistance(DistanceStyle.LINEAR_SQUARED, q),
        1e-12);
    double outside = path.computeOutsideDistance(DistanceStyle.LINEAR, q);
    assertEquals(
        "computeOutsideDistance(LINEAR_SQUARED)",
        outside * outside,
        path.computeOutsideDistance(DistanceStyle.LINEAR_SQUARED, q),
        1e-12);
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or a later commit where GeoDegeneratePath.java is unchanged)
cp $REPRO/TestGeoDegeneratePathSquaredStyles.java lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/
./gradlew :lucene:spatial3d:test --tests org.apache.lucene.spatial3d.geom.TestGeoDegeneratePathSquaredStyles -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false
Expected
computePathCenterDistance(LINEAR_SQUARED) = 9.999916666944443E-5, the square of the LINEAR value 0.009999958333385416.
Observed
expected:<9.999916666944443E-5> but was:<0.009999958333385416> on main (assertions on and off) and on the 10.6.0, 10.5.2, 9.12.3 and 9.5.0 jars (assertions on); 9.4.2 passes.

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

More files, environment, full output181 words

The test sits in org.apache.lucene.spatial3d.geom only because upstream's TestGeoPath does; it uses public classes and methods only (GeoPathFactory, GeoPath, DistanceStyle, PlanetModel, GeoPoint). Nothing is randomized, so the result does not depend on the seed (it fails with DEADBEEF and 5EED0741).

On stock code it stops at the first assertion (path-center). The second assertion is the outside-distance fix site: with only the path-center line fixed it fails with expected:<9.999916666944447E-5> but was:<9.999833334583335E-9>, the same value the stock code returns. NORMAL_SQUARED goes through the same code and is left out.

The same file fails unchanged on the 10.6.0, 10.5.2, 9.12.3 and 9.5.0 release jars (run with assertions on) and passes on 9.4.2.

Environment

lucenemain 645d4fe14a20: affected; also the 10.6.0, 10.5.2, 9.12.3 and 9.5.0 release jars (fail) and 9.4.2 (passes) with this test
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

TestGeoDegeneratePathSquaredStyles > testZeroWidthPathLinearSquared FAILED
    java.lang.AssertionError: computePathCenterDistance(LINEAR_SQUARED) expected:<9.999916666944443E-5> but was:<0.009999958333385416>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:37E69C1AE26C7456]: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 org.apache.lucene.spatial3d.geom.TestGeoDegeneratePathSquaredStyles.testZeroWidthPathLinearSquared(TestGeoDegeneratePathSquaredStyles.java:31)

# with only the path-center line fixed, the second assertion:
    java.lang.AssertionError: computeOutsideDistance(LINEAR_SQUARED) expected:<9.999916666944447E-5> but was:<9.999833334583335E-9>

Why it looks unintended

  • DistanceStyle: convert in, combine, convert back once "to yield a final result". One method never converts back; the other converts back a value never converted in.
  • The width > 0 class converts back in both methods, and 9.4.2 returned the square on zero-width paths too.
  • LUCENE-7942 added aggregation forms because squared path distances were not "true squared distances".
The full argument240 words
  • The contract is one conversion back for a final result. DistanceStyle.java:107-148: "Distances should be converted to aggregation form before aggregation is attempted, and they should be converted back from aggregation form to yield a final result." The squared styles are the "Linear distance squared" and "Normal distance squared" calculators (:36-38, :44-46).
  • Why aggregation forms exist. #8991 (LUCENE-7942, 2017, by the geo3d author): "The xxxSquaredDistance computations, for example, do not produce true squared distances but rather a distance metric that is a combination of both squared and linear." Its fix added the three conversion methods (lucene/CHANGES.txt:7864-7865).
  • The same refactor converts back once in the sibling class. GeoStandardPath returns fromAggregationForm(...) from both methods (:278-285, :326-333); c9c27c755a4 ("Make sure use of aggregation form is consistent throughout ...") put those two conversions in a day before the introducing commit. (The same commit left a double conversion behind GeoStandardPath.computeDistance, LFV-271, so "consistent" holds for these two methods only.)
  • 9.4.2 returned d^2: every piece returned the plain value, so the minimum was already the answer. The introducing commit converted the pieces but not the two places that combine them.
  • No test pins the current values. No spatial3d test uses a squared style, nothing calls computePathCenterDistance, and computeOutsideDistance is tested only on boxes, circles and polygons, never on a path. The tests the commit changed compare paths with ARC, which cannot see the bug.
The devil's-advocate check169 words

The strongest case for "intended" (the intended-behaviour check and the cross-model reviews): the geom API is @lucene.experimental and GeoDegeneratePath @lucene.internal; the path-center distance is only "meant to allow comparisons", and since sqrt is monotone, zero-width paths still rank correctly among themselves; the squared styles are second-class (they throw "Reverse mapping not implemented" from findDistancePoints and friends); and the aggregation rule speaks of sums, not minima.

Why it fails: experimental and internal allow API changes, not wrong values from the public GeoPath interface; the comparison the javadoc names is "between different paths", and a zero-width and a wider path swap order under LINEAR_SQUARED (executed); computeOutsideDistance has no comparison-only wording and returns d^4, which across the chord-1 crossover is not even one monotone function of d; the same author's design takes minima in aggregation form and converts back once (GeoStandardPath); and 9.4.2 returned the squares. Verdict: not intended, a defect (the independent reproduction; the refute-lens and intended-lens cross-model reviews; the intended-behaviour check; and the adjudication, with high confidence).

Suggested fix

Recommended A: convert back once, and return the segment's outside distance in aggregation form

Two lines in GeoDegeneratePath: return distanceStyle.fromAggregationForm(closestDistance) from computePathCenterDistance, and wrap PathSegment.outsideDistance's result in toAggregationForm, as the sibling GeoStandardPath and the end-point code do. The repro passes, the lucene/spatial3d tests give 182 run, 0 failures (3 skipped), and the module's lint checks pass.

@@ computePathCenterDistance
-    return closestDistance;
+    return distanceStyle.fromAggregationForm(closestDistance);
@@ PathSegment.outsideDistance
-      return Math.min(Math.min(startDistance, endDistance), distance);
+      return distanceStyle.toAggregationForm(
+          Math.min(Math.min(startDistance, endDistance), distance));
All options and trade-offs3 options ยท 441 words

Two options were built and validated through gradle on 645d4fe14a20; a third is listed, not built. Workaround until fixed: call the method with LINEAR and square the result. NORMAL works the same way on the sphere, but has its own defect on ellipsoids such as WGS84 (LFV-740), so prefer LINEAR.

A: convert back once, and return the segment's outside distance in aggregation form (validated by the checks) (recommended)

2 lines in GeoDegeneratePath.java (:179, :873), the pattern of GeoStandardPath and of SegmentEndpoint.outsideDistance. ARC, LINEAR and NORMAL are unchanged (identity conversions).

@@ computePathCenterDistance
-    return closestDistance;
+    return distanceStyle.fromAggregationForm(closestDistance);
@@ PathSegment.outsideDistance
-      return Math.min(Math.min(startDistance, endDistance), distance);
+      return distanceStyle.toAggregationForm(
+          Math.min(Math.min(startDistance, endDistance), distance));

Trade-offs

None known; local to the zero-width class. Code that worked around the wrong values (for example by squaring the path-center value itself) would have to drop that; none is known.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF: the repro fails on stock and passes with A, assertions on and off.
  • The whole :lucene:spatial3d:test with A and the repro (including TestGeoPath, TestRandomGeoShapeRelationship, TestGeo3DPoint, TestGeo3DDocValues): 20 suites, 182 tests, 0 failures, 3 skipped.
  • :lucene:spatial3d:check -x test (checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest): passes.
  • An independent reviewer's 7-test class (WGS84, both squared styles, both methods, a pole probe, two controls): 5 of 7 fail on stock 645d4fe14a20, 7 of 7 pass with A.
  • Earlier, the same two-line change made every verifier's test pass: the independent reproduction 5 of 5 and the adjudication 12 of 12 on a9779ef3c193, a separate test 2 of 2 on ea57360912ed (lucene/spatial3d is the same on both).

B: A, plus the pole branch (validated, consistency only)

Also return distanceStyle.toAggregationForm(distanceStyle.computeDistance(start, x, y, z)) from the pole branch of PathSegment.pathCenterDistance (:713), so that every piece returns aggregation form (3 lines in total).

Trade-offs

No executed input differs between A and B: in the pole probe the end points win the minimum. The same branch in GeoStandardPath (:1593) belongs to the other class and was not changed here.

What the checks validated

Gradle on 645d4fe14a20: the repro passes; :lucene:spatial3d:test 20 suites, 182 tests, 0 failures, 3 skipped; check -x test passes; the reviewer's class 7 of 7.

C: take the minimum over plain values, as in 9.4.2 (not built)

Since toAggregationForm is monotone, revert the three wrapped piece returns (:522, :536, :739) and the final fromAggregationForm (:286).

Trade-offs

More lines, and it diverges from the design the same refactor applied to GeoStandardPath; A is smaller.

Downstream note

Lucene's own queries and sorts use ARC and are unaffected; an application that ranks paths by computePathCenterDistance under a squared style can pick the wrong path when zero-width and wider paths are mixed.

How it was found

A harness recomputed geo3d path distances with an independent 50-digit reference and, on zero-width paths, flagged 2,487 LINEAR_SQUARED and 2,487 NORMAL_SQUARED path-center mismatches on the sphere and 2,554 LINEAR_SQUARED on WGS84, off by up to 1e9 times. Reading the code showed the outside distance is squared twice; a public-API test confirmed both.

The method in full124 words

Method: semi-formal (bounded-exhaustive checking of the real jar against exact semantics).

  • A translation-validation harness recomputed the documented path distances with an independent BigDecimal reference (50-digit precision), the squared styles through the aggregation-form contract. On zero-width paths it flagged computePathCenterDistance: 2,487 LINEAR_SQUARED and 2,487 NORMAL_SQUARED mismatches on the sphere and 2,554 LINEAR_SQUARED on WGS84, off by ratios up to 1e9 (d returned for d^2).
  • The harness checks outside distances in ARC only; the outside-distance half was found by reading the code while writing the aggregation-form part of the reference.
  • The finder, an AI tool, then wrote a public-API JUnit on the stock jar. Independent reproducers wrote their own tests from the claim alone; the one-test repro was cut from the finder's test on 2026-10-29.

Prior reports, history and checks

Prior reports

No prior report: 215 recorded searches over GitHub issues, PRs and commits, the mailing lists, JIRA, downstream projects and the web, plus the files of all 410 open PRs (2026-10-27), rechecked on 2026-10-31. Closest: LUCENE-7942 (#8991), which defined the conversion rule this breaks.

Searches4 searches

Verdict new: nothing reports squared styles returning the unsquared or the fourth-power distance on a zero-width path, or the missing conversions in GeoDegeneratePath, and no fix exists. Related, not reports of this bug: #8991 (LUCENE-7942, the contract source), #9004 (LUCENE-7955, added GeoDegeneratePath), #9011 (LUCENE-7962, added computePathCenterDistance), #11965 (tracking issue of the 2022 refactor), #11975 (a branch_9x compile fix in the same class that keeps toAggregationForm), and the dev@ replies to the introducing commit (a precommit dead-code failure only). Gaps: GitHub search does not index review comments on PR diffs; closed unmerged PRs were covered by text search only; list search is keyword-based.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; downstream repositories2026-10-27the scripted prior-report search in two runs (65 queries each) plus the finder's earlier run; 47 manual searches on apache/lucene, Elasticsearch, OpenSearch, Solr and all of GitHub; the full module:spatial3d label list (134 items); comments since 2022-11-20 on the 175 items foundonly the related items above
Open PRs2026-10-27the changed files of all 410 open apache/lucene PRsnone touches GeoDegeneratePath.java, GeoPath/GeoBasePath or the DistanceStyle classes
Lists, JIRA, web2026-10-2724 direct searches over every lucene.apache.org and solr.apache.org list (the 5 dev@ replies to the refactor read in full); 8 LUCENE and SOLR JIRA queries; 13 web queriesno report
Recheck (GitHub)2026-10-31GeoDegeneratePath; LINEAR_SQUARED; computePathCenterDistance; fromAggregationForm; zero width path distance; the upstream file history (raw responses saved)GeoDegeneratePath: #11975, #9283, #9004; LINEAR_SQUARED: #7733; computePathCenterDistance, fromAggregationForm: nothing; zero width path distance: 11 unrelated items; none is this bug; the file's last commit is still #15889 (mechanical)

History

Introduced by 839dfb5a2dc (2022-11-24, the #11965 spatial3d refactor; branch_9x cherry-pick f69fb6ed99b), first released in 9.5.0. Executed: 9.5.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main fail; 7.1.0 to 9.4.2 return the square. Not fixed upstream.

Versions tested5 rows

Introduced by 839dfb5a2dc on main, "More refactoring work, and fix a distance calculation." (Karl David Wright, 2022-11-24 04:36 UTC), one commit of the #11965 spatial3d refactor series, pushed without a pull request; branch_9x cherry-pick f69fb6ed99b (same patch-id). It wrapped SegmentEndpoint.pathCenterDistance, SegmentEndpoint.outsideDistance and PathSegment.pathCenterDistance in toAggregationForm and the result of outsideDistance in fromAggregationForm, but left computePathCenterDistance's return closestDistance; (2017) and PathSegment.outsideDistance unconverted. Commit-level runs (the geom package of each commit over the 9.4.2 jars): the parents 5e4623af1fd and cab1802308d pass, 839dfb5a2dc and f69fb6ed99b fail. A regression: 9.4.2 (2022-11-21) is the last good release, 9.5.0 (2023-01-30) the first affected. Releases before 7.1.0 have no such API. Upstream main 2f424fd428da (2026-10-30) still has both lines.

Versions and branches executed, with results
Version or commitVerdictResult
7.0.1 and older no such APIzero-width paths, computePathCenterDistance and the aggregation forms all arrived in 7.1.0
7.1.0 to 9.4.2 last good: 9.4.2returns d^2 (executed at 7.1.0, 7.7.3, 8.11.4, 9.0.0 and 9.4.2; this doc's test passes on 9.4.2)
9.5.0 to 9.12.3 (14 releases) first: 9.5.0this doc's test fails on 9.5.0 and 9.12.3; the other tags carry the same code
10.0.0 to 10.5.2 (12), 10.6.0 affectedfails on 10.0.0 (follow-up), 10.5.2 and 10.6.0 (this doc's test)
main 645d4fe14a20 affectedthis doc's test fails through gradle and the standalone runner, assertions on and off; passes with fix A or B

Independent checks

An independent reproducer, an intended-behaviour devil's advocate, two executed reviews by another model family (OpenAI GPT-5.6) and an adjudication all rated it real; the adjudication confirmed it with high confidence. The one-test repro still fails on main 645d4fe14a20.

Checks and verdicts242 words

Before the finding was recorded, an executed refute review by OpenAI GPT-5.6 reproduced it with its own WGS84 test (5 of 7 fail, controls pass) and rated the intended-behaviour argument weak.

Each independent check and its verdict
CheckVerdictWhat it found
Finder candidateReference harness: 2,487 (sphere) and 2,554 (WGS84) LINEAR_SQUARED path-center mismatches on zero-width paths. JUnit fails 2 of 2 on main (assertions on and off), 10.5.2 and 9.12.3; passes with the fix.
Independent reproducer realOwn test with an analytic oracle: 4 of 5 fail on main (assertions on and off), 10.5.2, 9.12.3 and 9.5.0; 5 of 5 pass on 9.4.2 and with the fix. Found the beyond-the-end case, the chord-1 crossover and the cross-path misranking.
Intended lens (OpenAI GPT-5.6, executed) real (high)Own contract test: 2 of 2 fail on main and 9.5.0, pass on 9.4.2 and with the fix. Intended-behaviour argument: weak.
Devil's advocate real (high)Built the strongest intended case and rejected it; its probe shows the cross-path swap and a GeoCircle returning d^2 where the path returns d^4.
Adjudication confirmed (high)Re-ran the independent reproduction's, the finder's and its own tests: 12 run, 9 fail (assertions on and off), 3 controls pass; 12 of 12 pass with the fix.
Repro minimization verifiedThe one-test repro fails on 645d4fe14a20 with assertions on and off (two seeds) and passes with the fix; each assertion is tied to its own fix site.

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

Finding record

LFV-741, snapshot of 2026-11-06

Registry title
On a zero-width geo3d path (GeoDegeneratePath), computePathCenterDistance with DistanceStyle.LINEAR_SQUARED or NORMAL_SQUARED returns the unsquared distance and computeOutsideDistance returns the fourth power, instead of the squared distance, because the 2022 path refactor converted the path components to aggregation form but not the two places that combine them (regression since 9.5.0)
Area
spatial3d / geom path distances (GeoDegeneratePath, the zero-width path; DistanceStyle aggregation forms)
Priority
Low, rank 883: The experimental squared distance styles return a wrong value only to direct callers, and it also needs a zero-width GeoDegeneratePath, so it is narrower.
Impact class
minor
Impact note
Only code that calls the experimental geom API GeoPath.computePathCenterDistance or computeOutsideDistance itself with DistanceStyle.LINEAR_SQUARED or NORMAL_SQUARED on a path whose width is below Vector.MINIMUM_ANGULAR_RESOLUTION (for example width 0, which GeoPathFactory.makeGeoPath turns into a GeoDegeneratePath) gets a wrong value, for every query point and at no special scale: d instead of d^2 from the path-centre distance, and d^4 from the outside distance for points within a chord of 1 planet radius (executed on SPHERE and WGS84); ARC, LINEAR and NORMAL are unaffected and no Lucene query, sort or comparator uses the squared styles, so nothing in-tree changes, but comparing a zero-width path with a path of width > 0 by path-centre distance, the comparison the GeoPath javadoc names, can rank them in the wrong order (executed).
Summary
You build a zero-width path with GeoPathFactory.makeGeoPath(planetModel, 0, points) and call computePathCenterDistance or computeOutsideDistance with DistanceStyle.LINEAR_SQUARED (or NORMAL_SQUARED), expecting the square of the LINEAR (or NORMAL) distance, which is what DistanceStyle's 'convert to aggregation form, aggregate, convert back' contract gives, what the same path with width 1e-6 returns and what Lucene 9.4.2 returned. Instead, for a point 0.01 rad beside the path, computePathCenterDistance returns about 0.01 (the unsquared distance) and computeOutsideDistance returns about 1e-8 (the fourth power) where about 1e-4 is expected. The cause is an incomplete edit in the 2022 path refactor: GeoDegeneratePath's components now return the square root (the aggregation form of the squared styles), but computePathCenterDistance never squares the minimum back, and PathSegment.outsideDistance still returns the plain square, which outsideDistance then squares a second time.
Introduced
839dfb5a2dc46c4b2d16d9db5ea9f31ca1e8d907 ('More refactoring work, and fix a distance calculation.', GitHub kwrightapache (git author Karl David Wright), 2022-11-24T04:36:15Z; parent 5e4623af1fd), one commit of the #11965 spatial3d refactoring series, pushed directly to main with no pull request; the same patch went to branch_9x as cherry-pick f69fb6ed99b (identical git patch-id; parent cab1802308d). It wrapped SegmentEndpoint.pathCenterDistance, SegmentEndpoint.outsideDistance and PathSegment.pathCenterDistance in toAggregationForm and GeoDegeneratePath.outsideDistance's result in fromAggregationForm, but left computePathCenterDistance's return (unchanged since 8cb64ed926d, 2017) and PathSegment.outsideDistance unconverted. [exec: commit-level runs with the geom package of each commit shadowed over the 9.4.2 jars: the parents 5e4623af1fd and cab1802308d pass, 839dfb5a2dc and f69fb6ed99b fail 2 of 2 (JUnit) and 1 of 1 (main()); read: git blame in the history clone, GitHub commit metadata (read-only GET)] (2022-11-24 (839dfb5a2dc at 04:36:15Z, 2022-11-23 23:36 in the author's UTC-5 time zone; the branch_9x cherry-pick f69fb6ed99b committed at 04:46:56Z))
First affected version
9.5.0 [exec] (tag 2023-01-30, through the branch_9x cherry-pick f69fb6ed99b; the candidate JUnit and a version-portable main() both fail); on the 10.x line 10.0.0 [exec]; 9.4.2 (tag 2022-11-21), the last release before the commit, passes [exec]
Affected versions
every release from 9.5.0 through 9.12.3 (14 releases) and from 10.0.0 through 10.5.2 (12 releases) and 10.6.0, and main through a9779ef3c193 (not fixed) [exec at 9.5.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 (this doc's test, 2026-10-31) and main, -ea; main also -da; the other tags contain 839dfb5a2dc or f69fb6ed99b (git tag --contains) and carry the same half-converted GeoDegeneratePath.java (code-level scan of every 9.x and 10.x tag) [read]]; branch_10_6 (10.6.0, released 2026-10-29), the unreleased branch_10x and upstream main a95c754ee003 have the same code [read]. Not affected: 7.1.0 through 9.4.2, where the API exists and returns d^2 [exec at 7.1.0, 7.7.3, 8.11.4, 9.0.0 and 9.4.2; the 7.x and 8.x lines never received the refactor]; releases before 7.1.0 lack the API (zero-width paths, LUCENE-7955; computePathCenterDistance, LUCENE-7962; aggregation forms, LUCENE-7942; none exists in 7.0.1). All runs on JDK 25.0.3, aarch64 Linux; plain double arithmetic, x86 not checked.
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, code reading
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record, verdict new: 215 recorded queries, namely two scripted prior-report searches over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web, plus the finder's earlier run; manual GitHub searches on apache/lucene, Elasticsearch, OpenSearch, Solr and all of GitHub, and the full module:spatial3d label list (134 items); the changed files of all 410 open apache/lucene PRs; the upstream history of GeoDegeneratePath.java on five branches; direct searches over every lucene.apache.org and solr.apache.org list, with the 5 dev@ replies to the 2022 refactor commits read in full; LUCENE and SOLR JIRA queries; web queries; and a scan of all comments since 2022-11-20 on the 175 apache/lucene items found; 17 hits read, none the same bug). Related, not reports: apache/lucene#8991 (LUCENE-7942, 2017), which introduced the aggregation-form contract this bug breaks; #9004 (LUCENE-7955), which added GeoDegeneratePath; #9011 (LUCENE-7962), which added computePathCenterDistance; #11965, the tracking issue of the 2022 refactor that introduced the regression; #11975, a branch_9x compile fix in SegmentEndpoint.pathCenterDistance that keeps toAggregationForm; the dev@ replies to the introducing commit, about a precommit dead-code failure only. Not fixed upstream: GeoDegeneratePath.java on upstream main a95c754ee003 last changed in dfd536186fe (2026-04-01, mechanical), no commit after 2022-11-24 changes the two conversions on main, branch_10x, branch_10_6 or branch_9_12, and no open PR touches GeoDegeneratePath.java, GeoPath/GeoBasePath or the DistanceStyle classes. Gaps: GitHub search does not index review comments on PR diffs; closed unmerged PRs were covered by text search only; list search is keyword-based, so a report that uses none of the queried words could be missed.
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): this doc's one-method test through gradle in a scratch worktree, with assertions on and off (seed DEADBEEF), and on the standalone runner; the same test on the 10.6.0, 10.5.2, 9.12.3 and 9.5.0 release jars (fails, assertions on) and 9.4.2 (passes). The hunt, verifiers, judge and follow-up ran on main a9779ef3c193 (the cross-model reviews on ea57360912ed); nothing in lucene/spatial3d changed between those commits and 645d4fe14a20. Fixes A and B were run through gradle on 645d4fe14a20 on 2026-10-31. Upstream main 2f424fd428da (2026-10-30) still has both lines.

Lucene and upstream sources

Finding record
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoDegeneratePath.java:161-180 (at a9779ef3c193) (computePathCenterDistance: minimum of PathSegment and SegmentEndpoint pathCenterDistance, returned at :179 without fromAggregationForm), :271-287 (outsideDistance: minimum of SegmentEndpoint.outsideDistance and PathSegment.outsideDistance, then fromAggregationForm at :286), :522 and :536 (SegmentEndpoint.pathCenterDistance and outsideDistance return toAggregationForm(...)), :739 (PathSegment.pathCenterDistance returns toAggregationForm(...)), :713 (its pole branch returns a plain value), :866-874 (PathSegment.outsideDistance returns the plain Math.min(...) at :873), :33-35 (@lucene.internal, package-private class GeoDegeneratePath extends GeoBasePath)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/DistanceStyle.java:107-148 (at a9779ef3c193) (toAggregationForm, aggregateDistances and fromAggregationForm javadoc: 'Distances should be converted to aggregation form before aggregation is attempted, and they should be converted back from aggregation form to yield a final result'), :38 and :46 (public LINEAR_SQUARED and NORMAL_SQUARED), :22 (@lucene.experimental)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/LinearSquaredDistance.java:63-71 (at a9779ef3c193) and NormalSquaredDistance.java:63-71 (toAggregationForm = Math.sqrt, fromAggregationForm = square)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardPath.java:278-285 (at a9779ef3c193) and :325-332 (the width > 0 sibling converts back from aggregation form in computePathCenterDistance and outsideDistance), :1593 (the same plain pole branch in PathSegment.pathCenterDistance)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPath.java:59-61 (at a9779ef3c193) and :72-74 (computePathCenterDistance: 'The distance is meant to allow comparisons between different paths to find the one that goes closest to a point'), :22 (@lucene.experimental)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPathFactory.java:38-44 (at a9779ef3c193) (makeGeoPath returns GeoDegeneratePath when maxCutoffAngle < Vector.MINIMUM_ANGULAR_RESOLUTION, else GeoStandardPath), :25 (@lucene.experimental)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseMembershipShape.java:46-53 (at a9779ef3c193) (computeOutsideDistance: 0.0 if isWithin, else outsideDistance) and GeoOutsideDistance.java:19-24 ('the distance from a point to the closest outside edge', @lucene.experimental)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:85, (at a9779ef3c193) :131, :174 and Geo3DPointOutsideDistanceComparator.java:101, :144 (the in-tree sorts pass DistanceStyle.ARC); git grep finds LINEAR_SQUARED and NORMAL_SQUARED nowhere in lucene/ outside DistanceStyle.java
  • lucene/CHANGES.txt:7842 (at a9779ef3c193) ('LUCENE-7955: Add support for zero-width paths.') and :7847 (LUCENE-7942: accurate aggregation of distances)
The finding in 30 seconds
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPathFactory.java:25, (at 645d4fe14a20) :38-44
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoDegeneratePath.java:33-35, (at 645d4fe14a20) :160-180 (:179), :270-287 (:286), :522, :534-537, :712-714, :739, :866-874 (:873)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/LinearSquaredDistance.java:63-71; (at 645d4fe14a20) NormalSquaredDistance.java:63-71; DistanceStyle.java:36-38, :44-46, :107-148 (:116-118, :146-148)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardPath.java:278-285, (at 645d4fe14a20) :326-333, :1592-1594; GeoBaseMembershipShape.java:46-53
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/Vector.java:26, (at 645d4fe14a20) :29 (MINIMUM_ANGULAR_RESOLUTION = PI * 1e-12)
Who is affected
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:85, (at 645d4fe14a20) :131, :174; Geo3DPointOutsideDistanceComparator.java:101, :144; Geo3DDocValuesField.java:365-374 (git grep LINEAR_SQUARED, NORMAL_SQUARED, computePathCenterDistance)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPath.java:22, (at 645d4fe14a20) :60-61, :73-74; GeoPathFactory.java:25; DistanceStyle.java:22; GeoOutsideDistance.java:23
  • lucene/CHANGES.txt:7859 (at 645d4fe14a20) (LUCENE-7955 zero-width paths, under 7.1.0)
Why it looks unintended
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/DistanceStyle.java:36-38, (at 645d4fe14a20) :44-46, :107-148; GeoStandardPath.java:278-285, :326-333; GeoOutsideDistance.java:19-24, :44-46; GeoPath.java:60-61, :73-74; LinearSquaredDistance.java:80, :85, :90
  • lucene/CHANGES.txt:7864-7865 (at 645d4fe14a20) (LUCENE-7942, under 7.1.0)
  • gh api repos/apache/lucene/issues/8991 (LUCENE-7942 description, read-only, 2026-10-31)
  • lucene/spatial3d/src/test (at 645d4fe14a20) (git grep: no *_SQUARED, no computePathCenterDistance; computeOutsideDistance only in TestGeoBBox, TestGeoCircle, TestGeoConvexPolygon, TestRandomGeoPolygon)
Suggested fix
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoDegeneratePath.java:179, (at 645d4fe14a20) :522, :536, :713, :739, :873, :286; GeoStandardPath.java:1593
History
  • a Lucene clone releases/lucene/10.6.0 lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoDegeneratePath.java:179, :875 (same lines)
  • gh api repos/apache/lucene/contents/lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoDegeneratePath.java?ref=main and commits?path=... (read-only, 2026-10-31)
Diagrams
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoDegeneratePath.java:179, (at 645d4fe14a20) :286, :873

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

Repro files

About the diagrams

A picture of the minimal repro and the two missing conversions, made in parallel with this doc from the code at 645d4fe14a20; the PNG is the summary still.

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.