All findings · Lucene finding · LFV-1128 · circle2d-haversin-segment-test-uses-degree-space-closest-point

LatLonShape circle queries miss a line or polygon whose edge crosses the circle with no vertex inside

A LatLonShape distance query can miss a line or polygon edge that crosses the circle when neither end is inside: Lucene tests one point of the edge, chosen in raw lon/lat degrees, which off the equator is not the nearest point. A line 8.95 km from the centre of a 10 km circle at latitude 60 gets 0 hits.

Summary

Priority
Medium, rank 40LatLonShape distance queries silently miss lines and polygons under INTERSECTS, and match them under DISJOINT, at ordinary radii away from the equator, with default settings in every release since 8.5.0. It needs a long edge that crosses only the circle's outer band with both ends outside, which densely sampled shapes rarely have, so the trigger is rare.
Impact
silent wrong results
Since
8.5.0 (as old as the API, LUCENE-8707)introduced 2020-02-19 · about 6 years
Affected
8.5.0 to 10.6.0 and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of Circle2D's contract, witnessed by an executable repro

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

When neither end of an edge is inside the circle, Circle2D tests only the edge point nearest the centre in raw degrees. Haversine distance shrinks east-west steps by cos(lat), so for a diagonal edge that is the wrong point, and an edge crossing only the circle's rim band can be reported as not touching it.

Background: how a shape edge is tested against a circle

  1. LatLonShape indexes a line or polygon as small triangles; their edges are straight lines in (lon, lat) degrees.
  2. A distance query (LatLonShape.newDistanceQuery) asks of every indexed edge whether it touches the circle. The circle is measured in metres with the haversine (great-circle) distance.
  3. On the ground one degree of longitude is cos(lat) times one degree of latitude: the same length at the equator, half as long at latitude 60.
Diagram of the bug: a 10 km query circle around latitude 60, longitude 10 and a straight line whose two end points are outside the circle; the line passes through the circle's outer part, 8.95 km from the centre at its nearest point, but Lucene tests only the point found by projecting the centre in raw degrees, 11.19 km away and outside, so the query reports no intersection; scaling longitude by cos(latitude) moves the tested point to the nearest point and fixes it.
The 10 km circle at latitude 60 and the line from (60.38, 9.8) to (59.78, 10.4): the point Lucene tests, picked in raw degrees, lies 11.19 km from the centre, outside; the nearest point of the line, 8.95 km away, is inside but never tested. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index a two-point LatLonShape line from (lat 60.38, lon 9.8) to (lat 59.78, lon 10.4) and query LatLonShape.newDistanceQuery with INTERSECTS and a 10 km circle around (60, 10).
  2. Both ends are outside the circle (43.7 km and 33.1 km away), but the line passes 8.95 km from the centre: about 12% of it lies inside. You expect 1 hit.
  3. You get 0 hits and no error, with assertions on and off. A DISJOINT query returns the line instead.
  4. Why: with no vertex inside, Circle2D tests one point of the edge: the centre's foot computed in raw degrees, as if a degree of longitude were as long as a degree of latitude. At latitude 60 it is half as long, so the tested point is 11.19 km away, outside, and nothing else is tested.
  5. So an edge that dips only into an outer band of the circle can be missed, depending on its direction. For ordinary radii the band's worst depth depends on latitude alone: 0 at the equator, 5.7% of the radius at 45°, 20% at 60°.

Who is affected

Anyone who queries LatLonShape lines or polygons with a circle: INTERSECTS can miss them, DISJOINT then over-matches, CONTAINS can over-match. No special configuration, ordinary radii, any latitude off the equator. It needs an edge long enough to cross the circle's rim band with both ends outside (two-point lines, coarse polygons), so it is rare in practice.

Mechanism, terms and numbers1,019 words

LatLonShape.newDistanceQuery decides for each indexed edge whether it touches the query circle. When neither end point of an edge is inside the circle, Circle2D tests one more point of the edge and nothing else: the foot of the circle's centre on the edge, computed with a Euclidean dot product on raw (lon, lat) degrees. On the ground a degree of longitude is only cos(lat) times a degree of latitude, so for a diagonal edge away from the equator that foot is not the point nearest the centre, and an edge that enters only an outer band of the circle can be silently missed by INTERSECTS and matched by DISJOINT, depending on its direction; through the same test a polygon can also be reported to CONTAIN a circle that crosses one of its edges. In the repro, a line passes 8.95 km from the centre of a 10 km circle at latitude 60, the tested point is 11.19 km away, and the query returns 0 hits. For circles up to about 100 km the band's worst depth, 1 - 2c/(1+c²) of the radius with c = cos(lat), does not depend on the radius (at 1,000 km a scan found slightly deeper misses): the Circle javadoc's own 1 km example circle misses a line 953 m from its centre. Point documents and XYShape circles are not affected. The defect is as old as the API (8.5.0, LUCENE-8707) and present in 10.6.0 and main. In #10279 (2020) Ignacio Vera already named this mechanism for a CONTAINS test failure; only withinTriangle was changed then. Scaling longitude differences by cos(centre latitude) in the projection fixes the repro and keeps the geo and shape test suites green.

The mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/)

  1. Contracts. LatLonShape.newDistanceQuery: "create a query to find all polygons that intersect a provided circle" (document/LatLonShape.java:315-319). Component2D.intersectsLine: "return true if this component2D intersects the provided line" (geo/Component2D.java:49).
  2. The query. For an indexed LINE the INTERSECTS predicate calls component2D.intersectsLine (document/LatLonShapeQuery.java:101-108), for a TRIANGLE intersectsTriangle (:109-118). DISJOINT is intersects().negate() (document/SpatialQuery.java:129-130), so each INTERSECTS miss is a DISJOINT match.
  3. Vertices first. Circle2D.intersectsLine returns contains(a) || contains(b) || calculator.intersectsLine(a, b) (geo/Circle2D.java:81); intersectsTriangle checks the vertices, the centre in the triangle, then the three edges the same way (:99-106). withinLine (:165) and withinTriangle (:198-205, used by CONTAINS) use the same edge test.
  4. The haversine calculator calls the shared static helper with the centre, and again with the centre shifted by 360° for circles that cross the dateline (geo/Circle2D.java:449-459).
  5. The defect (fix site). The static intersectsLine (geo/Circle2D.java:219-256) projects the centre onto the segment with the Euclidean dot product on raw (lon, lat) degrees (:229-238), rejects a foot outside the segment (:240-242), builds that one point (:244-245) and returns calculator.contains(pX, pY) (:253), the haversine test (:427-447: a bounding-rectangle pre-check, then SloppyMath.haversinSortKey compared with the radius's sort key). No other point of the edge is tested.
  6. Why that point is wrong. The raw-degree foot minimises lon² + lat² differences, so it counts east-west steps as 1/cos(lat) times too long and slides along a diagonal edge towards the part that is closer to the centre in longitude (and farther in latitude): in the repro (60.09, 10.09) is 0.09° off the centre in both coordinates, the nearest point (60.036, 10.144) 0.036° in latitude and 0.144° in longitude. The Cartesian calculator (XYShape circles, :356-357) uses the same helper in its own matching metric and is correct. Point documents use contains only (document/LatLonShapeQuery.java:95-100).

The numbers (the repro)

Circle(lat 60, lon 10, 10 km); line A = (60.38, 9.8), B = (59.78, 10.4), 74.6 km long; A is 43.7 km and B 33.1 km from the centre.

  • Raw-degree foot: t = 0.483, (60.09, 10.09), 11,186 m from the centre: outside, so intersectsLine is false.
  • Nearest point of the line: t = 0.573, (60.036, 10.144), 8,947 m: inside. The test's witness (60.04, 10.14) is on the line at t = 17/30, 8,961 m away. About 12% of the line lies inside the circle.
  • Through LatLonGeometry.create(circle) the same component answers contains(10.14, 60.04) = true and intersectsLine = false.
  • The index stores quantized vertices (about 4.2e-8° latitude, 8.4e-8° longitude); with margins of about a kilometre this does not matter here.

How deep a missed edge can reach

In local metric coordinates the tested distance is at most (1 + c²)/(2c) times the true minimum, c = cos(lat), so an edge whose true minimum lies between 2c/(1 + c²)·R and R can be missed: a band of depth 1 - 2c/(1 + c²) of the radius R. Latitude 0: 0%; 30°: 1.0%; 45°: 5.7%; 48.86° (the javadoc example): 8.2%; 60°: 20%; 75°: 51.5%. In this small-circle approximation it does not depend on R: a numerical scan of random crossing segments found worst depths just below the bound for radii from 100 m to 100 km, and slightly deeper misses at 1,000 km (6.5% at 45°, 21.5% at 60°). Whether an edge in the band is missed depends on its direction: in that scan the stock code missed about half of the band crossings (53.4% at 45° and at 60°, R = 10 km).

Other relations (diagnostics, not extra tests)

  • The repro's line itself: INTERSECTS 0, DISJOINT 1; with the fix INTERSECTS 1, DISJOINT 0.
  • A triangle polygon with the repro's edge, not containing the centre: INTERSECTS 0, DISJOINT 1.
  • The Circle javadoc's own example circle, 1 km around (48.8584, 2.2945): a line from (48.8782, 2.2903) to (48.8542, 2.3143), ends 2,223 m and 1,522 m away, passes 953 m from the centre: INTERSECTS 0, DISJOINT 1; a triangle with that edge: INTERSECTS 0, DISJOINT 1.
  • A polygon that contains that centre but has the same edge 953 m from it: CONTAINS 1, although the circle crosses the polygon's boundary.
  • With the fix all four turn correct (INTERSECTS 1, DISJOINT 0, CONTAINS 0); a control line at the equator whose nearest point is 11.79 km from a 10 km circle stays unmatched.
Exposure and likelihood286 words
  • Who is hit. Users of LatLonShape line and polygon fields queried with newDistanceQuery, or with newGeometryQuery and a Circle: INTERSECTS misses the document, DISJOINT matches it, and CONTAINS can match a polygon whose edge cuts the circle (through withinTriangle). No special configuration: the repro uses a plain IndexWriterConfig and searcher and fails under every seed run (the test framework randomizes the codec), with assertions on or off; no error, and the index itself is fine.
  • Ordinary parameters. For ordinary radii (up to about 100 km) the band depends on latitude only: 5.7% of the radius at 45°, 20% at 60°. The Circle javadoc's own 1 km example circle misses a line 953 m from its centre.
  • What it takes. Both end points of the edge outside the circle while the edge passes through the outer band, so the edge must be long relative to the circle and run diagonally in lon/lat: two-point lines, coarse polygons, long road or pipeline segments. Densely sampled shapes likely have a vertex inside first (not measured). The finding is rated rare; no user report was found in the recorded searches.
  • How often on random crossings (a synthetic scan: end points uniform in a box of ±3 radii around the centre, only segments with both ends outside that truly cross, 5,071-5,286 per cell): the stock code misses 0% at the equator, 0.5-0.8% at 30°, 3.5-3.8% at 45°, 10.7-12.1% at 60° and 24.7-26.2% at 75°, for every radius from 100 m to 1,000 km (at 75° only up to 100 km: the 1,000 km cell leaves the lat/lon range).
  • Not affected. Point documents (they use contains), XYShape circles (matching Cartesian metric), and edges with a vertex inside the circle.

Minimal repro

One JUnit test for lucene/core, public API only: index one two-point line, check with SloppyMath.haversinMeters that a point of it is within 10 km of the circle's centre (it is 8.96 km), then count INTERSECTS hits. Fixed geometry (the outcome does not depend on the seed), single thread; fails with assertions on and off.

TestLatLonShapeCircleCrossingEdge.java (lucene/core, public API)55 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.document;

import org.apache.lucene.document.ShapeField.QueryRelation;
import org.apache.lucene.geo.Circle;
import org.apache.lucene.geo.Line;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.SloppyMath;

public class TestLatLonShapeCircleCrossingEdge extends LuceneTestCase {

  public void testLineThroughCircleWithBothEndsOutside() throws Exception {
    Circle circle = new Circle(60, 10, 10_000);
    Line line = new Line(new double[] {60.38, 59.78}, new double[] {9.8, 10.4});
    // (60.04, 10.14) is on the line and inside the circle: expected 1 INTERSECTS hit, actual 0.
    assertTrue(SloppyMath.haversinMeters(60, 10, 60.04, 10.14) < 10_000);

    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        for (Field f : LatLonShape.createIndexableFields("shape", line)) {
          doc.add(f);
        }
        w.addDocument(doc);
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        assertEquals(
            1,
            new IndexSearcher(r)
                .count(LatLonShape.newDistanceQuery("shape", QueryRelation.INTERSECTS, circle)));
      }
    }
  }
}
Command
cp $REPRO/TestLatLonShapeCircleCrossingEdge.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestLatLonShapeCircleCrossingEdge
# fails the same way with -Ptests.asserts=false and under every seed tried
Expected
1 hit: the line passes 8.95 km from the centre of the 10 km circle.
Observed
0 hits, no error: expected:<1> but was:<0>, with assertions on and off.

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

