LatLonShape and XYShape store polygons as triangles. A CONTAINS query keeps a document if one of its triangles may contain the query and none proves it cannot. A triangle proves it cannot when a real boundary edge of the indexed shape runs through the query polygon. The query polygon may have holes.
withinTriangle its corners are in the holes and its edges never reach the outer ring, so every triangle answers CANDIDATE and CONTAINS returns doc 0; the fix tests the real edges against the holes. Select the still to open it at full size. Generated with AI, checked against the code.- Ask which indexed regions contain a query region with two lakes cut out: the square [0,10] x [0,10] minus the lakes [1,3] x [4,6] and [7,9] x [4,6]. The land between the lakes, for example the point (5,5), belongs to the query.
- Index three squares [-1,11] x [-1,11]: doc 0 with its own hole [2,8] x [4.5,5.5], which reaches from lake to lake and removes (5,5); doc 1 without a hole; doc 2 with a small hole inside a lake. You expect CONTAINS to return docs 1 and 2.
- You get docs 0, 1 and 2, with
XYShapeandLatLonShape, default settings and no error. On the same index a point query at (5,5) returns only docs 1 and 2, and CONTAINS of the hole-free square [4,6] x [4,6], a part of the query, also leaves doc 0 out. - Why:
Polygon2D.withinTrianglerejects a shape only if one of its corners is on the query's land or one of its boundary edges crosses the query's outer ring. It never looks at the lake shores, so doc 0's hole edge from (2,4.5) to (8,4.5), whose ends sit in the lakes, goes unnoticed. - One U-shaped lake is enough. One added check in
withinTriangle, for boundary edges that leave the lakes, returns docs 1 and 2, and the wholelucene/coretest suite (8,896 tests) passes with it.
Anyone running LatLonShape or XYShape CONTAINS queries with a query polygon that has holes, on 8.4.0 to 10.6.0 (10.6.0 by reading) or main, default settings. It needs two or more query holes (or one non-convex hole) and an indexed shape, in practice one with its own hole, whose boundary runs from hole to hole with no corner on land. Rare, but silent.
Mechanism, terms and numbers814 words
LatLonShape and XYShape CONTAINS queries ask Polygon2D.withinTriangle about each triangle of an indexed shape in the BKD cells that cross the query: NOTWITHIN, CANDIDATE or DISJOINT. A document matches if one triangle is CANDIDATE and none is NOTWITHIN. Each triangle knows which of its edges are real boundary edges of the indexed shape; a real edge that runs through the query polygon proves that the shape does not cover the query and must give NOTWITHIN. withinTriangle gives NOTWITHIN only when a vertex lies inside the query (the hole-aware contains()) or a real edge crosses the query's outer ring; it never tests the query's holes. So a real edge that starts in one query hole, crosses land and ends in another is missed. With the query [0,10] x [0,10] minus holes [1,3] x [4,6] and [7,9] x [4,6], the square [-1,11] x [-1,11] with its own hole [2,8] x [4.5,5.5] is returned by CONTAINS although it does not cover (5,5), and although CONTAINS of [4,6] x [4,6], a part of the query, correctly leaves it out. It needs a query polygon with two or more holes, or one non-convex hole. The method was written with CONTAINS in LUCENE-8620 (8.4.0, 2019); every release from 8.4.0 to 10.6.0 (10.6.0 by reading) and main is affected. One added check for real edges that leave the holes fixes the repro; lines between holes (withinLine has the same gap) and an edge between two corners of one non-convex hole need more.
- Query polygon with holes: an outer ring (clockwise) and holes (counter-clockwise) inside it, as the
Polygonjavadoc describes (XYPolygontakes holes the same way). Points in a hole are not part of the polygon; the rest of it is called land here. - Real edge: indexed polygons are tessellated into triangles; each triangle edge carries a flag that says whether it is an edge of the original shape (real) or one added by tessellation.
withinTriangle: the CONTAINS leaf predicate inPolygon2D(Polygon2D.java:277-342), called for each indexed triangle in a BKD leaf cell that crosses the query (LatLonShapeQuery.java:193,XYShapeQuery.java:175; a cell wholly inside the query excludes its documents without that call,SpatialQuery.java:601-604). It answers NOTWITHIN, CANDIDATE or DISJOINT; a document matches if some triangle is CANDIDATE and none is NOTWITHIN (SpatialQuery.java:619-628,:376).- CONTAINS per geometry: Lucene does not take the union of a document's geometries; a document matches only if one of its geometries contains the query and no other geometry of the document intersects it (the comments at
TestXYShape.java:266-270andTestLatLonShape.java:1002-1006). So any real edge of any of the document's shapes that runs through the query rules the document out.
withinTrianglefirst returns NOTWITHIN if a vertex is inside the query (:298-300);contains()is hole-aware (:111-117, the hole test at:114), so a vertex in a hole does not count.- It then tests each real edge against
tree, the query's outer ring only (:306-327), and returns NOTWITHIN if one crosses it; a tessellation edge that crosses it makes the triangle CANDIDATE. - It never consults
holes, unlikeintersectsLine,intersectsTriangle,containsLineandcontainsTrianglein the same class (:171,:197-198,:219,:243-244). A real edge whose ends lie in query holes and which crosses land between them is therefore never seen. - The documented rule is broken: NOTWITHIN means "The query shape intersects an edge that does belong to the original shape or any point of the triangle is inside the shape" (
Component2D.java:108-111), and the method's own comment says the same (:303).
Query: [0,10] x [0,10] minus holes A = [1,3] x [4,6] and B = [7,9] x [4,6]; (5,5) is land. Documents: squares [-1,11] x [-1,11].
| indexed document | covers (5,5)? | CONTAINS expected / actual |
|---|---|---|
| doc 0, with a hole [2,8] x [4.5,5.5] from hole A to hole B | no | no match / match |
| doc 1, no hole | yes | match / match |
| doc 2, with a hole [1.5,2.5] x [4.5,5.5] inside hole A (control) | yes | match / match |
Doc 0 is stored as 8 triangles. Its hole's bottom and top edges cross land from x = 3 to x = 7, but their ends lie in the holes and they never reach the outer ring, so all 8 triangles answer CANDIDATE and none NOTWITHIN (executed per-triangle trace). With the fix, the two triangles that hold those edges answer NOTWITHIN. Results: CONTAINS [0, 1, 2] instead of [1, 2] for XYShape and LatLonShape, assertions on and off; doc 0 is also wrongly returned for one U-shaped hole (arms [1,3] x [3,6] and [7,9] x [3,6]), for a two-polygon CONTAINS query and for a polygon-plus-point geometry query, which Lucene runs as a conjunction of one-geometry CONTAINS queries (LatLonShape.java:354-370, XYShape.java:263-270).
Exposure and likelihood491 words
- Who is exposed. Applications that query
LatLonShapeorXYShapefields with CONTAINS (newPolygonQuery,newGeometryQuery) and a query polygon that has holes, on every release since 8.4.0 (in 8.4.x the shape API lived in the sandbox module, in core since 8.5.0). Default codec,IndexWriterConfigand searcher; nothing has to be enabled. Downstream products that build CONTAINS shape queries on these classes with holed polygons would inherit it; no downstream product was tested. - What it takes. (1) CONTAINS, which is used less than INTERSECTS. (2) A query polygon with two or more holes, or one non-convex hole: an edge with both ends in one convex hole stays inside it. (3) An indexed shape with a real edge that starts and ends in query holes and crosses land, while none of its vertices is on land and none of its real edges crosses the query's outer ring. By the independent reproducer's reasoning (not exhaustively checked), for a single indexed polygon that means its own hole. These are necessary, not sufficient: the document also needs at least one CANDIDATE triangle (a tessellation edge that crosses the outer ring, or the query's first vertex inside a triangle) and no NOTWITHIN from any other triangle or BKD cell (
SpatialQuery.java:601-628). - What the damage looks like. Silent extra hits: no exception and no log line. The error is geometric, not a precision effect: no vertex of the query's rings is closer than 0.5 units (or degrees) to an edge of the rings of docs 0-2, and no vertex of theirs to an edge of the query's (computed; only the U-hole variant's subset control touches that hole). Only query answers are wrong and this bug stores nothing wrong, so its fix needs no reindex (by reading). The fix reads the stored edge flags, though: indexes hit by the separate LFV-204 (8.8.0 on), which can store wrong flags, need that bug's fix and a reindex of the affected shapes. By reading, index size does not matter: a BKD cell that holds one of the offending triangles also holds a point outside the query or in a hole, so it is never treated as inside the query and the leaf predicate decides.
- How likely. Rare: real boundaries are dense, so a vertex usually falls on land, which is caught; it takes a long, vertex-free edge spanning the land between two holes. No report was found since 2019.
- Not affected (executed). Query polygons without holes; an indexed hole wholly inside one query hole; an indexed hole with a corner on land. Indexed holes whose edges lie on a query hole's edge give the same answers with and without the fix; those answers are not always right (a separate boundary effect, see the repro). INTERSECTS and DISJOINT have their own, independent hole bug in
intersectsLine/intersectsTriangleof the same class (LFV-698). Not run: shape doc values, whose only working public queries are box queries (ShapeDocValuesField.newGeometryQueryalways throws).
The test shows that XYShape CONTAINS with a query polygon that has two holes matches an indexed polygon whose own hole runs across the query's land between them, because Polygon2D.withinTriangle never tests the query's hole edges.
/*
* 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.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 TestXYShapeContainsHoledQuery extends LuceneTestCase {
public void testContainsChecksQueryHoleEdges() throws Exception {
// the doc's hole removes (5,5), a point of the query: CONTAINS expected 0 hits, actual 1
XYPolygon query =
new XYPolygon(
new float[] {0, 0, 10, 10, 0},
new float[] {0, 10, 10, 0, 0},
new XYPolygon(new float[] {1, 3, 3, 1, 1}, new float[] {4, 4, 6, 6, 4}),
new XYPolygon(new float[] {7, 9, 9, 7, 7}, new float[] {4, 4, 6, 6, 4}));
XYPolygon doc =
new XYPolygon(
new float[] {-1, -1, 11, 11, -1},
new float[] {-1, 11, 11, -1, -1},
new XYPolygon(new float[] {2, 8, 8, 2, 2}, new float[] {4.5f, 4.5f, 5.5f, 5.5f, 4.5f}));
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document d = new Document();
for (Field f : XYShape.createIndexableFields("f", doc)) {
d.add(f);
}
w.addDocument(d);
}
try (DirectoryReader r = DirectoryReader.open(dir)) {
IndexSearcher s = new IndexSearcher(r);
assertEquals(
"doc does not cover the query point (5,5)",
0,
s.count(XYShape.newPointQuery("f", QueryRelation.INTERSECTS, new float[] {5, 5})));
assertEquals(
"doc must not CONTAIN the query",
0,
s.count(XYShape.newPolygonQuery("f", QueryRelation.CONTAINS, query)));
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-711/, 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.TestXYShapeContainsHoledQuery
- Expected vs actual
- Expected: 0 CONTAINS hits (the doc's hole removes (5,5), and the point query confirms the doc does not cover it). Actual: 1 (
doc must not CONTAIN the query expected:<0> but was:<1>, with -ea and -da).
More files, environment, full output39 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: doc must not CONTAIN the query expected:<0> but was:<1>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.document.TestXYShapeContainsHoledQuery.testContainsChecksQueryHoleEdges(TestXYShapeContainsHoledQuery.java:58)- Lucene contradicts itself: CONTAINS of the holed query returns doc 0; CONTAINS of [4,6] x [4,6], a part of it, does not.
- The documented rule says NOTWITHIN when "the query shape intersects an edge that does belong to the original shape"; here it does.
- Nothing limits CONTAINS to hole-free queries; the random tests use
withinTriangleitself as oracle.
The full argument296 words
- Self-contradiction. On the same index, CONTAINS of the holed query returns doc 0 and CONTAINS of [4,6] x [4,6], a subset of it, does not; any shape that contains a region contains each part of it. The point query at (5,5) also leaves doc 0 out. All 16 ring-orientation combinations give the same wrong answer, so it is not a winding misuse.
- Contract.
Component2D.WithinRelation.NOTWITHIN(Component2D.java:108-111,@lucene.internal): "The query shape intersects an edge that does belong to the original shape or any point of the triangle is inside the shape". The method's comment (Polygon2D.java:303): "if any of the edges intersects an the edge belongs to the shape then it cannot be within". The publicShapeField.QueryRelation.CONTAINSsays only "used for CONTAINS Queries" (ShapeField.java:126-127); thenewPolygonQueryjavadoc says "intersect" for every relation and names the dateline as its only limitation (LatLonShape.java:294-297);XYShape.java:227-230has the same "intersect" prose and states no limitation at all.Polygon.java:31-36: clockwise shells, counter-clockwise holes, closed rings, no self-crossing; the repro follows all of them, and its shapes passcreateIndexableFields(..., true). - Tests. The random CONTAINS validators compute the expected answer with the same method (
TestLatLonPolygonShapeQueries.java:46-52returnstestWithinQuery(...) == CANDIDATE;BaseSpatialTestCase.java:800-845callsquery.withinTriangle), and the random polygon generators build no query holes (TestPolygon2D.java:364: "currently we don't generate these"). No test asserts the current answer, and the fix breaks no test. - History. LUCENE-8620 (2019-12-11) wrote the method together with CONTAINS and tested real edges against the outer ring only from its first version; the issue (#9666) and PRs #546, #608 and #872 never discuss query holes. Later edits (LUCENE-9152, LUCENE-9273, LUCENE-9570) never touched hole handling.
- Precedent. Comparable CONTAINS false positives were fixed as bugs: LUCENE-10514 (#11550 / #809) and #12020 / #12022.
The devil's-advocate check164 words
The strongest "intended" case: the public CONTAINS javadoc is one line and newPolygonQuery says "intersect"; Component2D and its WithinRelation contract are @lucene.internal; Polygon is @lucene.experimental and points to spatial-extras for more advanced operations; CONTAINS is a per-triangle approximation, and maintainers accepted rounding at shared boundaries (LUCENE-8736, #9782, open); the random tests pass; the trigger is rare.
Why it fails: the subset contradiction holds under any meaning of "contains"; @lucene.experimental is about API stability, not wrong answers; the repro is half a unit from every boundary, far from the encoding quantum of about 1e-7 degrees; the method's own comment and the internal contract state the rule that is broken; the passing tests use the method as their oracle and never build holed query polygons; nothing documents a limit on holed query polygons; rarity bounds the impact but does not make the answer intended. Verdict: a defect (adjudication: confirmed, high; the reproducer, the devil's advocate and the intended-lens review all rated the intended case weak).
Recommended Test the real edges against the holes right after the vertex test
In Polygon2D.withinTriangle, right after the vertex test, return NOTWITHIN when a real edge touches the query's holes but does not lie inside a single hole (+14 lines, 12 non-blank). The repro and all 8,896 lucene/core tests pass, assertions on and off. Lines between holes (withinLine) and an edge between two corners of one non-convex hole stay uncovered.
All options and trade-offs4 options ยท 1,066 words
Both options add the same test for each real edge of the triangle, "does it touch a hole without lying inside a single hole?" (holes.intersectsLine && !holes.containsLine); after the vertex test, such an edge crosses land (or passes from one hole into another through a shared corner). They differ only in where the test sits, and since NOTWITHIN wins in either order they return the same relation for every triangle (executed: identical outputs on every test and probe). Boundary behaviour (executed, 15 component-level cases): only the three cases where a real edge leaves the holes change, from CANDIDATE to NOTWITHIN: hole A to hole B across land, and, for two holes that touch at the corner (4,4), an edge from one into the other across land and one through the shared corner only. By reading, NOTWITHIN is right for the last one too (near the corner both sides of the edge are land, and the shape lies on one side only), but only the component probe was run; whether holes that touch at a corner are valid input, and what the public query returns, were not checked, so it is behaviour for maintainers to review. Real edges inside a hole, ending on its edge or corner, or running along its edge keep their answer, and so do indexed holes equal to or sharing an edge with a query hole, end to end. Only query code changes, so this bug's fix needs no reindex; the check reads the stored edge flags, so it is only as right as those flags (LFV-204). Limits of both options (executed, probe-limits): (1) Polygon2D.withinLine (Polygon2D.java:255-274) has the same outer-ring-only test and neither option touches it: a document holding the square [-1,11] x [-1,11] plus the line (2,5)-(8,5) from hole A to hole B is still returned by CONTAINS, for XYShape and LatLonShape, although under Lucene's per-geometry CONTAINS it must not match (the same square with a line that ends on land is correctly left out); a matching check in withinLine is the obvious extension, not written or run. (2) The helper asks holes.containsLine, which, by reading, accepts a segment whose ends are on the hole's boundary when no hole edge crosses it strictly (Polygon2D.java:204-221, GeoUtils.java:217-227; the shortcut behind LFV-206); so a real edge (3,6)-(7,6) between two corners of one U-shaped hole, which crosses land, stays CANDIDATE, and XYShape still returns a square whose own hole has this edge (LatLonShape answered correctly here already on stock). It needs coordinates exactly equal to the hole's corners; with two holes the same chord is caught. (3) The missing boundary-sharing documents of the reach probe are unchanged. Add the component tests to TestPolygon2D, and consider letting the random shape tests build query polygons with holes: their CONTAINS oracle calls the method under test. The INTERSECTS sibling (LFV-698) needs its own change in the same file.
Test the real edges against the holes right after the vertex test (validated by the checks) (recommended)
After the vertex test, return NOTWITHIN when a real edge touches the query's holes but is not inside a single hole; a private helper leavesHoles does the two hole lookups. +14 lines in Polygon2D.java (12 non-blank); the finder's fix, and an independent reproducer wrote an equivalent one. Query polygons without holes are untouched (holes == null).
if (contains(aX, aY) || contains(bX, bY) || contains(cX, cY)) {
return WithinRelation.NOTWITHIN;
}
// an edge of the original shape that touches a hole but is not inside a single hole runs
// through the polygon (between holes, or out of a hole), so the polygon cannot be within it
if (holes != null
&& ((ab && leavesHoles(aX, aY, bX, bY))
|| (bc && leavesHoles(bX, bY, cX, cY))
|| (ca && leavesHoles(cX, cY, aX, aY)))) {
return WithinRelation.NOTWITHIN;
}
...
/** true if the segment touches a hole and is not contained in a single hole */
private boolean leavesHoles(double aX, double aY, double bX, double bY) {
return holes.intersectsLine(aX, aY, bX, bY) && holes.containsLine(aX, aY, bX, bY) == false;
}Trade-offs
- Smallest reviewable change, next to the vertex test it complements.
- Like option B, it leaves
withinLineand a chord between two corners of one non-convex hole uncovered (see above). - Cost: up to one
intersectsLineand onecontainsLineon the hole tree per real edge of each triangle that passes the vertex test, only for holed query polygons; no benchmark was run.
What the checks validated
- Scratch worktree of
a9779ef3c193, gradle, JDK 25: the new tests fail 6 of 7 on stock (assertions on and off) and pass 7 of 7 with the fix; the 50 geo and shape suites, 426 tests, 0 failures with assertions on and off;checkGoogleJavaFormatpasses; the whole:lucene:core:test, 750 suites and 8,896 tests, 0 failures, with assertions on and off. - Standalone runner: 7 of 7 pass with
-eaand-da; the reach probe no longer returns doc 0 and is otherwise unchanged; 12 of the 15 boundary cases keep their answer. Earlier, the independent reproducers' and reviewers' tests and 57 upstream shape andPolygon2Dtests passed with the same fix.
Put the same block below the three tree.crossesLine checks, before the CANDIDATE return. +14 lines (12 non-blank). Same relation for every triangle.
Trade-offs
Skips the hole lookups for triangles whose real edge already crosses the outer ring, so slightly cheaper than the recommended option in that case and the same otherwise; not benchmarked.
What the checks validated
Same worktree: the new tests, the 50 geo and shape suites (426 tests) with assertions on and off, checkGoogleJavaFormat, and the whole :lucene:core:test (8,896 tests) with assertions on and off, 0 failures; standalone 7 of 7 pass with -ea and -da, and every probe prints exactly what the recommended option prints.
Build the crossing test from the outer ring and the holes together.
Trade-offs
Changes the data structure of every holed Polygon2D for one method. Not written or run, so this is a judgement, not a measurement; how it would treat the boundary cases is not known.
State that CONTAINS may be wrong for query polygons with holes.
Trade-offs
No javadoc limits holed query polygons today and the fix is small. Not recommended.
Products that answer "which stored regions fully cover this area?" with holed areas, such as service zones with lakes or enclaves cut out, can return a region that leaves part of the area uncovered until a fix is released; this bug stores nothing wrong, so its fix needs no reindex.
Code reading. While triaging a sibling bug (INTERSECTS misses shapes between query holes), the AI finder checked the other Polygon2D predicates for the same "outer ring only" pattern and found it in withinTriangle; a component probe and a public-API test confirmed it. The bounded-exhaustive comparison with an exact geometry library did not hit it.
The method in full122 words
Method: code reading (non-formal), a hunt unit, "shape relations against an exact reference", 2026-10-27.
- The unit's bounded-exhaustive comparison of
LatLonShape/XYShaperelation queries with JTS DE-9IM relations had found the sibling INTERSECTS bug (intersectsLine/intersectsTriangleignore hole edges). - While triaging it, the finder read the other
Polygon2Dpredicates for the same pattern.withinTriangletests real edges against the outer ring only; an executed component-level probe and a public-API JUnit confirmed the CONTAINS false positive. - The lattice comparison itself did not hit it: its query polygons have at most one hole, and its holed documents never had a hole edge crossing the land between two query holes.
- Then independent AI tools reproduced, reviewed and judged it (see the checks).
No prior report found (best-effort search, 2026-10-27): four runs of the scripted prior-report search (197 queries over GitHub, commits, downstream projects, mailing lists and JIRA), about 60 manual GitHub searches, 12 web searches and all 409 open PRs. The nearest item is LUCENE-8620 (#9666), which added the method.
Searches4 searches
No issue, PR, commit, JIRA entry, list thread or web page found reports this defect. Upstream Polygon2D.java last changed in de3b294be44 (formatting only; authored 2023-10-10, committed 2023-10-11), and no open PR touches it; the file is byte-identical to a9779ef3c193 on upstream main 20ced71c1e6 (2026-10-27) and on branch_10_6, branch_10x and branch_10_5 (checked 2026-10-28).
Nearest items, none of them this bug: #9666 (LUCENE-8620), which added CONTAINS and the method and never discusses query holes; #11550 / #809 (LUCENE-10514), within* consistency, which changed withinLine; #12020 / #12022, a CONTAINS false positive from the edge flag of very flat triangles, no holes; #9782 (LUCENE-8736, open), WITHIN at shared boundaries; #9680 (LUCENE-8634, open), identical shapes that may not match; #10313 (LUCENE-9273), which edited the method but kept the outer-ring-only test.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr) | 2026-10-27 | four runs of a scripted prior-report search (197 queries in total, also covering downstream repositories, the lists and JIRA) plus about 60 manual searches: Polygon2D withinTriangle, CONTAINS polygon with holes, LatLonShape / XYShape CONTAINS hole, WithinRelation NOTWITHIN, LUCENE-8620 | no report; the items listed above |
| Open PRs | 2026-10-27 | the changed files of all 409 open apache/lucene PRs, and the upstream commit log of Polygon2D.java | none touches Polygon2D.java |
| Downstream repositories | 2026-10-27 | Elasticsearch, OpenSearch, its geospatial plugin and Solr (tool queries and manual searches) | no report |
| JIRA, mailing lists, web | 2026-10-27 | 16 JIRA-archive and 80 mailing-list queries over dev@, java-user@, issues@ and commits@ (scripted prior-report search); 12 web searches; about 35 threads read in full | no report |
Introduced with CONTAINS itself by LUCENE-8620 (a06a2ea3da2 / 2ef2ddd77c5, 2019-12-11), first released in 8.4.0; not a regression. A probe fails on all 54 releases from 8.4.0 to 10.5.2 and on main; 8.3.1 has no CONTAINS. Not fixed upstream.
Versions tested4 rows
Introduced by LUCENE-8620 "Add CONTAINS support for LatLonShape and XYShape" (lucene-solr PR #872, superseding #608; issue now apache/lucene#9666; author iverase), merged 2019-12-11 as a06a2ea3da2 on branch_8x (shipped in 8.4.0) and 2ef2ddd77c5 on master (identical Polygon2D.java). From its first version withinTriangle tests real edges against the outer ring only. Later edits (LUCENE-9152 in 8.5.0, LUCENE-9273 in 8.6.0, LUCENE-9570) never touched hole handling; LUCENE-10514 (9.2.0) changed withinLine. In 8.4.x the shape API lived in the sandbox module; LUCENE-8621 moved it to core in 8.5.0.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the brief's tests 6 of 7 fail (gradle and the standalone runner, assertions on and off); CONTAINS [0, 1, 2] |
| 10.0.0-10.5.2 (12), 9.0.0-9.12.3 (21), 8.4.0-8.11.4 (21) | affected | the history probe gives CONTAINS [0, 1] (expected [1]) for XYShape and LatLonShape on each, with the sanity check and the subset control correct; the brief's tests 6 of 7 fail on 10.5.2 and 9.12.3 (assertions on; 9.12.3 also with assertions off in the fact check) |
| 8.3.1 | no CONTAINS | the API does not exist: QueryRelation has no CONTAINS, the probes fail to compile |
| main + fix (either option) | not affected | all repros pass; the 50 geo and shape suites and the 8,896 lucene/core tests, 0 failed, each option with assertions on and off |
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 verdicts513 words
Every verification check (all but the security assessment) wrote or ran its own test against the stock jars.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-API JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3, and its main() port on 8.5.2 and 8.4.0; passes with the fix; still fails with five other known geo fixes and the sibling's fix applied. |
| cross-model review, refute lens | real (high) | An independent reviewer from another model family (OpenAI GPT-5.6) wrote its own test with other geometry and point-query controls: CONTAINS [0,1,2] instead of [1,2] for both shape types; with the fix it passes, as do TestPolygon2D and the TestXYShape CONTAINS tests. |
| Independent reproducer | real | Own test from the claim only (a thin diagonal document hole between two query holes): 3 of 3 fail on main (30 of 30 with 10 iterations), 10.5.2 and 9.12.3; found that one U-shaped query hole is enough; its own equivalent fix passes. |
| Cross-model review, intended lens | real (high) | OpenAI GPT-5.6: the intended-behaviour argument is weak; its own test fails on main with -ea and -da and passes with the fix. |
| Devil's advocate | real (high) | No doc, test or discussion limits CONTAINS to hole-free query polygons; showed the subset contradiction and that all 16 ring orientations fail; its probe fails 4 of 5 on main and 10.5.2 and passes with the fix. |
| adjudication | confirmed (high) | Re-ran the independent reproduction's test (3 of 3 fail with -ea and -da, fresh seeds; 3 of 3 pass with the fix) and the finder's; 57 upstream shape and Polygon2D tests pass with the fix (their CONTAINS oracle is the same method, so no-regression only). |
| security assessment | none | Extra CONTAINS hits only; no guarantee for untrusted input is broken; file publicly. |
| registry re-run and audit | kept | Follow-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 the finder's and the independent reproduction's tests again with new seeds and passed the finder's with the fix. |
| brief runs | reproduced; both fixes validated | Gradle in a scratch worktree: 6 of 7 new tests fail on stock with assertions on and off; both fixes pass the new tests, the geo and shape suites and the whole lucene/core suite with assertions on and off; reach, boundary, per-triangle and fix-limits probes on stock and both fixes. |
| fact checks of this doc | reproduced; corrections applied | An independent fact check re-ran the doc's gradle command (6 of 7 fail, assertions on and off), both fixes (7 of 7 pass; option A also the 50 geo and shape suites) and the standalone runner on main, 10.5.2 and 9.12.3, and the probes. A second one, by another model family (OpenAI GPT-5.6, read-only), re-read the sources and logs. Both found the bug as described; their counting and wording corrections and the missing caveats (lines, one non-convex hole, stored edge flags, boundary false negatives) are applied, the caveats with an executed probe. |
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.
- PNG 422 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,381 words
- Registry title
- LatLonShape/XYShape CONTAINS with a query polygon that has holes returns indexed shapes that do not contain it, because Polygon2D.withinTriangle never tests the query's hole edges
- Area
- geo / shape queries (Polygon2D.withinTriangle; LatLonShape and XYShape CONTAINS with a query polygon that has holes)
- Priority
- Low, rank 406: Core LatLonShape/XYShape CONTAINS wrongly matches for holed query polygons only with two holes or a non-convex hole plus a vertex-free indexed edge spanning them, on the less used CONTAINS relation, which is far rarer than its INTERSECTS sibling LFV-698 and about as rare as LFV-206.
- Impact class
- silent wrong results
- Impact note
- Applications that run LatLonShape or XYShape CONTAINS queries (newPolygonQuery or newGeometryQuery; default codec, IndexWriterConfig and searcher, any index size, every release from 8.4.0 to 10.5.2 and, by reading, 10.6.0) with a query polygon that has holes silently get extra hits: an indexed shape whose own boundary (in practice an edge of the indexed shape's own hole) runs through the query's solid area with both ends inside query holes, without crossing the query's outer ring, is reported as containing the query; it needs two query holes or one non-convex hole, hole-less queries and indexed holes that lie wholly inside one query hole are handled correctly, the error is geometric (half a unit or more from any boundary in the repros), not a precision effect, and only query answers are wrong (the index is unchanged).
- Summary
- You index polygons with LatLonShape or XYShape and ask which of them CONTAIN a query polygon that has holes, for example a region with two lakes cut out; an indexed polygon whose own hole runs across the land between the two lakes does not cover that land, so it must not match. Polygon2D.withinTriangle rejects an indexed triangle only when one of its vertices is inside the query or one of its real (original-shape) edges crosses the query's outer ring; it never tests the query's hole edges, so an edge that starts and ends inside query holes and crosses the land between them goes unnoticed and the shape is returned as CONTAINS, with no error. Repro: query = the square [0,10]^2 with holes [1,3]x[4,6] and [7,9]x[4,6]; doc 0 = the square [-1,11]^2 with a hole [2,8]x[4.5,5.5] (it does not cover the query point (5,5)), doc 1 = the same square without a hole; CONTAINS returns [0, 1] instead of [1] for XYShape and LatLonShape, while a point query at (5,5) correctly returns only doc 1.
- Introduced
- a06a2ea3da28457429b297ca178eaecb1c6c4a58 (branch_8x, shipped in 8.4.0) and 2ef2ddd77c59bceb4704e1e091653a8ae42e4139 (the same change on the then-master branch, identical Polygon2D.java, first released in 9.0.0): LUCENE-8620 'Add CONTAINS support for LatLonShape and XYShape', apache/lucene-solr#872 (supersedes #608; issue now apache/lucene#9666), author iverase [read: git log -L and git show of Polygon2D.withinTriangle in the history clone]. The method was written together with the CONTAINS relation and has tested original-shape edges only against the outer-ring EdgeTree ('tree') from its first version; it never consults the 'holes' component, so this is not a regression and there is no earlier correct state to bisect against (8.3.1 has no CONTAINS relation [exec]). Later edits never touched hole handling: LUCENE-9152 (29542c7f59e, 8.5.0: includeBoundary=true), LUCENE-9273 (f914e08b364 / a001f135f22, 8.6.0: parameter renames, degenerate triangles moved to withinLine/withinPoint), LUCENE-9570 (2d6ad2fee6d, reformatting); LUCENE-10514 (9.2.0) changed withinLine, not withinTriangle [read: normalised body hashes of withinTriangle at every release tag]. (2019-12-11)
- First affected version
- 8.4.0 [exec] (in 8.4.x the LatLonShape/XYShape/QueryRelation API was in the lucene-sandbox module while Polygon2D was in lucene-core; LUCENE-8621 moved the shape API to core in 8.5.0); 8.3.1 cannot express the query (no CONTAINS relation, no Component2D) [exec]
- Affected versions
- Every release from 8.4.0 to 10.5.2 (54 releases) and main a9779ef3c193; no earlier release has CONTAINS. [exec] the follow-up's history probe (public API: an INTERSECTS sanity check at (5,5), a CONTAINS control with the hole-less square [4,6]^2, which is a subset of the query, and the two-hole CONTAINS query) and the finder's main() port on all 54 SHA-1-checked release jars from 8.4.0 to 10.5.2 (-ea) and on main (-ea and -da): every run returns CONTAINS=[0, 1] (expected [1]) for XYShape and LatLonShape while the sanity check and the control are correct; 8.3.1 fails to compile on QueryRelation.CONTAINS; with the fix shadowed main returns [1]. The finder's JUnit fails 2 of 2 on main (-ea and -da), 10.5.2, 10.0.0, 9.12.3 and 9.1.0; the independent reproduction's test fails 3 of 3 on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's probe fails 4 of 5 on main (-ea and -da) and 10.5.2. Not fixed upstream: Polygon2D.java last changed on upstream main on 2023-10-10 (de3b294be44, formatting only) and none of the 409 open apache/lucene PRs touches it (checked 2026-10-27). Release branches after 10.5.2 were not checked in the hunt; the bug doc's check of 2026-10-28 finds Polygon2D.java byte-identical on branch_10_6, from which 10.6.0 was released on 2026-10-29, so 10.6.0 is affected too [read]. 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
- non-formal: code reading
- Upstream and prior reports
- none found (the prior-report record, verdict new, searched 2026-10-27 plus, read-only): four scripted prior-report searches with 197 queries in total over GitHub issues, PRs and commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; about 60 manual GitHub searches across apache/lucene, apache/lucene-solr, elastic/elasticsearch, opensearch-project/OpenSearch, opensearch-project/geospatial and apache/solr; 12 web searches; the changed-file lists of all 409 open apache/lucene PRs; about 35 threads read in full. Nearest items, not this bug: apache/lucene#9666 (LUCENE-8620, added CONTAINS and withinTriangle; the issue and lucene-solr PRs #546/#608/#872 never discuss query holes), #11550 / #809 (LUCENE-10514, within* inner-node/leaf consistency; it changed withinLine), #12020 / #12022 (a CONTAINS false positive from the edge flag of very flat triangles, no holes), #9782 (LUCENE-8736, open, WITHIN at shared boundaries), #9680 (LUCENE-8634, open, boundary precision), #10313 (LUCENE-9273, edited withinTriangle but kept the edge test). Not fixed upstream: Polygon2D.java is unchanged on upstream main (head a95c754ee00 at search time) since 2023-10-10 and no open PR touches it.
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27 and 2026-10-28) 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, assertions on; 9.12.3 also off in the fact check); a version-independent probe on all 54 release jars from 8.4.0 to 10.5.2 (8.3.1 has no CONTAINS). 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 wholelucene/coretest suite (8,896 tests) with assertions on and off for each option. Reach, boundary, per-triangle and fix-limits probes ran on stock and with each fix.
- Finding record
lucene/core/src/java/org/apache/lucene/geo/Polygon2D.java:277-342 (withinTriangle: hole-aware vertex test at :298-300, outer-ring-only tree.crossesLine edge tests at :306-327; the hunt's record and the verifiers cite :277-344)lucene/core/src/java/org/apache/lucene/geo/Component2D.java:101-115 (WithinRelation; NOTWITHIN javadoc at :108-111)lucene/core/src/java/org/apache/lucene/document/SpatialQuery.java:591-650 (getContainsDenseVisitor: a document matches when some triangle is CANDIDATE and none is NOTWITHIN)lucene/core/src/java/org/apache/lucene/document/LatLonShapeQuery.java:193 and XYShapeQuery.java:175 (CONTAINS leaf calls component2D.withinTriangle); LatLonShape.java:298 and XYShape.java:231 (newPolygonQuery); ShapeField.java:126-127 (QueryRelation.CONTAINS)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/geo/Polygon2D.java:111-117, :171, :197-198, :219, :243-244, :277-342, :369-386lucene/core/src/java/org/apache/lucene/geo/Component2D.java:101-112lucene/core/src/java/org/apache/lucene/document/SpatialQuery.java:368-381, :591-650 (:601-604 cells inside the query); LatLonShapeQuery.java:193; XYShapeQuery.java:175; LatLonShape.java:336-337, :354-370; XYShape.java:263-270lucene/core/src/test/org/apache/lucene/document/TestXYShape.java:266-270; TestLatLonShape.java:1002-1006 (CONTAINS per geometry)
- Who is affected
lucene/core/src/java/org/apache/lucene/document/SpatialQuery.java:168-175 (segment-level shortcut), :591-650; geo/Polygon2D.java:120-153 (relate checks holes first)lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:263-284; XYShape.java:214-217 (doc-values queries take a box only); ShapeDocValuesField.java:89-95 (newGeometryQuery throws); geo/Polygon.java:31-36
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/geo/Component2D.java:27, :101-112; geo/Polygon2D.java:303; document/ShapeField.java:126-127; document/LatLonShape.java:294-297; document/XYShape.java:227-230; geo/Polygon.java:31-41lucene/core/src/test/org/apache/lucene/document/TestLatLonPolygonShapeQueries.java:46-52; document/BaseSpatialTestCase.java:800-845; geo/TestPolygon2D.java:364
- Suggested fix
lucene/core/src/java/org/apache/lucene/geo/Polygon2D.java:204-221, :255-274; geo/GeoUtils.java:217-227; lucene/core/src/test/org/apache/lucene/document/TestXYShape.java:266-270
- TestXYShapeContainsHoledQuery.java (JUnit test):
repro/LFV-711/src/org/apache/lucene/document/TestXYShapeContainsHoledQuery.java(65 lines)
A light step-by-step card of the finder's two-document XYShape example (docs 0 and 1), made in parallel with this doc from an executed probe on main a9779ef3c193 and the code; the PNG is the summary card.
assets/LFV-711/bug.png(422 KB)
![Summary card of the XYShape example. The query square [0,10] x [0,10] with holes A = [1,3] x [4,6] and B = [7,9] x [4,6]; doc 0, the square [-1,11] x [-1,11], whose own hole [2,8] x [4.5,5.5] reaches from hole A to hole B across the land; doc 1, the same square without a hole. Inside Polygon2D.withinTriangle for doc 0's triangles: the corners are outside the query or in its holes, the hole edges cross only the query's hole edges, which are never tested, so every triangle answers CANDIDATE and CONTAINS returns docs 0 and 1 instead of doc 1 alone, while CONTAINS of [4,6] x [4,6] correctly leaves doc 0 out. The fix returns NOTWITHIN for a real edge that touches the holes but does not stay inside one. Since 8.4.0 (LUCENE-8620).](../assets/LFV-711/bug.png)