All findings · Lucene finding · LFV-206 · polygon2d-contains-boundary-vertices-misses-exit

WITHIN shape queries with a non-convex polygon match lines and polygons that leave it between boundary vertices

For a 4x4 square notched down to (2,1), a WITHIN query returns the road (0,4)-(4,4) across the notch and the notch triangle itself: 2 hits instead of 0. Lucene treats "every corner on or inside the polygon, no edge crossed" as contained, which holds only for convex polygons.

Summary

Priority
Low, rank 405Polygon2D's convexity shortcut makes WITHIN queries with a non-convex polygon return shapes whose vertices lie on its boundary but which leave it, again exact shared coordinates (common for planar XYShape, on-grid only for LatLonShape); #9782 is related but about a different defect (false negatives on a convex query).
Impact
silent wrong results
Since
8.1.0 (2019) for polygons, 8.5.0 for lines; a narrower form from 7.7.0introduced 2019-01-31 · about 7 years
Affected
7.7.0 through 10.6.0 and main
Status
documented
Found by
a bounded-exhaustive consistency check of the geometry predicates against an exact integer oracle

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

For each indexed segment or triangle, WITHIN asks the polygon whether it contains it. Polygon2D says yes when every corner is inside or on the boundary and no polygon edge crosses the shape strictly. On a dented polygon, corners on the boundary plus edges that only touch are not enough: the shape can pass outside between the corners.

Background: how a WITHIN shape query decides

  1. XYShape and LatLonShape index every shape as triangles; a line becomes one degenerate triangle per segment.
  2. A WITHIN query matches a document only if every one of its triangles lies inside the query polygon.
  3. For a polygon query that per-triangle test is Polygon2D.containsLine (segments) or Polygon2D.containsTriangle.
The query polygon (0,0) (4,0) (4,4) (2,1) (0,4), a square with a V-shaped notch in its top edge, with two indexed shapes: the segment (0,4)-(4,4) across the notch and the triangle (0,4) (4,4) (2,1) that is the notch. All their corners are polygon corners and the polygon's edges only touch them, so containsLine and containsTriangle return true, although (2,4) and (2,3) are outside the polygon; WITHIN returns 2 shapes instead of 0.
The notched polygon with the road across the notch and the notch triangle: every corner is on the boundary and no edge crosses, so both are reported as contained although their middles are outside. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Take the query polygon (0,0) (4,0) (4,4) (2,1) (0,4): a 4x4 square with a V-shaped notch cut from its top edge down to (2,1). Index two XYShape documents: the segment (0,4)-(4,4), a road between the two top corners, and the triangle (0,4) (4,4) (2,1), the notch itself.
  2. Both touch the polygon, so INTERSECTS returns 2. You expect WITHIN to return 0: the segment's midpoint (2,4) and the triangle's centre (2,3) lie in the notch, outside the polygon.
  3. Instead WITHIN returns 2, with no error, with assertions on and off: on main and on the 9.1.0, 9.12.3 and 10.6.0 releases. Earlier runs found it in every release since 8.5.0.
  4. Why: for each indexed segment or triangle, Lucene answers "contained" when every corner is inside or on the boundary and no polygon edge crosses it strictly. Here every corner is a polygon corner and the polygon's edges only touch the shapes there or run along their sides, so both checks pass. That shortcut is exact only for convex polygons.
  5. XYShape hits it whenever data and query polygons share exact corners (parcels, districts and roads from one map); LatLonShape only when the shared coordinates lie on its encoding grid.

Who is affected

Applications running XYShape or LatLonShape WITHIN queries with non-convex polygons, whose indexed lines or polygons have every vertex exactly on the polygon's boundary: shared corners in planar data, or on-grid coordinates for lat/lon. No special codec or settings; no error, and the wrong region is a whole area, not a precision sliver. Rare. Priority Low.

Mechanism, terms and numbers575 words

XYShape and LatLonShape WITHIN queries test each indexed triangle with Polygon2D.containsLine (a segment) or Polygon2D.containsTriangle. Both return true when every vertex is contains()-ed, where a point on the polygon's boundary counts as inside, and no polygon edge crosses the shape strictly, where an edge that only ends on the shape or runs along it does not count. That implies containment only for convex polygons: on a non-convex polygon a segment or triangle can meet the boundary only at its own vertices and still pass outside between them. For the polygon (0,0) (4,0) (4,4) (2,1) (0,4), WITHIN returns the segment (0,4)-(4,4) and the triangle (0,4) (4,4) (2,1), although (2,4) and (2,3) are outside the polygon. No error is raised; the wrong region has positive area, so it is not a precision effect. XYShape hits it with exactly shared float coordinates, LatLonShape only with coordinates on its encoding grid. The current form dates from LUCENE-8736 (8.1.0, polygons) and LUCENE-9152 (8.5.0, lines), a narrower one from 7.7.0; it is on main and in 10.6.0 and is not fixed upstream.

The code

