All findings · Lucene finding · LFV-794 · tessellator-bridge-along-outer-edge-loses-from-polygon-flag

Tessellator flags a hole bridge that runs along an outer edge as internal, so LatLonShape and XYShape CONTAINS queries match boxes that stick out of a polygon whose two holes touch that edge

When two holes touch the same straight outer edge, the Tessellator can mark the piece of that edge between them as internal, so LatLonShape and XYShape CONTAINS queries match boxes that leave the polygon there, even one 99.6% outside. Every release since 8.4.0; a one-line fix.

Summary

Priority
Low, rank 398A wrong edge flag gives silent CONTAINS false positives for every ring start when two holes touch the same outer edge, so its trigger is broader than LFV-793's, but it only affects the less used CONTAINS relation.
Impact
silent wrong results
Since
8.4.0 (LUCENE-8620, 2019; flag wrong since 8.3.0)introduced 2019-09-09 · about 7 years
Affected
8.4.0 to 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
lattice translation validation (semi-formal)

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

Background: Lucene stores a polygon as triangles and flags each triangle edge that lies on the polygon's boundary; CONTAINS treats crossing a flagged edge as leaving the shape. Holes are cut in first, by joining each hole to the outer ring with a bridge, a cut that later becomes a triangle edge.

Diagram of the bug: a triangular polygon (0,0) (7,12) (12,0) with two small triangular holes, one touching the bottom edge y = 0 at (2,0) and one at (5,0). Hole A is bridged to (0,0) along the bottom edge; hole B's bridge runs from (5,0) to (2,0), also along the bottom edge, but is flagged as an internal edge because Lucene only compares it with the ring edges at its two ends, not with the long bottom edge (0,0)-(12,0). The triangle (5,1) (2,0) (5,0) is indexed with its bottom side unflagged, so the CONTAINS query for the box x [3,4], y [-0.5,0.3], which sticks out below the polygon, matches the document (1 hit instead of 0). Boxes crossing the bottom edge left of (2,0) or right of (5,0) correctly give 0. With the whole-ring check the bridge is flagged and the box gives 0.
The executed example step by step: the triangle (0,0) (7,12) (12,0) with two holes touching its bottom edge at (2,0) and (5,0), the two bridges along that edge, the piece (2,0)-(5,0) indexed as internal, and the box x [3,4], y [-0.5,0.3] that leaves through it and is still "contained". Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index the triangle (0,0) (7,12) (12,0) with two small holes, (2,0) (5,1) (3,3) and (5,0) (8,1) (6,3), each touching the bottom edge y = 0 at one point, with XYShape or LatLonShape.createIndexableFields. The polygon is valid (JTS reports it valid; the Tessellator allows that "Holes may only touch at one vertex"), and listing (5,0) and (2,0) as outer-ring vertices too changes nothing.
  2. You ask CONTAINS for the box x [3,4], y [-0.5,0.3]. It sticks 0.5 below the bottom edge, between the two touch points, so you expect 0 hits. You get 1, with no error. The inside box x [3,4], y [0.05,0.3] correctly gives 1; boxes crossing the bottom edge left of (2,0) or right of (5,0) correctly give 0.
  3. Why: hole B's bridge runs from (5,0) to (2,0), straight along the bottom edge. Lucene flags a bridge as boundary only if it overlaps a ring edge at one of its two ends, and here the bottom edge is one long ring edge, (0,0)-(12,0), next to neither end. So the bridge is flagged internal.
  4. The triangle (5,1) (2,0) (5,0) is therefore indexed with its boundary edge (2,0)-(5,0) unflagged, and CONTAINS reads crossing it as "still inside": any box that leaves through that piece matches, even x [3,4], y [-80,0.3], 99.6% outside (for LatLonShape about 8,900 km of latitude).
  5. XY polygon, line and circle CONTAINS queries are hit too, and for this polygon every ring start and both hole orders fail. Every release from 8.4.0 to 10.6.0 (10.6.0 by reading) and main; the wrong flag is stored in the index, so affected shapes must be reindexed after a fix.

Who is affected

Anyone who indexes polygons with holes through LatLonShape or XYShape (default settings; one document is enough) and runs CONTAINS queries, when two holes touch the same straight outer edge and the later hole's bridge runs along it. Only silent false positives. The flag is stored in the index, so affected shapes need reindexing after a fix.

Mechanism, terms and numbers875 words

LatLonShape and XYShape index a polygon as triangles and store, for each triangle edge, a flag that says whether the edge lies on the polygon's boundary. CONTAINS queries rely on it: crossing a flagged edge means the query leaves the shape, crossing only unflagged edges means it stays inside. Before ear clipping, the Tessellator joins each hole to the outer ring with a bridge, and Tessellator.eliminateHole flags the bridge as a boundary edge only when one of four isPointInLine checks finds it on a ring edge next to one of its two ends (Tessellator.java:392-397). When two holes touch the same straight outer edge and the second hole's bridge runs along that edge (here because each hole touches it at its leftmost vertex), the bridge lies inside a long ring edge that is next to neither of its ends, so it is flagged internal. For the triangle (0,0) (7,12) (12,0) with holes (2,0) (5,1) (3,3) and (5,0) (8,1) (6,3), the piece (2,0)-(5,0) of the bottom edge is indexed as internal, and CONTAINS for the box x [3,4], y [-0.5,0.3], which sticks out below the polygon, matches; it still matches with the box stretched to y = -80. INTERSECTS and boxes inside the polygon are right, and no error is raised. The flag has been wrong since the flags were added (LUCENE-8620, 8.3.0) and visible since CONTAINS shipped in 8.4.0; every release executed through 10.5.2 and main is affected, and 10.6.0 has the same code (by reading). Adding the whole-ring scan isEdgeFromPolygon, which ear clipping already uses for its diagonals, to the bridge's flag fixes it: the repro passes, and 45 geo and shape suites (393 test cases, 367 run, 26 skipped as nightly-only or similar) pass with assertions on and off.

