All findings · Lucene finding · LFV-762 · tessellator-leftmost-shared-vertex-first-copy-wrong-sector

Tessellator rejects valid polygons whose holes meet at one vertex, depending on the order of the holes

Since 10.4.0, LatLonShape and XYShape reject some valid polygons whose holes meet at one vertex with "Unable to Tessellate shape", depending only on the order of the holes: a three-hole polygon fails in 3 of its 6 hole orders, and so does upstream's LUCENE-9400 test polygon.

Summary

Priority
Low, rank 402A released 10.4 regression that loudly rejects one valid polygon with three or more holes meeting at one vertex and indexes no wrong triangles, so it follows the two silent Tessellator rows LFV-210 and LFV-757.
Impact
crash
Since
10.4.0 (#15700)introduced 2026-02-17
Affected
10.4.0, 10.5.0-10.5.2, 10.6.0 (by reading) and main; 9.11.1 to 10.3.2 accept every hole order
Status
documented
Found by
translation validation of the Tessellator on 3,000 lattice polygons

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

LatLonShape and XYShape turn a polygon into triangles with the Tessellator, which first joins every hole into the outer ring. When three holes meet at one vertex, it can join the last one at the wrong copy of that vertex.

Diagram of the bug: the triangle (0,0) (7,12) (12,2) with three holes A, B and C that meet only at P = (7,6). After hole C is bridged to the outline and hole A is spliced in at P, the outline passes P twice, through two copies of P each time, and each pass bounds a different gap between the holes. For hole B the first copy of P met is taken; in 3 of the 6 hole orders it bounds the gap between A and C, and createIndexableFields throws 'Unable to Tessellate shape'; in the other 3 the polygon gives 13 triangles with area 59.
The repro polygon: holes A, B and C meet at P = (7,6). After C is bridged in and A is spliced in at P, the outline passes P twice (four copies of P), and the copy picked for hole B decides whether the polygon is accepted. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index a polygon whose holes touch at one corner, which Lucene allows ("Holes may only touch at one vertex"): the triangle (0,0) (7,12) (12,2) with holes A (above), B (lower right) and C (lower left) that meet only at P = (7,6). Its area is 59.
  2. You expect XYShape.createIndexableFields to return triangles covering area 59 whatever order the holes are listed in, as every release from 9.11.1 to 10.3.2 does for all 6 orders.
  3. Instead the orders ABC, ACB and BAC throw IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected., while BCA, CAB and CBA give 13 triangles with area exactly 59. Upstream's LUCENE-9400 test polygon is rejected in 3 of its 6 hole orders too.
  4. Why: holes are joined into the outline left to right (C, A, B). Splicing A in at P, where it touches C, makes the outline pass P twice (two copies of P each time), each pass bounding its own gap around P. For B (left-most corner P), Lucene takes the first copy of P met without checking that B lies in its gap.
  5. Whether that first copy is used depends on a comparison of node numbers, which follow the input order of the holes. In the failing orders the first copy bounds the gap between A and C; joined there, the outline winds more than a full turn around P and ear clipping gives up.

Who is affected

Applications on 10.4.0, 10.5.0-10.5.2, 10.6.0 (by reading) or main that index polygons with holes through LatLonShape or XYShape, as when three holes meet at one vertex that is one hole's left-most point. That document cannot be indexed; no wrong triangles were observed. Probably rare: it also needs an unlucky hole order (3 of 6 here).

Mechanism, terms and numbers942 words

LatLonShape and XYShape index a polygon as triangles made by the Tessellator, which first joins every hole into the outer ring, left to right. A hole that touches the ring at a vertex is spliced in there, and the splice copies that vertex: in the repro, splicing hole A in at the vertex P where it touches hole C leaves four nodes at P, so the ring visits P twice, through two adjacent copies each time, and each visit bounds a different gap ("wedge") between the holes around P. Since #15700 (10.4.0), when a later hole's left-most vertex is P, maybeMergeHoleWithSharedVertices takes the first copy of P in ring order without checking that the hole lies in that copy's wedge, and an index comparison that follows the input order of the holes decides whether that copy is used. When a copy in the wrong wedge is used, the merged ring winds more than a full turn around P, ear clipping fails and createIndexableFields throws IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected. A valid three-hole polygon is rejected in 3 of its 6 hole orders, and so is upstream's own LUCENE-9400 regression polygon (TestTessellator#testComplexPolygon41); every release checked from 9.11.1 to 10.3.2 accepts every order of both. The failure is loud: a rejected polygon yields no fields, and no wrong triangles were observed in the accepted orders.

Terms

  • Hole elimination: before ear clipping, the Tessellator splices every hole into the outer ring, so that one ring is left. Holes are spliced left to right, by their left-most vertex (smallest x, then smallest y): fetchLeftmost (Tessellator.java:595-611), the sort in eliminateHoles (:310-323) and its loop (:326-363).
  • Shared vertex and copies: a hole that touches the ring at a vertex is spliced in at that vertex (maybeMergeHoleWithSharedVertices, :411-472). splitPolygon (:1175-1199) inserts a copy of both nodes (new Node(a), new Node(b), :1176-1177), so the splice leaves extra copies of the shared vertex in the ring. In the repro the one splice of A at P takes P from one ring node to four: two visits of two adjacent copies.
  • Wedge: around a vertex where several holes meet, the polygon's interior is split into gaps between the holes. Each visit of the ring to the vertex (one copy, or several adjacent copies) bounds one of these gaps; a hole can only be spliced in at a copy whose gap contains it.
  • Node index (idx): the number given to each input vertex: the outer ring first, then each hole in the order the caller passed them (:284, :302 for Polygon; :253, :271 for XYPolygon). Copies keep the idx of the original (:1650-1651).

The bug

  1. In the repro the holes are spliced in the order C (left-most (6,4)), A ((6,7)), B ((7,6)), whatever the input order.
  2. C touches nothing on the outer ring: it is bridged from (6,4) to the outer vertex (0,0) (:388-399), which leaves one node at P = (7,6). A touches C at P and is spliced in there; that one splice leaves four nodes at P, so the ring visits P twice, through two adjacent copies each time. One visit bounds the gap between A and C (directions 135° to 180° from P), the other the gap from C round the right-hand side to A (270° through 0° to 63°), the only gap that can hold B (304° to 326°).
  3. For B, whose left-most vertex is P, the scan keeps the first ring copy of P (if (leftmostSharedVertexConnection == null), :434-436; "For leftmost, take first match only — don't use getSharedInsideVertex.", :437) and, separately, the older angle-based choice getSharedInsideVertex (:439-446, :487-518). If leftmostSharedVertexConnection.idx >= sharedVertexConnection.idx (:455-456) it splices B at the first copy (:457), otherwise at the angle choice (:466-469). Nothing checks that B lies in the chosen copy's gap.
  4. The comment above the guard assumes a higher idx means "from a hole that was already merged" (:452-454), but idx follows the input order while holes are merged in sorted order. Order ABC: first copy idx 13, in the A-C gap (wrong); angle choice idx 5, in the right gap; 13 >= 5, so B is spliced at the copy in the wrong gap; the merged ring then winds more than a full turn around P (its corners at P span 535.8° instead of 175.8°), although Lucene's self-intersection check, run on the merged ring with checkSelfIntersections=true, finds no crossing, and ear clipping throws at :687-688. Order CAB: first copy idx 5 (wrong gap), angle choice idx 10 (right gap); 5 >= 10 is false, so the angle choice is used and the result is 13 triangles with area 59.

The numbers

hole ordermain, 10.4.0, 10.5.0-10.5.29.11.1 to 10.3.2 (nine releases)
ABC, ACB, BACUnable to Tessellate shape13 triangles, area 59
BCA, CAB, CBA13 triangles, area 5913 triangles, area 59
LUCENE-9400 polygon (1,2,0), (2,0,1), (2,1,0)Unable to Tessellate shape13 triangles
LUCENE-9400 polygon (0,1,2), (0,2,1), (1,0,2)13 triangles13 triangles

The same orders fail through both XYShape and LatLonShape (the LatLon version of the small polygon uses x as longitude and y as latitude) with checkSelfIntersections true and false, for the LUCENE-9400 polygon with true too, with assertions on and off, and after translating the small polygon by +100 or scaling it by 0.5. Every one- and two-hole subset tessellates with the exact area. JTS 1.20.0 IsValidOp says the polygon is valid.

Exposure and likelihood291 words
  • Who is hit. LatLonShape.createIndexableFields and XYShape.createIndexableFields with either checkSelfIntersections setting [exec], and createDocValueField of both [read: same Tessellator.tessellate call], on 10.4.0, 10.5.0-10.5.2 and main. The exception comes before any IndexWriter call, so the codec and IndexWriterConfig play no part. 10.6.0 (branch_10_6, released 2026-10-29) and the unreleased branch_10x carry the 10.5.2 Tessellator.java [read].
  • Trigger. Both executed polygons have three holes that meet at one vertex P, one of them with P as its left-most vertex, and fail in the hole orders for which the guard picks a copy in the wrong gap (3 of 6 for each). Their one- and two-hole subsets all work; other layouts were not searched, so three holes is shown to be enough, not to be required. No size is needed: 13 triangles, integer coordinates.
  • Effect. The exception is thrown while the document's fields are built, before IndexWriter sees anything, so that one document cannot be indexed; a rejected polygon yields no fields at all. No wrong triangles were observed in the accepted orders (the small polygon's triangles add up to exactly its area; a total-area check, not a full containment check), and no other document, query or index state is affected.
  • Order dependence. Whether a document is accepted depends on how the data source happens to order the holes. Listing the holes in another order indexed both tested polygons, but no documented rule tells an application which order works, so it can only retry other orders.
  • How likely. Probably rare (a judgement): the layout is uncommon, and the documented search found no report in the 8 months since 10.4.0. Real-world frequency was not measured. It is the topology LUCENE-9400 fixed in 2020, and its test polygon now fails again in half of its hole orders.

