All findings · Lucene finding · LFV-735 · geostandardpath-outside-distance-end-cap-to-centre

A geo3d path measures its end caps to their centre point, not their edge, so newOutsidePathSort misorders documents

A geo3d path measures its end caps to their centre point, not their edge, so distances past an end are too large (on the sphere by up to the half-width): newOutsidePathSort ranks a document 1 km past a path end (5,992 m) after one 3 km beside it (3,067 m).

Summary

Priority
Low, rank 410The outside-path sort and GeoPath outside distance overstate end-cap distances by up to the half-width and misorder documents, but matching is unchanged and the API is barely used.
Impact
silent wrong results
Since
5.3.0 (LUCENE-6578, 2015)introduced 2015-07-01 · over 11 years
Affected
5.3.0 to 10.5.2, 10.6.0 (by reading) and main (18 releases and main run)
Status
documented
Found by
a bounded-exhaustive comparison with 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

A geo3d path with a width is a corridor along each segment plus a cap at each end and joint, bounded by a circle plane of the half-width (round on the sphere). Its outside distance, which newOutsidePathSort sorts by, is documented as "the distance from a point to the closest outside edge", and 0 inside.

A path with a width drawn as a corridor with round caps at both ends. A point beside the corridor is measured to the corridor's edge (correct). A point just past the end cap, on the path's axis, is measured to the end point at the centre of the cap, so its outside distance is its gap plus the half-width. A plot shows the value jumping from 0.0 to the half-width at the cap's edge. In the sort example the document 1 km past the end gets 5,992 m and ranks after the document about 3 km beside the path (3,067 m).
The path as a corridor with round end caps on the unit sphere; a point beside it is measured to the edge, a point past its end, on the axis, to the end point, which adds the half-width. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You sort with Geo3DDocValuesField.newOutsidePathSort along a path on the equator from longitude 20 to 22 degrees, with pathWidthMeters 5000, which acts as the half-width. Document A is 1 km past the east end, document B about 3 km beside the middle.
  2. You expect A first: the sort is by "ascending outside distance", the distance to the path's closest edge, so about 1,000 m for A and 3,000 m for B.
  3. You get B first (3,067 m) and A second (5,992 m), with no error. 5,992 m is A's distance to the end point itself: its 1 km gap plus the 5 km half-width. With the fix A gets 997 m and comes first.
  4. The geometry API shows it directly. On the unit sphere a one-point path of half-width 0.01 reports 0.011 for a point 0.001 outside, where a GeoCircle of the same radius reports 0.001; and 2e-6 rad across the end cap's edge the value jumps from 0.0 to 0.010001.
  5. Why: points beside a segment are measured to the corridor's edge, but the caps at the ends, at joints and of one-point paths inherit a method that measures to their centre point, which is the edge only for a zero-width endpoint.

Who is affected

Applications that sort with Geo3DDocValuesField.newOutsidePathSort or call GeoPath.computeOutsideDistance on a path with a width. Every point whose nearest part of the path is an end cap, a one-point path or the outer side of a turn gets a value that is too large (on the sphere by up to the half-width). One document is enough; matching is unchanged.

Mechanism, terms and numbers919 words

A geo3d path with a width (GeoPathFactory.makeGeoPath with a cutoff angle, or the path of Geo3DDocValuesField.newOutsidePathSort) is a corridor along each segment plus a cap at each end and joint, bounded by a circle plane of the half-width (round on the sphere). Its outside distance is documented as "the distance from a point to the closest outside edge", and that is what you get beside a segment. The caps are never measured to their edge: each reports the distance to its centre point. So beyond either end and on the outer side of a turn the value comes from the end point, the segment's end cutoff plane or a corner, all inside the cap or on its rim, and around a one-point path it is the distance to the centre: on the unit sphere too large by up to the half-width, exactly the half-width on the path's axis. On the unit sphere a point 0.001 rad beyond the end of a path of half-width 0.01 gets 0.011, a one-point path reports 0.011 where a GeoCircle of the same radius reports 0.001, and the value jumps from 0.0 to 0.010001 across a 2e-6 rad step at the cap edge. On WGS84, newOutsidePathSort with a 5 km half-width gives a document 1 km past the end 5,992 m and sorts it after a document about 3 km beside the path (3,067 m). The cause is BaseSegmentEndpoint.outsideDistance (GeoStandardPath.java:945-949), which the three endpoint classes with a width inherit unchanged. It dates from the outside-distance API itself (LUCENE-6578, 5.3.0, 2015) and is in every release to 10.6.0 (10.6.0 by reading) and on main. Matching is unchanged; the distances are wrong, silently, and so is the order of hits, which in a top-N sort also decides which matching documents make the top N. The recommended fix measures each cap to its circle's edge: exact on the unit sphere, close but not exact on WGS84; it passes the whole lucene/spatial3d module, and unlike the hunt's first sketch it stays finite on WGS84.

Terms

  • Path with a width (GeoStandardPath, from GeoPathFactory.makeGeoPath(planet, cutoffAngle, points) or Geo3DUtil.fromPath behind newOutsidePathSort): "a path across the surface of the globe, with a specified half-width". It is built from components: a segment (a corridor bounded by two side planes and two cutoff planes) for each pair of points, and an endpoint for each point: an end cap at the first and last point, a joint where two segments meet, or a single circle for a one-point path. Each endpoint is bounded by a circle plane of the half-width (a joint by two): a round cap on the sphere, "an ellipse otherwise" (GeoStandardCircle.java:23-25).
  • Outside distance (computeOutsideDistance): 0.0 for points inside the shape, else the distance "to the closest outside edge". The path takes the minimum over all its components.
  • Half-width: the cutoff angle (radians); newOutsidePathSort converts its pathWidthMeters into it, so that argument also acts as the half-width.