Terms

  • Tessellation triangles and edge flags. LatLonShape.createIndexableFields and XYShape.createIndexableFields call Tessellator.tessellate (LatLonShape.java:85-114, XYShape.java:83-112) and index one ShapeField.Triangle per triangle, with three flags (ShapeField.java:80-92). Tessellator.Triangle.isEdgefromPolygon is documented as "get if edge is shared with the polygon for the given edge" (Tessellator.java:1723-1724).
  • CONTAINS. Per triangle, the query is NOTWITHIN when it crosses a flagged edge and CANDIDATE when it crosses only unflagged edges (Component2D.java:101-115; LatLon boxes SpatialQuery.java:947-1001, XY boxes Rectangle2D.java:180); a document matches when some triangle is CANDIDATE and none is NOTWITHIN (SpatialQuery.java:591-650 records both; :376 drops the NOTWITHIN documents with result.andNot(excluded)). Only the queries' contains() functions read the flags.
  • Hole bridge. Holes are processed left to right by their leftmost vertex (Tessellator.java:309-325). fetchHoleBridge casts a ray to the left from that vertex and picks a ring vertex to connect to (:536-592); splitPolygon links the hole into the ring through that bridge and stores the bridge's flag on both copies (:1175-1197).

The bug, step by step (executed trace on main)

  1. createDoublyLinkedList ends with filterPoints (:243), which drops collinear vertices whose two edges have the same flag (:1424-1441). The outer ring reaches hole elimination as (0,0) (12,0) (7,12): the bottom edge is one ring edge, (0,0)-(12,0), even when the caller listed (2,0) and (5,0) as vertices.
  2. Hole A (leftmost (2,0)) is bridged to (0,0). That bridge lies on the bottom edge and shares the end (0,0) with it, so it is flagged as boundary. The ring is now (2,0) (3,3) (5,1) (2,0) (0,0) (12,0) (7,12) (0,0): the edge (0,0)-(12,0) is still one ring edge, with both touch points in its interior.
  3. Hole B (leftmost (5,0)): the ray to the left hits hole A's edge (5,1)-(2,0) at its end, so the bridge is (5,0)-(2,0), exactly along the bottom edge. eliminateHole (:392-397, under the comment "compute if the bridge overlaps with a polygon edge") asks four questions: is (5,0) on (2,0)-(0,0) or on (2,0)-(5,1), the ring edges at the bridge end? Is (2,0) on one of hole B's two edges at (5,0)? All four are no, so fromPolygon is false.
  4. Ear clipping copies the flag into the triangle (:650-656): (5,1) (2,0) (5,0) is stored with its edge (2,0)-(5,0) unflagged, and the box that leaves through it is a CANDIDATE in that triangle and touches no other triangle, so the document matches.

The whole-ring scan that would have found (0,0)-(12,0) exists: isEdgeFromPolygon (:1067-1083), used by ear clipping for its own diagonals (:654). The commit that added the flags introduced both and used the scan for every diagonal except the bridge.

The numbers (main a9779ef3c193, executed; the same on 10.5.2)

CONTAINS query boxexpectedXYShapeLatLonShape
x [3,4], y [0.05,0.3] (inside, control)111
x [3,4], y [-0.5,0.3] (0.5 below the edge)011
x [2.5,4.5], y [-1,0.1] (91% outside)011
x [3,4], y [-80,0.3] (99.6% outside)011
x [1,1.5], y [-0.5,0.3] (crosses the edge left of hole A, control)000
x [9,10], y [-0.5,0.3] (crosses the edge right of hole B, control)000

INTERSECTS is 1 for every box. With the fix, every wrong 1 becomes 0 and the controls are unchanged. The credibility audit found the same false positive for XY polygon, line and circle CONTAINS queries, and for three other outer rings (a rectangle, a pentagon, a wider triangle) with the same two holes.