More files, environment, full output187 words

The test is the hunt's minimal repro, unchanged; it passes :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs). It uses a plain IndexWriterConfig, a ByteBuffersDirectory and fixed geometry; the test framework still randomizes codec, locale and time zone per seed, but the outcome does not depend on it (the test failed under every seed run).

Executed: gradle on main 5adff78bb168 with assertions on and off: fails at the count assertion (line 48) after the haversine precondition passed; with the fix it passes. The hunt's standalone runs, a separate re-run and the audit's and judge's re-runs fail the same way.

Use the same test as the regression test.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (only this platform was run)
Command (environment)
cp $REPRO/TestLatLonShapeCircleCrossingEdge.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestLatLonShapeCircleCrossingEdge
# fails the same way with -Ptests.asserts=false and under every seed tried

Observed output

TestLatLonShapeCircleCrossingEdge > testLineThroughCircleWithBothEndsOutside FAILED
    java.lang.AssertionError: expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([5EED1128:E3B67604A7BF46BD]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.junit.Assert.assertEquals(Assert.java:633)
        at org.apache.lucene.document.TestLatLonShapeCircleCrossingEdge.testLineThroughCircleWithBothEndsOutside(TestLatLonShapeCircleCrossingEdge.java:48)

# with the fix: BUILD SUCCESSFUL, the test passes with assertions on and off

Why it looks unintended

  • The same component says a point of the edge is inside the circle but the edge does not touch it.
  • In #10279 the maintainer described this mechanism and called its effect an error; #10909 fixed another missed match of this method as a bug.
  • Not a large-circle limit: the javadoc's own 1 km example misses a line 953 m away.
The full argument220 words
  • Self-contradiction. Through LatLonGeometry.create(circle), contains(10.14, 60.04) is true for a point of the edge while intersectsLine is false for the edge. Lucene's edges are straight lines in lon/lat by design, and the expected answer holds in that model; as a cross-check only, the great-circle arc between the same end points also comes within the radius (9.10 km in the repro, 953.6 m in the javadoc example).
  • Contracts are unqualified: "find all polygons that intersect a provided circle" (LatLonShape.java:315), "return true if this component2D intersects the provided line" (Component2D.java:49).
  • Maintainer's words. In #10279 (LUCENE-9239), explaining a CONTAINS test failure, Ignacio Vera wrote: "The current implementation uses cartesian geometry to find the closest point from a line to a point (our edges are cartesian!). Then it uses the spherical geometry to calculate the distance between this two points. This error is a side effect of this approach." The fix (b732ce70025) changed only withinTriangle; the projection stayed.
  • Precedent. #10909 (LUCENE-9870, PR #41) fixed another way this method missed matches along indexed lines, as a bug, in 8.8.2.
  • Why the tests are silent. The random shape-query tests use the query's own Circle2D as the oracle (BaseSpatialTestCase.java:641, :664), and TestCircle2D's fixed line cases use circles at latitude 0, where the band is empty.
The devil's-advocate check139 words

Strongest case for "intended": Circle is @lucene.experimental; its javadoc says "The circle is approximated on the spherical Earth model" and "For very large circles or circles near poles, consider using polygons instead" (geo/Circle.java:29-47); Component2D is @lucene.internal; and LUCENE-9239 knowingly kept the Cartesian closest point.

Why it fails: the miss happens inside Lucene's own spherical model (the same component says the point is inside); for ordinary radii the band depends on latitude, not on size, so a 1 km circle at 48.86° is neither "very large" nor "near poles"; @lucene.experimental allows API changes, not wrong answers; the maintainer called the effect an error, and #10909 treated a sibling miss as a bug. Cross-model reviews (OpenAI GPT-5.6) rated the argument moderate and rejected it, as did the devil's-advocate verifier and the adjudication. Verdict: not intended, a defect.

Suggested fix

Recommended Project in a cos(latitude)-scaled metric (fix A)

In Circle2D's static intersectsLine, multiply longitude differences by cos(centre latitude) before the dot product (1 for the Cartesian calculator), so the tested foot is close to the true nearest point. The repro then passes, 54 geo and shape test suites stay green, and :lucene:core:check -x test passes.

    final double vectorAPX = (centerX - aX) * xScale; // xScale: 1 cartesian, cos(centerLat) haversine
    final double vectorAPY = centerY - aY;
    final double vectorABX = (bX - aX) * xScale;
    final double vectorABY = bY - aY;
    ...
    final double pX = aX + (bX - aX) * distance; // the foot itself stays in degrees

    // HaversinDistance: this.lonScale = Math.cos(Math.toRadians(centerLat));
    // Circle2D.intersectsLine(centerLon, centerLat, aX, aY, bX, bY, this, lonScale)
All options and trade-offs4 options · 606 words

Every point the helper tests lies on the edge, so within Lucene's model (lon/lat-straight edges and the existing contains predicate) a positive answer is right; a fix only has to test a better point, or more points, and options A-C add no geometric false match beyond that predicate's existing boundary behaviour. One fix was built and tested (not proven) in a scratch worktree of 5adff78bb168; the alternatives are listed with their trade-offs.

Project in a cos(latitude)-scaled metric (fix A) (validated by the checks) (recommended)

Add an xScale parameter to the static helper and scale both x differences by it; build the foot from the unscaled difference. HaversinDistance passes cos(toRadians(centerLat)), computed once in its constructor, for both the normal and the dateline-shifted call; CartesianDistance passes 1 (unchanged behaviour). Full patch: 13 insertions, 7 deletions in geo/Circle2D.java.

    final double vectorAPX = (centerX - aX) * xScale; // xScale: 1 cartesian, cos(centerLat) haversine
    final double vectorAPY = centerY - aY;
    final double vectorABX = (bX - aX) * xScale;
    final double vectorABY = bY - aY;
    ...
    final double pX = aX + (bX - aX) * distance; // the foot itself stays in degrees

    // HaversinDistance: this.lonScale = Math.cos(Math.toRadians(centerLat));
    // Circle2D.intersectsLine(centerLon, centerLat, aX, aY, bX, bY, this, lonScale)

Trade-offs

Approximate, not exact: cos(lat) is taken at the centre. On the random crossings of the scan it misses none for radii up to 100 km at latitudes 0-75°; at 1,000 km it misses 0.02-0.15% (1, 7 and 8 of about 5,160 at 30°, 45° and 60°), all within 0.74% of the radius of the rim, and in one cell it missed one crossing the stock code caught. Cost: one multiplication per difference and one Math.cos per circle. At a pole the projection becomes a latitude-only projection. Not run separately at the poles, on dateline-crossing segments or circles, on zero-length segments, or for long edges of large circles beyond the scan, which models the helper only (no quantization, rectangle pre-check or dateline path).

What the checks validated

Scratch worktree of 5adff78bb168, gradle, JDK 25: the repro fails stock and passes fixed, with assertions on and off; 54 suites (org.apache.lucene.geo.*, document.TestLatLon*, TestXY*, TestShape*, search.TestLatLon*, TestXY*; 585 tests, 0 failures) pass twice with random root seeds (and a third time in the fact check); :lucene:core:check -x test passes. The diagnostic probe's javadoc-example line and polygon, 45° line and CONTAINS polygon all turn correct; its equator control stays unmatched. Tested, not proven: these runs do not cover every input.

Minimise the haversine distance along the edge (exact if the search finds the global minimum)

Search t in [0, 1] for the minimum of haversinSortKey along the edge (for example golden-section or Newton steps seeded by fix A's foot, on the part of the edge inside the circle's bounding box) and compare it with the radius.

Trade-offs

Removes fix A's residual, including for large circles, but costs several distance evaluations per edge on a hot leaf path, and its convergence needs an argument (the distance along a lon/lat-straight edge is not guaranteed to be unimodal for very long edges). Not implemented or run.

Test both feet

Keep today's raw-degree foot and also test fix A's foot.

Trade-offs

Never worse than today on any input (it only adds a test of a point on the edge); still approximate; two contains calls per edge. Not run.

Accept within a tolerance

Accept the edge when the tested point is within the radius plus an error bound.

Trade-offs

Not recommended: it adds false matches for edges that pass just outside the circle, trading one wrong answer for another. Not run.

Downstream note

Downstream (not examined further): opensearch-project/OpenSearch#2516 (closing #2514) routes geo_distance queries on geo_shape fields through its shape query with INTERSECTS, and elastic/elasticsearch#108823 notes that geo_distance relies on Lucene's Circle2D; whether their users get the same misses was not checked.

How it was found

A proof-obligation audit of Circle2D's contract asked whether intersectsLine is true whenever an edge shares a point with the circle. The closest-point step failed that obligation for haversine circles off the equator, and an executable public-API repro witnessed it. An independent reproducer, cross-model reviews, a devil's-advocate verifier and an adjudication confirmed it.

The method in full170 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The obligation: Component2D.intersectsLine must be true when the segment shares a point with the circle. For the haversine calculator the proof step "the tested foot is the nearest point of the segment" does not hold, because the foot is computed in raw degrees; a public-API JUnit (circle at 45°, a diagonal two-point line) witnessed the miss on main, 10.6.0 and 9.12.3, and a cos-latitude fix sketch made it pass.
  • Then an independent reproducer wrote its own test from the claim (the 60° repro here), cross-model reviews (OpenAI GPT-5.6, intended and refute lenses, executed) wrote and ran their own tests, a devil's-advocate verifier argued the intended-behaviour case and found the javadoc-example miss, and an adjudication re-ran the tests and checked the geometry. An audit and a final repro check followed.
  • Earlier hunt coverage had not reached it: its Circle2D checks used XY circles only, and its distance-query checks used point documents, which this bug does not affect.

Prior reports, history and checks

Prior reports

Known in part: in #10279 (LUCENE-9239, closed in 8.5.0) Ignacio Vera named this mechanism for a CONTAINS test failure and changed only withinTriangle. No report of the INTERSECTS miss or DISJOINT match for lines and polygons, or that small circles at mid latitudes are hit, was found in the recorded searches.

Searches3 searches

Same method, different bugs: #10909 (LUCENE-9870, the t range check, fixed in 8.8.2 by PR #41) and #10290 (LUCENE-9250, dateline, fixed in 8.5.0). In lucene-solr#1345 (LUCENE-9275, #10315) the same maintainer noted that "distance point to line can have quite a bit of error" for a 1,320 km circle over the pole and changed only a test. Downstream context only: elastic/elasticsearch#108823 and opensearch-project/OpenSearch#2516, which closed #2514 (no missed match reported). No open apache/lucene PR touched Circle2D.java or LatLonShape.java in the 2026-11-02 scans of the open PRs' file lists. Search limits: keyword search only; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch)2026-11-02two scripted prior-report searches (43 and 54 queries: Circle2D, intersectsLine, distance query, closest point, cartesian vs haversine, line and polygon misses, DISJOINT), 20 GitHub API searches with AND terms, command-line GitHub searches; #10279, #10909, #10290, #9753, #10315, PR #41 and lucene-solr PRs #587, #1280, #1289, #1345 read in full#10279 (partial overlap: same mechanism, other symptom); other items are different bugs
Open PRs and upstream commits2026-11-02the file lists of all 400 open apache/lucene PRs; the newest commits on Circle2D.java; a re-check of new issues and PRsno PR touches the files; newest commit f22f2b70a7d (#14898, a typo fix, 2025-07-07)
Mailing lists (dev@, java-user@, issues@, commits@) and the web2026-11-02list archive search and 7 web queries (2 more throttled)no user report found

History

As old as the API: LUCENE-8707 (Ignacio Vera, 2020-02-19) added Circle2D with this projection, first released in 8.5.0; 8.4.1 has no circle query. Later commits on the method never changed the metric. Fails on every executed release up to 10.6.0 and on main; not new in 10.6; not fixed upstream.

Versions tested5 rows

Origin: d48bafb2999 on master and its branch_8x cherry-pick 50168ab5bc8 (LUCENE-8707 "Add LatLonShape and XYShape distance query", lucene-solr#587, author date 2020-02-19). git log -L on the method lists LUCENE-9250 (988ce9bff7e / 16663db0996, 8.5.0: made it static with an explicit centre for the dateline shift), LUCENE-9570 (2d6ad2fee6d, formatting) and LUCENE-9870 (06114459ee4, the t range check); none changed the metric. In 8.5.0 to 8.8.1 the older range check also rejects the 45° test's short segment, so there it fails for two reasons; an isolation probe with a longer segment shows the projection miss alone. geo/Circle2D.java is the same blob (8fd98adbc55) at 10.6.0 and 5adff78bb168. Releases between the executed ones were not run individually.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedthis doc's test fails through gradle with assertions on and off; passes with the fix
10.6.0, 10.5.2, 9.12.3 affectedthe 60° test fails (release jars, assertions off)
8.5.0, 8.8.2, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.6.0 affectedthe 45° twin fails expected:<1> but was:<0> (release jars)
8.5.0, 8.8.1 (isolation probe) affectedINTERSECTS 0 for a longer crossing segment that the older range check accepts
8.4.1 API absentcompile error: no Circle, no LatLonShape.newDistanceQuery

Independent checks

An independent reproducer, two executed cross-model reviews (OpenAI GPT-5.6, intended and refute lenses), a devil's-advocate verifier and an adjudication reproduced it with their own tests and agree on the root cause; an audit and a final repro check confirmed it. This doc's gradle runs reproduced it on main and tested the fix.

Checks and verdicts511 words

Every executed check saw the miss come from the raw-degree foot, and every one that ran a cos-latitude fix saw its test pass. The cross-model reviews ran on main ea57360912ed, whose involved files are identical to 5adff78bb168's.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproduced45° public-API test fails on main (on, off), 10.6.0 and 9.12.3; passes with a cos-latitude fix sketch; model files show contains true and intersectsLine false, INTERSECTS 0 and DISJOINT 1.
cross-model review, intended lens real (high)OpenAI GPT-5.6, executed: its own tests fail on main, 10.6.0 and 9.12.3 and pass with the finder's cos-latitude fix sketch; TestCircle2D passes 7 of 7 with that sketch; the intended-behaviour argument rated moderate and rejected.
independent reproducer realOwn test (the 60° repro here) fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3 and passes with its own shadow fix; contains true vs intersectsLine false; a triangle polygon gives INTERSECTS 0, DISJOINT 1.
cross-model review, refute lens real (high)OpenAI GPT-5.6, executed: its own test with a different diagonal segment at latitude 60 fails with assertions on and off while a larger-radius control passes; with the finder's fix sketch its tests and TestCircle2D pass.
devil's-advocate verifier realRejected the spherical-model note, "very large or polar circles", @lucene.experimental and LUCENE-9239 as defences; the javadoc's 1 km example misses a line 953 m away; CONTAINS 1 for a polygon whose edge cuts the circle; derived the depth formula.
adjudication confirmed (high), MediumRe-ran the reproducer's and the finder's tests on clean main: fail; independent geometry check (foot 11,186 m, minimum 8,947 m).
audit and final repro check confirmedThe audit re-ran the test on main with assertions on and off (fail) and confirmed it; the final check reduced the repro to this one test, which fails on main and passes with the fix sketch.
this doc reproduced; fix testedGradle on main 5adff78bb168: the test fails with assertions on and off; with fix A it passes, 54 geo and shape suites pass twice and :lucene:core:check -x test passes; the diagnostic probe's misses all turn correct; a random-crossing scan quantifies the residual.
fact check real as described; 2 medium and 6 low wording/citation errors, fixedRe-ran the repro on main (assertions on and off, 5 of 5 random seeds, the literal gradle command), on 10.6.0, 10.5.2 and 9.12.3, and with fix A (the repro and 54 suites, a third random seed); probed the repro's own line (DISJOINT 1, flipped by the fix) and recomputed the geometry. Its corrections (the foot leans towards the part closer in longitude; band edges can be missed, about half in a random scan; small-circle qualifiers; citations) are applied.
cross-model fact check (OpenAI GPT-5.6) defect real; corrections neededReviewed the brief against the code, the history and the logs: the defect is real and the intended-behaviour case moderate but not persuasive; fix A is a tested approximation. Its valid corrections (qualifiers, citations, caveats) are applied; two points are rejected with reasons.

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

Finding record

LFV-1128, snapshot of 2026-11-06

Registry title
LatLonShape circle (distance) queries miss lines and polygons whose edge crosses the circle with no vertex inside, because Circle2D tests only the edge point closest to the centre in raw lon/lat degrees, not the point closest by haversine distance (DISJOINT matches them instead)
Area
core / geo (Circle2D haversine segment test behind LatLonShape.newDistanceQuery)
Priority
Medium, rank 40: LatLonShape distance queries silently miss lines and polygons under INTERSECTS, and match them under DISJOINT, at ordinary radii away from the equator, with default settings in every release since 8.5.0. It needs a long edge that crosses only the circle's outer band with both ends outside, which densely sampled shapes rarely have, so the trigger is rare.
Impact class
silent wrong results
Impact note
LatLonShape line and polygon documents queried with LatLonShape.newDistanceQuery are hit when an edge crosses the circle with both ends outside and runs diagonally in lon/lat away from the equator: an edge that passes only through an outer band of the circle is silently missed by INTERSECTS and matched by DISJOINT (and, through the same test, a polygon can be reported to CONTAIN a circle that crosses one of its edges), with the default codec, IndexWriterConfig and searcher, -ea and -da alike; the band's worst-case depth depends only on latitude, 1-2c/(1+c^2) of the radius with c = cos(lat) (0 at the equator, 5.7% at 45 deg, 20% at 60 deg), so ordinary radii are affected (the Circle javadoc's own 1 km example circle misses a line 953 m from its centre), but the edge must be long compared with the band's chord, so densely sampled shapes rarely meet it; point documents and XYShape circles are not affected.
Summary
You index a two-point LatLonShape line from (lat 60.38, lon 9.8) to (lat 59.78, lon 10.4) and run LatLonShape.newDistanceQuery with INTERSECTS and a 10 km circle around (lat 60, lon 10). The line passes 8.95 km from the centre, so you expect 1 hit, and the circle's own contains() accepts a point of the line; you get 0 hits and no error, and DISJOINT matches the line instead. When neither end is inside, Circle2D tests only the point of the segment closest to the centre in raw lon/lat degrees (here 11.19 km away), not the point closest by haversine distance, so diagonal edges that cross the outer part of the circle are missed.
Introduced
d48bafb2999542224c86f278d1fd0dcb949388cc on master and its branch_8x cherry-pick 50168ab5bc85c382a6d558553caa1f752bf5afc1 (LUCENE-8707 'Add LatLonShape and XYShape distance query', apache/lucene-solr#587 by iverase, opened 2019-02-25, merged 2020-02-19; JIRA LUCENE-8707 = apache/lucene#9753): the first version of Circle2D already projects the centre onto the segment with the Euclidean metric in raw degrees and calls the haversine contains() once at that point. git log -L on the method lists only LUCENE-9250 (988ce9bff7e / 16663db0996, 8.5.0: made it static with an explicit centre for the dateline shift, which is why git blame credits the vector lines to it), LUCENE-9570 (2d6ad2fee6d, formatting) and LUCENE-9870 (06114459ee4, the t range check), and none of them changed the metric; the introducing PR, its JIRA issue and the commit message do not mention the approximation [read: git log -L and git show in the history clone; exec: version runs, see affected_versions] (2020-02-19 (author date 2020-02-19T16:03:30+01:00 of both commits; branch_8x commit date 2020-02-19T16:05:31+01:00))
First affected version
8.5.0 [exec: the finder's test fails on 8.5.0, and an isolation probe with a longer segment, which the older range check cannot reject, also misses on 8.5.0 and 8.8.1; 8.4.1 has no org.apache.lucene.geo.Circle and no LatLonShape.newDistanceQuery (compile error), so the defect is as old as the API and there is no earlier correct version]
Affected versions
every release since the API exists: 8.5.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT). Executed: the finder's test fails on 8.5.0, 8.8.2, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.6.0 (-da) and on main (-ea and -da), and isolation probes miss on 8.5.0 and 8.8.1; the independent reproduction's test fails on main (-ea and -da) and on 10.6.0, 10.5.2 and 9.12.3 (-da) [exec]; the other releases by reading (no commit changed the degree-space projection) [read]. In 8.5.0-8.8.1 the older range check (fixed by LUCENE-9870 in 8.8.2) also rejects the finder's short segment, so there the test fails for two reasons; the isolation probe separates them. Not new in 10.6 (10.5.2 fails). Not fixed upstream: Circle2D.java is the same blob (8fd98adbc55) at ea57360912ed, 5adff78bb168 and 10.6.0, its newest upstream commit is f22f2b70a7d (author date 2025-07-07, a typo fix, #14898), and upstream main 1c2072f0284 (author date 2026-11-02) has no later geo commit [read through the GitHub API, 2026-11-02]. Runs used JDK 25.0.3 on Linux aarch64.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: proof-obligation audit
Upstream and prior reports
known (partial overlap), the symptom not reported, not fixed (2026-11-02, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): two scripted prior-report searches (43 and 54 queries over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 99 and 203 distinct hits), 20 GitHub API searches, command-line GitHub searches, reads of #10279, #10909, #10290, #9753, #10315, PR #41 and apache/lucene-solr#587, apache/lucene-solr#1280, apache/lucene-solr#1289 and apache/lucene-solr#1345, the file lists of all 400 open apache/lucene PRs (none touches Circle2D.java or LatLonShape.java) and 7 web searches (two more were throttled). Partial overlap: closed #10279 (LUCENE-9239, 'TestLatLonMultiPolygonShapeQueries error with CIRCLE queries', fixed in 8.5.0 by b732ce70025 / lucene-solr#1280): the maintainer (iverase) explained a CONTAINS test failure with this mechanism ('The current implementation uses cartesian geometry to find the closest point from a line to a point (our edges are cartesian!). Then it uses the spherical geometry to calculate the distance between this two points. This error is a side effect of this approach.') and changed only withinTriangle; lucene-solr#1345 (LUCENE-9275, #10315) adds that 'distance point to line can have quite a bit of error' for a 1,320 km circle over the pole and changed only a test. Neither reports INTERSECTS misses or DISJOINT over-matches for line or polygon documents, or misses for small circles at mid latitudes. Same method, different bugs: #10909 (LUCENE-9870, t range clamp, fixed in 8.8.2 by PR #41; the repro fails on code that has that fix) and #10290 (LUCENE-9250, dateline, fixed in 8.5.0). Downstream context only: elastic/elasticsearch#108823 (geo_distance on geo_shape delegates to Circle2D; an options issue) and opensearch-project/OpenSearch#2514 (geo_distance on geo_shape wired to this query). A recheck on 2026-11-02 found that a GitHub search on apache/lucene for 'Circle2D' returns only #14898, #10290, #41, #10909 and #10279; 'circle intersectsLine', 'newDistanceQuery LatLonShape' and 'circle closest point cartesian' return nothing; none of the 38 issues and PRs updated since 2026-10-31 has geo, circle, shape, latlon, haversin or distance in its title; the newest commit on Circle2D.java is still f22f2b70a7d (2025-07-07).
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT, 2026-11-02) through gradle in a scratch worktree, with assertions on and off, without and with the fix (the repro, 54 geo and shape test suites twice with random root seeds, and :lucene:core:check -x test); the hunt ran the repro or its 45° twin on the 8.4.1 (API absent), 8.5.0, 8.8.2, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 release jars, plus an isolation probe on 8.5.0 and 8.8.1. Cross-model reviews ran on main ea57360912ed, whose Circle2D.java, LatLonShape.java and LatLonShapeQuery.java are identical. Only Linux aarch64 with OpenJDK 25.0.3 was run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/geo/Circle2D.java:68-82 (at 5adff78bb168) (intersectsLine: contains(a) || contains(b) || calculator.intersectsLine, :81), :84-107 (intersectsTriangle: :104-106 the three edge tests), :149-169 and :172-217 (withinLine :165, withinTriangle :198-205 use the same test), :219-256 (static intersectsLine: Euclidean projection of the centre onto the segment in raw degrees, then one calculator.contains at the projected point :253; fix site), :356-357 (CartesianDistance.intersectsLine: the same method in the XY metric, correct there), :403-460 (HaversinDistance: :427-447 haversine contains, :450-459 intersectsLine with the +-360 shifted centre for dateline circles)
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:315-319 (at 5adff78bb168) ('create a query to find all polygons that intersect a provided circle'); lucene/core/src/java/org/apache/lucene/geo/Component2D.java:49 ('return true if this component2D intersects the provided line'); lucene/core/src/java/org/apache/lucene/geo/Circle.java:29-47 (spherical Earth model note, 'For very large circles or circles near poles, consider using polygons instead', the 1 km example circle, @lucene.experimental)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:315-319; (at 5adff78bb168) geo/Component2D.java:49
  • lucene/core/src/java/org/apache/lucene/document/LatLonShapeQuery.java:95-118; (at 5adff78bb168) document/SpatialQuery.java:123-136
  • lucene/core/src/java/org/apache/lucene/geo/Circle2D.java:68-107, (at 5adff78bb168) :148-217, :219-256 (fix site), :356-357, :427-447, :449-459
Who is affected
  • lucene/core/src/java/org/apache/lucene/geo/Circle2D.java:165, (at 5adff78bb168) :198-205 (within paths); document/LatLonShapeQuery.java:95-100 (points)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:315; (at 5adff78bb168) geo/Component2D.java:49; geo/Circle.java:29-47
  • lucene/core/src/test/org/apache/lucene/document/BaseSpatialTestCase.java:641, (at 5adff78bb168) :664; lucene/core/src/test/org/apache/lucene/geo/TestCircle2D.java:30, :54, :76, :96, :120, :188
  • https://github.com/apache/lucene/issues/10279 (LUCENE-9239, Ignacio Vera's comment, 2020-02-24); https://github.com/apache/lucene/issues/10909 (LUCENE-9870)
Prior reports
  • https://github.com/apache/lucene/issues/10279; https://github.com/apache/lucene/issues/10909; https://github.com/apache/lucene/issues/10290; https://github.com/apache/lucene-solr/pull/1345

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

Repro files

About the diagrams

A summary still (PNG).

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.