All findings · Lucene finding · LFV-271 · geostandardpath-segment-distance-double-aggregation-conversion

GeoStandardPath converts a segment point's squared distance back twice: LINEAR_SQUARED and NORMAL_SQUARED return the fourth power (since 9.5.0)

On a GeoStandardPath (any makeGeoPath width of at least about 3e-12 rad), computeDistance(LINEAR_SQUARED, p) (and NORMAL_SQUARED) returns the fourth power of the distance for points inside a segment: 0.00803 instead of 0.0896. Every release since 9.5.0 has it; Lucene's own path queries and sorts use ARC and are unaffected.

Summary

Priority
Low, rank 882The experimental squared-distance styles on wide GeoPaths give a deterministic wrong value only to direct callers, and no Lucene query or sort uses them, so it follows LFV-288.
Impact
minor
Since
9.5.0 (commit c9c27c755a4, 2022-11-22)introduced 2022-11-22 · about 3 years
Affected
9.5.0 to 10.6.0 and main
Status
documented
Found by
code reading of the path's distance tree, then a public-API test

AI-assisted page: this finding was first signalled by code reading or testing 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

A path's distance to a point is the sum of pieces: along the route, then across it. For the squared styles the pieces are added as square roots and the sum is squared once at the end. For a point in a straight segment, Lucene squares the result a second time.

Background in 3 steps

  1. GeoPathFactory.makeGeoPath(planet, width, points) builds a corridor around a route; the width is an angle, and the class javadoc calls it a half-width (the corridor reaches it on either side). For any width of at least about 3.14e-12 rad it returns a GeoStandardPath. path.computeDistance(style, p) returns the distance from the start along the route to the foot of p, plus the offset of p across; a point outside gets +Infinity.
  2. DistanceStyle picks the metric: ARC (angle), LINEAR (straight line), NORMAL, and the squared variants LINEAR_SQUARED and NORMAL_SQUARED.
  3. Pieces are added in "aggregation form" and the sum is converted back once. For the squared styles the aggregation form is the square root and converting back squares it, so a path's LINEAR_SQUARED distance is its LINEAR distance squared. For ARC, LINEAR and NORMAL both conversions do nothing.
Diagram of the repro: a SPHERE path from (0, 0) to (0, 0.5) with width 0.1 and a point (0.05, 0.25) inside its straight segment. LINEAR is 0.2993; the segment converts its LINEAR_SQUARED result back once (0.0896, correct) and GeoStandardPath.distance converts it back again, giving 0.00803, the fourth power.
The repro path and its segment point: the segment's result is converted back from aggregation form inside the segment and again at the root, so LINEAR_SQUARED comes out 0.00803 instead of 0.0896. 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.1, ...) from (lat 0, lon 0) to (lat 0, lon 0.5) and take the point (0.05, 0.25), inside the straight part. computeDistance(LINEAR, p) is 0.2993: about 0.2493 along the route plus 0.0500 across.
  2. Ask for LINEAR_SQUARED. You expect 0.2993^2 = 0.0896: the squared styles add the square roots of the pieces and square the sum once. A point in the round start cap gets exactly that (LINEAR 0.05, LINEAR_SQUARED 0.0025), and 9.4.2 gave 0.0896 for this point too.
  3. You get 0.00803 = 0.0896^2, with no error. The segment's result is squared once inside PathSegment.distance (at that point the value, 0.0896, is right) and again by GeoStandardPath.distance; the inner squaring is the leftover. NORMAL_SQUARED gives 0.00782 instead of 0.0884.
  4. So one path disagrees with itself: on a path to lon 0.4, the value jumps 6.3x across the segment's end (0.0249 just before, 0.158 just after), and sorting by the squared value puts a segment point 0.10 along ahead of a cap point 0.05 away. In one random sweep (points near path vertices) about three quarters of the points inside got the wrong value.

Who is affected

Only code that calls computeDistance on a GeoStandardPath itself, with LINEAR_SQUARED or NORMAL_SQUARED (an @lucene.experimental API). Lucene's path query and path sorts use ARC and give identical results with and without the fix. For such callers the wrong value is silent and deterministic: about three quarters of the in-path points of one random sweep (sampled near path vertices).

Mechanism, terms and numbers540 words

GeoPathFactory.makeGeoPath returns a GeoStandardPath for any width of at least about 3.14e-12 rad (Vector.MINIMUM_ANGULAR_RESOLUTION). Its computeDistance(style, point) adds the pieces of a path distance in "aggregation form" and should convert the sum back once; for LINEAR_SQUARED and NORMAL_SQUARED the aggregation form is the square root and converting back squares it, so the result should be the LINEAR (or NORMAL) distance squared. For a point inside a straight segment the result is converted back twice, once in PathSegment.distance and again in GeoStandardPath.distance, so it comes out as the fourth power: 0.00803 instead of 0.0896 in the repro. Points that lie only in a round cap (at the ends and joints) are converted once and are right; points exactly on a cutoff plane, which lie in both a segment and a cap, can get the fourth power too, so one path jumps 6.3x at a segment's end and can rank a farther point ahead of a nearer one.