// Polygon2D.java at 5adff78bb168 (excerpt, "// :NNN" notes added)
public boolean containsLine(...) {                                   // :204
  if (contains(aX, aY)                                                // :216
      && contains(bX, bY)
      && tree.crossesLine(minX, maxX, minY, maxY, aX, aY, bX, bY, false) == false) {
    return holes == null || holes.intersectsLine(...) == false;      // :219
  }
  return false;
}
public boolean containsTriangle(...) {                               // :225
  if (contains(aX, aY) && contains(bX, bY) && contains(cX, cY)        // :239-241
      && tree.crossesTriangle(..., aX, aY, bX, bY, cX, cY, false) == false) { // :242
    return holes == null || holes.intersectsTriangle(...) == false;
  }
  return false;
}

// EdgeTree.java: contains() "Returns true if the point is on an edge or crosses the edge subtree
// an odd number of times" (:65-68); with includeBoundary == false the crossing test is
// GeoUtils.lineCrossesLine (:205-207, :318), which needs both orientation products < 0 (GeoUtils.java:217-228)

The bug, on the repro

  1. XYShapeQuery.within() calls containsLine for a LINE triangle and containsTriangle for a TRIANGLE (XYShapeQuery.java:109, :127, :137; LatLonShapeQuery.java:127, :145, :155). A document matches only if every triangle passes (SpatialQuery.java:296-300, :354-356, :560-569).
  2. Segment (0,4)-(4,4). Both ends are polygon vertices, so contains returns true (on an edge). Each of the four polygon edges that reach the segment ends exactly on it: every orientation product is 0, so lineCrossesLine is false for all of them and crossesLine(..., false) is false. containsLine returns true; contains(2,4) is false.
  3. Triangle (0,4) (4,4) (2,1). All three vertices are polygon vertices; two of its edges lie on polygon edges (collinear, orientation 0) and the third is the segment above. crossesTriangle(..., false) is false, so containsTriangle returns true; its centroid (2,3) is outside.
  4. With includeBoundary == true both crossing tests would return true. That is why the shortcut was safe in 7.5.0-7.6.0, while a touch still counted as a crossing; LUCENE-8669 (7.7.0, 8.0.0) first made the test strict, and LUCENE-8736 (8.1.0, triangles) and LUCENE-9152 (8.5.0, segments) gave it today's strict form (history below).
  5. For a convex polygon the shortcut is exact: a segment or triangle whose vertices are in the closed polygon is inside it. The reflex vertex (2,1) is what lets the shape leave.

Executed on main 5adff78bb168 (diagram probe, assertions on and off identical): contains(2,4) false, contains(2,3) false, containsLine((0,4),(4,4)) true, containsTriangle((0,4),(2,1),(4,4)) true, the control containsTriangle((0,0),(4,0),(2,1)) true. Shape vertices on edge interiors trigger it too: containsLine((1,2.5),(3,2.5)) is true while contains(2,2.5) is false.

Exposure and likelihood287 words
  • Who is hit. XYShape/LatLonShape polygon queries with QueryRelation.WITHIN against a non-convex polygon, with indexed shapes whose vertices lie exactly on that polygon's boundary, at its vertices or on its edges.
  • Planar data. Likely when query polygons and indexed shapes come from one dataset and share corners. In an L-shaped zone (0,0) (10,0) (10,4) (4,4) (4,10) (0,10), the road (10,4)-(4,10) across the inner corner and a neighbouring parcel (10,4) (4,4) (4,10) that fills the corner (two of its edges lie on the zone's boundary, its interior is outside the zone) both match WITHIN; a road that stays inside is the control (main and 10.5.2, assertions on and off).
  • Lat/lon data. Only when the shared coordinates sit on the encoding grid, for example a query polygon built from decoded index data: placed 300 times at random positions and scales, the configuration reproduces 300 of 300 times on-grid and 0 of 300 times with arbitrary doubles, because quantization moves the indexed vertices off the query vertices.
  • No special settings. No codec or setting chosen by hand: the plain-program probe used the real default codec and IndexWriterConfig, the gradle runs the test framework's seed-chosen codec; assertions on and off. Multi-polygon queries delegate to each polygon (ComponentTree.java:198-277) and inherit it (read, not run). Box queries use the convex Rectangle2D and are not affected (read).
  • Silent, not a precision effect. No exception. The wrongly accepted region has positive area (the whole notch, 6 of the square's 16) and grows with the input; XY float coordinates are stored exactly.
  • How likely. Rare: a non-convex query polygon, every shape vertex exactly on its boundary and a path that leaves it are needed together; no user report was found since 2019.

Minimal repro

One JUnit test, single thread, fixed geometry: index the segment (0,4)-(4,4) and the notch triangle as two XYShape documents and query the notched square. INTERSECTS returns 2 as expected; WITHIN must return 0. It fails on main with assertions on and off and passes with fix A.

TestXYShapeWithinNotchedPolygon.java (one test, lucene/core)68 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.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

/**
 * The query polygon is a 4x4 square with a V-shaped notch cut into its top edge: (0,0) (4,0) (4,4)
 * (2,1) (0,4). The segment (0,4)-(4,4) and the triangle (0,4) (4,4) (2,1) have every vertex on the
 * polygon's boundary, but both run through the notch, which is outside the polygon: (2,4) and (2,3)
 * are not in it. So neither shape is within the polygon.
 */
public class TestXYShapeWithinNotchedPolygon extends LuceneTestCase {

  public void testShapesAcrossTheNotchAreNotWithin() throws Exception {
    XYPolygon notched =
        new XYPolygon(new float[] {0, 4, 4, 2, 0, 0}, new float[] {0, 0, 4, 1, 4, 0});
    XYLine segment = new XYLine(new float[] {0, 4}, new float[] {4, 4});
    XYPolygon notch = new XYPolygon(new float[] {0, 4, 2, 0}, new float[] {4, 4, 1, 4});
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (Field[] fields :
            new Field[][] {
              XYShape.createIndexableFields("shape", segment),
              XYShape.createIndexableFields("shape", notch)
            }) {
          Document doc = new Document();
          for (Field f : fields) {
            doc.add(f);
          }
          w.addDocument(doc);
        }
      }
      try (IndexReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        // both shapes touch the polygon
        assertEquals(
            2, searcher.count(XYShape.newPolygonQuery("shape", QueryRelation.INTERSECTS, notched)));
        // neither lies within it
        assertEquals(
            0, searcher.count(XYShape.newPolygonQuery("shape", QueryRelation.WITHIN, notched)));
      }
    }
  }
}
Command
cd $LUCENE
cp $REPRO/TestXYShapeWithinNotchedPolygon.java lucene/core/src/test/org/apache/lucene/document/
./gradlew :lucene:core:test --tests org.apache.lucene.document.TestXYShapeWithinNotchedPolygon -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
Expected
WITHIN returns 0 documents: the segment's midpoint (2,4) and the triangle's centre (2,3) are outside the polygon.
Observed
WITHIN returns 2 documents, the segment and the notch triangle (expected:<0> but was:<2>), with no error.

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

