All findings · Lucene finding · LFV-698 · polygon2d-intersects-ignores-hole-edges

Shape INTERSECTS misses lines and polygons that cross a query polygon between its holes, and DISJOINT returns them

With a query polygon that has holes, LatLonShape and XYShape INTERSECTS miss a line or polygon whose vertices all sit in the holes, even when it crosses the land between them, and DISJOINT returns it. Every release since 8.6.0 (2020) does this.

Summary

Priority
Medium, rank 39Core LatLonShape/XYShape INTERSECTS and DISJOINT have silently returned wrong hits for holed query polygons since 8.6.0 on the default and most used relations.
Impact
silent wrong results
Since
8.6.0 (LUCENE-9273, 2020); 8.5.2 is correctintroduced 2020-04-20 · about 6 years
Affected
8.6.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
bounded-exhaustive comparison with an exact geometry library

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

LatLonShape and XYShape index lines and polygons as segments and triangles. An INTERSECTS polygon query asks, for each of them, whether it touches the query polygon; DISJOINT returns the documents where none does. The query polygon may have holes.

Summary card. Title: XYShape / LatLonShape INTERSECTS misses a shape that crosses the query polygon only between its holes. Left: the query square [0,10] x [0,10] minus holes A and B, with the line (2,5)-(8,5) and the box [2,8] x [4.5,5.5] reaching from hole A to hole B (missed) and the point (5,5) on the land between them (found). Middle: s.count on main with assertions on and off, INTERSECTS expected 3, actual 1; DISJOINT expected 0, actual 2. Right: inside Polygon2D.intersectsLine for the line, contains(2,5) and contains(8,5) are false (in the holes), tree.crossesLine is false (outer ring only), the hole edges crossed at (3,5) and (7,5) are never tested, so it returns false; the box's two triangles fare the same. Bottom: what it needs, the introducing change LUCENE-9273 (8.6.0), the release sweep, and the fix, which tests the corners against the outer ring only and gives INTERSECTS 3, DISJOINT 0.
Left: the query square with holes A and B, the line and the box crossing the land between them, and the point. Middle: INTERSECTS 3 expected, 1 actual; DISJOINT 0 expected, 2 actual. Right: the steps inside Polygon2D.intersectsLine that never test the hole edges, and the fix. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Query with a polygon that has holes, like a region with two lakes cut out: the square [0,10] x [0,10] with lakes [1,3] x [4,6] and [7,9] x [4,6]. The land strip between the lakes, for example the point (5,5), belongs to the polygon.
  2. Index three shapes that all touch (5,5): the line (2,5)-(8,5) from one lake to the other, the box [2,8] x [4.5,5.5] with its corners in the lakes, and the point (5,5). You expect INTERSECTS to return 3 documents and DISJOINT 0.
  3. You get INTERSECTS 1 (only the point) and DISJOINT 2 (the line and the box), with XYShape and LatLonShape alike, default settings, and no error.
  4. Why: for each indexed segment or triangle, Polygon2D first asks "is a vertex on land, or does an edge cross the outer ring?". A vertex in a lake does not count as on land, and that first test does not look at the lake shores, so the line fails it and is reported as not intersecting.
  5. 8.5.2 returns 3 and 0: LUCENE-9273, a speed-up in 8.6.0, dropped its hole-first check. A few-line fix restores 3 and 0, and all 8,894 lucene/core tests pass with it.

Who is affected

Anyone running LatLonShape or XYShape polygon or geometry queries with INTERSECTS or DISJOINT on 8.6.0 to 10.6.0 (10.6.0 by reading) or main, default settings. The query polygon needs two or more holes, or one non-convex hole, and a missed document has every vertex in holes. Point documents are not affected. Rare, but silent.

Mechanism, terms and numbers671 words

LatLonShape and XYShape polygon queries test each indexed segment and triangle with Polygon2D.intersectsLine / intersectsTriangle; DISJOINT is the negation of the same test. Both methods first ask whether a vertex lies inside the polygon, with the hole-aware contains() (a vertex in a hole counts as outside), or whether an edge crosses the outer ring; only then do they consult the holes, and only to exclude a shape that lies inside one hole. That first test never looks at hole edges. So a segment or triangle whose vertices all lie in holes, and which does not reach the outer ring, is reported as not intersecting even when it crosses polygon area between the holes. With the square [0,10] x [0,10] minus holes [1,3] x [4,6] and [7,9] x [4,6], the line (2,5)-(8,5) and the box [2,8] x [4.5,5.5] are missed by INTERSECTS and returned by DISJOINT, although contains(5,5) is true. It needs a query polygon with two or more holes (or one non-convex hole); point documents are not affected. LUCENE-9273 (8.6.0, 2020), a performance refactoring, dropped the hole-first check that 8.5.2 had; every release from 8.6.0 to 10.6.0 (10.6.0 by reading) and main is affected. Testing the vertices against the outer ring only fixes it.