The extra conversion is a leftover of a November 2022 refactor (c9c27c755a4, "Make sure use of aggregation form is consistent throughout"), first released in 9.5.0; 9.4.2 is correct. Affected: 9.5.0 to 10.6.0 and main. Reach is narrow: only code that calls the @lucene.experimental geom distance API itself with a squared style; Lucene's path query and path sorts use ARC and are unchanged. Fix: drop the inner conversion (one line, validated).

The mechanism (main 645d4fe14a20)

Since the 2022 refactor, a GeoStandardPath evaluates a binary tree of components: PathNodes whose leaves are PathSegments (the straight corridors) and BaseSegmentEndpoints (the round caps at the ends and joints).

  1. Leaves add their pieces in aggregation form. PathSegment.pathDistance returns aggregateDistances(toAggregationForm(across), toAggregationForm(along)) (GeoStandardPath.java:1777-1780); BaseSegmentEndpoint.pathDistance returns toAggregationForm(distance to the cap centre) (:924).
  2. The cap leaf stays in aggregation form: BaseSegmentEndpoint.distance returns distanceStyle.aggregateDistances(startingDistance, pathDistance) (:878-886, return at :885).
  3. The segment leaf already converts back: PathSegment.distance returns distanceStyle.fromAggregationForm(distanceStyle.aggregateDistances(startingDistance, pathDistance)) (:1538-1549, return at :1547-1548).
  4. The tree takes the smaller child: PathNode.distance checks isWithin, then returns Math.min(child1Distance, child2Distance) (:678-687, :686). With step 3 that compares a final-form value with aggregation-form ones; it matters for points exactly on a cutoff plane, which lie in both a segment and a cap.
  5. The root converts back again: GeoStandardPath.distance returns distanceStyle.fromAggregationForm(rootComponent.distance(distanceStyle, x, y, z)) (:301-307, :306).
  6. For the squared styles that is a second squaring. LinearSquaredDistance.toAggregationForm is Math.sqrt and fromAggregationForm is aggregateDistance * aggregateDistance (LinearSquaredDistance.java:64-70; NormalSquaredDistance.java:64-70 is the same). A point only in a cap is converted back once (d^2), a segment point twice ((d^2)^2). The defaults for ARC, LINEAR and NORMAL are the identity (DistanceStyle.java:116-118, :146-148), so nothing else notices.

Worked example (the repro)

SPHERE path (0, 0) -> (0, 0.5), width 0.1, point (0.05, 0.25) in the segment. LINEAR = 0.29934425859988006. In LINEAR_SQUARED the segment leaf adds the square roots of its pieces (sum 0.2993) and squares: 0.08960698515671187, the right answer. The root squares that again: 0.00802941178887518. A start-cap point (0, -0.05) goes through the cap leaf and the root only: LINEAR 0.0499948, LINEAR_SQUARED 0.00249948, right.

The fix that removes only the segment's conversion makes the repro pass, and the whole spatial3d test suite still passes.