Exposure and likelihood357 words
  • Who. Applications that index polygons with holes through LatLonShape.createIndexableFields or XYShape.createIndexableFields (the default path, either checkSelfIntersections setting, default IndexWriterConfig and codec) and query them with QueryRelation.CONTAINS.
  • Trigger. Two holes that each touch the same straight outer edge at one point (as vertices of both rings or in the interior of the outer edge), in a layout where the later hole's bridge runs along that edge, plus a CONTAINS query that leaves the polygon through the piece of that edge between the touch points. Every outer-ring start vertex and both hole orders fail. It is geometry-sensitive: the second hole's bridge must run along the edge, which needs that hole's leftmost vertex to be its touch point (read from the mechanism). An independent reviewer's rectangle whose holes touch the bottom edge with a vertex that is not their leftmost one did not trigger it; the audit's rectangle with these two holes did.
  • How often. Not measured on real data. Touching-hole layouts are supported input and have come up in upstream bug reports (upstream fixed tessellation failures for them, for example #15205, #15554, #15695 and #15731). In the finder's random lattice polygons, built so that about half of the holes touch something, about 1-2% of polygon/mode pairs hit this fix site (a generator rate, not a real-data rate). The documented search found no prior report of the symptom.
  • Effect. Silent false positives only, possibly large (a box 99.6% outside matched). No exception. INTERSECTS, WITHIN and DISJOINT do not read the flags; boxes inside the polygon and single-hole polygons are right.
  • Default vs opt-in. Indexing is the default path; CONTAINS is a relation the caller must choose explicitly; how often it is used was not measured. Shape doc-values queries reject CONTAINS (BaseShapeDocValuesQuery.java:47-49).
  • Persistence. The flag is computed at index time and stored with each triangle, so shapes indexed with an affected version keep it until reindexed (read, not executed for this bug).
  • Not executed. LatLon polygon, line and circle CONTAINS queries (the XY ones were); polygons above 80 vertices, which use Morton-ordered ear clipping after the same bridge code; other JDKs or CPUs than JDK 25 on aarch64.

Minimal repro

The test shows that XYShape CONTAINS matches a box that leaves a valid polygon, outer ring (0,0) (7,12) (12,0) (5,0) (2,0) with holes (2,0) (5,1) (3,3) and (5,0) (8,1) (6,3) that each touch the edge y = 0 at one vertex, because the Tessellator flags the hole bridge (2,0)-(5,0) on that edge as an internal edge.

TestHolesTouchingSameOuterEdgeContains.java (JUnit test)55 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.document;

import org.apache.lucene.document.ShapeField.QueryRelation;
import org.apache.lucene.geo.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 TestHolesTouchingSameOuterEdgeContains extends LuceneTestCase {

  public void testBoxCrossingOuterEdgeIsNotContained() throws Exception {
    XYPolygon holeA = new XYPolygon(new float[] {2, 5, 3, 2}, new float[] {0, 1, 3, 0});
    XYPolygon holeB = new XYPolygon(new float[] {5, 8, 6, 5}, new float[] {0, 1, 3, 0});
    XYPolygon polygon =
        new XYPolygon(
            new float[] {0, 7, 12, 5, 2, 0}, new float[] {0, 12, 0, 0, 0, 0}, holeA, holeB);

    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        for (Field f : XYShape.createIndexableFields("shape", polygon)) {
          doc.add(f);
        }
        w.addDocument(doc);
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        // expected 0: the box x [3,4], y [-0.5,0.3] sticks out below the outer edge y = 0
        int hits =
            new IndexSearcher(r)
                .count(XYShape.newBoxQuery("shape", QueryRelation.CONTAINS, 3, 4, -0.5f, 0.3f));
        assertEquals("CONTAINS hits for a box that leaves the polygon", 0, hits);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-794/, 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.TestHolesTouchingSameOuterEdgeContains
Expected vs actual
Expected 0 hits for the CONTAINS box x [3,4], y [-0.5,0.3], which sticks out below y = 0; actual: 1 (expected:<0> but was:<1>), with -ea and -da.

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

More files, environment, full output42 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: CONTAINS hits for a box that leaves the polygon 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.TestHolesTouchingSameOuterEdgeContains.testBoxCrossingOuterEdgeIsNotContained(TestHolesTouchingSameOuterEdgeContains.java:51)

Why it looks unintended

  • The flag means "edge is shared with the polygon"; the code comment says "compute if the bridge overlaps with a polygon edge".
  • Upstream's own TestTessellator flag oracle fails on this polygon (32 of 32 combinations; 0 with the fix).
  • The polygon is valid ("Holes may only touch at one vertex"); upstream fixed a similar lost-flag CONTAINS bug in #12020.
The full argument334 words
  • The contract is the flag. Tessellator.Triangle.isEdgefromPolygon: "get if edge is shared with the polygon for the given edge" (Tessellator.java:1723-1724). The comment on the expression: "compute if the bridge overlaps with a polygon edge" (:392). 8.3.0 CHANGES: "Tessellator labels the edges of the generated triangles whether they belong to the original polygon" (lucene/CHANGES.txt:6219-6220).
  • CONTAINS is built on it. Component2D.WithinRelation: CANDIDATE when the query "intersects only edges that do not belong to the original shape", NOTWITHIN when it "intersects an edge that does belong to the original shape" (Component2D.java:101-115); 8.4.0 CHANGES: "LUCENE-8620: Add CONTAINS support for LatLonShape and XYShape" (lucene/CHANGES.txt:6049). The newBoxQuery javadoc says "intersect a defined bounding box" for every relation (LatLonShape.java:238, XYShape.java:204), so the CONTAINS meaning comes from the relation name, the CHANGES entry and the WithinRelation javadoc.
  • Upstream's test asserts exact flags. TestTessellator.checkPolygon -> checkTriangleEdgesFromPolygon (TestTessellator.java:1011-1024, 1047-1120) asserts that every triangle edge's flag equals "lies on a ring segment". The intended-behaviour devil's advocate ran it verbatim on 32 combinations (ring starts, hole orders, both checkSelfIntersections values): 32 mismatches on main and on 9.12.2, 0 with the fix. A 7-line TestTessellator method with this polygon fails on main.
  • Upstream treats such errors as bugs. #12020 / #12022 fixed a CONTAINS false positive from a lost boundary flag at another site (ShapeField.resolveTriangleType, 2022); LUCENE-9251 fixed conflicting flags in filterPoints; #15739 (2026) widened this expression from 2 to 4 adjacent-edge checks for another touching-hole layout.
  • Legal input. Clockwise outer ring, counter-clockwise holes (Polygon.java:31-32), disjoint holes, each touching the shell at one point (Tessellator.java:47), small integer coordinates; JTS 1.20 IsValidOp reports it valid, with and without (2,0) and (5,0) listed as outer-ring vertices. Listing them removes any doubt about "touch at one vertex", and that variant fails the same way. The 0.5 overhang is far above the coordinate quantization (below 1e-7 degrees). The wrong answer comes through the public XYShape/LatLonShape calls.
The devil's-advocate check131 words

The strongest "works as designed" case (an independent reviewer from another model family, the devil's advocate and the adjudication all built it): the four adjacent-edge checks are a deliberate constant-time shortcut, Tessellator is @lucene.internal (Tessellator.java:72), and CONTAINS is thinly documented.

Why it fails: the public LatLonShape/XYShape entry points carry no such tag, and nothing documents CONTAINS as approximate beyond quantization; the commit that added the flags used the whole-ring scan for every other diagonal; upstream's own oracle asserts exact flags; and the fix costs at most one extra ring scan per bridged hole, which fetchHoleBridge already pays. The reviewer rated the argument weak, and the devil's advocate found no intended, documentation, misuse or reachability argument that survives (both from their review records, which rest on executed probes).

Suggested fix

Recommended Add the whole-ring scan to the bridge flag

In Tessellator.eliminateHole, also run the whole-ring scan isEdgeFromPolygon(bridge, holeNode, false) that ear clipping already uses (+3/-1). The repro passes, and 45 geo and shape suites (367 tests run, 26 skipped) pass with assertions on and off. Shapes indexed before the fix keep the wrong flag until reindexed.

           isPointInLine(bridge, bridge.next, holeNode)
               || isPointInLine(bridge, bridge.previous, holeNode)
               || isPointInLine(holeNode, holeNode.next, bridge)
-              || isPointInLine(holeNode, holeNode.previous, bridge);
+              || isPointInLine(holeNode, holeNode.previous, bridge)
+              // the bridge may also lie inside a longer ring edge that is not next to either end
+              || isEdgeFromPolygon(bridge, holeNode, false);
All options and trade-offs2 options ยท 707 words

Option 1 was applied in a scratch worktree at a9779ef3c193 and run through gradle; option 2 was not run.

Wider effect (option 1). In the finder's lattice sweeps, on top of the registered Tessellator fixes, it cut the polygon/mode pairs whose only error is a missing flag from 54, 66 and 70 to 2, 0 and 0 (6x6, 8x8 and 13x13 lattices) and added no failure; on 8x8 two pairs of one polygon, where a degenerate triangle also misses a flag, remain. So it covers more layouts than the repro; how many of those gave a wrong query answer was not measured.

Not sufficient: keeping the touch vertices on the outer ring. A full-ring filterPoints variant (another unit's experiment, not a registered fix) happens to fix this polygon, because the adjacent-edge checks then see the outer edge, but the same sweeps still show 26, 12 and 4 pure missing-flag pairs with it (13x13, 8x8, 6x6), and 0 with option 1 added (the two composite 8x8 pairs above remain).

Coupling. Other hunt rows fix other Tessellator sites (LFV-210, LFV-755, LFV-757, LFV-762, LFV-795, LFV-796; none owns this expression); LFV-755 comes from #15739, the commit that also widened this expression. The repro still fails with the LFV-210, LFV-757 and LFV-762 fixes applied, and option 1 passed the 123 upstream geo-shape tests together with the LFV-795 and LFV-796 fix sketches.

Workaround until a fix (not executed): an application that needs exact answers can re-check CONTAINS hits against the source geometry.

Add the whole-ring scan to the bridge flag (validated by the checks) (recommended)

In Tessellator.eliminateHole (Tessellator.java:393-397) add || isEdgeFromPolygon(bridge, holeNode, false) as a fifth term. isEdgeFromPolygon walks the ring and returns the flag of the ring edge that contains both ends of the bridge (:1067-1083), so it adds true only when the bridge lies on a ring edge that is itself flagged as boundary; for a bridge along an internal edge the added term returns false (the four existing terms are unchanged). isMorton = false is right, because holes are eliminated before the ring is sorted by Morton code (:115-133). The finder wrote this line, and the independent reproducer reports writing the same line itself. Add the repro's tests (or the TestTessellator method) as regression tests, and a CHANGES line saying affected shapes must be reindexed.

           isPointInLine(bridge, bridge.next, holeNode)
               || isPointInLine(bridge, bridge.previous, holeNode)
               || isPointInLine(holeNode, holeNode.next, bridge)
-              || isPointInLine(holeNode, holeNode.previous, bridge);
+              || isPointInLine(holeNode, holeNode.previous, bridge)
+              // the bridge may also lie inside a longer ring edge that is not next to either end
+              || isEdgeFromPolygon(bridge, holeNode, false);

Trade-offs

  • Cost: one extra ring scan per bridged hole, only when the four cheap checks fail; fetchHoleBridge already walks the ring for every hole (twice on its normal path), so the work per hole stays linear in the ring size (read, not benchmarked).
  • Existing indexes: the flag is stored at index time, so the fix helps only shapes indexed after it.
  • Scope: it fixes the bridge flag only; ear clipping and split diagonals already use the scan.

What the checks validated

Gradle, scratch worktree at a9779ef3c193: the 4 repro tests pass; org.apache.lucene.geo.*, org.apache.lucene.document.*Shape* and the new class (45 suites, 393 test cases: 26 skipped (24 nightly-only, 1 awaits-fix, 1 other), 367 run; including TestTessellator (78 cases, 7 skipped, many through the exact-flag checkPolygon), TestLatLonShape, TestXYShape and the polygon, multi-polygon, line, point and doc-values shape-query suites) pass with assertions on (seed DEADBEEF) and off (5EED0794); :lucene:core:check -x test (format, ecjLint, forbiddenApis) passes. The full :lucene:core test suite was not run. The standalone runner passes the class with the fix shadowed (assertions on and off), and every outside box of the probe gives 0. Earlier stages: the same line cures 8.3.0, 8.4.0, 9.12.3, 10.5.2 and main in the version-independent check.

Use only the whole-ring scan

Replace the four isPointInLine terms with isEdgeFromPolygon(bridge, holeNode, false). Simpler and uniform with the other diagonals. Not run (no check executed this option).

Trade-offs

It changes behaviour beyond this bug: when an adjacent edge that contains the hole vertex is itself an internal bridge, the current terms say true and the scan returns that edge's false (read). That case would need its own analysis and tests.

Downstream note

Downstream relevance: a server that answers a geo-shape "contains" request with these queries returns the same false positive, so a decision taken on CONTAINS alone (a geofence or coverage check) can accept an area that leaves the polygon.

How it was found

A bounded check of the Tessellator on small integer grids (about 21 million tessellations of valid polygons with up to 4 holes, against exact oracles such as "an edge is flagged exactly when it lies on a ring") left missing-flag cases after every known fix; a traced lead turned them into this CONTAINS false positive.

The method in full170 words

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

  • The finder tessellated JTS-valid polygons with 0 to 4 holes on 6x6, 8x8 and 13x13 XY lattices and on grid-exact LatLon lattices, with every outer-ring start, every hole order (sampled for polygons with 4 holes), the 8 symmetric images and both checkSelfIntersections modes, about half of the holes touching something: about 21 million tessellations, compared with exact oracles, among them "Triangle.isEdgefromPolygon(k) is true exactly when edge k lies on a ring".
  • Every registered Tessellator fix was switched in as a patched class; the missing-flag rows that survived matched a lead an earlier unit had left unexecuted. A debug copy of the Tessellator traced it to the bridge expression, and the finder turned it into an end-to-end CONTAINS test through the public API and shrank it to the 3-ring lattice polygon (2026-10-27).
  • Then an independent reproducer, executing reviews by a model from another family (OpenAI GPT-5.6, refute and intended lenses), an intended-behaviour devil's advocate, an adjudication and a credibility audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): 220 logged queries over GitHub issues, PRs and commits, downstream projects, the mailing lists, the JIRA archive and the web, plus the changed files of 408 open PRs. Closest: #15739, which widened this same expression for another case, and #12020, a CONTAINS false positive from a lost flag elsewhere.

Searches5 searches

Verdict new. Nearest: #15739 / #15731 (widened this expression from 2 to 4 adjacent-edge checks for a tessellation failure; nothing about flags or CONTAINS), #12020 / #12022 (a CONTAINS false positive from a lost boundary flag, different site ShapeField.resolveTriangleType, fixed 2022; the repro still fails on 10.5.2), lucene-solr#771 / LUCENE-8620 (#9666) (the introducing change; its review comments are naming suggestions, none about this case), LUCENE-9251, LUCENE-9580 and LUCENE-10470 (flag-related tessellation failures fixed elsewhere), and the hole-bridge fixes #13980, #15211, #15657, #15700, #14974 and #11988. Only #12036 and #12295 among open PRs touch Tessellator.java, and neither changes the expression. Earcut has no edge flags, so earcut prior art does not apply. Keyword search cannot prove absence (first result page per query; one Q&A page could not be fetched).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits: apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, OpenSearch geospatial, Solr, mapbox/earcut2026-10-27the hunt's prior-report tool, three runs (147 queries over all legs below); 30 manual searches on apache/lucene and 19 on the downstream projects; 12 list-archive searches and 12 other manual checks (code searches, open-PR file scan, branch, commit and history reads, two JIRA entries, extra web searches) make 220no report; only the related items above
Open apache/lucene PRs2026-10-27changed files of all 408 open PRs; the two that touch Tessellator.java were readnone changes the expression
Mailing lists (dev@, java-user@, issues@, commits@) and the JIRA archive2026-10-27list and JIRA-archive queries through the tool, 12 manual list-archive searches, two JIRA entries read in fullno report
The web and code search2026-10-27web queries through the tool; a code search for the expressiononly apache/lucene has the expression; no fork adds the whole-ring check
Upstream branches2026-10-27current source of main 20ced71c1e6e, branch_10x and branch_10_6not fixed: the same four-term expression

History

Introduced by LUCENE-8620 (apache/lucene-solr#771, 2019-09-09, in 8.3.0), which added the edge flags; visible since CONTAINS shipped in 8.4.0 (#872). Wrong on every release executed from 8.3.0 to 10.5.2 and on main; 8.2.0 has no flags. The same one-line change cures 8.3.0, 8.4.0, 9.12.3, 10.5.2 and main.

Versions tested8 rows

Introduced by LUCENE-8620 "Update Tessellator logic to label if triangle edges belongs to the original polygon" (apache/lucene-solr#771 by iverase, merged 2019-09-09; 62001b9b965 on master, 252421bb77c on branch_8x), first released in 8.3.0. It set the bridge flag from two adjacent-edge checks, while the same commit used the whole-ring isEdgeFromPolygon for ear-clipping and split diagonals; its one reviewer (nknize) left naming suggestions (one review in state "changes requested") and then an "LGTM" comment; nothing in the review is about the bridge flag.

Visible since 8.4.0: "LUCENE-8620: Add CONTAINS support for LatLonShape and XYShape" (apache/lucene-solr#872, 2ef2ddd77c5 / a06a2ea3da2, 2019-12-11) added the only relation that reads the flags.

Later edits did not cover this case: 2d6ad2fee6d (LUCENE-9570, reformatting), 748570531e0 (#15700, rename to bridge), faef795a460 (#15739, 2 to 4 adjacent-edge checks, first in 10.5.0). Not fixed: upstream main 20ced71c1e6e, branch_10x and branch_10_6 (read, 2026-10-27). Releases between the executed ones are affected by inference: the expression never gained a non-adjacent check. Before 9.1.0 only the LatLon CONTAINS path was executed (the version-independent main()); in 8.3.x and 8.4.x Tessellator, LatLonShape and XYShape lived in the lucene-sandbox module, in core from 8.5.0.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's class 3 of 3 fail (assertions on and off), with the TestTessellator method 4 of 4 through gradle; the finder's test 4 of 4
10.5.2 affectedthis doc's class 3 of 3 fail (assertions on and off); the probe matches main
9.12.3 affectedthis doc's class 3 of 3 fail (assertions off); the finder's and the reproducer's tests fail
10.4.0, 10.3.2, 9.12.2, 9.1.0 affectedthe finder's test 4 of 4 fail
8.4.0, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 10.0.0 main()wrong flag 16/16 and CONTAINS false positive 16/16 (version-independent check, LatLon)
8.3.0, 8.3.1 main()wrong flag 16/16 in the tessellation and the stored triangles; no CONTAINS relation yet
8.2.0 main()no edge flags and no CONTAINS: not applicable
fix on a9779ef3c193 not affectedthe 4 repro tests pass; 45 geo and shape suites (393 test cases, 367 run, 26 skipped) pass, assertions on and off; the same line also cures 8.3.0, 8.4.0, 9.12.3 and 10.5.2

Independent checks

An independent reproducer, executing cross-model reviews, a devil's advocate running upstream's own flag oracle (32 of 32 mismatches) and an adjudication (confirmed, high) found it real; a credibility audit added polygon, line and circle queries. This doc's runs re-confirmed it and validated the fix through gradle.

Checks and verdicts384 words

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

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateLattice translation validation, traced lead, public-API JUnit: 4 of 4 fail on main (assertions on and off) and six releases; with the fix 4 of 4 pass and 123 upstream geo-shape tests pass; with the LFV-210, LFV-757 and LFV-762 fixes applied it still fails.
cross-model review, refute lens real (high)An independent reviewer from another model family (OpenAI GPT-5.6), executing: its own public-API tests (one with checkSelfIntersections=true) fail with assertions on and off and pass with the fix; root cause agrees; inputs in contract; intended argument weak.
Independent reproducer realOwn JUnit from the claim, another shell and other holes: 32 of 32 two-hole cases wrong, always on (2,0)-(5,0); the single-hole control passes; fails on main, 10.5.2 and 9.12.3; reports writing the same one-line fix itself (5 of 5 pass).
Cross-model review, intended lens real (high)Executing: its own tests on a translated and scaled triangle fail on main, 10.5.2 and 9.12.3; a direct flag test isolates the flag; a rectangle with other holes does not trigger it; "constant-time shortcut" case rated weak.
Intended-behaviour devil's advocate real (high)Ran upstream's checkTriangleEdgesFromPolygon verbatim: 32 of 32 mismatches on main and 9.12.2, 0 with the fix; no intended, documentation, misuse or reachability argument survived.
adjudication confirmed (high)Re-ran the independent reproduction's test (seed 5EEDF00D, 4 of 5 fail with assertions on and off, 5 of 5 pass with the fix) and the finder's (assertions off, 4 of 4 fail); duplicate of none.
credibility audit keepOwn test: the false positive also for XY polygon, line and circle CONTAINS queries and for a rectangle, a pentagon and a wider triangle with the same holes; fails on main and 10.5.2, passes with the fix.
this doc's runs reproduced; fix validatedThe 3-test class fails on stock main (gradle and the standalone runner), 10.5.2 and 9.12.3, and the TestTessellator method on main (gradle); its shell-vertex variant fails the same way; JTS reports both variants valid; with the fix 45 geo and shape suites pass with assertions on and off, and the core static checks pass; the box probe shows the 99.6%-outside match.

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

Finding record

LFV-794, snapshot of 2026-11-06

Registry title
Tessellator flags a hole bridge that lies inside a longer outer-ring edge as an internal edge, so LatLonShape and XYShape CONTAINS queries match boxes that stick out of a valid polygon whose two holes touch the same outer edge
Area
geo / Tessellator hole-bridge edge flags (LatLonShape and XYShape CONTAINS queries)
Priority
Low, rank 398: A wrong edge flag gives silent CONTAINS false positives for every ring start when two holes touch the same outer edge, so its trigger is broader than LFV-793's, but it only affects the less used CONTAINS relation.
Impact class
silent wrong results
Impact note
Hits applications on 8.4.0 to 10.6.0 (10.6.0 by reading) and main that index polygons with holes through the default LatLonShape or XYShape createIndexableFields (default codec and IndexWriterConfig, either checkSelfIntersections setting, no scale needed) and query them with the CONTAINS relation, when two holes each touch the same outer-ring edge at one point (as vertices of both rings or in the interior of the outer edge; for every outer-ring start vertex and both hole orders): the piece of the outer edge between the two touch points is indexed as an internal edge, so a CONTAINS query whose box leaves the polygon through that piece matches the document with no error; only false positives were seen (INTERSECTS, the inside control box and single-hole polygons are correct), only box query geometries were executed (polygon, line and circle CONTAINS read the same flags but were not run), shape doc-values queries do not support CONTAINS, the wrong flag is stored in the index so a fix needs reindexing of affected shapes [read], and how often real data has this layout was not measured.
Summary
You index a valid polygon whose two holes each touch the same straight outer edge at one point, for example the triangle (0,0) (7,12) (12,0) with the holes (2,0) (5,1) (3,3) and (5,0) (8,1) (6,3), both touching the bottom edge y = 0, and you expect a CONTAINS box query to match only boxes that lie inside the polygon. While joining the second hole to the ring, Tessellator.eliminateHole draws a bridge from (5,0) to (2,0) that runs along the outer edge but marks it as an internal edge, because it only compares the bridge with the ring edges next to its two ends, so the indexed triangle loses its 'belongs to the polygon' flag on that piece of the boundary. LatLonShape and XYShape CONTAINS queries then match the box x [3,4], y [-0.5,0.3], which sticks out below the polygon, with no error, on every release executed since 8.4.0.
Introduced
62001b9b9651e54b54f73352801061d40da75168 on main and its branch_8x backport 252421bb77c (the commit 8.3.0 shipped): LUCENE-8620 'Update Tessellator logic to label if triangle edges belongs to the original polygon', apache/lucene-solr#771 by iverase, merged 2019-09-09. It added the triangle edge flags and set the bridge flag only from the ring edges adjacent to the bridge ends (two isPointInLine checks), although the same commit added the whole-ring isEdgeFromPolygon scan and used it for ear-clipping and split diagonals [read: git log -S/-L/-G on the expression; exec: 8.2.0 has no flag API, 8.3.0 fails the flag oracle 16 of 16, and adding the whole-ring check to each release's own expression removes every wrong flag and every wrong CONTAINS match on 8.3.0, 8.4.0, 9.12.3, 10.5.2 and main]. The user-visible symptom needs the CONTAINS relation, added by 2ef2ddd77c5 (apache/lucene-solr#872, LUCENE-8620 'Add CONTAINS support for LatLonShape and XYShape', 2019-12-11; branch_8x a06a2ea3da2, first released in 8.4.0). Later edits of the expression do not cover a bridge inside a non-adjacent ring edge: 2d6ad2fee6d (LUCENE-9570 reformat), 748570531e0 (#15700, rename to 'bridge') and faef795a460 (#15739, two to four adjacent-edge checks, first in 10.5.0) (2019-09-09 (edge flags, 62001b9b965 / 252421bb77c); 2019-12-11 (the CONTAINS relation that reads them, 2ef2ddd77c5 / a06a2ea3da2))
First affected version
8.4.0 [exec] for the wrong CONTAINS result (the first release with CONTAINS on LatLonShape and XYShape); the wrong flag itself is already in 8.3.0's Tessellator.tessellate output and stored triangle encoding [exec], where no query relation reads it; 8.2.0 has no edge flags and no CONTAINS [exec]
Affected versions
every release from 8.4.0 (the flag is wrong from 8.3.0) through 10.6.0 (10.6.0 by reading) and 9.12.3 (the latest 9.x), and main a9779ef3c193. [exec] the follow-up's version-independent main() check (flag oracle of upstream TestTessellator plus a LatLon CONTAINS box check, 16 cases) is wrong in 16 of 16 cases on 8.4.0, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da), flag only on 8.3.0 and 8.3.1 (no CONTAINS yet), and not applicable on 8.2.0; the finder's JUnit (XY and LatLon) fails 4 of 4 on main (-ea, -da), 10.5.2 (-ea, -da), 10.4.0, 10.3.2, 9.12.3, 9.12.2 and 9.1.0; the independent reproduction's JUnit fails 4 of 5 on main, 10.5.2 and 9.12.3 and its main() variant on 9.0.0 and 8.11.4; the cross-model reviewer's own tests fail on 10.5.2 and 9.12.3; the intended-behaviour check's upstream-oracle probe mismatches 32 of 32 on main and 9.12.2. Before 9.1.0 only the LatLon box path was executed. Releases in between were not executed and are affected by inference (the expression never gained a non-adjacent check [read]). Not fixed upstream: main 20ced71c1e6e, branch_10x and branch_10_6 still carry the four-term expression [read, 2026-10-27].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record, verdict new): 220 recorded queries, including three scripted prior-report searches (the finder's 45 queries, 229 hits; follow-up runs A and B, 51 queries each, 292 and 227 hits, over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and ingested web results), 30 manual searches on apache/lucene and 19 on Elasticsearch, OpenSearch, OpenSearch geospatial, Solr, lucene-solr and mapbox/earcut, the changed files of all 408 open apache/lucene PRs (only #12036 and #12295 touch Tessellator.java; neither changes the expression), a code search for the expression and 12 list-archive searches. Closest items, none a report of this bug: #15739 / #15731 (widened this exact expression from 2 to 4 adjacent-edge checks to fix a tessellation failure for a hole touching another hole mid-segment; nothing about flags or CONTAINS), #12020 / #12022 (the same symptom class, a CONTAINS false positive from a missing boundary flag, but a different defect and fix site, ShapeField.resolveTriangleType for flat triangles, fixed 2022-12), lucene-solr#771 / LUCENE-8620 (#9666) (the introducing change; no comment mentions this case), LUCENE-9251, LUCENE-9580 and LUCENE-10470 (flag-related tessellation failures fixed in filterPoints, isValidDiagonal and splitEarcut), and the hole-bridge fixes #13980, #15211, #15657, #15700, #14974 and #11988. Not fixed upstream: main 20ced71c1e6e, branch_10x and branch_10_6 still carry the four-term expression.
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27 and 28) through the standalone JUnit runner (assertions on and off) and through gradle in a scratch worktree, stock and with the fix; the 10.5.2 (assertions on and off) and 9.12.3 release jars; earlier stages ran a version-independent main() on 12 releases from 8.2.0 to 10.5.2 and the finder's JUnit on 10.4.0, 10.3.2, 9.12.2 and 9.1.0. JDK 25.0.3 on Linux aarch64 only.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:40-52 (at a9779ef3c193) (class javadoc NOTES: 'Requires valid polygons ... Holes may only touch at one vertex'), :72 (@lucene.internal), :236-243 (createDoublyLinkedList ends with filterPoints), :386-399 (eliminateHole: the comment 'compute if the bridge overlaps with a polygon edge' and the four-term fromPolygon expression at :393-397, splitPolygon at :399), :536-566 (fetchHoleBridge scans the ring), :650-656 (ear clipping copies isNextEdgeFromPolygon into the Triangle; caFromPolygon uses isEdgeFromPolygon), :1067-1083 (isEdgeFromPolygon, the whole-ring scan), :1175-1197 (splitPolygon stores the flag on both bridge edges at :1182 and :1191), :1723-1726 (Triangle.isEdgefromPolygon javadoc 'get if edge is shared with the polygon for the given edge'); Tessellator.java byte-identical at a Lucene main checkout ea57360912ed (diff -q)
  • lucene/core/src/java/org/apache/lucene/document/ShapeField.java:80-92 (at a9779ef3c193) (the Triangle field stores the three flags), :126-127 (QueryRelation.CONTAINS 'used for CONTAINS Queries'); lucene/core/src/java/org/apache/lucene/document/SpatialQuery.java:947-1001 (withinTriangle: NOTWITHIN only when the box crosses a flagged edge); lucene/core/src/java/org/apache/lucene/geo/Component2D.java:101-115 (WithinRelation javadoc: CANDIDATE when the query 'intersects only edges that do not belong to the original shape', NOTWITHIN when it 'intersects an edge that does belong to the original shape')
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:85-114 (at a9779ef3c193) and lucene/core/src/java/org/apache/lucene/document/XYShape.java:83-112 (createIndexableFields and createDocValueField call Tessellator.tessellate), LatLonShape.java:238 and XYShape.java:204 (newBoxQuery javadoc says 'intersect a defined bounding box' for every relation), LatLonShape.java:304 and XYShape.java:237 (newPointQuery javadoc: 'comply the QueryRelation'); lucene/core/src/java/org/apache/lucene/document/BaseShapeDocValuesQuery.java:49 (shape doc-values queries 'do not yet support CONTAINS')
  • lucene/CHANGES.txt:6049 (at a9779ef3c193) (8.4.0 New Features: 'LUCENE-8620: Add CONTAINS support for LatLonShape and XYShape') and :6219-6220 (8.3.0: 'Tessellator labels the edges of the generated triangles whether they belong to the original polygon'); lucene/core/src/test/org/apache/lucene/geo/TestTessellator.java:1016-1057 (checkPolygon -> checkTriangleEdgesFromPolygon asserts exact flags)
The finding in 30 seconds
  • a Lucene main checkout @ a9779ef3c193 lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:243, 309-325, 392-399, 536-592, 650-656, 1067-1083, 1175-1197, 1424-1441, 1723-1724
  • a Lucene main checkout @ a9779ef3c193 lucene/core/src/java/org/apache/lucene/document/ShapeField.java:80-92; SpatialQuery.java:368-381, 591-650, 947-1001; LatLonShape.java:85-114; XYShape.java:83-112; geo/Component2D.java:101-115; geo/Rectangle2D.java:180
  • a Lucene main checkout @ a9779ef3c193 lucene/core/src/java/org/apache/lucene/document/LatLonShapeQuery.java:165-209, XYShapeQuery.java:147-191, LatLonShapeBoundingBoxQuery.java:160-205 (only contains() calls withinTriangle)
Who is affected
  • a Lucene main checkout @ a9779ef3c193 lucene/core/src/java/org/apache/lucene/document/BaseShapeDocValuesQuery.java:47-49; geo/Tessellator.java:115-133 (Morton ordering after hole elimination); document/ShapeField.java:80-92
Why it looks unintended
  • a Lucene main checkout @ a9779ef3c193 lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:47, 72, 392, 1723-1724; geo/Component2D.java:101-115; geo/Polygon.java:31-32; document/LatLonShape.java:238; document/XYShape.java:204
  • a Lucene main checkout @ a9779ef3c193 lucene/CHANGES.txt:6049, 6219-6220
  • a Lucene main checkout @ a9779ef3c193 lucene/core/src/test/org/apache/lucene/geo/TestTessellator.java:1011-1024, 1047-1120 (checkPolygon, checkTriangleEdgesFromPolygon)
Suggested fix
  • a Lucene main checkout @ a9779ef3c193 lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:115-133, 393-397, 536-592, 1067-1083
History
  • apache/lucene-solr#771 reviews, review comments and issue comments (read-only GitHub API, 2026-10-28): one review by nknize, state CHANGES_REQUESTED, 11 inline naming comments, then "Awesome. LGTM! Thanks!"

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

Repro files

About the diagrams

Made from the code at main a9779ef3c193 and an executed trace of this example (a plain main()); the PNG is the summary still. The movie uses the shell-vertex variant (the outer ring also lists (2,0) and (5,0)), which filterPoints reduces to the same ring. Its triangle mesh shows the flags that Tessellator.tessellate computes. A dump of the flags as stored in the index would show one more wrong flag in this example, on the boundary edge (7,12)-(12,0) of a triangle that is DISJOINT from both boxes, so no count changes; that is a separate hunt row (LFV-204, the clockwise flip in ShapeField.encodeTriangle).

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.