Minimal repro

The test shows that upstream's own LUCENE-9400 polygon (TestTessellator#testComplexPolygon41), with its third hole listed first, is rejected by LatLonShape.createIndexableFields, although the original hole order is accepted.

TestTessellatorThreeHolesSharedVertex.java (JUnit test)39 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.geo.Polygon;
import org.apache.lucene.geo.SimpleWKTShapeParser;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTessellatorThreeHolesSharedVertex extends LuceneTestCase {

  public void testComplexPolygon41WithLastHoleFirst() throws Exception {
    // TestTessellator#testComplexPolygon41 (LUCENE-9400), its third hole moved to the front
    String wkt =
        "POLYGON((-1.569137181294115 54.4855283059375, -1.5692505240440333 54.48535373128068, -1.5684753656387294 54.48534438253056, -1.568606793880459 54.485674703738624, -1.5694141387939453 54.48611720532629, -1.569137181294115 54.4855283059375),"
            + "(-1.5689449291711688 54.48547663706703,  -1.5689879483854345 54.485580118416785, -1.5687756358893499 54.485612860811244, -1.568765285875931 54.485496217554285, -1.5689449291711688 54.48547663706703),"
            + "(-1.569137181294115 54.4855283059375, -1.5690783030431206 54.48545352137167, -1.5689449291711688 54.48547663706703, -1.569137181294115 54.4855283059375),"
            + "(-1.5689449291711688 54.48547663706703, -1.5689437289004642 54.48535482680399, -1.5687730514221028 54.48538045082698, -1.5689449291711688 54.48547663706703))";
    Polygon polygon = (Polygon) SimpleWKTShapeParser.parse(wkt);
    try {
      LatLonShape.createIndexableFields("shape", polygon);
    } catch (IllegalArgumentException e) {
      fail("expected: indexes as with the original hole order; actual: " + e.getMessage());
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-762/, 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.TestTessellatorThreeHolesSharedVertex
Expected vs actual
Expected the polygon to index as with the original hole order; actual IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected. (-ea and -da).

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

More files, environment, full output45 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: indexes as with the original hole order; actual: Unable to Tessellate shape. Possible malformed shape detected.
       org.junit.Assert.fail(Assert.java:89)
       org.apache.lucene.document.TestTessellatorThreeHolesSharedVertex.testComplexPolygon41WithLastHoleFirst(TestTessellatorThreeHolesSharedVertex.java:36)

Why it looks unintended

  • The Tessellator javadoc allows it ("Holes may only touch at one vertex") and never says hole order matters; JTS says the polygon is valid.
  • LUCENE-9400 fixed exactly this topology as a bug in 2020; its test polygon now fails in 3 of 6 hole orders.
  • Regression: every release checked from 9.11.1 to 10.3.2 accepts every hole order.
The full argument219 words
  • Contract. Tessellator 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 (e.g., no "line" boxes) / sensitive to overflow (...)". The polygon meets every condition. Polygon NOTES (Polygon.java:28-39) ask for counter-clockwise, closed, non-crossing holes and say nothing about their order; XYPolygon(float[] x, float[] y, XYPolygon... holes) takes them as varargs.
  • Validity. JTS 1.20.0 IsValidOp: valid; each pair of holes intersects only in POINT (7 6).
  • History. CHANGES.txt:5813: "LUCENE-9400: Tessellator might build illegal polygons when several holes share the shame vertex." and LUCENE-9417 (:5815). The LUCENE-9400 issue (#10440) says "We found a new case where tessellation might fail on a valid polygon. The issue shows when a polygon has several holes sharing the same vertex". Its test, testComplexPolygon41 (TestTessellator.java:724-731), passes only because its hole order happens to be one of the 3 accepted orders.
  • Precedent. Earlier "Unable to Tessellate" reports on valid polygons were fixed as bugs: #15554, #15695 (CHANGES.txt:1494, fixed by #15700 itself), #15731, #15205, #13841.
  • Regression. Every release checked from 9.11.1 to 10.3.2 (10.3.0, 10.3.1 and 10.3.2 among them) accepts all 6 orders of both polygons; the change is pinned to one sub-change of #15700.
The devil's-advocate check175 words

The strongest case for "intended" (built by the devil's-advocate checker; the cross-model reviews rated it moderate): #15700's comments say "the first copy from outerNode is the correct connection point for chained shared-vertex holes" (Tessellator.java:422-424) and "Prefer this connection point if it is from a hole that was already merged (higher idx)" (:452-454); Tessellator is @lucene.internal (:72); and the message says "Possible malformed shape detected".

Why it fails: the comments are design claims, not a documented limitation, and the trace refutes their premise (idx follows the input order, not the merge order). @lucene.internal does not cover the public createIndexableFields that reaches the code. The shape is not malformed: it meets the documented conditions and JTS accepts it. The #15700 review reports "a set of 400 million polygons" passing; the PR is about holes that share a left-most vertex with an outer ring vertex, and nobody in it mentions hole order, three or more holes at one vertex, or a copy in the wrong wedge. Verdict: not intended, a regression.

Suggested fix

Recommended Pick a copy of the shared vertex whose wedge contains the hole's next vertex (validated)

Recommended (validated): when the direction to the hole's next vertex is not inside the first copy's wedge, walk the ring to a copy whose wedge contains it, measuring the wedge past adjacent copies of the same point. +42 lines in maybeMergeHoleWithSharedVertices and one helper; 707 geo and document tests and the nightly shape suites pass.

// Earlier holes merged at this vertex leave several copies of it in the ring, and different
// copies can bound different wedges around the vertex. The first copy in ring order may lie
// in a wedge that does not contain this hole: then use a copy whose wedge does.
if (leftmostSharedVertexConnection != null
    && isInsideVertexWedge(leftmostSharedVertexConnection, holeNode.next) == false) {
  Node candidate = leftmostSharedVertexConnection.next;
  while (candidate != leftmostSharedVertexConnection) {
    if (isVertexEquals(candidate, holeNode) && isInsideVertexWedge(candidate, holeNode.next)) {
      leftmostSharedVertexConnection = candidate;
      break;
    }
    candidate = candidate.next;
  }
}
All options and trade-offs4 options · 790 words

One fix was validated in a scratch worktree; it is a clean rewrite of the independent reproducer's selection-only variant. A plain revert of #15700 is not a fix.

Pick a copy of the shared vertex whose wedge contains the hole's next vertex (validated) (recommended)

In maybeMergeHoleWithSharedVertices, right after the scan, replace the first-match copy with the next copy of the same point whose wedge contains holeNode.next; if no copy passes, the first copy is kept, as on main. The new private helper isInsideVertexWedge(a, b) is isLocallyInside measured between a's nearest neighbours at a different position, skipping adjacent copies of a. The full change, with the helper, is +42 lines in Tessellator.java.

// Earlier holes merged at this vertex leave several copies of it in the ring, and different
// copies can bound different wedges around the vertex. The first copy in ring order may lie
// in a wedge that does not contain this hole: then use a copy whose wedge does.
if (leftmostSharedVertexConnection != null
    && isInsideVertexWedge(leftmostSharedVertexConnection, holeNode.next) == false) {
  Node candidate = leftmostSharedVertexConnection.next;
  while (candidate != leftmostSharedVertexConnection) {
    if (isVertexEquals(candidate, holeNode) && isInsideVertexWedge(candidate, holeNode.next)) {
      leftmostSharedVertexConnection = candidate;
      break;
    }
    candidate = candidate.next;
  }
}

Trade-offs

  • Cost: the helper runs once whenever a left-most shared copy was found; the ring walk runs only when that copy fails the test.
  • It keeps the idx guard, and keeps #15700's first match when that copy's wedge contains the hole's next vertex; otherwise it replaces the first match with a later copy, so "take first match only" no longer holds there. The #15695 polygon (testComplexPolygon63) still passes. The guard's dependence on input order remains; the fix makes it harmless for the tested polygons only.
  • The helper tests one direction (from P to holeNode.next), not the whole hole. If no copy passes, the first copy is silently kept; degenerate wedges return false with no explicit failure path.
  • The helper's reflex branch uses a strict < 0, like earcut's locallyInside; Lucene's isLocallyInside is inclusive there since #15739 (LFV-755).
  • Not run with this fix: a benchmark, the 3,000-polygon sweep, earcut, the full Lucene suite, x86 or another JDK.
  • The 10.x backport should apply as is: 10.5.2's Tessellator.java differs from main's only in three hunks elsewhere (two comment typo fixes, #16255; final removed on Node.getX/getY, #16063) [read], and the fix diff applies to it in a dry run; not built there.

What the checks validated

  • Stock a9779ef3c193 through gradle: the repro fails 2 of 2, the regression test fails, and the finder's and verifiers' tests fail 10 of 13; the other 70 executed TestTessellator tests pass.
  • With the fix, org.apache.lucene.geo.* and org.apache.lucene.document.* including all those tests: 81 classes, 707 tests, 0 failures (33 skipped), seed DEADBEEF with assertions on and seed 1234ABCD with assertions off.
  • Nightly: TestTessellator (all six @Nightly tests: five fixed polygons, among them LUCENE-9417's testComplexPolygon42, and testSimpleTessellationAtNight, a 1,000,000-vertex regular polygon with two holes) and every document.*Shape* suite: 31 classes, 280 tests, 0 failures.
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest pass with the fix, the repro and the regression test (with the finder's and verifiers' test files also copied in, the format check fails on three of those files only).
  • Together with the LFV-757 fix: 82 classes, 713 tests, 0 failures. Shadowed onto the stock jars: the repro passes 2 of 2.
  • After a comment-only rewording (the fix's comment, one javadoc line of the repro), shadowed onto the stock jars: 17 of 17 pass (repro, checkSelfIntersections=true check, finder, the independent reproduction, the intended-behaviour check) with assertions on and off; stock fails 14 of 17.

Duplicate-aware isLocallyInside everywhere plus a copy switch (the finder's sketch) (validated by the checks)

Make isLocallyInside skip same-position neighbours for every caller (the same change as LFV-757's fix) and, during the scan, replace the first copy with a later copy of the same idx whose wedge contains the hole's next vertex.

Trade-offs

Validated by the finder on the repro (4 of 4) and 123 geo shape tests, but it changes ear and diagonal tests everywhere and only switches between copies with equal idx.

What the checks validated

Finder: repro 4 of 4; TestTessellator and six shape query and shape suites, 123 tests, two seeds.

Also replace the idx guard (untested)

Compare candidates by the order in which holes were merged, or drop the idx comparison and always use a wedge-checked copy.

Trade-offs

Removes the order dependence at its source, but needs checking against the #15695, #15554 and LUCENE-9400/9417 polygons.

Revert #15700, or restore only the angle tie-break (not a fix)

Accepts this polygon in every order.

Trade-offs

Makes upstream's testComplexPolygon63 (the #15695 polygon) fail again: TestTessellator 69 of 70.

Downstream note

A service that indexes user-supplied polygons through LatLonShape rejects such a document with this error, while the same shape with its holes listed in another order is accepted.

How it was found

Translation validation: 3,000 valid lattice polygons with up to 4 holes, about half touching at vertices, were tessellated and checked exactly. With fixes for the Tessellator bugs already found, 3 valid polygons were still rejected: 2 with a hole touching an outer edge (another bug family) and a four-hole one that minimised to this repro; bisection pinned #15700.

The method in full214 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 JTS-valid XY polygons with 0 to 4 holes on a 13 x 13 integer lattice (seed 585), 1,608 of them with holes touching each other or the outer ring at a vertex, and checked every result: no rejection of a valid polygon, every triangle inside the polygon, pairwise disjoint interiors, exact doubled-area equality, edge flags.
  • With the fixes of the already registered Tessellator bugs shadowed in, 8 polygons still failed, 6 of them valid polygons rejected with "Unable to Tessellate shape" (#8, #139, #1499, #1919, #2013, #2379). With the fix of the sibling bug LFV-757 (found in the same unit) also shadowed in, 3 rejections remained: #8 and #2379, which have a hole touching an outer edge (filed under the LFV-210 family), and the four-hole polygon #1499 (this repro plus a small square hole touching hole A at (8,8)). With the finder's fix for this bug too, #1499 is accepted and only #8 and #2379 remain.
  • Minimising it by hole subsets gave the three-hole polygon; a bisection over the Tessellator.java history pinned #15700. Independent verifiers then showed that upstream's own LUCENE-9400 polygon fails the same way when its holes are reordered.

Prior reports, history and checks

Prior reports

No prior report (searched 2026-10-27) in Lucene's GitHub issues, PRs and commits, the mailing lists, JIRA, downstream projects, earcut or the web. Closest: #15700 and #15695 (the introducing change and the polygon it fixed), LUCENE-9400 (the same topology, fixed in 2020) and earcut #177 (the rule #15700 ported).

Searches4 searches

Verdict new: no report by anyone. No open PR touches the fix site: only #12036 and #12295 touch Tessellator.java, neither in hole merging. Context: #15657 / #15554 (the left-most preference and the idx guard; that state passes), #13980 / #13841 and #15205 / #15211 (earlier shared-vertex logic), #15731 / #15739 / #15764 (a hole touching another mid-segment), earcut #219 / #221 (October 2026: a later hole bridged from outside its angle, with non-touching holes and a different trigger). Gaps: GitHub search is keyword-based and was rate-limited; earcut forks and private channels were not searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; two downstream repositories2026-10-27the hunt's prior-report tool, three runs (identifiers, symptoms, hole-order phrasings): 298 distinct hitsno report; nearest #15700, #15695, #15657, LUCENE-9400, LUCENE-9417
GitHub: apache/lucene, Elasticsearch, OpenSearch, Solr, OpenSearch geospatial, mapbox/earcut, earcut.hpp, all of GitHub2026-10-2737 REST and 25 GraphQL issue and PR searches; full reads of 14 Lucene and 12 earcut itemsno report; earcut #177 and #219 / #221 are related rules and triggers, not this bug
apache/lucene open PRs and recent items2026-10-27the changed files of all 408 open PRs; every issue and PR updated since 2026-02-15 (1,397 items)only the closed #15695, #15700, #15731, #15739; open #12036 and #12295 do not touch hole merging
Mailing lists, JIRA, the web2026-10-2715 direct list-archive searches and the 16 messages of 2026 that mention Tessellator; 4 JIRA queries; 11 web queriesnothing after 10.4.0

History

Introduced by #15700 (748570531e0, 2026-02-17), first released in 10.4.0; affects 10.4.0, 10.5.0-10.5.2, 10.6.0 (by reading) and main, while 10.3.2 and 9.12.3 accept every hole order. Isolated to that one commit and, by ablation, to its "take first match only" sub-change.

Versions tested7 rows

Introduced by #15700 "Fix Tessellation error for holes that share the same leftmost vertex with an outer ring vertex" (fixes #15695; by @roblburris, approved and merged by @iverase on 2026-02-17 as 748570531e0 on main). Backports 8073c18bfd8 (in the 10.4.0 tag only) and 8f62c009615 (branch_10x, in 10.5.0 to 10.5.2). Of its four sub-changes, the one that replaced the angle tie-break (getSharedInsideVertex) among copies of the left-most vertex with "take first match only" is responsible: restoring only the tie-break passes, restoring only the strict idx > guard or only removing the outerNode repositioning still fails. The prerequisite #15657 (43792b9870e, 2026-02-02) added the left-most preference and the idx guard but kept the tie-break; that state passes. The isolation and the ablations ran the finder's 4-method test (order ABC, with order CAB and the two-hole subsets as controls). No 9.x branch carries #15657 or #15700 (read-only GitHub API: branch_9x retired in 2024-10, branch_9_12's last Tessellator.java commit is the #15211 backport). Not fixed upstream as of 20ced71c1e6e (2026-10-27, the newest main fetched).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected3 of 6 orders rejected for both polygons; assertions on and off; gradle and standalone runner
10.4.0, 10.5.0, 10.5.1, 10.5.2 affectedthe same 3 of 6 orders rejected for both polygons (release jars, the doc's repro on each)
branch_10x, branch_10_6 by readingthe 10.5.2 Tessellator.java (identical blob); not executed
9.11.1, 9.12.2, 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.0, 10.3.1, 10.3.2 not affectedall 6 hole orders of both polygons accepted (release jars, the doc's repro on each)
748570531e0 (#15700) vs its parent 43792b9870e bisect#15700 rejects; its parent accepts (the finder's test on the 10.4.0 and main jars; in the devil's-advocate checker's bisection, all 6 orders of the LUCENE-9400 polygon and 2 orders of the small one)
main with #15700 reverted, or only the tie-break restored not a fixaccepts this polygon but testComplexPolygon63 (#15695) fails again
fix, scratch worktree at a9779ef3c193 not affectedevery order accepted; 707 geo and document tests and 280 nightly shape tests pass

Independent checks

An independent reproducer, a devil's-advocate check and an adjudication confirmed the finder's finding. Of read-only reviews by another model family (OpenAI GPT-5.6), one said real; the other said not real only because it could not run code. An audit re-ran it; this doc re-ran it on main and 13 releases and validated a fix; fact checks re-ran it.

Checks and verdicts447 words

The checks ran on main a9779ef3c193; the cross-model reviews read the code at ea57360912ed, where Tessellator.java is byte-identical.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateTranslation-validation sweep, hole-subset minimisation, bisection to #15700; its test fails 2 of 4 with assertions on and off; its fix sketch passes it and 123 geo shape tests.
cross-model review (refute lens) not real (low), on procedure onlyOpenAI GPT-5.6, reading only: it could not run code; its reading did not substantively refute the bug; strongest counterpoint, the #15700 comment, rated moderate. A separate run re-ran the finder's test: 2 of 4 fail.
independent reproducer realOwn test fails 4 of 5 on main (assertions on and off), 10.5.2, 10.5.0, 10.4.0, passes on 10.3.2, 10.3.0, 9.12.3; JTS valid; a ring probe shows the four copies of P; its selection-only variant fixes every repro and keeps TestTessellator 70 of 70.
cross-model review (intended lens) real (low)OpenAI GPT-5.6, reading only: the javadoc's one-vertex allowance and the LUCENE-9400/9417 fixes outweigh the @lucene.internal tag and the #15700 comment. A separate re-run fails on main and 10.4.0 and passes on 10.3.0.
devil's advocate realNo intent argument survives; found that upstream's LUCENE-9400 polygon fails in 3 of 6 hole orders; the trace refutes the "higher idx = already merged" premise; independent bisection agrees.
adjudication confirmed (high), duplicate of noneRe-ran the reproducer's test with a new seed (4 of 5 fail, assertions on and off) and the LUCENE-9400 test; with the selection-only variant 9 of 9 pass and TestTessellator 70 of 70.
credibility audit keepRe-ran the finder's and the LUCENE-9400 tests with new seeds; its own plain-Java probe rejects the same 3 orders through every entry point on main and 10.5.2 and accepts all 6 on 10.3.2.
this doc reproduced; fix validatedNew repro fails 2 of 2 on main (on and off), 10.4.0 and 10.5.0-10.5.2, passes on the nine releases 9.11.1 to 10.3.2; the fix passes 707 geo and document tests, the nightly shape suites and the lint checks.
fact check of this doc holds; wording fixedRe-ran the gradle repro on a clean copy of main with assertions on and off (2 of 2 fail each), the standalone runs on main, 10.5.2, 10.4.0, 10.3.2 and 9.12.3, the regression test and the fix (694 geo and document tests, nightly TestTessellator); its five wording errors were fixed.
cross-model fact check real; wording and caveats fixedOpenAI GPT-5.6, reading only: the regression and the mechanism are well supported; the intended case is moderate, the counter-case stronger. 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,653 words

Finding record

LFV-762, snapshot of 2026-11-06

Registry title
Tessellator: when three or more holes meet at one vertex P, maybeMergeHoleWithSharedVertices joins the hole whose left-most vertex is P to the first copy of P in ring order (since #15700) even when the hole lies outside that copy's wedge, so LatLonShape and XYShape reject a valid polygon with 'Unable to Tessellate shape' depending on the order of its holes; upstream's own LUCENE-9400 test polygon is rejected in 3 of its 6 hole orders (regression in 10.4.0)
Area
geo / Tessellator hole elimination (polygon indexing for LatLonShape and XYShape)
Priority
Low, rank 402: A released 10.4 regression that loudly rejects one valid polygon with three or more holes meeting at one vertex and indexes no wrong triangles, so it follows the two silent Tessellator rows LFV-210 and LFV-757.
Impact class
crash
Impact note
Hits applications on 10.4.0, 10.5.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, either checkSelfIntersections setting, no scale needed; the doc-values fields use the same tessellation, by code reading) when three or more holes meet at one vertex P and one of them has P as its left-most vertex: for some hole orders (3 of 6 for the repro polygon and for upstream's LUCENE-9400 test polygon) the valid polygon (JTS IsValidOp accepts it; 10.3.x and 9.12.3 accept every order) is rejected with IllegalArgumentException 'Unable to Tessellate shape. Possible malformed shape detected.' while its fields are built, so that one document cannot be indexed; the failure is loud (no wrong triangles are indexed, the accepted orders tessellate with the exact area, no other document, query or index state is affected), and how often real data has this layout was not measured [exec on main, 10.5.2, 10.5.0, 10.4.0].
Summary
You index a valid polygon whose three holes meet only at one corner P, for example the triangle (0,0) (7,12) (12,2) with the holes A (8,8) (6,7) (7,6), B (9,3) (10,4) (7,6) and C (6,6) (6,4) (7,4) (7,6), which all touch at P=(7,6) (the Tessellator javadoc allows 'Holes may only touch at one vertex'), and you expect XYShape.createIndexableFields or LatLonShape.createIndexableFields to return its triangles, as 10.3.x does for every hole order. Since #15700 (first released in 10.4.0), once the first holes are joined at P the ring holds several copies of P, each bounding a different wedge around P, and when hole B (whose left-most vertex is P) is joined, the Tessellator takes the first copy of P in ring order, through an index comparison that depends on the input order of the holes, without checking that B lies in that copy's wedge. For the hole orders ABC, ACB and BAC this picks a wrong copy and both calls throw IllegalArgumentException 'Unable to Tessellate shape', and upstream's own LUCENE-9400 regression polygon (TestTessellator.testComplexPolygon41) is likewise rejected in 3 of its 6 hole orders.
Introduced
748570531e081efadb643a22075e47019304c475 on main: #15700 'Fix Tessellation error for holes that share the same leftmost vertex with an outer ring vertex' (fixes #15695; by roblburris, approved and merged by iverase), specifically its sub-change that replaced the getSharedInsideVertex angle tie-break among copies of the hole's left-most vertex by 'take first match only' in ring order from outerNode. Backports 8073c18bfd8 (10.4 release line, only in the 10.4.0 tag) and 8f62c009615 (branch_10x, in 10.5.0-10.5.2) [exec: single-commit isolation, 10.4.0 and main jars with only Tessellator.java at 748570531e0^ pass and at 748570531e0 fail, 10.3.2 plus the 748570531e0 file fails, main with #15700 reverse-applied passes with -ea and -da; ablation on main: restoring only the tie-break passes, restoring only the strict 'idx >' guard or only removing the outerNode repositioning still fails; the independent reproduction's and the intended-behaviour check's bisections agree]. Prerequisite 43792b9870e (#15657, fixes #15554, 2026-02-02, also first in 10.4.0) added the left-most preference and the idx guard but kept the tie-break; the repro passes on that state [exec]. A plain revert is not a fix: with the tie-break restored, upstream TestTessellator.testComplexPolygon63 (the #15695 polygon) fails again [exec] (2026-02-17 (main 748570531e0, 10.4 release line 8073c18bfd8 and branch_10x 8f62c009615; PR opened 2026-02-11))
First affected version
10.4.0 [exec] (tagged 2026-02-25; the first release with the #15700 backport). 10.3.2 is the last unaffected release [exec]; no 9.x release is affected (no 9.x branch carries #15657 or #15700; 9.12.3 executed)
Affected versions
[exec] the finder's JUnit (seed DEADBEEF) fails 2 of 4 (both indexing methods; the two controls pass) on 10.4.0, 10.5.0, 10.5.1, 10.5.2 and main a9779ef3c193 (-ea, and -da on main); the independent reproduction's JUnit fails 4 of 5 on main (-ea and -da), 10.5.2, 10.5.0 and 10.4.0; upstream's LUCENE-9400 polygon is rejected in the hole orders (1,2,0), (2,0,1) and (2,1,0) on 10.4.0, 10.5.2 and main. [exec] not affected: 9.11.1, 9.12.2, 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.0, 10.3.1, 10.3.2 (every hole order of both polygons accepted where tested). [read] older releases lack the introducing lines; branch_10_6 (2763db11b0a, contained in 10.6.0, released 2026-10-29) and the unreleased branch_10x (1309534296e) carry the 10.5.2 Tessellator.java (identical blob, not executed), so 10.6.0 has it by reading. Still unfixed on upstream main f7bcc0abd816 (2026-10-27; no geo/ change after a9779ef3c193) and branch_10x.
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): the finder's scripted prior-report search (51 queries) plus two follow-up runs (identifier and symptom phrasings, each with 16 GitHub issue/PR, 3 commit, 2 downstream-repository, 20 mailing-list (dev@, java-user@, issues@, commits@) and 4 JIRA-archive queries; 298 distinct hits across the three runs), 37 manual REST and 25 GraphQL GitHub searches (apache/lucene, Elasticsearch, OpenSearch, Solr, OpenSearch geospatial, mapbox/earcut, mapbox/earcut.hpp and all of GitHub), the changed files of all 408 open apache/lucene PRs, the Tessellator.java history on main and branch_10x, full reads of #15700, #15695, #15657, #15554, #13980, #13841, #15205, LUCENE-9400, LUCENE-9417, #15731, #15739, #15764 and 9 earcut items, all 221 earcut issue/PR titles, 15 direct list-archive searches and the text of the 16 2026 dev@/java-user@ messages that mention Tessellator, 4 JIRA REST queries and 11 web queries; the verifiers' listing of every apache/lucene issue and PR updated since 2026-02-15 (1,397 items) found only the closed #15695, #15700, #15731 and #15739. Closest items, none a report of this bug: #15700 (the introducing change; its reviewer checked 400 million random polygons, and nobody mentions hole order or three or more holes at one vertex), #15695 (the polygon #15700 fixed; passes on main), #15657/#15554 (added the idx-guarded left-most preference; passes the repro), #13980/#13841 and #15205/#15211 (earlier shared-vertex logic), #15731/#15739/#15764 (a hole touching another hole mid-segment; other site), LUCENE-9400/LUCENE-9417 (several holes sharing one vertex, fixed in 8.6 on code since rewritten; LUCENE-9400's own test polygon is now rejected again in 3 of its 6 hole orders, which nobody has reported), and mapbox/earcut #177 (2024, the first-equal-vertex rule that #15700 ported; it reports holes whose left-most points coincide, not a first copy in the wrong wedge) and #219/#221 (October 2026, a later hole bridged into a vertex from outside its angle, but with non-touching holes and through a different trigger and site). Not fixed upstream: upstream main f7bcc0abd816 (3 commits ahead of a9779ef3c193, none in geo/) and branch_10x have no Tessellator.java logic change after #15739; the only open PRs touching Tessellator.java (#12036, #12295) do not touch hole merging. Gaps: GitHub search is keyword-based and was rate-limited (rerun through GraphQL); earcut forks and private channels were not searched.
Lucene commit tested
main a9779ef3c193 (2026-10-26) through gradle in a clean scratch worktree (assertions on; the fact check also ran it with assertions off) and a standalone JUnit runner (assertions off); the doc's repro also on the release jars 9.11.1, 9.12.2, 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.0-10.3.2, 10.4.0 and 10.5.0-10.5.2; the finder's test on the same releases (the follow-up's sweep) and the verifiers' tests on 9.12.3 to 10.5.2; the fix in a scratch worktree of a9779ef3c193, removed afterwards. The newest upstream main fetched, 20ced71c1e6e (2026-10-27), has the same Tessellator.java and no change under geo/ after a9779ef3c193.

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), :411-470 (maybeMergeHoleWithSharedVertices; :422-424 the 'first copy from outerNode is the correct connection point' comment, :434-437 'take first match only', :452-457 the 'higher idx' comment, the 'idx >=' guard and splitPolygon(leftmostSharedVertexConnection, holeNode, true)), :687 (the 'Unable to Tessellate shape' throw)
  • lucene/core/src/java/org/apache/lucene/document/XYShape.java:83-85, 96-100, 109-112 and LatLonShape.java:85-87, 98-102, 111-114 (createIndexableFields and createDocValueField call Tessellator.tessellate; the two-argument forms use checkSelfIntersections=false)
  • lucene/core/src/test/org/apache/lucene/geo/TestTessellator.java:724 (testComplexPolygon41, the LUCENE-9400 polygon) and lucene/CHANGES.txt:1494 (GITHUB#15695), :5813 (LUCENE-9400)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:253, 271, 284, 302, 310-323, 326-363, 411-472, 487-518, 595-611, 687-688, 1175-1199, 1650-1651
  • lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:117, 133-135, 180, 197-198 (checkIntersection runs after eliminateHoles), 388-399 (C's bridge)
Who is affected
  • lucene/core/src/java/org/apache/lucene/document/XYShape.java:83-112; lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:85-114
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:40-52, 72, 422-424, 452-454
  • lucene/core/src/java/org/apache/lucene/geo/Polygon.java:28-39; lucene/core/src/java/org/apache/lucene/geo/XYPolygon.java:47-48
  • lucene/CHANGES.txt:1494, 5813, 5815; lucene/core/src/test/org/apache/lucene/geo/TestTessellator.java:724-731
Suggested fix
  • lucene/core/src/test/org/apache/lucene/geo/TestTessellator.java:81, 718, 734, 790, 904, 910 (the six @Nightly tests)
  • a Lucene clone: git diff releases/lucene/10.5.2 a9779ef3c193 -- Tessellator.java (three hunks: two typo fixes, #16255 54283150a81; `final` removed on Node.getX/getY, #16063 c2825cfc4b2)
History
  • a Lucene clone: git tag --contains 8073c18bfd8 (10.4.0), 8f62c009615 (10.5.0-10.5.2); tag dates

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 executed traces on main a9779ef3c193 (the finder's polygon in all 6 hole orders, a print-only copy of main's Tessellator.java that shows the ring and the copies of P, and the LUCENE-9400 polygon); every value on screen comes from those traces.

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.