More files, environment, full output186 words

Public API only (XYShape, XYLine, XYPolygon, newPolygonQuery); the package org.apache.lucene.document is only for locality, and the file belongs in lucene/core/src/test/org/apache/lucene/document/. It passes :lucene:core:checkGoogleJavaFormat, :lucene:core:ecjLintTest and :lucene:core:forbiddenApisTest. It does not compile before 9.1.0 (older test-framework package). The geometry is fixed; the test framework still picks the directory and codec from the seed (newDirectory(); gradle reported codec=Asserting(Lucene104)), and the plain-program probe with the real default codec gives the same result.

Regression test: this same test. It passes with fix A, with assertions on and off.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); the same test also fails on the 10.6.0, 9.12.3 and 9.1.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

# gradle, main 5adff78bb168, seed DEADBEEF, assertions on; -Ptests.asserts=false gives the same failure
TestXYShapeWithinNotchedPolygon > testShapesAcrossTheNotchAreNotWithin FAILED
    java.lang.AssertionError: expected:<0> but was:<2>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:A5C964C3A8848642]: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.TestXYShapeWithinNotchedPolygon.testShapesAcrossTheNotchAreNotWithin(TestXYShapeWithinNotchedPolygon.java:63)

# release jars, plain JUnit runner: 10.6.0 (seed DEADBEEF assertions on; seed 5EEDF00D assertions off), 9.12.3, 9.1.0
testShapesAcrossTheNotchAreNotWithin  expected:<0> but was:<2>

# with fix A: BUILD SUCCESSFUL, assertions on and off

Why it looks unintended

  • The javadoc promises to "return true if this component2D contains the provided line"; the polygon's own contains() says (2,4) and (2,3) are outside.
  • Boundary points count as inside on purpose (LUCENE-8736); here a whole region of each shape lies outside.
  • The hand-written shared-boundary tests use only convex polygons and expect "not contained" once a triangle leaves the polygon.