Terms

  • Query polygon with holes: an outer ring (clockwise) and holes (counter-clockwise) inside it, as the Polygon javadoc describes (XYPolygon takes holes the same way). Points in a hole are not part of the polygon.
  • Polygon2D: the query-side geometry built from such a polygon. tree holds the outer ring's edges; the holes are a separate component (Polygon2D.java:45, :369-386).
  • Leaf predicate: for every indexed segment and triangle, INTERSECTS calls intersectsLine / intersectsTriangle (LatLonShapeQuery.java:107, :117; XYShapeQuery.java:89, :99). A document matches if any of its segments or triangles does. DISJOINT is the negation (SpatialQuery.java:129-130).

The bug

  1. contains(x, y) is "inside the outer ring and not in a hole" (Polygon2D.java:111-117, the hole test at :114).
  2. intersectsLine (:155-174) returns true only if contains(a) || contains(b) || tree.crossesLine(...) (:168-170); then it excludes a segment that one hole contains (holes.containsLine(...) == false, :171). intersectsTriangle (:176-201) does the same with three vertices, the outer ring's first vertex and tree.crossesTriangle (:191-198).
  3. tree is the outer ring only, so this first test never looks at hole edges. For the line (2,5)-(8,5): contains(2,5) is false (hole A), contains(8,5) is false (hole B), and the line does not reach the outer ring, so the method returns false at :173. The hole check that would accept it is never reached.
  4. relate() (:119-153), used for BKD inner nodes, checks the holes first (:127-135), so nothing is pruned early; the wrong answer comes from the leaf predicate, which is final.
  5. Multi-polygon and mixed geometry queries reach the same method through ComponentTree (ComponentTree.java:112-150, :153-195). Point documents use contains() and are correct.

The numbers

Query: the square [0,10] x [0,10] with holes A = [1,3] x [4,6] and B = [7,9] x [4,6]; contains(5,5) is true.

indexed documenttouches the land at (5,5)?INTERSECTS expected / actualDISJOINT expected / actual
line (2,5)-(8,5), ends in holes A and Byesmatch / missedno match / match
box [2,8] x [4.5,5.5], corners in the holesyesmatch / missedno match / match
point (5,5) (control)yesmatch / matchno match / no match

Counts: INTERSECTS 1 instead of 3, DISJOINT 2 instead of 0, for XYShape and LatLonShape (degrees), with assertions on and off. The same counts with the holed polygon inside a two-polygon query or a polygon-plus-point geometry query (run with LatLonShape; XYShape goes through the same ComponentTree code, by reading). WITHIN on the same data is correct (1, the point). Other shapes that trigger it: a line between the two arms of one U-shaped hole, through the land in the notch; a triangle with one vertex in each of three holes.

Exposure and likelihood334 words
  • Who is exposed. Applications that query LatLonShape or XYShape fields with a polygon that has holes, through newPolygonQuery or newGeometryQuery, with INTERSECTS or DISJOINT, on every release since 8.6.0. Default codec, IndexWriterConfig and searcher; nothing has to be enabled. Downstream products that build their shape queries on these classes with holed polygons would inherit it; no downstream product was tested.
  • What it takes. (1) A query polygon with two or more holes, or one non-convex hole: one convex hole cannot trigger it, because a segment or triangle with all its vertices in a convex hole lies inside it. (2) An indexed line or polygon whose every segment or triangle has all its vertices in holes, does not reach the outer ring, and crosses land between the holes. A single vertex on land, or one edge that crosses the outer ring, makes the document match. Examples: a straight two-point line between two lakes cut out of a region; a coarse rectangle whose corners fall in two lakes.
  • What the damage looks like. Silent: no exception and no log line; INTERSECTS drops the document and DISJOINT returns it. The error is geometric, not a precision effect: every vertex of the repro is at least 0.5 units (or degrees) from every ring edge, more than a million times the encoding quantum (4.19e-8 degrees of latitude, 8.38e-8 of longitude). By reading, index size does not matter: a BKD cell holding such a shape also covers part of a hole, so relate() reports CROSSES and the leaf predicate decides.
  • How likely. Rare: most indexed shapes have a vertex on land or cross the outer ring, and no report was found since 2020.
  • Not affected. Point documents; LatLonPoint and XYPoint polygon queries; query polygons without holes; WITHIN on this data. CONTAINS has its own, independent hole bug in the same class (Polygon2D.withinTriangle, since 8.4.0). Not run: shape doc values. Their only public query is a box query (newSlowDocValuesBoxQuery), although ShapeDocValues calls the same two predicates.

