All findings · Lucene finding · LFV-757 · tessellator-shared-vertex-copies-break-later-hole-bridge

Tessellator can bridge a later hole across an earlier hole that touches the outer ring, so a valid polygon is indexed covering its own hole

Since 9.12.3 and 10.4.0, when one hole of a valid polygon touches the outer ring at a corner and a later hole's left-most corner is level with it, LatLonShape and XYShape can silently index triangles covering the first hole: a point inside it matches INTERSECTS. With checkSelfIntersections=true it is rejected.

Summary

Priority
Low, rank 396It has the same profile as its Tessellator sibling LFV-210 at the top of Low, silently covering a hole or rejecting a valid polygon when one hole touches the ring at a vertex, so as the only other silent Tessellator row.
Impact
silent wrong results
Since
9.12.3 and 10.4.0 (#15211, merged 2025-09-23)introduced 2025-09-23 · about 1 year
Affected
9.12.3, 10.4.0 to 10.5.2, 10.6.0 (by reading) and main; 10.3.x and 9.12.2 are correct
Status
documented
Found by
translation validation of the Tessellator against exact geometry, then hand reduction

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

To index a polygon, Lucene cuts it into triangles, and a shape query matches the document if a triangle matches. Holes are first joined to the outer ring by cuts called bridges, one hole at a time from left to right.

Diagram of the bug: a 10 x 10 square dented in to the corner V = (2,5), with hole A touching V and hole B to its right; after hole A is joined at V, two pairs of copies of V stay in the ring, hole B's bridge goes to (0,10) and crosses hole A instead of going to V, and the resulting triangles cover hole A, so a point inside hole A matches.
The polygon with its two holes, the bridge hole B should get (to V) and the one it gets (to (0,10), across hole A), and the triangles that then cover hole A. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index a 10 x 10 square whose left side is dented in to the corner V = (2,5), with two triangular holes: hole A (2,5) (5,6) (4,8) touches the outer ring only at V, which the Tessellator javadoc allows; hole B (6,5) (8,4) (8,6) touches nothing, and its left-most corner is level with V.
  2. You expect triangles that cover exactly the polygon (area 90 - 3.5 - 2 = 84.5), so the point (3.75, 6.5), inside hole A, does not match an INTERSECTS query.
  3. Instead the 11 triangles add up to 112.5 (they overlap, and one is emitted twice) and together cover all of hole A: (6,5) (0,10) (2,5) covers most of it, including the point, and (10,10) (0,10) (6,5) the rest. The point matches INTERSECTS (1 hit) and not DISJOINT (0) for XYShape and LatLonShape; a small box inside hole A also matches the XYShape doc-values field. With checkSelfIntersections=true: Polygon self-intersection.
  4. Why: Lucene joins each hole to the outer ring with a cut (a bridge), left to right. Joining hole A at V leaves two pairs of copies of V (four nodes) in the ring. Hole B's bridge should go left to V, but the corner test checks each copy's neighbours, finds its twin at the same point and rejects all four; the bridge goes to (0,10), through hole A.
  5. A regression from #15211, which stopped cleaning up those copies after each hole: 9.12.2 and 10.3.x tessellate this polygon exactly; 9.12.3, 10.4.0 to 10.5.2, 10.6.0 (by reading) and main do not.

Who is affected

Anyone indexing polygons with holes through LatLonShape or XYShape (triangle fields and shape doc values) on 9.12.3, 10.4.0 to 10.5.2, 10.6.0 (by reading) or main; the default path is silent. It needs a hole touching the outer ring at one corner V and a later hole whose left-most corner has exactly V's encoded y: rare, likelier in grid-snapped data.

Mechanism, terms and numbers732 words

The Tessellator joins each hole to the outer ring with a bridge, holes sorted left to right, before it clips ears. When a hole touches the outer ring at exactly one vertex V (allowed: "Holes may only touch at one vertex"), the merge at V leaves four nodes with V's coordinates in the ring, in two adjacent pairs; since #15211 they are filtered only once, after all holes. A later hole whose left-most vertex lies at exactly V's y first gets the far end of the outer edge that ends at V as its bridge candidate; the copies of V are the right candidates, but each has a same-coordinate neighbour, so isLocallyInside sees a zero area and rejects all of them. The bridge then crosses the earlier hole. On the default indexing path the triangles cover that hole and the document matches queries inside it, silently; with checkSelfIntersections=true the valid polygon is rejected. 9.12.2 and 10.3.x tessellate the repro polygon exactly; 9.12.3, 10.4.0 to 10.5.2, 10.6.0 (by reading) and main do not.

Terms

  • Tessellation: LatLonShape and XYShape index a polygon as triangles (Tessellator.tessellate); a point query matches the document when a triangle contains the point.
  • Bridge: to turn a polygon with holes into one ring, eliminateHoles joins each hole to the outer ring with a two-way cut from the hole's left-most vertex to a visible vertex of the ring, holes sorted by x (Tessellator.java:307-366, sort at :309-323).
  • Copies: splitPolygon (:1175-1199) links two vertices and adds a copy of each, so the ring can run along both sides of the cut. A cut of zero length (a hole merged at a vertex it shares with the ring) leaves copies with the same coordinates next to each other.

The bug, step by step

  1. Hole A (left-most x = 2) goes first. Its left-most vertex is the shared vertex V = (2,5), so maybeMergeHoleWithSharedVertices (:411-472) splits the ring there (:455-457). The ring becomes (0,0) (10,0) (10,10) (0,10) V V (4,8) (5,6) V V (debug trace).
  2. Nothing removes the copies before hole B. #15211 removed the filterPoints call after each split; duplicates are filtered once after all holes (:364-365). The per-hole filterPoints(result, result.next) (:347) removes nothing here: it tests only result, (0,0), against its two neighbours and stops (:1405-1453).
  3. Hole B's ray goes left from (6,5) along y = 5 (fetchHoleBridge, :536-592). The first edge it meets in ring order is (0,10) -> V, and the candidate is that edge's end with the smaller x (:555): (0,10). The edges (5,6) -> V and V -> (0,0) hit at the same x and do not replace it (:549).
  4. The search for a better vertex in the triangle (6,5) (0,10) (2,5) (:568-590) finds the four copies of V at angle 0, the right answer, but requires isLocallyInside(p, holeNode) (:583-584). That test starts with area(a.previous, a, a.next) and returns false if (area == 0) (:1251-1257). Each copy has its twin as a neighbour, so all four fail.
  5. The bridge (6,5)-(0,10) is kept (:388-399). It crosses hole A's edge (2,5)-(4,8) at (24/7, 50/7). Ear clipping of that ring produces 11 triangles that also cover hole A. With checkSelfIntersections=true, checkIntersection runs on the ring after hole elimination (:178-199) and reports the crossing point.

On 10.3.0 the copies were merged after each split, each V kept its real neighbours, and the copy between (5,6) and (0,0) passed the test, giving the bridge (6,5)-(2,5) along y = 5.

The numbers (main a9779ef3c193)

caseexpectedmain
tessellate(polygon, false), twice the triangle area169225 (11 triangles, a sum that counts overlaps; (8,4) (2,5) (10,0) twice, see LFV-792)
union of the triangles (JTS)84.588.036 = 84.5 + hole A 3.5 + hole B 0.036
each hole alone173, 176173, 176
XYShape / LatLonShape point (3.75, 6.5): INTERSECTS, DISJOINT0, 11, 0
XYShape doc values, box x 3.7-3.8, y 6.4-6.6, INTERSECTS01
tessellate(polygon, true)acceptedPolygon self-intersection at lat=7.142857142857143 lon=3.4285714285714284 = (24/7, 50/7)
hole B 0.5 off y = 5, or hole A 0.5 off the ringexactexact
variant: outer (0,0) (3,4) (0,8) (12,8) (12,0), A (3,4) (6,6) (4,7), B (8,4) (10,3) (10,5)acceptedUnable to Tessellate shape (default path)
Exposure and likelihood334 words
  • Who is hit. Applications on 9.12.3, 10.4.0 to 10.5.2, 10.6.0 (by reading) or main that index polygons with holes through LatLonShape or XYShape: createIndexableFields and createDocValueField (both executed). No custom codec or IndexWriterConfig: the JUnit runs under the test framework's randomized codec, and the diagram probe, a plain main, gets the same counts with Lucene's default codec; the defect is in tessellation, before any codec runs. No scale needed.
  • Default vs opt-in. The two-argument methods pass checkSelfIntersections=false (XYShape.java:83-90, LatLonShape.java:85-92), so the wrong triangles are indexed silently. Callers that pass true get an IllegalArgumentException for a valid polygon.
  • Effect. The document matches queries inside its own hole: executed for point INTERSECTS and DISJOINT and a doc-values box; WITHIN and CONTAINS evaluate the same triangles (by reading). Here all of hole A and a sliver of hole B are covered. Other documents and queries are not affected: queries do not tessellate.
  • What it takes. A hole touching the outer ring at exactly one vertex V, and a later hole whose left-most vertex has exactly V's encoded y, with no other vertex in the bridge search triangle that would be picked first. Moving either hole slightly makes the tessellation exact. One-vertex touches occur in real data (#15554, #15695 and #15731 reported valid polygons with touching holes); exact alignment is rare in arbitrary data and more likely in grid-snapped data. No public report of this symptom was found (see Prior reports), 13 months after 9.12.3.
  • The symptom varies. For other shapes with the same trigger the default path rejects the polygon (Unable to Tessellate shape) instead of covering a hole.
  • Wider sweep. In the finder's sweep of 3,000 valid lattice polygons with seven known geo fixes applied, 6 valid polygons were still rejected; the recommended fix removes 3 of those rejections (the other 3 have other causes). Two become exact; the third then shows a missing edge flag instead, so the count of wrong results drops from 8 to 6.

Minimal repro

The test shows that a valid XY polygon with two holes, one touching the outer ring at a single vertex, is tessellated over that hole, so a point inside the hole matches the indexed shape.

TestTessellatorSharedVertexLaterHoleBridge.java (JUnit test)53 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.search.Query;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTessellatorSharedVertexLaterHoleBridge extends LuceneTestCase {

  public void testPointInsideHoleDoesNotIntersect() throws Exception {
    XYPolygon holeA = new XYPolygon(new float[] {2, 5, 4, 2}, new float[] {5, 6, 8, 5});
    XYPolygon holeB = new XYPolygon(new float[] {6, 8, 8, 6}, new float[] {5, 4, 6, 5});
    XYPolygon polygon =
        new XYPolygon(
            new float[] {0, 2, 0, 10, 10, 0}, new float[] {0, 5, 10, 10, 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)) {
        Query q = XYShape.newPointQuery("shape", QueryRelation.INTERSECTS, new float[] {3.75f, 6.5f});
        // (3.75, 6.5) is strictly inside hole A, which touches the outer ring at (2,5): expected 0 hits, actual 1
        assertEquals(0, new IndexSearcher(r).count(q));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-757/, 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.TestTessellatorSharedVertexLaterHoleBridge
Expected vs actual
Expected 0 hits for an XYShape INTERSECTS point query at (3.75, 6.5) inside hole A; actual 1: expected:<0> but was:<1> (-ea and -da).

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

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<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.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.document.TestTessellatorSharedVertexLaterHoleBridge.testPointInsideHoleDoesNotIntersect(TestTessellatorSharedVertexLaterHoleBridge.java:49)

Why it looks unintended

  • The Tessellator javadoc allows "Holes may only touch at one vertex", and JTS accepts the polygon.
  • Upstream tests require exact area for holes on outer-ring corners (testComplexPolygon60); four earlier touching-hole failures were fixed as bugs (#15205, #15554, #15695, #15731).
  • 9.12.2 and 10.3.x tessellate this polygon exactly; #15211 set out to fix a rejection (#15205).
The full argument360 words
  • Javadoc (Tessellator.java:40-52): "Requires valid polygons: No self intersections; Holes must be inside the polygon; Holes may only touch at one vertex; Polygon must have an area". The repro meets every condition, and JTS IsValidOp reports it valid. (The XYPolygon/Polygon constructors check only the ring format, not topology: XYPolygon.java:48-90.)
  • Public entry points (LatLonShape.java:84, :94-97): "create indexable fields for polygon geometry"; "If checkSelfIntersections is set to true, the validity of the provided polygon is checked". The check rejects Lucene's own bridge, not the input.
  • Tests: TestTessellator.checkPolygon asserts that the tessellation's area equals the polygon's (TestTessellator.java:1016-1023); testComplexPolygon60 (:916-930) does so with delta 0.0 for twenty holes that touch the shell at four vertices (dented (1 5) and (5 1), convex (11 5) and (5 11)): exact area is required for holes on shell vertices, though that test is more complex than the repro and has no free hole level with a shared vertex.
  • CHANGES: GITHUB#13841 "Improve Tessellatorlogic when two holes share the same vertex with the polygon which was failing in valid polygons" (lucene/CHANGES.txt:2136-2137); #15205, #15554, #15695 and #15731 are bug-fix entries for the same family (:1467-1470, :1488, :1494, :1174).
  • History: #15211's CHANGES text moves co-planar removal to "once after all holes are removed instead of doing it for each removed hole"; its PR has one LGTM review and does not discuss later holes. The area == 0 rule in isLocallyInside dates from LUCENE-8775 (2019); in that version every split was still followed by filterPoints(node, node.next), so split copies were merged right away (by reading).
  • Reference algorithm: current mapbox/earcut still heals the points around both new slit edges right after each hole is joined (eliminateHole, with one narrow exception), and its locallyInside has no zero-area rule. Its source comment there is about the opposite risk, filtering too much ("leave bridge2 alone: dropping it as collinear would leave bridge lying on the healed edge, where a later hole could bridge into it from outside its sector", added for earcut #219), so it is no argument for per-hole filtering.
The devil's-advocate check164 words

The strongest case for "intended": the Tessellator is @lucene.internal (Tessellator.java:72) and a port of earcut, whose README says it "doesn't guarantee correctness of triangulation"; #15211 keeps the copies on purpose and #15700 relies on them; the zero-area rule is deliberate; and the default path skips validation.

Why it fails: earcut's disclaimer covers input that breaks its assumptions ("The input is assumed to be a valid polygon"), and this input is valid by JTS and by Lucene's own javadoc; the zero-length edges come from Lucene's splitPolygon, not from the input. The public createIndexableFields reaches the code with the default flag, and the strict path is wrong too: it rejects a valid polygon. Keeping copies is a design choice; covering a hole is not its intended outcome. The cross-model reviewer rated the intended-behaviour argument weak in all three of its reviews and in its fact check; the devil's-advocate checker found none of its eight 'intended' arguments holds; the other checkers gave no rating.

Suggested fix

Recommended isLocallyInside looks past same-coordinate neighbours

Make isLocallyInside look past neighbours that are copies of the vertex, so each copy is judged by the vertex's real neighbours (+20/-8 lines in Tessellator.java). With it the repro and all geo and document tests pass (697 tests, two seeds, assertions on and off), nightly geo and shape tests and lint too.

private static boolean isLocallyInside(final Node a, final Node b) {
  // skip copies of a (zero-length edges left by a shared-vertex hole merge) to find the
  // vertex's real neighbours
  Node prev = a.previous;
  while (prev != a && isVertexEquals(prev, a)) {
    prev = prev.previous;
  }
  Node next = a.next;
  while (next != a && isVertexEquals(next, a)) {
    next = next.next;
  }
  return isLocallyInside(prev, a, next, b);
}
All options and trade-offs5 options · 682 words

One fix was validated with gradle in a scratch worktree, a narrower variant with the standalone runner; two filtering variants were tried and break existing tests. The fix does not cover three sibling Tessellator findings (LFV-755, LFV-762, LFV-795), which still fail with it alone and need their own changes.

isLocallyInside looks past same-coordinate neighbours (validated by the checks) (recommended)

Tessellator.java only. The copies stay in the ring, so the #15211 and #15700 logic that relies on them is untouched; only the corner test sees each copy with the neighbours of the vertex it stands for. The old body becomes isLocallyInside(aprev, a, anext, b).

private static boolean isLocallyInside(final Node a, final Node b) {
  // skip copies of a (zero-length edges left by a shared-vertex hole merge) to find the
  // vertex's real neighbours
  Node prev = a.previous;
  while (prev != a && isVertexEquals(prev, a)) {
    prev = prev.previous;
  }
  Node next = a.next;
  while (next != a && isVertexEquals(next, a)) {
    next = next.next;
  }
  return isLocallyInside(prev, a, next, b);
}

Trade-offs

  • isLocallyInside is also used by ear clipping (:875-876, isValidDiagonal :1205-1225). After the final filterPoints(outerNode, null) adjacent copies are normally gone, so ear clipping should be unchanged, but that rests on the test suites, not on a proof.
  • The loops stop at a, so they terminate; each is linear in the number of consecutive same-coordinate neighbours, so a vertex without copies costs one extra comparison per side.
  • In the repro both copies of the pair between (5,6) and (0,0) then pass, and the tie rule of fetchHoleBridge (:583) keeps the first one met; the effect beyond this case rests on the test suites.
  • Backport: the same wrapper applies to 10.5.2 and 9.12.3 (9.x keeps its strict < 0 comparisons); with it the repro passes on both release jars. Add a CHANGES entry under Bug Fixes.

What the checks validated

  • Scratch worktree at a9779ef3c193, gradle. Stock: the repro fails 5 of 5; TestTessellator with testComplexPolygon66 added fails 1 of 78 (two seeds).
  • With the fix: org.apache.lucene.geo.* and org.apache.lucene.document.*, 77 classes, 697 tests (33 skipped), 0 failures with seed DEADBEEF and assertions on, and with seed 1234ABCD and assertions off; -Ptests.nightly=true for geo.* and document.*Shape*, 393 tests, 0 failures; checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass. The worktree was removed afterwards.
  • Shadowed onto stock jars: the repro passes 5 of 5 with assertions on and off; also on the 9.12.3 and 10.5.2 jars with the change applied to their own sources. Earlier checks with the same change: an independent 7-test repro and TestTessellator 70 of 70 on two seeds.

Skip copies only in the bridge search

Leave isLocallyInside as is and call a copy-skipping variant only at fetchHoleBridge's candidate check (:584).

Trade-offs

Leaves ear clipping untouched by construction. Validated less: the repro (5 of 5) and TestTessellator (71 run, 0 failed) pass with the standalone runner; not run through the wider suites.

Restore per-hole filtering of the copies (not recommended as is)

Filter the split nodes after each hole again, as before #15211.

Trade-offs

Both forms tried pass the repro but break existing tests: filterPoints(split, split.next) after the two shared-vertex splits fails 4 of 70 TestTessellator tests (testComplexPolygon17/41/60/61, 61 being #15211's own test); merging only adjacent identical nodes after each hole fails testComplexPolygon41 and 60 (Unable to Tessellate shape). Later shared-vertex merges need the copies.

Follow current earcut more closely (untested)

Re-filter after every hole and break equal-coordinate ties by which copy's sector contains the hole, as earcut's findHoleBridge does.

Trade-offs

A larger change; Lucene has a shared-vertex merge that earcut does not.

Revert #15211 (not a fix)

Go back to filtering after every split.

Trade-offs

On main and 10.4.0, undoing exactly #15211's three hunks still fails this repro, because #15657's leftmost-shared-vertex split never had a filter; filtering after every split site passes the repro but was not run through TestTessellator. A revert would also remove the code that fixed #15205 (the independent reproduction's narrower restore fails #15211's own test for it).

Downstream note

Downstream: an application that makes access or alerting decisions from shape relations on user-supplied polygons (a geofence, say) could see that one document match inside its own hole.

How it was found

Translation validation: a harness tessellated 3,000 valid polygons with up to four holes on a 13 x 13 integer grid, many with holes touching at a corner, and checked every result exactly (inside, no overlaps, exact area). With seven known geo fixes applied, 6 valid polygons were still rejected; tracing them gave this mechanism, reduced by hand.

The method in full175 words

Method: translation validation of the stock Tessellator.tessellate against exact geometric checks (semi-formal; no model checker or prover found it).

  • The harness generated 3,000 polygons that JTS calls valid, with 0 to 4 holes on a 13 x 13 integer lattice (seed 585), about half with holes touching each other or the outer ring at a vertex. For each it checked: no rejection, every triangle inside the polygon, pairwise disjoint interiors, exact doubled-area equality, and edge flags equal to "the triangle edge lies on a ring".
  • With the fixes of seven registered geo bugs shadowed, 8 polygons were still wrong, 6 of them valid polygons that were rejected. Minimising them by hole subsets and tracing hole elimination with a debug copy of the Tessellator showed the copies of the shared vertex and the rejected bridge candidates.
  • The mechanism was then reduced by hand to the 3-ring polygon of the repro, where the default path covers a hole without any error. With the recommended fix, 3 of the 6 rejections disappear; the other 3 have other causes.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27, re-checked 2026-10-28) in Lucene's GitHub issues, PRs, commits and review comments, JIRA, the mailing lists, downstream projects or the web. Nearest: #15700 (the same leftover copies, another consumer, fixed) and earcut #219/#221 (an analogous problem in the original library, different code).

Searches4 searches

Verdict new: no report by anyone. #15211 / #15205 introduced it / fixed what it fixed; #15700 / #15695 deal with the same leftover copies after #15211 but a different consumer (a wrong copy chosen when several holes share one leftmost vertex), and main has that fix and still fails; #15657, #15739, #13980 and #13841 are the same family with other mechanisms, all fixed. Outside Lucene, mapbox/earcut #219 and #221 (someone else, October 2026) report a later-hole bridge problem in the JavaScript original, where filtering removed a copy of an earlier bridge vertex so that a vertex outside its sector was accepted. That is a different defect, not a report of this one: different code (earcut's own 3.1.0 change; earcut has no shared-vertex merge), the opposite cause (a copy filtered away, not copies kept), and current earcut has neither ingredient of this bug. Cited as context. Gaps: one GitHub query hit the rate limit (covered by the full listing); downstream coverage is search-based; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@ and commits@; JIRA archive; downstream repositories; the web2026-10-27the hunt's prior-report tool, two runs of 51 queries each: fetchHoleBridge, isLocallyInside, maybeMergeHoleWithSharedVertices, eliminateHoles, splitPolygon, filterPoints, "Unable to Tessellate shape", "Polygon self-intersection", "tessellator regression 15211", hole touching outer ring vertex61 and 205 distinct hits; none describes this defect
GitHub (apache/lucene and three downstream projects)2026-10-2716 manual searches; a full listing of all 2,146 apache/lucene issues and PRs updated since 2025-09-15, grepped for Tessellator terms; the changed files of all 408 open PRs; the review comments of #15211, #15657, #15700, #15739, #13980 and #14974only the known Tessellator items (#15205, #15211, #15554, #15657, #15695, #15700, #15731, #15739, #15764); open PRs #12036 and #12295 are refactorings
mapbox/earcut; mailing-list archives; the web2026-10-27all 221 earcut issues and PRs listed, #45, #219, #220 and #221 read; direct list-archive searches; 8 web searchesearcut #219/#221: an analogous earcut defect (different code, opposite cause), not a report of this bug; nothing else
GitHub issues and PRs (apache/lucene); the commit list of Tessellator.java2026-10-28search "Tessellator" sorted by updatenothing after #15731/#15739; the file unchanged upstream since 2026-06-14

History

Introduced by #15211 (d04c5e3530a, merged 2025-09-23), which filters the copies once after all holes instead of after each one; first released in 9.12.3 and 10.4.0. Bisected on the file's history and isolated on release jars; 10.3.x never got it.

Versions tested8 rows

Introduced by #15211 "[Tessellator] Fix failure due to hole bridge being coplanar with polygon edge" (fixes #15205), by iverase, merged 2025-09-23 as d04c5e3530a on main; backports 8af285735be (branch_10x, in 10.4.0 to 10.6.0) and 911b518aa8a (branch_9_12, in 9.12.3). It removed filterPoints(node, node.next) after the bridge split and after the shared-vertex split and made eliminateHoles end with return filterPoints(outerNode, null).

Later code on the same path: #15657 (main 43792b9870e, in 10.4.0 through e59cb5bca5d) added the leftmost-shared-vertex split without a per-split filter, so on 10.4.0 and main undoing only #15211's hunks still fails, while filtering after every split site passes the repro. #15700 (main 748570531e0, in 10.4.0 through 8073c18bfd8) moved the start of the existing per-hole filter, there since LUCENE-8396 (2018), from outerNode to result; neither form removes these copies. Not fixed upstream.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedrepro 5 of 5 fail, assertions on and off, through gradle and the standalone runner
10.4.0, 10.5.0, 10.5.1, 10.5.2 affectedfail (this doc's repro on 10.4.0 and 10.5.2; the finder's on all four)
9.12.3 affectedfail; the #15211 backport is its only Tessellator.java change after 9.12.2
10.3.0, 10.3.1, 10.3.2 not affectedexact; branch_10_3 never got the backport
9.11.1, 9.12.0, 9.12.1, 9.12.2, 10.0.0, 10.1.0, 10.2.0, 10.2.2 not affectedexact (10.2.1 not run: its Tessellator.java is byte-identical to 10.2.2's)
4b821699e36 vs d04c5e3530a (Tessellator.java on main jars) bisectthe parent passes, #15211 fails; the later logic versions run (#15657, #15700, #15739) and main fail; #16063 and #16255 change no logic and were not run separately
9.12.2 + backport file / 9.12.3 - backport file / 10.3.2 + #15211 hunks isolationfail / pass / fail
fix, scratch worktree at a9779ef3c193 not affectedrepro and 697 geo and document tests pass; nightly and lint pass

Independent checks

An independent reproducer, a devil's-advocate checker and an adjudication (fresh seed) confirmed it; a cross-model reviewer (OpenAI GPT-5.6, reading only) agreed with the root cause and rated the 'intended' case weak. An audit re-ran it on releases; this doc re-ran it on main and six releases and validated the fix; fact checks re-ran it.

Checks and verdicts474 words

The checks ran on main a9779ef3c193; the cross-model reviews read the code at ea57360912ed, where Tessellator.java is byte-identical. The cross-model reviewer could not run code (read-only access); all execution evidence comes from the other checkers and separate re-runs.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateTranslation validation over 3,000 lattice polygons; a 4-test JUnit fails on main, 10.4.0, 10.5.2 and 9.12.3 and passes on 10.3.0 and 9.12.2; the fix sketch passes it and 123 upstream geo shape tests; LFV-210's fix alone changes nothing.
cross-model review (refute lens) not real (low), on procedure onlyOpenAI GPT-5.6, reading only: it could not run code; every substantive point it listed supports real, and it rated the intended case weak. Separate re-run: 4 of 4 fail.
cross-model review (root-cause lens) uncertain (low); root cause agreesReading only; agrees that the copies from splitPolygon meet the zero-area return in isLocallyInside. Separate re-run: 4 of 4 fail.
independent reproducer realOwn 7-test JUnit fails on main (on and off), 10.5.2, 10.4.0 and 9.12.3, passes on 10.3.0 and 9.12.2; a variant polygon is rejected; 5 controls pass; its own re-filtering fix passes the repro but breaks 4 TestTessellator tests.
cross-model review (intended lens) real (medium)OpenAI GPT-5.6, reading only: the javadoc permits one-vertex touches, the public API reaches the code, and upstream tests require exact area. A separate scaled probe fails on main, 10.4.0 and 9.12.3 and passes on 10.3.0, 9.12.2 and with the fix.
devil's advocate real (high)JTS: the polygon is valid; the triangles cover all of hole A plus 0.036 of hole B; none of eight 'intended' arguments holds.
adjudication confirmed (high)Re-ran the reproducer's JUnit with seed 5EEDF00D: 7 of 7 fail, on and off; 0 of 7 with the fix.
audit keepRe-ran on main (on and off), 10.5.2 and 9.12.3 (fail), 10.3.2 and 9.12.2 (pass), and with the fix (pass); checked the crossing point by arithmetic.
this doc's runs reproduced; fix validatedNew 5-test repro, including shape doc values: fails on main, 10.5.2, 10.4.0 and 9.12.3, passes on 10.3.2, 10.3.0 and 9.12.2; the fix validated with gradle, backported onto two releases; two filtering variants break existing tests.
fact check of this doc holds; wording fixedRe-ran the gradle repro on stock main with assertions on and off (5 of 5 fail each), the standalone runs on main, 9.12.2, 9.12.3, 10.3.2, 10.4.0 and 10.5.2 and with the fix; replayed isLocallyInside by hand on the traced ring; clipped the triangles exactly. Four wording or attribution errors, all fixed.
cross-model fact check real; wording and caveats fixedOpenAI GPT-5.6, reading only: the mechanism, the regression boundary and fix option A are well supported; the intended case is weak. Its wording errors, overclaims and missing caveats were fixed or answered.

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

Finding record

LFV-757, snapshot of 2026-11-06

Registry title
Tessellator: after a hole that touches the outer ring at one vertex is merged, the leftover copies of that vertex make a later hole's bridge cross the first hole, so LatLonShape and XYShape index a valid two-hole polygon with triangles that cover a hole, or reject it (regression from #15211, in 10.4.0 and 9.12.3)
Area
geo / Tessellator hole elimination (polygon indexing for LatLonShape and XYShape)
Priority
Low, rank 396: It has the same profile as its Tessellator sibling LFV-210 at the top of Low, silently covering a hole or rejecting a valid polygon when one hole touches the ring at a vertex, so as the only other silent Tessellator row.
Impact class
silent wrong results
Impact note
Hits applications on 9.12.3, 10.4.0-10.5.2, 10.6.0 (by reading) or main that index polygons with holes through the default LatLonShape or XYShape createIndexableFields (default codec and IndexWriterConfig, no scale needed; the shape doc-values fields use the same tessellation, by code reading) when one hole touches the outer ring at exactly one vertex V and a later hole's left-most vertex lies at exactly the same encoded y as V, with no other vertex in the bridge search triangle picked first: on the default path (checkSelfIntersections=false) the triangles silently cover the first hole (and a sliver of the second), so a point inside the hole matches INTERSECTS and not DISJOINT; with checkSelfIntersections=true, and for other shapes also on the default path, the valid polygon is rejected with IllegalArgumentException ('Polygon self-intersection', 'Unable to Tessellate shape') [exec on main, 10.5.2, 10.4.0 and 9.12.3].
Summary
You index a valid polygon with two holes, where hole A touches the outer ring at one corner (the Tessellator javadoc allows 'Holes may only touch at one vertex') and hole B touches nothing, for example the outer ring (0,0) (2,5) (0,10) (10,10) (10,0) with hole A (2,5) (5,6) (4,8) and hole B (6,5) (8,4) (8,6), and you expect a point inside hole A not to match. Since #15211 the Tessellator leaves several copies of the shared corner (2,5) next to each other while it joins the holes to the outer ring; when it then chooses the bridge for hole B, whose left-most corner (6,5) is level with (2,5), it rejects every copy and bridges to (0,10) instead, a line that cuts through hole A. The triangles then cover hole A, and an INTERSECTS query for a point in it matches the document with no error, while with checkSelfIntersections=true the same valid polygon is rejected with 'Polygon self-intersection'.
Introduced
d04c5e3530a2b0a0d02bc8db45adaa677ee58577 on main: #15211 '[Tessellator] Fix failure due to hole bridge being coplanar with polygon edge' (fixes #15205, by iverase). It removed the filterPoints(node, node.next) call after the bridge split and after the shared-vertex split and added one final filterPoints(outerNode, null) at the end of eliminateHoles. Backported the same way as 8af285735bea (branch_10x, released in 10.4.0) and 911b518aa8af (branch_9_12, released in 9.12.3) [exec: main-history bisection with the exact historical Tessellator.java shadowed on main jars, 4b821699e36 (file-level parent) passes and d04c5e3530a fails; on release jars 9.12.2 plus only the backport's Tessellator.java fails, 9.12.3 with only that file reverted passes, and 10.3.2 plus the #15211 hunks fails]. The leftmost-shared-vertex split that #15657 (43792b9870e, 2026-02-02, in 10.4.0) added later never had a per-split filter, so on 10.4.0 and main reverting only #15211's three hunks still fails, while per-split filtering at every split site passes the repro [exec] (2025-09-23 (main and branch_10x); 2025-09-24 (branch_9_12))
First affected version
9.12.3 [exec] (tagged 2025-09-27; its only Tessellator.java change after 9.12.2 is the #15211 backport); on the 10.x line 10.4.0 [exec] (tagged 2026-02-25). 9.12.2 and 10.3.0 tessellate the repro polygon exactly [exec]; 10.3.1 and 10.3.2, released after the change, are not affected because branch_10_3 never got the backport [exec]
Affected versions
[exec] the finder's JUnit (seed DEADBEEF) fails 4 of 4 on 9.12.3 (-ea and -da), 10.4.0, 10.5.0, 10.5.1, 10.5.2 (-ea and -da) and main a9779ef3c193 (-ea and -da); the independent reproduction's JUnit fails 7 of 7 on main, 10.5.2, 10.4.0 and 9.12.3. [exec] not affected (4 of 4 pass): 9.11.1, 9.12.0, 9.12.1, 9.12.2, 10.0.0, 10.1.0, 10.2.0, 10.2.2, 10.3.0, 10.3.1, 10.3.2. [read] 8.5.0-9.11.0 and 10.2.1 still filter after each split and have no final filter, so they are not affected; branch_10_6 (10.6.0, released 2026-10-29) and the unreleased branch_10x and branch_10_5 carry the post-#15211 code (read, not executed). Still unfixed on upstream main f7bcc0abd81 (no Tessellator.java logic change after #15739, 2026-02-24).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), 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): two scripted prior-report searches (51 queries each over GitHub issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 61 and 205 distinct hits, none describing this defect), 16 manual GitHub searches (apache/lucene, Elasticsearch, OpenSearch, Solr), an exhaustive listing of all 2,146 apache/lucene issues and PRs updated since 2025-09-15 grepped for Tessellator terms, the changed files of all 408 open apache/lucene PRs, the Tessellator.java history on main, branch_10x, branch_10_5 and branch_9_12, the review comments of #15211, #15657, #15700, #15739, #13980 and #14974, direct list-archive searches, all 221 mapbox/earcut issues and PRs, and 8 web searches. Closest items, all judged not the same bug: #15211 (the introducing change, fixing #15205; its PR and comments never discuss later-hole bridges); #15700 (fixing #15695: notes that splitPolygon leaves copies of bridge endpoints in the ring and fixes a wrong-copy choice in maybeMergeHoleWithSharedVertices when several holes share one leftmost vertex; a different consumer and fix site, and main contains it and still fails); #15554/#15657, #15731/#15739, #13980, #13841 and #756 (same family of touching-hole tessellation failures, other mechanisms, all fixed on main); mapbox/earcut #219 and #221 (October 2026, by EpicDima: an analogous later-hole bridge problem in the original JavaScript library, where per-hole filtering removed a copy of an earlier bridge vertex and a wrong vertex was accepted; different code and the opposite failure) and earcut #45 (2016, a single-hole mechanism). Not fixed upstream: no logic change to Tessellator.java after #15739 (2026-02-24) on main (head f7bcc0abd81 at search time), branch_10x, branch_10_5 or branch_9_12; the only open PRs that touch Tessellator.java are #12036 and #12295 (refactorings). Gaps: one manual GitHub query hit the rate limit (covered by the exhaustive listing), downstream coverage is search-based, web coverage depends on the search index.
Lucene commit tested
main a9779ef3c193 (2026-10-26) with assertions on and off, through gradle in a clean scratch worktree and through a standalone JUnit runner on the built jars; this doc's repro on the release jars 9.12.2, 9.12.3, 10.3.0, 10.3.2, 10.4.0 and 10.5.2 (the finder's repro also on 9.11.1, 9.12.0, 9.12.1, 10.0.0, 10.1.0, 10.2.0, 10.2.2, 10.3.1, 10.5.0 and 10.5.1, and a bisection over the history of Tessellator.java); the fix in the same worktree (repro, the geo and document test packages on two seeds, nightly geo and shape tests, format and lint) and backported onto the 9.12.3 and 10.5.2 jars. Tessellator.java has not changed upstream since 2026-06-14 (upstream main 20ced71c1e6e, 2026-10-27; read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:40-52 (class javadoc NOTES 'Requires valid polygons ... Holes may only touch at one vertex'), :72 (@lucene.internal), :307-365 (eliminateHoles; :347 filterPoints(result, result.next), :365 the final filterPoints(outerNode, null)), :411-470 (maybeMergeHoleWithSharedVertices; unfiltered splitPolygon at :457 and :468), :536-590 (fetchHoleBridge), :1251-1268 (isLocallyInside; :1255 'if (area == 0)' returns false)
  • lucene/core/src/java/org/apache/lucene/document/XYShape.java:83-85, 96-100, 109-112 and lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:85-90, 98-102, 111-114 (createIndexableFields and createDocValueField call Tessellator.tessellate; the two-argument forms use checkSelfIntersections=false)
The finding in 30 seconds
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:178-199, 307-366, 380-404, 411-472, 536-592, 1175-1199, 1251-1268, 1405-1453
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:890, 892 (cureLocalIntersections adds the cut-off triangle twice; registry LFV-792)
Who is affected
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/document/XYShape.java:83-90, 96-100, 109-112; LatLonShape.java:85-92, 98-102, 111-114
Why it looks unintended
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:40-52, 72
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:84, 94-97
  • a Lucene main checkout lucene/core/src/test/org/apache/lucene/geo/TestTessellator.java:916-930, 1016-1023
  • a Lucene main checkout lucene/CHANGES.txt:1174, 1467-1470, 1488, 1494, 2136-2137
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/geo/XYPolygon.java:48-90 (constructor checks)
Suggested fix
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:584, 875-876, 1205-1225, 1251-1268
History
  • a Lucene clone: git tag --contains 8af285735be, 911b518aa8a, e59cb5bca5d, 8073c18bfd8; the 10.2.1 and 10.2.2 Tessellator.java blobs (identical)

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

Repro files

About the diagrams

Made in parallel from the repro polygon on main a9779ef3c193; the PNG is the summary frame.

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.