The full argument231 words
  • The contract. Component2D.java:73: "return true if this component2D contains the provided line"; :84: "... the provided triangle". No approximation is documented. The public QueryRelation.WITHIN says only "used for WITHIN Queries" (ShapeField.java:122), so what the user sees promised is the plain meaning of WITHIN, which these internal methods implement.
  • The tests. The hand-written shared-boundary tests use convex polygons: TestLatLonShape.testTriangleTouchingEdges (TestLatLonShape.java:560) checks shared points on the unit square and asserts containsTriangle == false for "1 shared point but out" (:602-614); testLUCENE8736 (:656) queries a 4x7 rectangle and testTriangleCrossingPolygonVertices (:701) a convex pentagon; TestPolygon2D.testIntersectsSameEdge (TestPolygon2D.java:333) asserts that a corner-to-corner diagonal of a convex square is contained (:339). No targeted test exercises containsLine/containsTriangle on a non-convex query polygon with every shape vertex on its boundary. Random tests do use non-convex polygons (GeoTestUtil.nextPolygon returns surpriseMePolygon() half the time, GeoTestUtil.java:453-479), but cannot see it: testIndexAndQuerySamePolygon (TestLatLonShape.java:769-812) queries a polygon WITHIN itself, which checks only the positive direction, and the random spatial tests' oracle calls the same containsLine/containsTriangle (BaseSpatialTestCase.java:769, :781).
  • The history. LUCENE-8736 (CHANGES.txt:6535-6538, "Point in Polygon now correctly includes boundary points") is about boundary points. LUCENE-9152 (#10192) states the line rule "If all points are inside the polygon, then do not consider the boundary". Neither discusses non-convex polygons.
The devil's-advocate check120 words

The strongest case for "intended": boundary inclusion is deliberate (LUCENE-8736), Component2D is @lucene.internal, XYShape warns that vertices are indexed "with some loss of precision" (XYShape.java:71), and #9782 is an open umbrella for wrong WITHIN results with shared boundaries, where boundary cases were treated as best effort.

Why it fails: inclusion covers boundary points, not shape interiors that lie outside the polygon; the failure goes through the public WITHIN query with valid simple polygons; the coordinates are exact and the wrong area is a whole region; and #9782 is open as a bug about the opposite direction (false negatives), with no statement accepting false positives. Every reviewer, including the one asked to argue for "intended", rated it real.

Suggested fix

Recommended A: refine the shortcut when the cheap checks pass (validated sketch)

After the existing checks pass, also check between the corners: split each segment and triangle edge at polygon vertices on it and require every piece's midpoint inside; reject a triangle with a polygon vertex strictly inside. Validated as a sketch: the repro passes with assertions on and off, and 390 geo and shape tests pass.

// containsLine: after the existing checks
    && tree.crossesLine(minX, maxX, minY, maxY, aX, aY, bX, bY, false) == false
    && segmentPiecesInside(aX, aY, bX, bY)
// containsTriangle: after the existing checks
    && segmentPiecesInside(aX, aY, bX, bY)
    && segmentPiecesInside(bX, bY, cX, cY)
    && segmentPiecesInside(cX, cY, aX, aY)
    && vertexStrictlyInside(aX, aY, bX, bY, cX, cY) == false
// segmentPiecesInside: split the segment at every polygon vertex on it
//   and require the midpoint of each piece to be contains()-ed.
// vertexStrictlyInside: true if a polygon vertex has the same nonzero
//   orientation against all three triangle edges.
All options and trade-offs4 options ยท 516 words

Option A is the validated sketch; B is the shape an upstream patch should take for large polygons, C a cheap complement, D the rejected alternative.

A: refine the shortcut when the cheap checks pass (validated sketch) (recommended)

fix.diff, +60/-2 in Polygon2D.java only. Only when the existing checks already say "contained": containsLine splits the segment at every polygon vertex lying on it and requires the midpoint of each piece to be contains()-ed; containsTriangle does the same for its three edges and also requires that no polygon vertex lies strictly inside the triangle.

// containsLine: after the existing checks
    && tree.crossesLine(minX, maxX, minY, maxY, aX, aY, bX, bY, false) == false
    && segmentPiecesInside(aX, aY, bX, bY)
// containsTriangle: after the existing checks
    && segmentPiecesInside(aX, aY, bX, bY)
    && segmentPiecesInside(bX, bY, cX, cY)
    && segmentPiecesInside(cX, cY, aX, aY)
    && vertexStrictlyInside(aX, aY, bX, bY, cX, cY) == false
// segmentPiecesInside: split the segment at every polygon vertex on it
//   and require the midpoint of each piece to be contains()-ed.
// vertexStrictlyInside: true if a polygon vertex has the same nonzero
//   orientation against all three triangle edges.

Trade-offs

Shows the root cause, but scans every polygon vertex on each call that passes the cheap checks (O(polygon vertices) per candidate triangle) and uses a boxed list: it may be too costly for WITHIN queries with large polygons (no benchmark was run). Holes keep their existing check.

What the checks validated

Gradle in a scratch worktree of 5adff78bb168, seed DEADBEEF: the repro fails stock and passes with the fix, assertions on and off; with the fix, the repro, every org.apache.lucene.geo test class and every org.apache.lucene.document.Test*Shape* class pass (45 classes, 390 tests, 26 skipped, 0 failures). Earlier the same sketch made the finder's, an independent reproducer's, reviewers' and a devil's-advocate verifier's repros pass, and passed the 16 TestPolygon2D tests. Targeted runs, not a full build, on simple polygons only: no targeted run used a query polygon with holes or a multi-polygon query, and numerical edge cases are covered only by the existing suites.

B: the same refinement through the EdgeTree (not implemented)

Find "a polygon vertex on the segment" and "a polygon vertex strictly inside the triangle" with a bounding-box walk of the existing edge tree (every polygon vertex is an edge end point).

Trade-offs

The extra cost is bounded by the edges near the shape, not by the polygon size; more code than A. The shape an upstream patch should take.

C: keep the plain shortcut for convex polygons (complement to A or B, not implemented)

Compute once at construction whether the polygon is convex; for a convex polygon without holes the current shortcut is exact.

Trade-offs

Keeps today's cost for convex query polygons; adds a flag and a construction-time pass.

D: count touching as crossing again (rejected)

Call the crossing tests with includeBoundary == true in these two methods.

Trade-offs

Removes the false positives but, by reading, brings back the false negatives LUCENE-8736 and LUCENE-9152 fixed: shapes that share a boundary with a convex query would stop matching WITHIN (testTriangleTouchingEdges, testIntersectsSameEdge). Not run.

Downstream note

An application that uses a WITHIN polygon query as an allow filter (a geofence or a region scope) lets such shapes through; a deny rule built with MUST_NOT WITHIN hides them instead.

How it was found

A bounded-exhaustive consistency check compared Lucene's geometry predicates with an exact integer oracle over every small grid polygon, box and triangle. relate, contains and intersectsTriangle agreed everywhere; containsLine and containsTriangle said "contained" for shapes holding a point strictly outside the polygon. The cause was then read from Polygon2D.

The method in full139 words

Method: semi-formal (a bounded-exhaustive consistency check on the stock main jar).

  • The oracle: exact integer geometry with sample points on a 1/4 grid, over every simple polygon with 3-5 vertices on 3x3 and 4x4 grids (with triangular holes for 3-4 vertices), every half-grid box and every grid triangle, degenerate ones included.
  • relate() against contains(), and intersectsTriangle and contains() against the oracle: 0 violations.
  • containsTriangle/containsLine returned true while a point strictly outside the polygon lay in the shape in 104 cases (3x3 grid, 3-4 vertices and holes), 868 (3x3, 3-5 vertices and holes), 4,908 (4x4, 3-4 vertices and holes) and 58,792 (4x4, 5 vertices).
  • The root cause was then read from Polygon2D and EdgeTree, and a JUnit on the public XYShape/LatLonShape API reproduced it on main, 10.5.2 and 9.12.3 with assertions on and off.

Prior reports, history and checks

Prior reports

No report of this defect found (GitHub issues, PRs and commits incl. the JIRA archive, the Lucene lists, downstream trackers, the web; rechecked 2026-11-04). Related: #9782, the same-sounding shared-boundary WITHIN issue, which is about false negatives on a convex query.

Searches4 searches

The recorded searches found no Lucene issue, PR, JIRA entry, list thread or commit, and nothing outside Lucene, that describes WITHIN false positives for shapes whose vertices lie on a non-convex polygon's boundary, or the convexity assumption in containsLine/containsTriangle. Two independent re-checks on 2026-11-03 found #9782, whose title sounds similar, related but about a different defect. Related items, none of them this defect:

  • #9782 / LUCENE-8736 "LatLonShapePolygonQuery returning incorrect WITHIN results with shared boundaries" (open, 2019): its body reports false negatives, squares touching the edges of a convex rectangle query that came back as CROSSES. Its commits made Polygon2D.contains include boundary points, one of this bug's two ingredients. None of its 25 comments mentions a non-convex polygon, containsLine/containsTriangle or a shape that leaves the polygon.
  • #10192 / LUCENE-9152 (8.5.0): states the line rule "If all points are inside the polygon, then do not consider the boundary", with no discussion of non-convex polygons. Design context.
  • #10313 / LUCENE-9273 (8.6.0): gave the code its current shape. Context.
  • Other WITHIN items with other causes: #9695 / LUCENE-8649 (multi-shape documents), #9758 / LUCENE-8712 (missed crossings, false negatives), #11550 / LUCENE-10514 (within* methods), #10635 / LUCENE-9595 (withinPoint consistency).

Keyword searches only; a report worded differently, or a private one, would not be found.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the JIRA archive, Elasticsearch, OpenSearch, Solr); dev@, java-user@, issues@, commits@; JIRA; web2026-10-26246 logged read-only queries, 38 issues and PRs read with comments, the changed files of all 413 open PRsonly the related items above
same sources2026-11-03two independent same-defect re-checks#9782 related but a different defect; no report
GitHub, commits, downstream trackers, the four lists, JIRA2026-11-04prior-report tool, new spec, 45 queries (199 distinct hits)only already-classified items: the related items above, Geo3D and LatLonPoint polygon items, and #9700, #10109 and #9568 (other Polygon2D or boundary defects)
apache/lucene items and open PRs since 2026-10-26; web2026-11-0410 searches over 8 terms for recently updated items, changed files of the 31 new open PRs, 4 manual web searchesnothing new; no open PR touches the geo package or the shape queries