Exposure and likelihood503 words
  • Silent wrong value. A finite, plausible number, about 11x too small at the repro point (0.00803 for 0.0896), no exception. One path's squared distance jumps 6.3x across a segment's end (0.0249 to 0.158 for centre-line points 0.0002 radians apart) and can rank a farther point first: a segment point with LINEAR 0.09996 gets 0.0000998, a cap point with LINEAR 0.0500 gets 0.0025 (an observed pair: a swap needs the segment point's LINEAR^4 to fall below the cap point's LINEAR^2).
  • How often. On 400 random 2-4 point paths (SPHERE and WGS84, widths 0.01-0.16), 3,827 of 14,587 in-path points were consistent for both squared styles; all 21,520 mismatches were fourth powers (within the verifier's 1e-9 relative tolerance). The share depends on the sampling: points were drawn within 1.5 widths of a random path vertex. Points exactly on a cutoff plane, such as the vertex where two segments join, get the fourth power too when LINEAR is below 1 (probed: lon 0 and 0.5 on the repro path, the joint of a path lon 0 -> 0.5 -> 1.0), because Math.min compares the segment's converted value with the cap's unconverted one; with the fix they get LINEAR^2.
  • Who is exposed. Applications that call GeoPath or GeoDistanceShape.computeDistance with DistanceStyle.LINEAR_SQUARED or NORMAL_SQUARED, on a path from GeoPathFactory.makeGeoPath with a width of at least about 3.14e-12 rad (a GeoStandardPath). The geom API is @lucene.experimental (GeoPath.java:22, GeoDistance.java:24, DistanceStyle.java:22, GeoPathFactory.java:25).
  • Lucene itself is not. The squared constants have no caller in Lucene beyond their declarations (DistanceStyle.java:38, :46). The Geo3D distance comparators pass DistanceStyle.ARC (Geo3DPointDistanceComparator.java:85, :131, :173-174; Geo3DPointOutsideDistanceComparator.java:101, :143-144), and Geo3DPoint.newPathQuery matches by shape. Executed: the path query and both path sorts give an identical hit, order and value fingerprint with and without the fix, and points outside a path still get +Infinity in every style.
  • Not affected: ARC, LINEAR, NORMAL (identity conversions); computeDistance of the zero-width GeoDegeneratePath (its computePathCenterDistance and computeOutsideDistance have a separate squared-style bug, LFV-741). The finder's style matrix found no mismatch in GeoStandardPath's computeNearestDistance, computePathCenterDistance and computeDeltaDistance for ordinary points on 2- and 3-point paths; that is not exhaustive (by reading, their degenerate branches for a point at the pole of a segment's plane skip toAggregationForm, GeoStandardPath.java:1592-1594, :1687-1689; not executed, not part of this bug).
  • Likelihood: plausible. No caller was found in Lucene, nor by a read-only code search of Elasticsearch, OpenSearch and Solr (three downstream queries were rate-limited), and no report was found since 9.5.0.
  • Related, separate bugs (other root causes; the fix here does not fix them): LFV-741 (GeoDegeneratePath's computePathCenterDistance and computeOutsideDistance with the squared styles, same 2022 refactor), LFV-742 (path distance methods throw "Can't find world intersection" at the edge of a cutoff plane's tolerance band), LFV-735 (outside distance measured to the end point instead of the cap edge), LFV-736 (WGS84 end caps shifted toward the equator).
  • Workaround: ask for LINEAR (or NORMAL) and square it yourself.

Minimal repro

One JUnit test in lucene/spatial3d, public API only: a two-point SPHERE path of width 0.1 and one point inside its segment, asserting LINEAR_SQUARED == LINEAR^2. No index and no random input; it fails with assertions on and off.

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

  public void testLinearSquaredIsSquareOfLinearInsideSegment() {
    PlanetModel pm = PlanetModel.SPHERE;
    GeoPath path =
        GeoPathFactory.makeGeoPath(
            pm, 0.1, new GeoPoint[] {new GeoPoint(pm, 0, 0), new GeoPoint(pm, 0, 0.5)});
    GeoPoint onSegment = new GeoPoint(pm, 0.05, 0.25);
    double linear = path.computeDistance(DistanceStyle.LINEAR, onSegment);
    // expected LINEAR_SQUARED = LINEAR^2; actual LINEAR^4
    assertEquals(
        "LINEAR_SQUARED vs LINEAR^2 (LINEAR=" + linear + ")",
        linear * linear,
        path.computeDistance(DistanceStyle.LINEAR_SQUARED, onSegment),
        1e-12);
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or a later commit where GeoStandardPath.java is unchanged)
cp $REPRO/TestGeoPathSquaredDistance.java lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/
./gradlew :lucene:spatial3d:test --tests org.apache.lucene.spatial3d.geom.TestGeoPathSquaredDistance -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false
Expected
0.0896, the square of LINEAR = 0.2993 (exactly 0.08960698515671187).
Observed
expected:<0.08960698515671187> but was:<0.00802941178887518>, the fourth power, on main (assertions on and off), 10.6.0, 10.5.2, 9.12.3 and 9.5.0.

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

More files, environment, full output195 words

The test builds the path with GeoPathFactory.makeGeoPath, computes LINEAR at the point (0.05, 0.25) and asserts that LINEAR_SQUARED equals its square within 1e-12. It sits in org.apache.lucene.spatial3d.geom next to TestGeoPath by convention; it needs no package access. Seed DEADBEEF fails with assertions on and off, and 10 of 10 random seeds fail with the same message. It checks LINEAR_SQUARED only, which is representative: both squared styles go through the same line, and with the fix the finder's test, which also checks NORMAL_SQUARED at this point, passes.

The same file fails unchanged on the 10.6.0, 10.5.2, 9.12.3 and 9.5.0 jars and passes on 9.4.2, the last release before the introducing commit. As a regression test, use this same test, as its own class or as one method in TestGeoPath; :lucene:spatial3d:check -x test passes with it.

Environment

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

Observed output

TestGeoPathSquaredDistance > testLinearSquaredIsSquareOfLinearInsideSegment FAILED
    java.lang.AssertionError: LINEAR_SQUARED vs LINEAR^2 (LINEAR=0.29934425859988006) expected:<0.08960698515671187> but was:<0.00802941178887518>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:60C682F5EE012AE8]: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.TestGeoPathSquaredDistance.testLinearSquaredIsSquareOfLinearInsideSegment(TestGeoPathSquaredDistance.java:31)

# 0.08960698515671187^2 = 0.00802941178887518; -Ptests.asserts=false: the same failure

Why it looks unintended

  • DistanceStyle's javadoc: distances "should be converted back from aggregation form to yield a final result", once (DistanceStyle.java:109-111).
  • LUCENE-7942 added aggregation forms so that squared path distances are true squared distances; the same path's cap-only points, the zero-width GeoDegeneratePath's computeDistance and 9.4.2 all return d^2.
  • The introducing commit aimed to make aggregation-form use "consistent throughout" and left one conversion behind.
The full argument274 words
  • The contract says convert back once. DistanceStyle javadoc, on all three methods: "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." (DistanceStyle.java:109-111, :122-124, :139-141).
  • The aggregation forms exist for exactly this case. LUCENE-7942 (#8991, Karl Wright, 2017), "For Geo3d paths, aggregating distance values using "+" is not adequate for squared distances": "The xxxSquaredDistance computations, for example, do not produce true squared distances ... I propose adding support in Distance for aggregation, which would allow distance calculators to compute an accurate distance". CHANGES.txt:7864-7865 (7.1.0): "Distance computations now have the ability to accurately aggregate distances, rather than just doing sums."
  • The same object and its sibling give d^2. Points only in a cap of the same path get LINEAR^2. GeoDegeneratePath, which the factory returns for width 0, converts back once per return in computeDistance (GeoDegeneratePath.java:223-253) and gives LINEAR^2 on its line where the width-0.1 path gives LINEAR^4 (executed); its other two path-distance methods have their own squared-style bug (LFV-741).
  • It used to work. 9.4.2 returns LINEAR^2 for the repro point, and so do both parent commits of the introducing change (executed).
  • History says leftover. c9c27c755a4 ("Make sure use of aggregation form is consistent throughout, ...") added the root conversion and removed the cap's, but left the segment's. The next commit, 1ded41ea20d, removed the same leftover from PathSegment.nearestDistance, not from PathSegment.distance. That it was an oversight is an inference; nobody said so.
  • No test covers it. No Lucene test uses a squared style; TestGeoPath measures path distances with ARC only.
The devil's-advocate check162 words

The strongest case for "intended" (built by the refute lens, the verifiers and the adjudication): the geom distance API is @lucene.experimental, GeoStandardPath is @lucene.internal (GeoStandardPath.java:34), GeoDistance calls the value an "internal distance" whose implementations "should clarify how this is computed when it's non-obvious" (GeoDistance.java:33-34, :46-47), and no sentence literally promises LINEAR_SQUARED(path) == LINEAR(path)^2.

Why it fails: experimental is a back-compat caveat, not a licence for wrong values, and GeoPathFactory is public and returns this class for any width of at least about 3.14e-12 rad; DistanceStyle prescribes converting back once, and LUCENE-7942 exists to make squared path distances true squared distances; whatever "internal distance" means, one path cannot return d^2 in its caps and d^4 in its segments, with a 6.3x jump between them and an order that disagrees with LINEAR. Verdict: not intended, a defect (the independent reproduction, the intended-lens cross-model review, the intended-behaviour check and the adjudication; the adjudication with high confidence).

Suggested fix

Recommended A: let PathSegment.distance stay in aggregation form

Drop the inner conversion: PathSegment.distance returns aggregateDistances(startingDistance, pathDistance) in aggregation form, like the cap's distance, so GeoStandardPath.distance converts back once (-2/+1 lines). Validated: the repro passes with assertions on and off, and the whole spatial3d module passes (182 tests, 0 failures, 3 nightly tests skipped).

       final double pathDistance = pathDistance(distanceStyle, x, y, z);
-      return distanceStyle.fromAggregationForm(
-          distanceStyle.aggregateDistances(startingDistance, pathDistance));
+      return distanceStyle.aggregateDistances(startingDistance, pathDistance);
     }
All options and trade-offs2 options · 307 words

One fix was built and run through the whole spatial3d module; the alternative was not built.

A: let PathSegment.distance stay in aggregation form (validated by the checks) (recommended)

In GeoStandardPath.java:1547-1548, return distanceStyle.aggregateDistances(startingDistance, pathDistance) without fromAggregationForm. This matches what c9c27c755a4's diff did for the caps: every leaf returns aggregation form, like BaseSegmentEndpoint.distance (:885), PathNode.distance compares like with like, and the root converts once (:306). Comparing aggregation forms in Math.min is safe because the conversions are monotonic on non-negative values; with the fix, points exactly on a cutoff plane get LINEAR^2 too (executed).

       final double pathDistance = pathDistance(distanceStyle, x, y, z);
-      return distanceStyle.fromAggregationForm(
-          distanceStyle.aggregateDistances(startingDistance, pathDistance));
+      return distanceStyle.aggregateDistances(startingDistance, pathDistance);
     }

Trade-offs

None observed in the validation below. ARC, LINEAR and NORMAL cannot change (identity conversions), so path queries and sorts are untouched; with the same change the path-query and path-sort fingerprint was identical (executed).

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF, 2026-10-31: stock, the repro fails; with the fix it passes, assertions on and off.
  • The whole :lucene:spatial3d:test (20 suites, including TestGeoPath 11 of 11 and the repro): 182 tests, 3 skipped, 0 failures.
  • :lucene:spatial3d:check -x test (checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest): passes.
  • Earlier, the same change made the independent reproducer's five tests pass on main ea57360912ed, and the finder's test pass at the introducing commit c9c27c755a4.

B: convert at every leaf and not at the root (not built)

Make BaseSegmentEndpoint.distance convert back too and drop the fromAggregationForm at :306.

Trade-offs

Same distance() values, but it touches two places and reverses the structure c9c27c755a4 introduced, which converts once at the root in every path-distance method (:278-333); the commit message itself says only that aggregation-form use should be "consistent throughout". Not recommended.

Downstream note

Only downstream code that filters or ranks by LINEAR_SQUARED or NORMAL_SQUARED path distance itself would get wrong values, and only for points already inside the path; no such caller was found in Elasticsearch, OpenSearch or Solr (partial code search).

How it was found

Code reading: while building a harness for spatial3d path geometry, an AI tool traced how GeoStandardPath's component tree converts distances to and from aggregation form, then confirmed the double conversion on the stock jar with a style-consistency matrix and a public-API JUnit. No model checker or solver was involved.

The method in full109 words

Method: non-formal (code reading; hunt unit for spatial3d geometry).

  • While auditing the GeoStandardPath component tree for the unit's metamorphic harness, the finder, an AI tool, traced the aggregation-form flow through PathSegment.distance, BaseSegmentEndpoint.distance, PathNode.distance and GeoStandardPath.distance.
  • A style-consistency matrix on the stock jar compared each squared style with the square of its plain style for four distance methods on 2- and 3-point paths: only computeDistance disagreed, and only for segment points.
  • A public-API JUnit confirmed it; an independent reproducer then wrote its own test from the claim alone, with an analytic oracle and a random sweep. 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: 229 recorded searches over GitHub, the mailing lists, JIRA, downstream repositories and the web, plus the files of all 413 open PRs (2026-10-26), rechecked on 2026-10-31. Closest: LUCENE-7942 (#8991), the feature this regresses, and #11965, the refactor that introduced it, which does not mention distance styles.

Searches4 searches

Verdict new: 17 hits read, none the same bug. This means no report was found, not proof that none exists. A report could only postdate 2022-11-22, so the frozen JIRA archive cannot hold one. Related, not reports of this bug: #8991 / LUCENE-7942 (2017, the contract source), #11965 (the refactor's tracking issue), the commits@ notification of c9c27c755a4 (no replies), #13056 (a different GeoStandardPath membership failure). Gaps: GitHub search does not index review comments on PR diffs; keyword search can miss a report that uses none of the queried words.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits2026-10-26the scripted prior-report search, plus 89 read-only GraphQL searches over apache/lucene, Solr, Elasticsearch, OpenSearch and all of GitHub (class and method names, LINEAR_SQUARED, NORMAL_SQUARED, "aggregation form", "path distance", items since 2022-11-20); every comment since 2022-11-20 on the 174 returned itemsonly #8991, #11965 and unrelated spatial3d items
Open PRs2026-10-26the changed files of all 413 open apache/lucene PRsnone touches GeoStandardPath.java or the DistanceStyle classes
Lists, JIRA, web2026-10-26dev@, java-user@, issues@, commits@ and the Solr lists; the two refactor-week threads read in full; LUCENE and SOLR JIRA; 7 web queriesno report
Recheck (GitHub)2026-10-31LINEAR_SQUARED; NORMAL_SQUARED; fromAggregationForm; "aggregation form"; GeoStandardPath distance squared; open PRs naming GeoStandardPath; the file's upstream history (raw responses saved)only #7733, #8267, #8991 and #12553 (faceting); no open PR found; the file's last commit is still the 2026-04-01 mechanical change

History

Introduced by c9c27c755a4 (2022-11-22), one commit of the 2022 GeoStandardPath refactor (#11965), first released in 9.5.0; 9.4.2 is correct. Bisected by execution, and every release from 9.5.0 to 10.6.0 fails. Not fixed upstream.

Versions tested7 rows

Introduced by c9c27c755a4 "Make sure use of aggregation form is consistent throughout, and fix segment endpoint computations of nearestDistance." (Karl Wright, 2022-11-22), part of the hierarchical-path refactor f3b4b057fa8 .. 839dfb5a2dc, pushed directly with no pull request and tracked as #11965. It reached main through the merge 49c8a759176 and 9.x through the identical backport ec97f1da35c (both 2022-11-23; git patch-id --stable 2f3bc8634ac1 for both). It moved the final conversion from the leaves to GeoStandardPath.distance and removed it from the cap, but not from PathSegment.distance. Both parents (ae5179986c2, 8cdb270b090) pass, the commit and its backport fail, and the one-line fix passes at the commit. Releases before 7.1.0 predate the aggregation forms; 7.1.0 .. 7.7.2 and 8.0.0 .. 8.11.3 were not executed; the same one-conversion loop was read at 7.7.3 and 8.0.0. Upstream main 2f424fd428da (2026-10-30): the file's last commit is still dfd536186fe4 (#15889, 2026-04-01, mechanical).

Versions and branches executed, with results
Version or commitVerdictResult
7.7.3, 8.11.4, 9.0.0 value probeLINEAR_SQUARED = LINEAR^2 at the segment point
9.1.0 .. 9.4.2 not affectedthe JUnit passes (9.4.2 with assertions on and off; this doc's test too)
ae5179986c2, 8cdb270b090 (parents) bisectpass
c9c27c755a4, ec97f1da35c (commit, 9.x backport) bisectfail; the one-line fix passes at c9c27c755a4
9.5.0 .. 9.12.3 (14 releases) first affected: 9.5.0fail with byte-identical values
10.0.0 .. 10.6.0 (13 releases) affectedfail; 10.6.0 with 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

Independent checks

An independent reproducer, an intended-behaviour devil's advocate, a reading-only review by another model family (OpenAI GPT-5.6) and an adjudication all rated it real; the adjudication confirmed it with high confidence. The repros still fail on the later main commits a9779ef3c193 and 645d4fe14a20.

Checks and verdicts389 words

Before the finding was recorded, a reading-only refute review by OpenAI GPT-5.6 agreed on the root cause and found the inputs in contract; its "not real" was procedural, because it could not run Java, and a separate re-run failed. The adjudication dropped the finder's early sub-claim that a point near a joint can pick a different, farther component: no run observed it. The fact checks of this doc added that points exactly on a cutoff plane, the vertex of a joint included, also get the fourth power when LINEAR is below 1 (a cross-model fact check by OpenAI GPT-5.6 found it; a boundary probe confirmed it and that the fix corrects it).

Each independent check and its verdict
CheckVerdictWhat it found
Finder (spatial3d geometry unit) candidateCode reading and a style matrix; own JUnit fails on main (assertions on and off), 10.5.2, 9.12.3 and 9.5.0, passes on 9.4.2 and with the fix; upstream TestGeoPath 11 of 11 with the fix.
Independent reproducer realOwn JUnit outside the geom package with an analytic oracle fails 4 of 5 on main, 10.5.2, 9.12.3 and 9.5.0, passes 5 of 5 on 9.4.2 and with its own one-line fix; sweep: every mismatch a fourth power (1e-9 relative tolerance).
Intended lens (OpenAI GPT-5.6, reading only) real (medium)Intended argument weak; separate runs fail on main and 10.5.2 and pass on 9.4.2 and with the fix.
Devil's advocate realBuilt the strongest intended case and rejected it; executed the rank inversion and the GeoDegeneratePath comparison.
Adjudication confirmed (high)Re-ran the independent reproduction's JUnit (4 of 5 fail, assertions on and off) and with the fix (5 of 5 pass); impact class minor, security none.
Repro minimization and main refresh verified; still reproducesThe one-test repro fails on 645d4fe14a20 (seed DEADBEEF, and 10 of 10 random seeds) and passes with the fix; the finder's and the independent reproduction's tests still fail on a9779ef3c193.
Fact checks of this doc (2026-10-31) real; wording fixedAn independent fact check re-ran the gradle repro, the fix through the spatial3d module and a value probe; a cross-model fact check (OpenAI GPT-5.6) ran its own probes. Scope fixes (cutoff-plane points, the zero-width path, the width threshold) and a boundary probe on main and with the fix are recorded in the hunt's record.

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

Finding record

LFV-271, snapshot of 2026-11-06

Registry title
GeoPath.computeDistance with DistanceStyle.LINEAR_SQUARED or NORMAL_SQUARED on a path of width > 0 (GeoStandardPath) returns the fourth power of the distance instead of its square for points inside a path segment, because the segment's result is converted back from aggregation form twice (regression since 9.5.0)
Area
spatial3d / geom path distances (GeoStandardPath component tree: PathSegment, BaseSegmentEndpoint, PathNode; DistanceStyle aggregation forms)
Priority
Low, rank 882: The experimental squared-distance styles on wide GeoPaths give a deterministic wrong value only to direct callers, and no Lucene query or sort uses them, so it follows LFV-288.
Impact class
minor
Impact note
Only code that calls the experimental geom API GeoPath/GeoDistanceShape.computeDistance itself with DistanceStyle.LINEAR_SQUARED or NORMAL_SQUARED on a path built by GeoPathFactory.makeGeoPath with width > 0 (a GeoStandardPath) gets a wrong value, and then for every point inside a segment corridor (about three quarters of in-path points of random 2-4 point paths): d^4 instead of d^2, while end-cap and joint points get d^2; ARC, LINEAR and NORMAL are unaffected, and every Lucene query, sort and comparator uses ARC, so search results, sort order and the index are unchanged in every release from 9.5.0 to 10.6.0 and main (security probe: identical path-query and path-sort fingerprint with and without the fix).
Summary
You build a path with GeoPathFactory.makeGeoPath and a width > 0 and call computeDistance with DistanceStyle.LINEAR_SQUARED (or NORMAL_SQUARED), expecting the square of the LINEAR (or NORMAL) path distance, which is what DistanceStyle's 'convert to aggregation form, aggregate, convert back' contract gives and what end-cap points, the zero-width path and Lucene 9.4.2 return. For a point inside a path segment you get the square of that value instead: 0.00803 rather than 0.0896 for a point whose LINEAR distance is 0.299, so the value jumps about 6x across a segment's end cutoff plane and can rank a farther point ahead of a nearer one. The cause is a leftover conversion from the 2022 path refactor: PathSegment.distance already converts its result back from aggregation form, and GeoStandardPath.distance converts the component tree's result back a second time.
Introduced
c9c27c755a469256f64845dc7653b31b32ea6916 ('Make sure use of aggregation form is consistent throughout, and fix segment endpoint computations of nearestDistance.', DaddyWri (git author Karl David Wright), 2022-11-22; parent ae5179986c2), one commit of the hierarchical GeoStandardPath refactor series f3b4b057fa8 .. 839dfb5a2dc (2022-11-18 .. 2022-11-23), pushed directly with no pull request and tracked afterwards as apache/lucene#11965. It added fromAggregationForm around rootComponent.distance(...) in GeoStandardPath.distance and removed it from BaseSegmentEndpoint.distance, but left the one in PathSegment.distance (from 20fcd0b757c, main-side copy fc7ce768514). It reached main through the merge 49c8a759176 (2022-11-23) and 9.x through the branch_9x backport ec97f1da35c (2022-11-23, same patch: git patch-id 2f3bc8634ac1). [exec: the parents ae5179986c2 and 8cdb270b090 pass, c9c27c755a4 and ec97f1da35c fail, and the one-line fix passes at c9c27c755a4; read: git blame, git log -L, method-body hashes at every release tag] (2022-11-22 (c9c27c755a4; main merge 49c8a759176 and branch_9x backport ec97f1da35c on 2022-11-23))
First affected version
9.5.0 [exec] (tag 2023-01-30, through the branch_9x backport ec97f1da35c; fails with -ea and -da); on the 10.x line 10.0.0 [exec]; 9.4.2 (tag 2022-11-21), the last release before the commit, passes [exec, -ea and -da]
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; 10.5.2 is the last before 10.6.0) [exec: the finder's JUnit fails with byte-identical values at each; -da also at 9.5.0, 9.12.3 and 10.5.2], and main from c9c27c755a4 through ea57360912ed [exec, -ea and -da]; branch_10_6 (the branch of 10.6.0, released 2026-10-29) and the unreleased branch_10x and branch_9_12 have the same four distance() bodies [read], and 10.6.0 fails [exec: this doc's test on the 10.6.0 jars, 2026-10-31]. Not affected: 9.1.0 through 9.4.2 [exec, the JUnit passes] and 9.0.0, 8.11.4 and 7.7.3 [exec with a value probe, because the JUnit does not compile against their test framework: LINEAR_SQUARED = LINEAR^2 at the segment point]; 7.1.0 .. 7.7.2 and 8.0.0 .. 8.11.3 were not executed (the same one-conversion loop is read at 7.7.3 and 8.0.0); releases before 7.1.0 predate the aggregation forms (LUCENE-7942). Not fixed upstream. All runs on JDK 25.0.3, aarch64 Linux; plain double arithmetic, no vector code, 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 could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, verdict new: 229 recorded queries, namely a scripted prior-report search over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 70 read-only GitHub GraphQL searches on apache/lucene and 19 on apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and all of GitHub; SOLR and LUCENE JIRA REST queries; Solr list queries; the two refactor-week list threads read in full; the changed files of all 413 open apache/lucene PRs; the upstream commit history of the affected files; a scan of every comment since 2022-11-20 on the 174 apache/lucene items returned; 7 web queries; 17 hits read, none the same bug). Related, not reports: apache/lucene#8991 (LUCENE-7942, closed 2017), the feature that introduced the convert-aggregate-convert-back scheme for exactly these squared styles, i.e. the contract this bug breaks; #11965 (closed 2025-09-30), the tracking issue of the 2022 refactor that introduced the bug, which does not mention distance styles; the commits@ notification of c9c27c755a4 (no replies). Not fixed upstream: the last commit to GeoStandardPath.java is dfd536186fe (2026-04-01, mechanical), and no open PR touches GeoStandardPath.java or the DistanceStyle classes. Gaps: GitHub search does not index review comments on PR diffs; closed unmerged PRs were covered by text search only; keyword search can miss a report that uses none of the queried words.
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): this doc's one-method test through gradle 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) and on 9.4.2 (passes). The hunt's tests ran on main ea57360912ed and again on a9779ef3c193; lucene/spatial3d is unchanged up to 645d4fe14a20. The history sweep executed every release from 9.0.0 to 10.5.2, value probes on 8.11.4 and 7.7.3, and the commits on both sides of c9c27c755a4. Fix A was run through gradle in a scratch worktree of 645d4fe14a20 on 2026-10-31. Upstream main 2f424fd428da (2026-10-30) still has the same code.

Lucene and upstream sources

Finding record
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardPath.java:301-307 (at ea57360912ed) (GeoStandardPath.distance: fromAggregationForm(rootComponent.distance(...)) at :306), :678-687 (PathNode.distance: isWithin gate, Math.min of the two children at :686), :878-886 (BaseSegmentEndpoint.distance returns aggregateDistances(...), the aggregation form, at :885), :1538-1549 (PathSegment.distance returns fromAggregationForm(aggregateDistances(...)), the final form, at :1547-1548), :34 (@lucene.internal)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/DistanceStyle.java:107-148 (at ea57360912ed) (toAggregationForm, aggregateDistances, 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', :109-111, :122-124, :139-141), :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 ea57360912ed) and NormalSquaredDistance.java:63-71 (toAggregationForm = Math.sqrt, fromAggregationForm = square)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPathFactory.java:38-44 (at ea57360912ed) (makeGeoPath returns GeoDegeneratePath below Vector.MINIMUM_ANGULAR_RESOLUTION, else GeoStandardPath), :25 (@lucene.experimental)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseDistanceShape.java:47-53 (at ea57360912ed) (computeDistance returns POSITIVE_INFINITY when !isWithin, else distance())
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoDistance.java:24 (at ea57360912ed) (@lucene.experimental), :33-57 (computeDistance returns the shape's 'internal' distance; 'Implementations should clarify how this is computed when it's non-obvious')
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoDegeneratePath.java:223-253 (at ea57360912ed) (the zero-width sibling converts back from aggregation form exactly once per return)
  • lucene/CHANGES.txt:7831 (at ea57360912ed) ('LUCENE-7942: Distance computations now have the ability to accurately aggregate distances, rather than just doing sums.')
The finding in 30 seconds
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardPath.java:301-307, (at 645d4fe14a20) :678-687, :878-886, :924, :1538-1549, :1777-1780
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/LinearSquaredDistance.java:64-70; (at 645d4fe14a20) NormalSquaredDistance.java:64-70; DistanceStyle.java:107-148
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPathFactory.java:38-44; (at 645d4fe14a20) GeoBaseDistanceShape.java:47-53
Who is affected
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointDistanceComparator.java:85, (at 645d4fe14a20) :131, :173-174; Geo3DPointOutsideDistanceComparator.java:101, :143-144; Geo3DPoint.java:203
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/DistanceStyle.java:22, (at 645d4fe14a20) :38, :46; GeoPath.java:22; GeoDistance.java:24; GeoPathFactory.java:25
Why it looks unintended
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/DistanceStyle.java:107-148; (at 645d4fe14a20) GeoDistance.java:33-34, :46-47; GeoStandardPath.java:34; GeoDegeneratePath.java:223-253
  • lucene/CHANGES.txt:7839, (at 645d4fe14a20) :7864-7865 (LUCENE-7942 in 7.1.0)
  • lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestGeoPath.java (at 645d4fe14a20) (DistanceStyle.ARC only)
Suggested fix
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardPath.java:278-333, (at 645d4fe14a20) :885, :1538-1549
Prior reports
  • gh api search/issues and repos/apache/lucene/commits?path=lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardPath.java (read-only, 2026-10-31)
History
  • gh api repos/apache/lucene/commits?path=lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardPath.java (read-only, 2026-10-31)
Diagrams
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardPath.java:306, (at 645d4fe14a20) :686, :885, :1547-1548

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 repro path and the component tree's two 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.