Minimal repro

The test shows that an XYShape INTERSECTS query with a polygon that has two holes misses a line whose end points lie in the holes but which crosses the polygon between them (Polygon2D.intersectsLine never tests the hole edges).

TestXYShapeLineBetweenHoles.java (JUnit test)54 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.XYLine;
import org.apache.lucene.geo.XYPolygon;
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;

public class TestXYShapeLineBetweenHoles extends LuceneTestCase {

  public void testLineFromHoleToHoleIntersects() throws Exception {
    XYPolygon holeA = new XYPolygon(new float[] {1, 3, 3, 1, 1}, new float[] {4, 4, 6, 6, 4});
    XYPolygon holeB = new XYPolygon(new float[] {7, 9, 9, 7, 7}, new float[] {4, 4, 6, 6, 4});
    XYPolygon square =
        new XYPolygon(new float[] {0, 0, 10, 10, 0}, new float[] {0, 10, 10, 0, 0}, holeA, holeB);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      for (Field f :
          XYShape.createIndexableFields("f", new XYLine(new float[] {2, 8}, new float[] {5, 5}))) {
        doc.add(f);
      }
      w.addDocument(doc);
      try (DirectoryReader r = DirectoryReader.open(w)) {
        // expected 1: the line crosses the polygon between the holes at (5,5); actual 0
        assertEquals(
            1,
            new IndexSearcher(r)
                .count(XYShape.newPolygonQuery("f", QueryRelation.INTERSECTS, square)));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-698/, 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: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.document.TestXYShapeLineBetweenHoles
Expected vs actual
Expected count 1 for the line (2,5)-(8,5) against the square [0,10]^2 minus [1,3]x[4,6] and [7,9]x[4,6]; actual 0 (expected:<1> but was:<0>), with -ea and -da.

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

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:<1> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.document.TestXYShapeLineBetweenHoles.testLineFromHoleToHoleIntersects(TestXYShapeLineBetweenHoles.java:47)

Why it looks unintended

  • newPolygonQuery promises "all indexed geo shapes that intersect a provided polygon"; its javadoc excludes only dateline crossing, and Polygon supports holes.
  • Polygon2D contradicts itself: contains(5,5) is true, yet the segment through (5,5) does not intersect.
  • 8.5.2 checked the holes first and answers 3 and 0; LUCENE-9273, a speed-up, never discussed holes.
The full argument277 words
  • Contract. Component2D.java:49 and :60: "return true if this component2D intersects the provided line" (triangle). LatLonShape.java:294-297: "create a query to find all indexed geo shapes that intersect a provided polygon (or array of polygons) note: does not support dateline crossing"; XYShape.java:227-230 says the same for cartesian shapes. Polygon.java:31-32: "Coordinates must be in clockwise order, except for holes. Holes must be in counter-clockwise order." Polygon.java:33-35 adds closed rings, no self-crossing and decimal degrees. The repro follows all of these rules: closed rings, clockwise shells, counter-clockwise holes strictly inside and disjoint.
  • Self-contradiction. The same object says (5,5) is inside (contains) and that the segment (2,5)-(8,5), which runs through (5,5), does not intersect.
  • History. Before 8.6.0, Polygon2D.relateTriangle began with "// check any holes": a shape crossing a hole edge counted as crossing the polygon (parent 37ad0e552dc, Polygon2D.java:145-152; the step dates from LUCENE-8435, 7.5.0). LUCENE-9273 (f914e08b364, 2020-04-20, "Speed up geometry queries by specialising Component2D spatial operations") replaced it with the specialised methods and dropped the step. CHANGES lists it under Optimizations; the issue, the PR and its single review never mention holes. Commit-level builds: both parents pass, both commits fail.
  • Tests. The random shape tests take their expected answer from the same predicates (BaseSpatialTestCase.java:747-797 calls intersectsLine / intersectsTriangle), and the random polygon generators build no query polygons with holes (TestPolygon2D.java:364: "currently we don't generate these"). TestLatLonShape's hole tests query indexed holed polygons with a box. No test asserts the current answer for a hole-to-hole shape, and the fix breaks no test.
  • Todo left open. LUCENE-8435 (gh#9481), which added these queries, lists "add testing for arbitrary polygons with holes".
The devil's-advocate check163 words

The strongest "intended" case: Component2D is @lucene.internal (Component2D.java:27) with one-line javadocs; Polygon is @lucene.experimental; LatLonShape warns that vertex values lose precision (LatLonShape.java:72); TestPolygon2D says relate(box) "can conservatively return CELL_CROSSES_QUERY" (TestPolygon2D.java:50); the random shape tests pass; and the trigger is rare.

Why it fails: the public newPolygonQuery states the contract and lists only the dateline as a limitation; @lucene.experimental is about API stability, not wrong answers; the precision warning covers about 1e-7 degrees, while every repro vertex is at least half a degree from any ring edge; "conservative" covers the inner-node relate(box) pre-filter, while the leaf predicate is final and DISJOINT is its negation; the passing tests use the code under test as their oracle and never build holed query polygons; rarity bounds the impact but does not make the answer intended; and 8.5.2 answered correctly. Verdict: a defect (adjudication: confirmed, high; the reproducer, the devil's advocate and the intended-lens review all rated the intended case weak).

Suggested fix

Recommended Test the vertices against the outer ring only

In Polygon2D.intersectsLine and intersectsTriangle, test the vertices against the outer ring only (bounding box and tree.contains) instead of the hole-aware contains(); the existing "inside one hole" check then does the hole work. The repro, the 50 geo and shape suites and all 8,894 lucene/core tests pass.

/** true if the point is inside or on the outer ring, ignoring holes */
private boolean shellContains(double x, double y) {
  return Component2D.containsPoint(x, y, minX, maxX, minY, maxY) && tree.contains(x, y);
}
...
// test against the outer ring only: a segment whose end points lie in holes can still cross
// the polygon between them; the holes are excluded by the containsLine check below
if (shellContains(aX, aY)
    || shellContains(bX, bY)
    || tree.crossesLine(minX, maxX, minY, maxY, aX, aY, bX, bY, true)) {
  return holes == null || holes.containsLine(minX, maxX, minY, maxY, aX, aY, bX, bY) == false;
}
// intersectsTriangle: the same three shellContains calls
All options and trade-offs4 options ยท 818 words

Both options give the same answers on every test and probe here; by reading, their whole pre-tests accept the same shapes whenever the holes lie inside the outer ring, so they differ only in cost. Near a single hole's edge nothing changes: of nine component-level cases, seven of them around hole A, only the two hole-to-hole cases flip, from false to true, with either option (executed). One case to decide first: holes may touch at one vertex (Tessellator.java:43-48). With holes [2,4] x [2,4] and [4,6] x [4,6], the line (3,3)-(5,5), which passes from one hole into the other through the shared corner only, is reported as intersecting by both options and by 8.5.2, but not by stock 8.6.0 and later, and contains(4,4) is false (executed, component level and end to end). Keep 8.5.2's answer, or add an exclusion that knows about the union of holes (not written), and add a test for this case either way. Only query answers change, so no reindex is needed. Add the component tests to TestPolygon2D, and consider letting the random shape tests build query polygons with holes: their current oracle calls the code under test. The CONTAINS sibling (Polygon2D.withinTriangle) needs its own change in the same file.

Test the vertices against the outer ring only (validated by the checks) (recommended)

A private shellContains(x, y) (bounding box and tree.contains) replaces contains() in the pre-tests of intersectsLine and intersectsTriangle; the existing holes.containsLine / containsTriangle check removes shapes that lie inside one hole. When the holes are strictly separated, a segment or triangle that lies wholly in holes lies in one hole; for holes that touch at a vertex, see above. Without holes, shellContains equals contains, so such polygons behave exactly as before. +13/-5 in Polygon2D.java.

/** true if the point is inside or on the outer ring, ignoring holes */
private boolean shellContains(double x, double y) {
  return Component2D.containsPoint(x, y, minX, maxX, minY, maxY) && tree.contains(x, y);
}
...
// test against the outer ring only: a segment whose end points lie in holes can still cross
// the polygon between them; the holes are excluded by the containsLine check below
if (shellContains(aX, aY)
    || shellContains(bX, bY)
    || tree.crossesLine(minX, maxX, minY, maxY, aX, aY, bX, bY, true)) {
  return holes == null || holes.containsLine(minX, maxX, minY, maxY, aX, aY, bX, bY) == false;
}
// intersectsTriangle: the same three shellContains calls

Trade-offs

  • The smallest change. By reading it is slightly cheaper than today for vertices on land (it skips the per-vertex hole lookup in contains()); no benchmark was run.
  • At a single hole the boundary behaviour is unchanged: a segment or triangle inside a hole that touches or runs along the hole's edge is still excluded, as on stock (executed). A segment through the shared corner of two touching holes now intersects (see above).

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: the new tests fail 4 of 5 on stock (assertions on and off) and pass with the fix; the 50 geo and shape suites, 424 tests, 0 failures with assertions on and off; checkGoogleJavaFormat passes; the whole :lucene:core:test, 750 suites and 8,894 tests, 0 failures, three times (assertions from the seed, on, off).
  • Standalone runner: 5 of 5 pass with -ea and -da; the reach probe prints 3 / 0 / 1 for all three query forms. Earlier, the independent reproducers' and reviewers' tests, TestPolygon2D (16 of 16) and TestXYPolygonShapeQueries (4 of 4) passed with the same fix.

Also let "touches a hole" through the pre-test (validated by the checks)

Keep the current tests and add || (holes != null && holes.intersectsLine(...)) (and holes.intersectsTriangle(...)) to the pre-test: a shape that touches a hole but is not inside one hole must cross land. Closest in spirit to the pre-8.6 hole-first step. +7/-2.

Trade-offs

One extra hole traversal for every segment or triangle that fails the first tests (shapes inside a hole, or outside the polygon within its bounding box), so a little more work than the recommended option; no benchmark was run. Holes that touch at a vertex behave as with the recommended option (executed).

What the checks validated

Same worktree: the 50 geo and shape suites, 424 tests, 0 failures with assertions on and off; checkGoogleJavaFormat passes; the whole :lucene:core:test with assertions on, 8,894 tests, 0 failures; standalone 5 of 5 pass with -ea and -da, and the probe prints 3 / 0 / 1.

Restore the old generic relate path for polygons with holes

Use the 8.5.2-style hole-first relation for holed query polygons.

Trade-offs

Correct (8.5.2 is), but gives back the LUCENE-9273 speed-up for every holed query. Not run.

Document it as a limitation

State that INTERSECTS and DISJOINT may be wrong for query polygons with holes.

Trade-offs

No javadoc limits holes today, 8.5.2 answered correctly, and the fix is small. Not recommended.

Downstream note

Products that pass polygons with holes to these queries, such as geo-shape search over regions with lakes or enclaves cut out, inherit the wrong matches until a fix is released and they upgrade; nothing wrong is stored, so a fix needs no reindex.

How it was found

A bounded-exhaustive comparison of Lucene's shape queries with an exact geometry library (JTS) over small grid shapes, about 1.94 billion checks. With the already-known geo bugs patched out, the only remaining INTERSECTS misses were shapes whose vertices sat in a hole of the query polygon; the cause was then read from Polygon2D.

The method in full176 words

Method: bounded-exhaustive differential testing against an exact reference (the hunt's "semi-formal" tier). A hunt unit, "shape relations against an exact reference", 2026-10-27.

  • Documents: lattice points, segments, triangles, quadrilaterals, 5-6-gons, polygons with a hole and multi-part documents; query shapes from the same lattices, on 6 x 6 grid-exact coordinates in 9 XY and LatLon families (including the dateline and the poles); all four relations, through the BKD shape index, shape doc values and the point fields.
  • Reference: JTS 1.20 DE-9IM relations on the decoded coordinates, with strict and permissive boundary bands; about 1.94 billion checks.
  • With the hunt's already-known geo fixes shadowed in, the only remaining INTERSECTS misses and DISJOINT false matches were documents whose vertices lie in or on a hole of the query polygon. The AI finder read the cause from Polygon2D, confirmed it at the Component2D level, and reduced it to a public-API JUnit.
  • A narrower hole-boundary form had been noted earlier, untriaged, by a hunt verifier of another geo bug.
  • Then independent AI tools reproduced, reviewed and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (best-effort search, 2026-10-27): two runs of the scripted prior-report search over GitHub, commits, downstream projects, mailing lists and JIRA; direct GitHub, JIRA, list and web searches; and all 409 open PRs. The nearest item is LUCENE-9273 (#10313), the change that introduced it.

Searches4 searches

No issue, PR, commit, JIRA entry, list thread or web page found reports this defect. Polygon2D.java on main, branch_10x and branch_10_6 is byte-identical to the tested copy, and no open PR touches it.

Nearest items, none of them this bug: #10313 (LUCENE-9273), the change that introduced it, which never mentions holes; #9782 (LUCENE-8736, open), WITHIN errors at shared boundaries on a convex polygon; #9481 (LUCENE-8435), which added polygon shape queries with the hole check and left the todo "add testing for arbitrary polygons with holes"; #10909 (LUCENE-9870), the same symptom in Circle2D, fixed; elastic/elasticsearch#9360 (2015), a false positive inside a query hole in an older prefix-tree filter, the opposite direction in a different engine.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-27two runs of a scripted prior-report search (51 and 56 queries, also covering downstream repositories, the lists and JIRA; 97 and 267 distinct hits, 0 rated high, the 36 medium hits read or triaged) plus 37 direct queries: polygon with holes, Polygon2D hole, intersectsLine, intersectsTriangle, between holes, two holes, LatLonShape / XYShape hole, LUCENE-9273no report; the items listed above
Open PRs2026-10-27the changed files of all 409 open apache/lucene PRsnone touches Polygon2D, EdgeTree, Component2D, ComponentTree, LatLonShapeQuery or XYShapeQuery
Downstream repositories2026-10-2733 queries: Elasticsearch (17), OpenSearch, its geospatial plugin, Solr, lucene-solr, and searches across all repositoriesonly elastic/elasticsearch#9360 (2015), the opposite direction in an older engine
JIRA, mailing lists, web2026-10-2710 JQL queries over LUCENE and SOLR; dev@, java-user@ and issues@ (7 queries each); 7 web queriesno report

History

Introduced by LUCENE-9273 (f914e08b364, 2020-04-20), first released in 8.6.0. A probe fails on all 49 releases from 8.6.0 to 10.5.2 and passes on all 17 from 7.5.0 to 8.5.2; commit-level builds confirm the change on both branches. Not fixed upstream.

Versions tested5 rows

Introduced by LUCENE-9273 "Speed up geometry queries by specialising Component2D spatial operations" (lucene-solr PR #1341, issue now apache/lucene#10313; author iverase, approved by nknize), merged 2020-04-20 as f914e08b364 on master and a001f135f22 on branch_8x (byte-identical Polygon2D.java). It replaced Polygon2D.relateTriangle, which checked the holes first (a step from LUCENE-8435, 7.5.0), with the specialised intersectsLine / intersectsTriangle. Later commits on Polygon2D leave the two methods unchanged. Only the two-hole geometry was run on releases before 8.6.0.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe brief's tests 4 of 5 fail (gradle and the standalone runner, assertions on and off); probe INTERSECTS=1, DISJOINT=2
10.0.0-10.5.2 (12), 9.0.0-9.12.3 (21), 8.6.0-8.11.4 (16) affectedthe release probe gives INTERSECTS=1, DISJOINT=2 for XYShape and LatLonShape on each; the brief's tests 4 of 5 fail on 10.5.2 and 9.12.3
7.5.0-8.5.2 (17) not affectedINTERSECTS=3, DISJOINT=0 (7.5.0-8.1.1 with a LatLonShape-only port, before XYShape existed; 7.5.0-8.4.1 with lucene-sandbox, where the shape classes lived)
f914e08b364 and a001f135f22 (LUCENE-9273) vs parents 37ad0e552dc, 62dd8ce8602 bisectboth commits fail (1 / 2), both parents pass (3 / 0); commit-level builds of lucene/core
main + fix (either option) not affectedall repros pass; the 50 geo and shape suites and the 8,894 lucene/core tests, 0 failed

Independent checks

An independent reproducer, a devil's advocate, an adjudication and reviews by another model family (which ran their own tests) all found it real; the adjudication confirmed it with high confidence. Registry re-runs, a final audit and this doc's gradle runs reproduced it with new seeds.

Checks and verdicts474 words

Every verification check (all but the security assessment) wrote or ran its own test against the stock jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-API JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3; passes with the fix and still fails with the five other known geo fixes applied (re-run by the fact check with this doc's 5 tests: 4 of 5 fail).
cross-model review, refute lens real (high)An independent reviewer from another model family (OpenAI GPT-5.6) wrote its own test with another shell and holes and controls: 4 of 5 fail with -ea and -da; with the fix 5 of 5 pass, and so do TestPolygon2D and TestXYPolygonShapeQueries.
Independent reproducer realOwn test from the claim only: 4 of 4 fail on main, 10.5.2 and 9.12.3; its 8.x port fails on 8.6.0 and passes on 8.5.2; passes with the fix.
Cross-model review, intended lens real (high)OpenAI GPT-5.6: the intended-behaviour argument is weak; its own test and main() fail on main and 8.6.0, pass on 8.5.2 and with the fix.
Devil's advocate realNo doc, test or discussion makes the miss intended; added the U-shaped-hole and three-hole forms (4 of 4 fail on main, 10.5.2, 9.12.3) and corrected a note: the single-hole form needs no other fix.
adjudication confirmed (high)Re-ran the independent reproduction's test: 4 of 4 fail with -ea and -da, 4 of 4 pass with the fix.
security assessment noneWrong match sets only; no guarantee for untrusted input is broken; file publicly.
registry re-run and audit keptFollow-up re-run, seed 5EEDF00D: 2 of 2 fail with -ea and -da, 2 of 2 pass with the fix. A fourth independent audit failed it again with -da (seed A0D17C4E) and on 10.5.2, passed it with the fix, and re-read the code and the LUCENE-9273 parent.
brief runs reproduced; both fixes validatedGradle in a scratch worktree: 4 of 5 fail on stock; both fixes pass the new tests, the geo and shape suites and the whole lucene/core suite.
fact check of this doc 3 wording errors, fixedRe-ran the gradle command, the standalone runner (main -ea and -da, 10.5.2, both fixes), the reach probe and the 8.6.0 / 8.5.2 ports, and re-read every cited line: the bug, the mechanism and the history hold. Three overstatements (test scope, fix B's full-suite modes, distance to the boundaries) were corrected.
cross-model fact check bug confirmed; patch caveat addedOpenAI GPT-5.6 (read-only): the core bug and the repro hold. It flagged that holes may touch at one vertex; a new probe showed both fixes then report a segment through the shared corner as intersecting (as 8.5.2 did), which is now an open question in the fixes section.

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

Finding record

LFV-698, snapshot of 2026-11-06

Registry title
Polygon2D.intersectsLine/intersectsTriangle never test the query polygon's hole edges, so LatLonShape/XYShape INTERSECTS misses, and DISJOINT matches, a line or polygon whose vertices all lie in holes but which crosses the polygon between them
Area
geo / shape queries (Polygon2D intersectsLine/intersectsTriangle; LatLonShape and XYShape INTERSECTS and DISJOINT with a query polygon that has holes)
Priority
Medium, rank 39: Core LatLonShape/XYShape INTERSECTS and DISJOINT have silently returned wrong hits for holed query polygons since 8.6.0 on the default and most used relations.
Impact class
silent wrong results
Impact note
Applications that run LatLonShape or XYShape polygon queries with INTERSECTS or DISJOINT (default codec, IndexWriterConfig and searcher, any index size, every release from 8.6.0 to 10.5.2 and, by reading, 10.6.0) silently miss (INTERSECTS) or wrongly return (DISJOINT) indexed lines and polygons whose vertices all lie in the query polygon's holes while they cross polygon area between them; the query polygon needs two or more holes or one non-convex hole, point documents are not affected, and the error is geometric (degrees or whole units away from any boundary), not a precision effect.
Summary
You index a line or polygon with LatLonShape or XYShape and search with a query polygon that has holes, for example a region with two lakes cut out; a line from one lake to the other crosses the land between them, so INTERSECTS should return it and DISJOINT should not. Polygon2D.intersectsLine/intersectsTriangle report an intersection only when a vertex of the indexed shape is inside the polygon (holes excluded) or one of its edges crosses the outer ring; they never test the hole edges, so when every vertex lies in a hole the shape counts as not intersecting. The query returns the wrong documents with no error (repro: the square [0,10]^2 with holes [1,3]x[4,6] and [7,9]x[4,6]; of the line (2,5)-(8,5), the box [2,8]x[4.5,5.5] and the point (5,5), INTERSECTS returns only the point and DISJOINT returns the line and the box).
Introduced
f914e08b3647ad91eb97ada6ebefe1e96ebd5a89 (LUCENE-9273 'Speed up geometry queries by specialising Component2D spatial operations', apache/lucene-solr#1341, issue now apache/lucene#10313; author iverase, approved by nknize; branch_8x backport a001f135f229a4c08afe4b6eb3dc11da5db91605 with a byte-identical Polygon2D.java, the commit shipped in 8.6.0) [exec: commit-level builds of lucene/core on both branches, the parents 37ad0e552dcb and 62dd8ce86022 pass and both commits fail]. It replaced Polygon2D.relateTriangle, which checked the holes first (a hole relation of CROSSES returned CROSSES, a step from LUCENE-8435 in 7.5.0), with intersectsLine/intersectsTriangle, whose pre-test uses the hole-aware contains() and outer-ring crossings only. Later commits on Polygon2D (LUCENE-9570 reformat, LUCENE-9595, LUCENE-10003, LUCENE-10514, GITHUB#12655) leave these two methods unchanged [read: git blame]. (2020-04-20)
First affected version
8.6.0 [exec] (8.5.2 is the last correct release [exec])
Affected versions
Every release from 8.6.0 to 10.5.2, 10.6.0 (by reading) and main a9779ef3c193; 7.5.0 to 8.5.2 are correct. [exec] a main() port of the repro geometry on all 66 lucene-core releases from 7.5.0 to 10.5.2 (SHA-1-checked release jars, -ea; 7.5.0-8.1.1 with a LatLonShape-only port because XYShape did not exist yet): 8.6.0-8.11.4 (16), 9.0.0-9.12.3 (21) and 10.0.0-10.5.2 (12) fail with XYShape and LatLonShape INTERSECTS=1 and DISJOINT=2 (expected 3 and 0), 7.5.0-8.5.2 (17) pass; at commit level the parents of f914e08b364 and a001f135f229 pass and both commits fail; the finder's JUnit fails 2 of 2 on 9.12.3, 10.5.2 and main (-ea and -da); the independent reproduction's test fails on main, 10.5.2 and 9.12.3 and its 8.x port fails on 8.6.0 and passes on 8.5.2; the intended-behaviour check's test (one non-convex hole, three holes) fails on main, 10.5.2 and 9.12.3. Only the two-hole geometry was executed on releases before 8.6.0. Not fixed upstream: Polygon2D.java on main, branch_10x and branch_10_6 is byte-identical to the tested copy (checked 2026-10-27). All runs with JDK 25 on aarch64.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-27 plus, read-only: two scripted prior-report searches with 51 and 56 queries over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive (97 and 267 distinct hits, 0 rated high, the 36 medium hits read or triaged); 37 direct apache/lucene GitHub queries, 33 downstream and cross-repository queries, 10 JQL queries, list-archive and web searches; the GraphQL list of all 409 open apache/lucene PRs with their changed files). Nearest items, not this bug: apache/lucene#10313 (LUCENE-9273, the change that introduced it, closed in 8.6.0; issue, PR and review never mention holes), #9782 (LUCENE-8736, open, WITHIN errors at shared boundaries on a convex polygon: different relation, trigger and code), #9481 (LUCENE-8435, added polygon shape queries with a hole check and a todo 'add testing for arbitrary polygons with holes'), #10909 (LUCENE-9870, the same method name and symptom in Circle2D, fixed), elastic/elasticsearch#9360 (2015, a false positive inside a query hole in an older prefix-tree filter: opposite direction, different engine). Not fixed upstream: Polygon2D.java on main, branch_10x and branch_10_6 is byte-identical to the tested copy, and none of the 409 open PRs touches Polygon2D, EdgeTree, Component2D, ComponentTree, LatLonShapeQuery or XYShapeQuery.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 and 9.12.3 release jars (the brief's tests); a version-independent probe on all 66 release jars from 7.5.0 to 10.5.2, and commit-level builds of LUCENE-9273 and its parents on both branches. Both fix options were validated in the scratch worktree: the new tests, the 50 geo and shape suites with assertions on and off, checkGoogleJavaFormat, and the whole lucene/core test suite (8,894 tests; option A three times, option B once). A probe with two holes that touch at a vertex ran on main, with each fix, and on 8.5.2, 8.6.0 and 10.5.2.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/geo/Polygon2D.java:111-117 (hole-aware contains), :119-153 (relate checks holes first), :155-174 (intersectsLine), :176-201 (intersectsTriangle)
  • lucene/core/src/java/org/apache/lucene/geo/Component2D.java:49 and :60 ('return true if this component2D intersects the provided line / triangle')
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:295 and XYShape.java:228 (newPolygonQuery: 'find all indexed geo shapes / cartesian shapes that intersect a provided polygon')
  • lucene/core/src/java/org/apache/lucene/document/LatLonShapeQuery.java:107, :117 and XYShapeQuery.java:89, :99 (INTERSECTS leaf predicates); SpatialQuery.java:129-130 (DISJOINT leaf = intersects().negate())
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/geo/Polygon2D.java:45, :60, :111-117, :119-153, :155-174, :176-201, :369-386
  • lucene/core/src/java/org/apache/lucene/geo/ComponentTree.java:112-150, :153-195
  • lucene/core/src/java/org/apache/lucene/document/LatLonShapeQuery.java:99, :107, :117; XYShapeQuery.java:81, :89, :99; SpatialQuery.java:129-130
Who is affected
  • lucene/core/src/java/org/apache/lucene/geo/Polygon.java:31-32 (holes counter-clockwise); document/LatLonShape.java:72 (precision warning)
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:72-74, :263; XYShape.java:214; ShapeDocValues.java:913, :932
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/geo/Component2D.java:27, :49, :60
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:72, :294-297; XYShape.java:227-230; geo/Polygon.java:31-35
  • lucene/core/src/test/org/apache/lucene/document/BaseSpatialTestCase.java:747-797; geo/TestPolygon2D.java:50, :364; document/TestLatLonShape.java:201-234, :257-291
Suggested fix
  • lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:43-48; geo/ComponentTree.java:205-243; geo/EdgeTree.java:65-67

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

Repro files

About the diagrams

A light summary card of the repro, made in parallel with this doc from executed runs on main a9779ef3c193 and the code.

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.