History

Current form since LUCENE-8736 (55c241d87f42, 2019, first in 8.1.0) for polygons and LUCENE-9152 (c98229948a0b, 2020, 8.5.0) for lines; a narrower form since LUCENE-8669 (7.7.0). 7.5.0 and 7.6.0 are clean. Not fixed upstream.

Versions tested9 rows

How it evolved. The triangle shortcut ("all vertices contained and no crossing") exists since 7.5.0 and was safe while a touch counted as a crossing. edb05314b315 (LUCENE-8669, #9715, nknize, 2019-01-31; 7.7.0, 8.0.0) stopped counting touches, which made it unsafe in a narrow form; LUCENE-8712 undid that on main and branch_8x, first released in 8.1.0; 7.7.x and 8.0.0 kept it. 55c241d87f42 (LUCENE-8736, #9782, nknize, 2019-04-11; branch_8x b518bed8b3ba, 8.1.0) made Polygon2D.contains boundary-inclusive and lineCrossesLine strict: the wide form for triangles. c98229948a0b (LUCENE-9152, #10192, iverase, 2020-01-29; 29542c7f59ee, 8.5.0) moved lines with both endpoints contained to crossesLine(..., false). f914e08b3647 (LUCENE-9273; branch_8x a001f135f22, 8.6.0) split the code into today's methods without changing behaviour.

Every maintained line has the 8.1.0 form (LUCENE-8736); both dates are executed. Upstream main last changed Polygon2D.java and EdgeTree.java in de3b294be44c (2023-10-11, formatting); 10.6.0 has the same code as main.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedthe minimal test fails through gradle, assertions on and off
10.6.0, 9.12.3, 9.1.0 affectedthe minimal test fails on the release jars (10.6.0 also with assertions off)
8.5.0 to 10.5.2 affectedsegments and triangles wrong (plain programs, every release)
8.1.0 to 8.4.1 polygons onlytriangles and polygons wrong, segments correct
7.7.0 to 8.0.0 narrow formwrong only where the old half-open point-in-polygon rule counted every vertex as inside
7.5.0, 7.6.0 not affecteda touch still counted as a crossing
b518bed8b3ba (LUCENE-8736 on branch_8x) vs its parent 5691f87ecc33 bisectthe commit fails; the parent is clean
8.5.0 with only LUCENE-9152's line reverted bisectthe segment cases pass again; triangles still fail
be471ea91d53 (LUCENE-8669) vs its parent 767f1be7d545 bisectthe commit fails; the parent is clean

Independent checks

An executed cross-model review (OpenAI GPT-5.6), an independent reproducer, an intended-behaviour review, a devil's-advocate verifier and the adjudication all rated it real (adjudication: confirmed, high confidence); a re-run on main on 2026-11-02 still reproduced it, and this doc's gradle runs validated fix A.

Checks and verdicts325 words

The earlier checks ran on main ea57360912ed, where the geo package and the shape queries are identical to 5adff78bb168; this doc's runs used 5adff78bb168 and release jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder foundIts JUnit (a dart-shaped polygon, XY and on-grid LatLon segments and a triangle) fails 4 of 4 on main (assertions on and off), 10.5.2 and 9.12.3, and passes with the fix sketch.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Its own test with other concave polygons fails 3 of 4 on main (several seeds, assertions on and off) with its controls passing; the fix passes it and the 16 TestPolygon2D tests.
independent reproducer realOwn test on the notched square fails 5 of 5 on main, 10.5.2 and 9.12.3, and as a plain program on 8.11.4; widened the trigger to edge-interior vertices; measured the lat/lon reach (300/300 on-grid, 0/300 off-grid).
intended-behaviour review (OpenAI GPT-5.6, reading only) real (medium)Found no intended reading covering shape interiors outside the polygon; its planned geometry, run separately, fails 4 of 6 on main and 10.5.2 (controls pass; the doc-values relate answers CROSSES there, as it does for any shape that touches the query boundary) and passes 6 of 6 with the fix.
devil's-advocate verifier realReplayed the finder's test; added the L-shaped zone road and parcel variants, which fail on main and 10.5.2 and pass with the fix, with an inside-road control passing.
adjudication confirmed (high)Re-ran the reproducer's test (5 of 5 fail, assertions on and off) and read the code path end to end.
re-verification still reproducesMain e1faeba08bd, 2026-11-02: the finder's test fails 4 of 4 with assertions on and off.
this doc's runs fails; fix A validatedThe minimal test fails on main 5adff78bb168 (on, off), 10.6.0, 9.12.3 and 9.1.0; with fix A it passes and 390 geo and shape tests pass.

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

Finding record

LFV-206, snapshot of 2026-11-06

Registry title
Polygon2D.containsLine/containsTriangle accept a segment or triangle whose vertices lie on the boundary of a non-convex polygon even when it passes outside between them, so LatLonShape/XYShape WITHIN queries return false positives
Area
geo / shape queries (Polygon2D containsLine/containsTriangle, LatLonShape and XYShape WITHIN)
Priority
Low, rank 405: Polygon2D's convexity shortcut makes WITHIN queries with a non-convex polygon return shapes whose vertices lie on its boundary but which leave it, again exact shared coordinates (common for planar XYShape, on-grid only for LatLonShape); #9782 is related but about a different defect (false negatives on a convex query).
Impact class
silent wrong results
Impact note
Applications that run LatLonShape or XYShape polygon queries with QueryRelation.WITHIN (default codec, IndexWriterConfig and searcher, any index size) against a non-convex query polygon get, with no error, indexed lines and polygons whose vertices all lie exactly on that polygon's boundary (at its vertices or on its edges) but which leave it between those vertices, for example a road between two corners of a district that cuts across a dent, or a neighbouring parcel that fills a notch and whose interior lies outside (two of its edges are on the boundary); XYShape hits it with any exactly shared float coordinates, LatLonShape only when the shared coordinates are on the encoding grid (for example taken from decoded index data), and the wrong region has positive area that scales with the input, so it is not a precision effect.
Summary
You index a line or polygon whose corner points lie exactly on the boundary of a non-convex (dented) query polygon, for example a road between two corners of a district, and search for shapes WITHIN that polygon; you expect only shapes that lie inside it. Polygon2D.containsLine/containsTriangle accept a segment or triangle when every vertex is contained, with boundary points counting as inside, and no polygon edge crosses it strictly; that shortcut is only valid for convex polygons, so a segment that cuts across a dent between two boundary points, or a triangle that fills the dent and whose interior lies outside, is reported as contained. The WITHIN query returns those documents with no error (repro: for the polygon (0,0),(4,0),(4,4),(2,1),(0,4), WITHIN returns the indexed segment (0,4)-(4,4) and the indexed notch triangle (0,4),(4,4),(2,1) although (2,4) and (2,3) are outside the polygon).
Introduced
edb05314b315acf9abc4f9fdb3d30e17aff7feba (LUCENE-8669 'Fix LatLonShape WITHIN queries that fail with Multiple search Polygons that share the dateline', nknize, direct commit on the main line, JIRA = apache/lucene#9715; backports be471ea91d53 branch_7x (7.7.0), fd92d54b38a7 (8.0.0), fade1a091bfa branch_8x): it made the triangle WITHIN shortcut ('all vertices contained and no crossing => INSIDE', present since 7.5.0 and safe while a touch counted as a crossing) ignore touching contacts, the narrow form [exec: a commit-level build of the geo sources over the 7.7.0 jars, parent 767f1be7d545 clean, the commit fails]. LUCENE-8712 (458205396efa / c70a404d070b, apache/lucene-solr#598, 2019-03-12) removed that form on main and branch_8x, first released in 8.1.0 (7.7.x and 8.0.0 kept it). Today's wide form: 55c241d87f42e568d0d40d7599d56e725d4d3b92 (LUCENE-8736, nknize, 2019-04-11, direct commit, JIRA = apache/lucene#9782, still open; branch_8x b518bed8b3ba, 8.1.0) made Polygon2D.contains boundary-inclusive and GeoUtils.lineCrossesLine strict, so every boundary-vertex triangle passes [exec: parent 5691f87ecc33 clean, b518bed8b3ba fails]; c98229948a0bfdd9fd4b845c4110be0a5246e09e (LUCENE-9152, iverase, apache/lucene-solr#1187, 2020-01-29; branch_8x 29542c7f59ee, 8.5.0) extended it to segments by switching the both-endpoints-contained line case to crossesLine(..., false) [exec: on 8.5.0 reverting only that line fixes the segment cases]; f914e08b3647 (LUCENE-9273, apache/lucene-solr#1341, 8.6.0) split the code into today's containsLine/containsTriangle with no behaviour change [exec: 8.5.x and 8.6.0 identical]. (2019-01-31 (LUCENE-8669, narrow form); 2019-04-11 (LUCENE-8736, the wide triangle form every maintained release has); 2020-01-29 (LUCENE-9152, segments))
First affected version
7.7.0 [exec] (and 8.0.0 on the 8.x line) in a narrow form that only fails when the old half-open point-in-polygon rule counts every shape vertex as inside; 8.1.0 [exec] for the wide form on triangles and polygons; 8.5.0 [exec] for segments and lines; 7.5.0 and 7.6.0 (the first releases with shape WITHIN, in lucene-sandbox) are not affected [exec]
Affected versions
Every release from 7.7.0 to 10.6.0 and main ea57360912ed. [exec, 2026-11-04] the one-test repro fails on the released 10.6.0 (-ea and -da), 9.12.3 and 9.1.0 jars and on main 5adff78bb168 (-ea and -da). [exec] a version-sweep main() with the repro geometry (A) and a second geometry from the grid search (B) on all 66 releases from 7.5.0 to 10.5.2 and on main (-da; pivotal releases also -ea): triangle/polygon WITHIN wrong in every release from 7.7.0 (7.7.0-8.0.0 geometry B only), segment WITHIN wrong in 7.7.0-8.0.0 (B only) and 8.5.0 on, correct in 8.1.0-8.4.1; 7.5.0 and 7.6.0 clean; the finder's JUnit fails 4 of 4 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da) and does not compile before 9.1.0; the independent reproduction's JUnit fails 5 of 5 on main (-ea, -da), 10.5.2 and 9.12.3 and its main() version fails on 8.11.4; the devil's-advocate verifier's and the cross-model reviews' own tests fail on main and 10.5.2. XYShape exists from 8.2.0 (sandbox; core from 8.5.0). Not fixed upstream: Polygon2D.java is identical on main, branch_10x and branch_10_6 (read 2026-10-26). All runs on aarch64 with JDK 25.0.3.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check and a prior-report search; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
new (related items only; no report of this defect found): apache/lucene#9782 (LUCENE-8736, 'LatLonShapePolygonQuery returning incorrect WITHIN results with shared boundaries', open since 2019, type:bug, JIRA Reopened) covers the same symptom family in the opposite direction: its body reports false negatives on a convex rectangle query (triangles touching its edges come back as CROSSES), and its 2019 commits (55c241d87f4, 8975ae5954b, faa78ad72cf) made Polygon2D.contains boundary-inclusive, one of the two conditions this bug needs; all 25 comments were read and none mentions a non-convex polygon, containsLine/containsTriangle or a shape that leaves the polygon ('false positive' appears only about quantization and determinant overflow). #10192 (LUCENE-9152, 8.5.0) states the line shortcut's design rule 'If all points are inside the polygon, then do not consider the boundary' without discussing non-convex polygons. No other report: 246 recorded queries (scripted prior-report searches on 2026-10-26 plus direct GitHub, JIRA, list and web searches) over apache/lucene issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, Elasticsearch, OpenSearch and Solr, and the web; the only WITHIN false-positive fix in history is LUCENE-8649 (#9695, multi-shape documents, a different cause). Not fixed upstream: Polygon2D.java on main, branch_10x and branch_10_6 is byte-identical to the tested copy, and none of the 413 open apache/lucene PRs touches Polygon2D, EdgeTree, Component2D, LatLonShapeQuery or XYShapeQuery.
Lucene commit tested
Lucene main 5adff78bb168 (12.0.0-SNAPSHOT): the one-test repro through gradle in a scratch worktree, assertions on and off, without and with fix A, plus the geo and shape test suites with fix A; standalone runs on the release jars of 10.6.0 (assertions on and off), 9.12.3 and 9.1.0. Earlier checks ran on main ea57360912ed (the geo package and shape queries are identical) and, as plain programs, on every release from 7.5.0 to 10.5.2. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/geo/Polygon2D.java:203-222 (containsLine: contains(a) && contains(b) && tree.crossesLine(..., false) == false) and :224-247 (containsTriangle: three contains() && tree.crossesTriangle(..., false) == false)
  • lucene/core/src/java/org/apache/lucene/geo/EdgeTree.java:65-68 ('Returns true if the point is on an edge or crosses the edge subtree an odd number of times'), :111-145 (containsPnPoly returns ON_EDGE), :205-207 and :318 (strict lineCrossesLine when includeBoundary is false)
  • lucene/core/src/java/org/apache/lucene/geo/GeoUtils.java:217 (lineCrossesLine), :252-255 (javadoc: use lineCrossesLine to exclude lines that terminate on each other)
  • lucene/core/src/java/org/apache/lucene/geo/Component2D.java:73 ('return true if this component2D contains the provided line') and :84 ('... the provided triangle')
  • lucene/core/src/java/org/apache/lucene/document/LatLonShapeQuery.java:145, :155 and XYShapeQuery.java:127, :137 (WITHIN per-triangle test calls containsLine/containsTriangle); geo/ComponentTree.java:198-277 (multi-geometry queries delegate to each component)
  • lucene/CHANGES.txt:6465-6466 (LUCENE-8736 'Point in Polygon now correctly includes boundary points'), :5925 (LUCENE-9152), :6891 (LUCENE-8669)
  • SECURITY.md lines 28, 46 and 50 (threat model, public route for out-of-scope issues, in-scope definition)
The finding in 30 seconds
  • lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/geo/Polygon2D.java:112-117, :204-222, :225-247
  • lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/geo/EdgeTree.java:65-68, :111-145, :205-207, :318
  • lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/geo/GeoUtils.java:217-228, :246-254
  • lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/document/XYShapeQuery.java:109, :127, :137; lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/document/LatLonShapeQuery.java:127, :145, :155
  • lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/document/SpatialQuery.java:296-300, :354-356, :560-569
Who is affected
  • lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/geo/ComponentTree.java:198-277; lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/geo/XYEncodingUtils.java:50-52 (float stored as a sortable int)
Why it looks unintended
  • lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/geo/Component2D.java:73, :84
  • lucene@5adff78bb168 lucene/core/src/test/org/apache/lucene/document/TestLatLonShape.java:560, :602-614, :656, :701, :769-812; lucene@5adff78bb168 lucene/core/src/test/org/apache/lucene/geo/TestPolygon2D.java:333, :339; lucene@5adff78bb168 lucene/core/src/test/org/apache/lucene/document/BaseSpatialTestCase.java:769, :781
  • lucene@5adff78bb168 lucene/test-framework/src/java/org/apache/lucene/tests/geo/GeoTestUtil.java:453-479 (nextPolygon: surpriseMePolygon half the time)
  • lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/document/ShapeField.java:122 (QueryRelation.WITHIN javadoc)
  • lucene@5adff78bb168 lucene/CHANGES.txt:5995, :6535-6538
  • lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/document/XYShape.java:71
Suggested fix
  • lucene@5adff78bb168 lucene/core/src/test/org/apache/lucene/document/TestLatLonShape.java:560; lucene@5adff78bb168 lucene/core/src/test/org/apache/lucene/geo/TestPolygon2D.java:333
History
  • lucene@5adff78bb168 lucene/CHANGES.txt:5995, :6535-6538, :6961
Diagrams
  • lucene@5adff78bb168 lucene/core/src/java/org/apache/lucene/geo/Polygon2D.java:204-247

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

Repro files

About the diagrams

A picture of the repro, made in parallel with this doc from the code at 5adff78bb168 and an executed probe; the PNG is the summary still.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.