The bug

  1. Each segment measures a point to its own edge: "the minimum distance from the point to any of the segment's bounding planes", plus its four corners (GeoStandardPath.java:1783-1839).
  2. Each endpoint inherits BaseSegmentEndpoint.outsideDistance (:945-949), which returns the distance to this.point, the end point at the centre of the cap. CircleSegmentEndpoint, CutoffSingleCircleSegmentEndpoint and CutoffDualCircleSegmentEndpoint do not override it, although their isWithin uses a circle of the half-width, so the caps are part of the shape.
  3. Beyond an end, the segment measures to its end cutoff plane (through the end point, bounded by the side planes) and its corners, which lie inside the cap or on its rim; nothing measures the cap's circle. So the minimum is the end-point distance on the path's axis (gap + half-width), the end cutoff plane's distance a little off the axis and a corner's distance farther off (the fact check's probe: end point at 0 degrees, cutoff plane at 10 to 40, a corner from 50). On the outer side of a turn the first segment's end cutoff plane gives 0.00778 instead of 0.001. On the unit sphere the excess is at most the half-width. A one-point path has no segment, so on the unit sphere it is gap + half-width at every bearing (12 of 12 checked).
  4. The formula is right only for a zero-width endpoint, where the end point is the edge: the zero-width GeoDegeneratePath gives the right ARC value beyond its end (its squared styles have a separate defect, registry LFV-741). Zero-width paths came in 2017 as that separate class (LUCENE-7955); the 2015 GeoPath rejected a cutoff angle of 0.

The numbers

input (unit sphere, half-width 0.01 unless stated)expectedactual
path (0, 0)-(0, 0.1); a point 0.001 beside the segment (control)0.0010.001
same path; a point 0.001 beyond the end cap0.0010.010999999999988425
one-point path at (0.3, 0.2) against GeoCircle of radius 0.01; a point 0.001 outside0.001 (the circle gives 9.999999999921686E-4)0.010999999999998518
1e-6 inside the cap edge, then 1e-6 outside0.0, then about 1e-60.0, then 0.010000999999982243
outer side of a 90-degree turn, 0.001 outside0.0010.00778
WGS84 newOutsidePathSort, equator path, half-width 5 km: a document 1 km past the end, one about 3 km besidepast-the-end firstbeside first (3,067 m), past-the-end second (5,992 m)
WGS84 one-point path, half-width 1 km at latitude 40, a point about 2.8 km awayabout 1,828 m2,828 m

All five distance styles (ARC, LINEAR, LINEAR_SQUARED, NORMAL, NORMAL_SQUARED) measure the cap and the one-point path to the end point.

Exposure and likelihood328 words
  • Who. Users of Geo3DDocValuesField.newOutsidePathSort (public, not tagged experimental) or of GeoPath.computeOutsideDistance (@lucene.experimental) on a path made with a width. For example, ranking places by their distance from a route: places past the start or end of the route, or around a route of one point, are normal input.
  • Effect. Silent wrong values and order. In the repro a document 1 km past the end gets 5,992 m (997 m with the fix) and sorts after one about 3 km beside the path; with a 20 km half-width a document 5 km past the end gets 24,972 m and sorts after one 10 km beside the path (10,055 m). The order breaks between documents whose nearest path parts differ (cap or joint against a segment side). In a top-N sort the comparator's compareBottom uses the value to decide whether a document enters the top N (Geo3DPointOutsideDistanceComparator.java:75-101), so a document past an end can drop out of the top N. Around a one-point path on the unit sphere the order is kept and every value is too large by the half-width; on WGS84 the excess varies a little with the bearing. The sort value returned in FieldDoc is that wrong number in meters.
  • Not affected. Which documents match (no query uses outside distance to match; the only callers outside the geometry package are the sort comparator's competitive check and its sort value); newPathSort, which uses the inside path distance; the zero-width GeoDegeneratePath (not by this bug); the index.
  • Default vs opt-in. Opt-in: the lucene/spatial3d module and a path outside-distance sort or call. Within that, default settings and any planet model; SPHERE and WGS84 were run.
  • Scale. None: one shape and one point, deterministic, with assertions on and off.
  • How likely. Plausible: deterministic once the feature is used, but the feature is little used (no upstream test calls a path outside distance or a path outside sort), and no report was found in the searches below.

Minimal repro

The test shows that a GeoStandardPath measures the outside distance beyond an end cap to the end point, not to the cap edge: SPHERE path (0,0)-(0,0.1) of half-width 0.01, point (0, 0.111).

TestGeoPathOutsideDistanceEndCap.java (JUnit test)32 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 TestGeoPathOutsideDistanceEndCap extends LuceneTestCase {

  public void testOutsideDistanceBeyondEndCap() {
    PlanetModel pm = PlanetModel.SPHERE;
    GeoPath path =
        GeoPathFactory.makeGeoPath(
            pm, 0.01, new GeoPoint[] {new GeoPoint(pm, 0, 0), new GeoPoint(pm, 0, 0.1)});
    // lon 0.111 is 0.001 rad beyond the cap edge: expected 0.001, actual 0.011 (to the end point)
    assertEquals(
        0.001, path.computeOutsideDistance(DistanceStyle.ARC, new GeoPoint(pm, 0, 0.111)), 1e-9);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-735/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/spatial3d/build/libs/lucene-spatial3d-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.spatial3d.geom.TestGeoPathOutsideDistanceEndCap
Expected vs actual
Expected (GeoOutsideDistance: distance to the closest outside edge, which is at longitude 0.11): 0.001. Actual: 0.010999999999988425 (expected:<0.001> but was:<0.010999999999988425>).

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

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<0.001> but was:<0.010999999999988425>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:555)
       org.junit.Assert.assertEquals(Assert.java:685)
       org.apache.lucene.spatial3d.geom.TestGeoPathOutsideDistanceEndCap.testOutsideDistanceBeyondEndCap(TestGeoPathOutsideDistanceEndCap.java:29)

Why it looks unintended

  • GeoOutsideDistance promises "the distance from a point to the closest outside edge".
  • The same path measures its sides to the edge, and on the unit sphere a one-point path disagrees with a GeoCircle of the same radius by the full half-width.
  • The value jumps from 0.0 to 0.010001 across a 2e-6 rad step at the cap edge.
The full argument276 words
  • The contract is the edge. GeoOutsideDistance: "Implemented by Geo3D shapes that can compute the distance from a point to the closest outside edge" (GeoOutsideDistance.java:20-21). CHANGES for LUCENE-6578: "Geo3D can now compute the distance from a point to a shape, both inner distance and to an outside edge" (lucene/CHANGES.txt:10299-10301). The issue (#7636) asked for the distance "from a GeoPoint to the nearest edge/point of a GeoShape". newOutsidePathSort: "orders documents by ascending outside distance from the described path. Points within the path are given the distance of 0.0" (Geo3DDocValuesField.java:346-347).
  • The same class and its sibling measure to the edge. PathSegment.outsideDistance measures to its bounding planes (GeoStandardPath.java:1787); GeoStandardCircle.outsideDistance measures to its circle plane (GeoStandardCircle.java:168-172), as its predecessor GeoCircle did in the same 2015 commit. A one-point path is built as a "Simple circle" (GeoStandardPath.java:152-153) yet on the unit sphere differs from the same-size GeoCircle by the half-width at all 12 bearings checked.
  • Not an estimate. The value jumps from 0.0 to about the half-width at the cap edge and inverts the order within one shape (0.001 beyond the end gets 0.011, 0.005 beside gets 0.005). It shows in ARC, the exact style.
  • Nothing pins it. No comment, javadoc, CHANGES or MIGRATE entry, test or issue calls the end-point distance intended, and no upstream test computes a path outside distance. The fix passes the whole spatial3d module.
  • Wording nit, separate from the bug. pathWidthMeters is documented as the "width of the path in meters" (Geo3DDocValuesField.java:361) and the cutoff angle as "the width of the path" (GeoPathFactory.java:34), but both act as the half-width.
The devil's-advocate check235 words

The strongest case for "works as designed" (rated moderate by the cross-model reviews, weak to moderate by the other checkers): the geometry API is @lucene.experimental and GeoStandardPath is @lucene.internal; the per-method javadoc only says "this shape's distance"; GeoDistance speaks of "distance estimates that can be computed more cheaply" (GeoDistance.java:21-22); the behaviour is 11 years old, no report of it was found, and on the sphere it is bounded by the half-width; and the endpoints were reworked twice (LUCENE-8696 in 2019, the #11965 refactor in 2022), the 2022 work editing this very line.

Why it fails: "experimental" is about API stability, not about the documented meaning; the "estimates" sentence is the javadoc of GeoDistance, the inside-distance interface ("distances from the shape to a point expected to be INSIDE the shape", GeoDistance.java:28-30), while outside distance is declared in GeoOutsideDistance, which promises the closest outside edge; the defect shows in ARC, the exact style; measuring to the circle costs about what GeoCircle already does; neither rework was about the outside distance (LUCENE-8696 reworked how endpoint circles are represented, and the 2022 commit's only change to this line was a toAggregationForm wrapper that leaves the ARC value unchanged); and a value that jumps from 0 to the half-width at the edge and disagrees with a circle of the same shape is not an estimate of the edge distance. Verdict: not intended, a defect.

Suggested fix

Recommended Measure each circle endpoint to its edge, finding the edge point through the centre (recommended)

Measure the caps, joints and one-point paths to their circle's edge, finding the edge point in the plane through the cap's centre and the point (+99 lines in GeoStandardPath.java). The repro and the whole spatial3d module pass. Exact on the unit sphere, close but not exact on WGS84, where unlike a plain plane distance it stays finite.

All options and trade-offs4 options ยท 1,434 words

Option A was applied in a scratch copy of a9779ef3c193 and validated with gradle; it was revised on 2026-10-28 after a review found that its first version returned the centre distance for a one-point path near the antipode of its centre, and every validation was re-run with the revised version. Option B is the hunt's first sketch, re-checked here and found to fail on WGS84; C was not run; D is not recommended.

Measure each circle endpoint to its edge, finding the edge point through the centre (recommended) (validated by the checks)

+99/-0 in GeoStandardPath.java:

  • a helper BaseSegmentEndpoint.circleEdgeDistance(style, circlePlane, x, y, z, bounds...): for ARC and the LINEAR styles it intersects the circle plane with the plane through the origin, the endpoint's centre and the point (normalized), keeps the intersection points that meet the bounds and returns the nearest; when the point is the centre's antipode (to within 1e-12), every plane through the centre contains it, and the helper takes the one through the coordinate axis least aligned with the centre; for the NORMAL styles it returns the plane distance, as GeoStandardCircle does;
  • CircleSegmentEndpoint.outsideDistance (one-point path): the circle's edge, with the centre distance kept as an upper bound;
  • CutoffSingleCircleSegmentEndpoint.outsideDistance (end cap): the part of the circle beyond the cutoff plane, and the two corners;
  • CutoffDualCircleSegmentEndpoint.outsideDistance (joint): both circles between both cutoff planes, and the corners.
protected double circleEdgeDistance(DistanceStyle distanceStyle, SidedPlane circlePlane,
    double x, double y, double z, Membership... bounds) {
  if (!(distanceStyle instanceof ArcDistance || distanceStyle instanceof LinearDistance
      || distanceStyle instanceof LinearSquaredDistance)) {
    return distanceStyle.computeDistance(planetModel, circlePlane, x, y, z, bounds);
  }
  // the nearest edge point lies in the plane through the origin, the center and the point
  Vector perpNormal = new Vector(point.y * z - point.z * y, point.z * x - point.x * z,
      point.x * y - point.y * x).normalize();
  if (perpNormal == null) {
    // the point is the center's antipode: any plane through the center contains it;
    // take the one through the coordinate axis least aligned with the center
    final double ax = Math.abs(point.x), ay = Math.abs(point.y), az = Math.abs(point.z);
    if (ax <= ay && ax <= az) perpNormal = new Vector(0.0, point.z, -point.y).normalize();
    else if (ay <= az) perpNormal = new Vector(-point.z, 0.0, point.x).normalize();
    else perpNormal = new Vector(point.y, -point.x, 0.0).normalize();
  }
  final Plane perpPlane = new Plane(perpNormal, 0.0);
  final GeoPoint[] edgePoints = circlePlane.findIntersections(planetModel, perpPlane, bounds);
  if (edgePoints == null) {
    return distanceStyle.computeDistance(planetModel, circlePlane, x, y, z, bounds);
  }
  double minDistance = Double.POSITIVE_INFINITY;
  for (final GeoPoint edgePoint : edgePoints) {
    minDistance = Math.min(minDistance, distanceStyle.computeDistance(edgePoint, x, y, z));
  }
  return minDistance;
}

// end cap (CutoffSingleCircleSegmentEndpoint); the joint does the same with both circles
@Override
public double outsideDistance(DistanceStyle distanceStyle, double x, double y, double z) {
  double minDistance = circleEdgeDistance(distanceStyle, circlePlane, x, y, z, cutoffPlane);
  for (final GeoPoint notablePoint : notablePoints) {
    minDistance = Math.min(minDistance, distanceStyle.computeDistance(notablePoint, x, y, z));
  }
  return distanceStyle.toAggregationForm(minDistance);
}

Trade-offs

  • Cost: for ARC and the LINEAR styles one plane intersection per circle (two per joint) per outside-distance call, with a Vector, a Plane and the intersection array allocated each time, instead of one point distance; for the NORMAL styles one plane distance per circle. The sort comparator calls it per document value; not benchmarked. GeoStandardCircle pays a similar cost for its plane distance.
  • Observable change: sort values of documents near path ends change; clients that keep FieldDoc sort values, for example to page with searchAfter across an upgrade, see different values. The index is not affected.
  • NORMAL styles: the fix gives what GeoCircle gives, the perpendicular distance to the circle's plane (1.05e-5 for a point 0.001 outside, against 0.001 beside a segment; stock gives 0.011). That keeps paths consistent with circles rather than with the path's own sides.
  • WGS84 caps are measured as built. A separate defect from the same hunt, not reported upstream yet (internal id LFV-736), shifts WGS84 end caps toward the equator; once that is fixed, this fix follows the corrected cap.
  • Shared helper possible: the same "plane through the centre" idea fixes the +Infinity that small GeoCircles return on WGS84 (another unreported defect from the same hunt, internal id LFV-274); a helper in Plane could serve both.

What the checks validated

  • Revised 2026-10-28; every run below was repeated with the revised fix. The first version built the plane through the centre and the point unnormalized, which is degenerate at the centre's antipode (Plane.findIntersections then returns an empty array, not null): a one-point path got the centre distance, pi instead of pi - W, within about 1e-11 rad of the antipode on the unit sphere and 1e-9 rad on WGS84 (1 km). The antipode probe: on the unit sphere the revised fix is within 4e-14 of pi - delta - W at every offset delta from 0 to 0.01 (stock and the first version near the antipode: the full half-width too much); on WGS84 it is within about 0.1% of the half-width of a traced edge distance; multi-point paths are right with both versions.
  • The new test: 5 of 5 pass in gradle and with the standalone runner, assertions on and off. The finder's, the independent reproducer's and the devil's advocate's JUnit classes: 17 run, 0 failed (13 fail on stock); the adjudication's probe: 4 of 4.
  • The whole lucene/spatial3d module, its 19 suites (181 tests) plus the new test: 20 suites, 186 tests, 3 skipped, 0 failed, with assertions on (seed DEADBEEF) and off (seed 5EED0735); TestGeoPath, TestRandomGeoShapeRelationship and TestGeo3DDocValues with 20 iterations: 360 tests, 20 skipped, 0 failed; :lucene:spatial3d:check -x test passes.
  • The hunt's reference harness on the unit sphere (1,231,737 checks): outside-distance mismatches 7,476 -> 0, order inversions 5,869 -> 0. Its floating-point category "outside == 0 just outside" (outside distance exactly 0.0 for points isWithin puts outside, all within 3e-8 rad, about 19 cm, of the edge: the floor of the arc distance's acos) grows from 814 to 1,668, because points just outside a cap now join those just outside a corridor side; that band predates the fix.
  • A random sweep of 20,000 paths (1 to 4 points, half-widths 1e-5 to 0.05 rad, 46,140 points just outside an end or joint), against the oracle "no larger than the distance to a point that isWithin puts inside": stock breaks it in 21,390 of 25,291 sphere cases and 13,503 of 20,849 WGS84 cases, by up to 0.999 half-widths; the fix in 0 sphere cases and 95 WGS84 multi-point cases, by at most 0.30 m (half-widths of 67 to 411 m). Never +Infinity, never an exception, never larger than stock. The same counts with both versions.
  • Status on ellipsoids: exact in every tested case on the unit sphere; on WGS84 a close prototype, not a proven edge distance (that the nearest edge point lies in the plane through the centre is not proved for an ellipsoid; the residuals above show the gap).

The hunt's first sketch: plane distance to the circle (validated by the checks)

The same three overrides, each with distanceStyle.computeDistance(planetModel, circlePlane, x, y, z, bounds...), as GeoStandardCircle does. On the unit sphere it is equivalent: the hunt's tests, 32 upstream tests and the harness (7,476 -> 0) pass.

Trade-offs

Worse on WGS84. Plane.arcDistance looks for the edge along the plane through the circle plane's normal, which misses small tilted circles (the mechanism of the small-circle +Infinity above). A one-point path with a 1 km half-width at latitude 40 then returns +Infinity at 10 of 12 bearings (stock: finite, too large), so the sort would treat such documents like documents without a value, and a 1 km end cap stays at the end-point distance. In the sweep: 1,261 +Infinity results among 9,382 WGS84 one-point cases and 4,525 WGS84 cases above the bound, by up to 13 km. The new test's testOnePointPathOnWgs84 fails with it. Use it only with a general Plane.arcDistance fix.

What the checks validated

Re-run here: the new test 4 of 5 pass (testOnePointPathOnWgs84 gets Infinity); the WGS84 probe and the sweep above.

Fix Plane.arcDistance and linearDistance for small circles on ellipsoids, then apply the plane distance

The general fix; it would also fix the small-circle +Infinity of GeoCircle.

Trade-offs

Changes a primitive that every geo3d shape uses, so it needs its own review and tests. Not run.

Document the centre distance as intended

Not recommended. It keeps today's sort values (no change for stored FieldDoc values) and costs nothing, and a maintainer who weighs compatibility of the long-standing values highly could choose it with a javadoc note.

Trade-offs

Contradicts GeoOutsideDistance, the LUCENE-6578 CHANGES entry and GeoCircle, keeps the jump at the cap edge, and leaves the sort misordering.

Downstream note

Downstream relevance: a ranking by distance from a route built on this sort shows places just past the route's start or end as farther away than they are and ranks them too low.

How it was found

A bounded-exhaustive comparison of the real geo3d classes with an independent 50-digit reference, on the unit sphere where a path is the exact buffer of its polyline, flagged 7,476 wrong outside distances and 5,869 order inversions in 1,231,737 checks. Reading GeoStandardPath found the cause; a public-API JUnit reproduced it.

The method in full147 words

Method: bounded-exhaustive translation validation on the real jar (semi-formal tier).

  • A hunt unit on geo3d distance functions (2026-10-27, main a9779ef3c193) recomputed every documented distance of circles and paths with an independent BigDecimal reference (50 digits). On the unit sphere a path is exactly the half-width buffer of its polyline, so the reference outside distance is the distance to the polyline minus the half-width.
  • In 1,231,737 checks over 72 paths (60 of them with a width) it flagged 7,476 outside-distance values and 5,869 order inversions, all on paths with a width; the root cause was then found by reading the endpoint classes, and with a fix shadowed in the same harness reports 0 of either.
  • Then a public-API JUnit on the stock jar, an independent reproducer, an intended-behaviour devil's advocate, two executed reviews by a model from another family (OpenAI GPT-5.6), an adjudication and a credibility audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): 199 logged queries over GitHub issues, PRs and commits, the mailing lists, the JIRA archive, downstream projects and the web, plus the changed files of 410 open PRs. Nearest: LUCENE-6578 (#7636), which added the formula, and LUCENE-7212 (#8267), which added the sort.

Searches5 searches

Verdict new. 16 hits were read, none this bug: #7636 (LUCENE-6578; the discussion covers only the API shape), #8267 (LUCENE-7212, open; 49 comments, none about path ends), #9742 (LUCENE-8696) and #11965 (reworks that kept the line), #9087 (LUCENE-8039; "distance beyond the end of a path" there is the delta distance of inside points), #9011, #8991, #9004, #9283, #8411, #8343, #11051, #8955 (other path topics) and open PR #14775 (inverse sort; leaves the values alone). No open PR touches GeoStandardPath.java. Gaps: 2 web queries throttled; review comments on PR diffs are not indexed; list search is keyword-based.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream repositories2026-10-27the hunt's prior-report tool, 4 runs (171 queries): endpoint classes and outsideDistance, newOutsidePathSort, Geo3DDocValuesField, GeoPathFactory, GeoOutsideDistance, end cap, path width, one-point path, sort symptoms; plus manual searchesno report of this defect
Open PRs2026-10-27the changed files of all 410 open apache/lucene PRsnone touches GeoStandardPath.java; #14775 (inverse sort) leaves the values alone
Related threads (read in full)2026-10-27bodies and comments of 54 hit issues and PRs grepped for end-cap and outside-distance terms; all comments of LUCENE-6578, 7212, 8696, 7962, 8039, 7942, 8236 and 7955none discusses path ends or end-cap outside distances
Mailing lists and JIRA archive2026-10-27dev@, java-user@, issues@, commits@; JIRA archive and SOLR JQL queriesno report
Web2026-10-273 queries (2 more throttled)nothing relevant

History

Introduced with the outside-distance API itself by LUCENE-6578 (688c67f8ff2, 2015-07-01), first released in 5.3.0; the sort is wrong since its first release, 6.1.0. Version-specific probes on 18 releases from 5.3.0 and on main give identical values; never fixed.

Versions tested7 rows

Introduced by LUCENE-6578 "Geo3D: compute the distance from a point to a shape" (JIRA, now #7636; patches by @DaddyWri, committed by @dsmiley; svn era, no pull request): 688c67f8ff2 on trunk and 780e4040238 on branch_5x, both 2015-07-01. The same commit measured segments and GeoCircle to their edges and end points to their centre.

Later changes kept the value: LUCENE-8696 (#9742; 7.7.2, 8.1.0) moved it into BaseSegmentEndpoint, which the circle endpoints inherit. The November 2022 path refactor for #11965 wrapped it in toAggregationForm (commit c9c27c755a4 on main, ec97f1da35c on branch_9x, first released in 9.5.0); up to 9.4.2 the line is the plain return distanceStyle.computeDistance(this.point, x, y, z);. Runs on both sides of LUCENE-8696 and of the refactor give identical values. The sort (newOutsidePathSort, LUCENE-7212) arrived in 6.1.0 and is wrong from its first release. All 94 release tags from 5.3.0 to 10.5.2 carry the line; branch_10_6, branch_10x and upstream main too.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectednew test 5 of 5 fail (assertions on and off); finder's test 3 of 3
10.5.2, 9.12.3, 9.5.0 affectednew test 5 of 5 fail, the same messages (9.5.0 run by the fact check)
9.4.2 test needs 9.5.0the new test does not compile there (IndexSearcher.storedFields() is new in 9.5.0); the main() probe above fails with identical values
5.3.0, 5.4.0, 5.5.5, 6.0.0, 6.1.0, 6.6.6, 7.7.1-7.7.3, 8.0.0, 8.1.0, 8.11.4, 9.0.0, 9.4.2, 9.5.0, 10.0.0 affectedversion-specific main() probes: identical values (0.011 beyond the end, 0.011 against the circle's 0.001); the beside control passes
6.1.0, 7.7.3 sortthe sort probe misorders: beside 3,061.50 m first, past the end 5,991.75 m second
5.2.1 no APIno outside-distance API
fix not affectednew test 5 of 5 pass; spatial3d module 186 tests, 0 failed

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and two executed reviews by a model from another family (OpenAI GPT-5.6) reproduced it with their own tests; an adjudication confirmed it (high) and an audit re-ran it. This doc's runs replaced the hunt's first fix (+Infinity on WGS84); its fact checks corrected history and wording and led to a revised fix.

Checks and verdicts345 words

Every executing check wrote or re-ran its own test on stock main; every control passed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateReference harness: 7,476 outside-distance mismatches and 5,869 order inversions on the sphere, 0 with its fix sketch; JUnit 3 of 3 fail on main (assertions on and off), 10.5.2 and 9.12.3.
cross-model review, refute lens (OpenAI GPT-5.6) real, highOwn test (half-width 0.02): 0.024 instead of 0.004 beyond the end, assertions on and off; root cause agrees; intended-behaviour argument moderate.
independent reproducer realOwn test with a reference-free oracle: 5 of 6 fail on main, 10.5.2 and 9.12.3 with identical values; WGS84 sort 24,972 m against 10,055 m.
cross-model review, intended lens (OpenAI GPT-5.6) real, highOwn test: one-point path 0.019 instead of 0.004, end cap 0.023 instead of 0.003, on main and 10.5.2; the intended reading rated moderate and rejected.
devil's advocate real, highOwn test: 5 of 8 fail, 3 controls pass; ARC and LINEAR wrong; 12 of 12 bearings; outer side of a turn 0.00778 instead of 0.001.
adjudication confirmed, highRe-ran the finder's and the reproducer's tests (8 of 9 fail) and its own probe from outside the package.
credibility audit keepFourth independent re-run: 3 of 3 fail with assertions on and off; root cause re-read.
this doc's runs confirmed; first fix sketch correctedNew 5-test repro on main, 10.5.2 and 9.12.3; the hunt's fix sketch returns +Infinity for small WGS84 one-point paths, so a different fix was written and validated with the whole spatial3d module.
fact checks of this doc (one by OpenAI GPT-5.6) and a diagram check bug confirmed; doc corrected, fix revisedRe-ran the repro on main, 10.5.2, 9.12.3 and 9.5.0 and the fix with the whole module. Found history and wording errors (the 2022 origin of the wrapper, the off-axis mechanism, the 9.5.0 minimum for the test, the control count) and a hole in the first fix at a cap centre's antipode; all fixed, the fix revised and re-validated.

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

Finding record

LFV-735, snapshot of 2026-11-06

Registry title
GeoStandardPath outside distance measures end caps (and one-point paths) to the end point instead of the cap edge, so values are too large by up to the path half-width and Geo3DDocValuesField.newOutsidePathSort misorders documents
Area
spatial3d / geom path outside distance (GeoStandardPath segment endpoints; Geo3DDocValuesField.newOutsidePathSort)
Priority
Low, rank 410: The outside-path sort and GeoPath outside distance overstate end-cap distances by up to the half-width and misorder documents, but matching is unchanged and the API is barely used.
Impact class
silent wrong results
Impact note
Only applications that call the experimental GeoPath.computeOutsideDistance on a path with a width (a GeoStandardPath from GeoPathFactory.makeGeoPath) or sort with Geo3DDocValuesField.newOutsidePathSort are hit, with no scale and no special input (executed on SPHERE and WGS84): every point whose nearest part of the path is an end cap, the outer side of a joint, or a one-point path gets a finite value that is too large by up to the half-width, so the outside-path sort can rank a document just beyond a path end after documents farther away beside the path (a one-point path keeps the order but every value is off by the width), while the set of matching documents and the index are unchanged.
Summary
You build a path with a width (GeoPathFactory.makeGeoPath, or the pathWidthMeters of Geo3DDocValuesField.newOutsidePathSort) and ask for the outside distance of a point beyond one of its ends, expecting 'the distance from a point to the closest outside edge' (GeoOutsideDistance), which is how the same path measures points beside its segments and how GeoCircle measures a circle. Instead you get the distance to the path's end point, i.e. the gap plus up to the half-width: on the unit sphere a point 0.001 rad beyond the end of a path of half-width 0.01 gets 0.011, a one-point path reports 0.011 where the same-size GeoCircle reports 0.001, the value jumps from 0 to about the half-width as a point crosses the end cap's edge, and on WGS84 the outside-path sort ranks a document 1 km beyond the end of a path of half-width 5 km (5992 m) after one 3 km beside it (3067 m). The cause is BaseSegmentEndpoint.outsideDistance, which every endpoint with a width inherits: it measures to the end point itself, which is the edge only for a zero-width path.
Introduced
688c67f8ff22b72b9df4ed2f92d26b909563b2b1 on trunk (svn r1688545) and its branch_5x backport 780e4040238713c2ff85c4b981cbc6b80aa4094f (svn r1688546), 'LUCENE-6578: Geo3D: compute the distance from a point to a shape.' (JIRA LUCENE-6578, now apache/lucene#7636, opened by DaddyWri (Karl Wright), who wrote the patches, with a revision by and committed by dsmiley (David Smiley); svn era, no pull request). The commit created GeoOutsideDistance and DistanceStyle and gave GeoPath an outside distance in which SegmentEndpoint.outsideDistance returned computeDistance(this.point, x, y, z), the distance to the end point, while PathSegment and GeoCircle in the same commit measured to their edges. Later commits changed the line's form, not its value (LUCENE-7942 added toAggregationForm in 7.1.0; LUCENE-8696, #9742, moved it into BaseSegmentEndpoint, from which the circle endpoint classes inherit it, in 7.7.2 and 8.1.0; the #11965 refactor kept it in 9.5.0). [read: git log -S outsideDistance / -S GeoOutsideDistance and git blame -w -M -C through every rename resolve today's GeoStandardPath.java:948 to 688c67f8ff2, whose parent has no outsideDistance; exec: 5.2.1 has no outside-distance API, 5.3.0 fails, and executions on both sides of LUCENE-8696 (7.7.1/7.7.2, 8.0.0/8.1.0) and of the 2022 refactor (9.4.2/9.5.0) give identical values] (2015-07-01 (trunk 02:03:43Z, branch_5x backport 02:16:55Z))
First affected version
5.3.0 [exec] (tag dated 2015-08-20), the first release with the outside-distance API; 5.2.1, the last release before, has no such API [exec]. The user-facing Geo3DDocValuesField.newOutsidePathSort (LUCENE-7212, apache/lucene#8267) is wrong from its first release, 6.1.0 [exec]; 6.0.1 has no Geo3DDocValuesField [exec]
Affected versions
every release from 5.3.0 through 10.5.2 (the last release before 10.6.0), 10.6.0 (by reading) and main [read: all 94 release tags from 5.3.0 to 10.5.2 in the history clone carry the same centre-distance endpoint line; 8.8.2 and 8.9.0-8.11.4 are not tagged in the clone]. Executed with version-specific public-API main() probes (-ea), all failing with identical values while their beside-the-segment control passes: 5.3.0, 5.4.0, 5.5.5, 6.0.0, 6.1.0, 6.6.6, 7.7.1, 7.7.2, 7.7.3, 8.0.0, 8.1.0, 8.11.4, 9.0.0, 9.4.2, 9.5.0, 9.12.3, 10.0.0, 10.5.2 and main a9779ef3c193; the outside-path sort probe misorders on 6.1.0 and 7.7.3; the finder's JUnit fails 3 of 3 on 9.12.3, 10.5.2 and main (-ea and -da on main). branch_10_6 (10.6.0, released 2026-10-29), the unreleased branch_10x and upstream origin/main a95c754ee00 have the same code [read]. Not affected: releases before 5.3.0 (no API). Never fixed and never a regression: no version measured end caps to their edge. All runs on JDK 25.0.3.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that 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: 199 recorded queries, namely 4 scripted prior-report searches (171 queries, no errors, over apache/lucene issues, PRs and commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists and the JIRA archive) and 28 manual records (the changed files of all 410 open apache/lucene PRs; upstream main against a9779ef3c193; the upstream history of GeoStandardPath.java; extra GitHub searches; the bodies and comments of 54 hit issues and PRs grepped for end-cap and outside-distance terms; all comments of LUCENE-6578, 7212, 8696, 7962, 8039, 7942, 8236 and 7955; SOLR JQL queries; web queries); 16 hits read, none the same bug). Related, not reports: apache/lucene#7636 (LUCENE-6578, closed), which introduced the outside distance, the endpoint formula and the 'outside edge' contract; #8267 (LUCENE-7212, open), the feature issue of the outside-distance sort, whose 49 comments never discuss path ends; #9742 (LUCENE-8696), the 2019 endpoint rework that made the circle endpoints inherit the method; #11965, the 2022 refactor that kept the line; open PR #14775 (inverse sort), which leaves the distance values alone. Not fixed upstream: upstream main is 2 commits ahead of a9779ef3c193 and neither touches spatial3d, the last commit to GeoStandardPath.java is dfd536186fe (2026-04-01, mechanical), and no open PR touches GeoStandardPath.java. Gaps: 2 web queries were throttled; GitHub search does not index review comments on PR diffs; closed unmerged PRs were covered by text search only; list search is keyword-based.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27 and 28) through the standalone JUnit runner with assertions on and off and through gradle in a scratch copy, stock and with the fix; the 10.5.2, 9.12.3 and (by the fact check) 9.5.0 release jars; version-specific main() probes on 18 releases from 5.3.0 and on main (2026-10-27). The fix, revised on 2026-10-28, was validated in a scratch copy of a9779ef3c193: the new test, the whole lucene/spatial3d module with assertions on and off, 20 iterations of the path and doc-values suites and :lucene:spatial3d:check -x test; probes (including an antipode probe), a random sweep and the reference harness ran with the fix shadowed in.

Lucene and upstream sources

Finding record
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardPath.java:945-949 (at a9779ef3c193) (BaseSegmentEndpoint.outsideDistance returns toAggregationForm(computeDistance(this.point, x, y, z)), the distance to the end point; inherited by CircleSegmentEndpoint, CutoffSingleCircleSegmentEndpoint and CutoffDualCircleSegmentEndpoint), :1784-1836 (PathSegment.outsideDistance measures to its bounding planes and corners), :772-778 (PathNode: minimum of the two children), :325-332 (GeoStandardPath.outsideDistance = fromAggregationForm of the root's value), :152-153 (a one-point path is built as a 'Simple circle'), :30-34 ('a path ... with a specified half-width', @lucene.internal)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoOutsideDistance.java:19-24 (at a9779ef3c193) ('compute the distance from a point to the closest outside edge', @lucene.experimental) and :27-33 (0.0 for points inside)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBaseMembershipShape.java:46-53 (at a9779ef3c193) (computeOutsideDistance: 0.0 if isWithin, else outsideDistance)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardCircle.java:168-172 (at a9779ef3c193) (a circle's outside distance is the distance to its circle plane, i.e. to the edge)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPathFactory.java:38-44 (at a9779ef3c193) (makeGeoPath returns GeoDegeneratePath below Vector.MINIMUM_ANGULAR_RESOLUTION, else GeoStandardPath)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:343-374 (at a9779ef3c193) (newOutsidePathSort: 'orders documents by ascending outside distance from the described path. Points within the path are given the distance of 0.0'; pathWidthMeters passed to Geo3DUtil.fromPath)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideDistanceComparator.java:101 (at a9779ef3c193) and :143 (the comparator's competitive check and the reported sort value call computeOutsideDistance(DistanceStyle.ARC, ...))
  • lucene/CHANGES.txt:10299-10301 (at a9779ef3c193) ('LUCENE-6578: Geo3D can now compute the distance from a point to a shape, both inner distance and to an outside edge.')
The finding in 30 seconds
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardPath.java:30 (at a9779ef3c193) ("with a specified half-width"), :152-185 (one-point path: "Simple circle" CircleSegmentEndpoint), :201, :219, :234 (endpoint classes), :327-333 (root outsideDistance), :772-778 (PathNode minimum), :945-949 (BaseSegmentEndpoint.outsideDistance), :991, :1085, :1198 (endpoint classes, no override), :1054-1057, :1127-1130, :1278-1286 (isWithin uses circle planes), :1783-1839 (PathSegment.outsideDistance)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoOutsideDistance.java:20-21; (at a9779ef3c193) geom/GeoBaseMembershipShape.java:46-53; geom/GeoPathFactory.java:38-44; Geo3DUtil.java:139-140
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardCircle.java:23-25 (at a9779ef3c193) (a circle on a sphere, an ellipse otherwise); geom/GeoDegeneratePath.java:271-286, :534-537, :866-873 (squared-style aggregation mismatch, LFV-741)
Who is affected
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:343-374 (at a9779ef3c193) (newOutsidePathSort javadoc; no @lucene.experimental), Geo3DPointOutsideDistanceComparator.java:75-101 (compareBottom), :125, :143
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoPath.java:22, (at a9779ef3c193) geom/GeoOutsideDistance.java:23 (@lucene.experimental)
Why it looks unintended
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoOutsideDistance.java:20-21; (at a9779ef3c193) geom/GeoStandardCircle.java:168-172; geom/GeoDistance.java:21-22, :28-30; geom/GeoPathFactory.java:34; Geo3DDocValuesField.java:346-347, :361
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoStandardPath.java:152-153, (at a9779ef3c193) :1787
  • lucene/CHANGES.txt:10299-10301 (at a9779ef3c193)

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

Repro files

About the diagrams

Made from the code at main a9779ef3c193 and an executed probe (stock with assertions on and off, and with the recommended fix); every number on screen is asserted against the probe output. The PNG is the summary still. Re-rendered on 2026-10-28 after the diagram check (the open and filled dots of the jump were swapped) and the fact checks (wording).

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.