To index a polygon, Lucene cuts it into triangles. It first joins the holes to the outer ring, then clips triangles (ears) off one by one; when no ear is left, it cuts the remaining ring in two along a diagonal and goes on with each piece.
- Index an L-shaped polygon, outer ring (0,0) (0,12) (4,12) (4,4) (12,4) (12,0), with three small triangular holes: two meet the outer ring at its inner corner V = (4,4), and the third touches one of them at (3,4). Every touch is a single shared corner, which the Tessellator javadoc allows.
- You expect
XYShape.createIndexableFields(orLatLonShape) to return triangles covering the polygon: 10.4.0 returns 13 triangles with total area 80 - 4.5 = 75.5. - Lucene joins the holes into one 17-vertex ring that passes through V three times, clips 4 triangles, gets stuck, and looks for a diagonal that cuts the rest in two. Since #15739 its corner test counts a point exactly on an edge as inside (
<= 0instead of< 0), so it accepts a cut of length zero between two copies of V. - That cut leaves hole 0's outline, (1,2) (4,4) (2,1), as a loop of its own, wound the wrong way, which cannot be cut into triangles. Indexing throws
IllegalArgumentException: Unable to Tessellate shape, and the document is not indexed. - 810 of the 972 hole orders and ring start vertices of this polygon fail on main; none fail on 10.4.0, which gives every one of them the exact area.
Anyone indexing polygons with holes through LatLonShape or XYShape (triangle fields and shape doc values, default and strict path) on 10.5.0 to 10.5.2, 10.6.0 (by reading) or main. The known trigger: two holes sharing a reflex corner of the outer ring and a third hole chained to one of them, at exact shared vertices; rare. The document fails loudly; nothing wrong is indexed.
Mechanism, terms and numbers659 words
Since 10.5.0, LatLonShape and XYShape refuse to index a valid polygon whose holes touch the outer ring and each other only at single vertices: createIndexableFields throws IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected. #15739 made the reflex-corner branch of Tessellator.isLocallyInside inclusive (<= 0) to pick the right hole bridge for #15731; the same function also guards where splitEarcut may cut a ring in two, and there the inclusive test accepts a cut of length zero between two copies of a vertex the holes share with the outer ring. One piece is then a hole's own outline, which cannot be triangulated. 10.4.0 tessellates the repro polygon exactly (13 triangles, area 75.5); 10.5.0 to 10.5.2, 10.6.0 (by reading) and main reject it (on main, 810 of its 972 hole orders and start vertices fail). The failure is loud: one document cannot be indexed, nothing wrong is indexed.
- Hole merging.
eliminateHolesjoins each hole to the outer ring, either at a vertex they share or through a bridge, which leaves two copies of each bridge end in the ring. The result is one ring that can pass through the same point several times. - Ear clipping.
earcutLinkedListcuts off convex corners (ears) as triangles. When none is left it filters points, cures small self-intersections and finally callssplitEarcut, which looks for a diagonal that cuts the ring in two, checked byisValidDiagonal. isLocallyInside(a, b)answers "does b lie inside the corner of the ring at a?". For a reflex corner (more than 180 degrees) it accepts b when b is on the inner side of either edge at a. #15739 changed both tests from< 0to<= 0(Tessellator.java:1263-1266), so a b exactly on one of the edges now counts as inside.
Outer ring (0,0) (0,12) (4,12) (4,4) (12,4) (12,0); V = (4,4) is its reflex corner. Hole 0 (4,4) (1,2) (2,1) and hole 1 (3,4) (4,4) (3,5) touch the ring (and each other) only at V; hole 2 (2,6) (1,5) (3,4) touches hole 1 only at (3,4). Area 80 - 2.5 - 0.5 - 1.5 = 75.5.
- Hole merging gives a 17-vertex ring that passes through V three times:
(1,2) (4,4) (4,12) (0,12) (0,0) (1,5) (2,6) (3,4) (3,5) (4,4) (3,4) (1,5) (0,0) (12,0) (12,4) (4,4) (2,1). - Ear clipping cuts 4 triangles and is stuck with 13 vertices; it falls back to
splitEarcut(Tessellator.java:681-689,:909-950). isValidDiagonal(a, b)(:1205-1229) is asked about a and b, two copies of V. Its first checks,isLocallyInside(a.previous, b)andisLocallyInside(b.next, a)(:1209-1210), look at the reflex corners (3,5) and (2,1). Because a and b are the same point, the tested point lies exactly on the corner's own edge to V, so one area is exactly 0:<= 0passes both checks,< 0fails both. For equal points the method then returns true at once (:1216-1217).- The ring is cut at V into
(3,5) (4,4) (2,1) (1,2) (4,4) (4,12) (2,6) (3,4)and(12,4) (3,4) (1,5) (0,0). The first piece clips 3 ears and is left with(1,2) (4,4) (2,1): hole 0's outline, wound the wrong way. It has no ear and no diagonal, soearcutLinkedListthrows (:687-688). - 10.4.0, with
< 0, refuses that cut, cuts (1,2)-(1,5) instead and returns 13 triangles of total area 75.5.
isLocallyInside is called seven times, in four places: the bridge search (fetchHoleBridge, :584), cureLocalIntersections (:875, :876), and isValidDiagonal's neighbour checks (:1209-1210) and main checks (:1219-1220). Running all 16 strict/inclusive combinations against the repro and upstream TestTessellator (70 tests run; its 6 @Nightly tests and 1 @AwaitsFix test are skipped): the repro fails exactly when the neighbour checks are inclusive, and testComplexPolygon64 (#15731) fails exactly when the bridge search is strict. The other two sites change neither result.
Exposure and likelihood358 words
- Who is hit. Applications on 10.5.0, 10.5.1, 10.5.2, 10.6.0 (by reading) or main that index polygons with holes through
LatLonShapeorXYShape:createIndexableFields(run) andcreateDocValueField(read: the sameTessellator.tessellatecall), with the defaultcheckSelfIntersections=falseand withtrue. No special configuration and no scale needed: the exception comes from building the fields, before any codec orIndexWriterConfigsetting plays a part. Users upgrading from 10.4.x are exposed: 10.4.0 was cut from branch_10_4, which never got the change. - Effect. That document cannot be indexed: the exception comes while its fields are built, so the application never gets fields to hand to
IndexWriter. The repro builds the fields only and opens no writer; by reading the code, nothing wrong is produced and nothing reaches the index, so other documents, queries and the index are not affected. - What it takes. The minimised repro needs two holes that share a reflex vertex of the outer ring, and a third hole that touches one of them at another vertex, all at exact shared vertices; this is one known trigger, not a proven necessary condition. The outcome depends on the hole order and the start vertex of each ring (810 of 972 combinations fail) and on the coordinates: of 8 scales x 5 real-world offsets, 15 of 40 LatLon and 17 of 40 XY variants fail on main and none of 80 on 10.4.0. 29 of the 32 failures are at scales 1, 0.5 and 0.25, where the scaled coordinates are exact binary fractions, 3 at 1e-3 or 1e-5, none at 0.1, 0.01 or 1e-4. That points to floating-point rounding, not lat/lon quantization: the Tessellator computes on the input coordinates, not the encoded ones (
Tessellator.java:1665-1672). A tilted variant without collinear edges fails too. - How likely. Touching holes do occur in real data (upstream fixed #15554, #15695 and #15731 for valid touching-hole polygons in early 2026), but this chained layout is narrow, and no public report of it was found since 10.5.0 shipped (2026-06-25). In a sweep of 9,000 random valid lattice polygons with the other known Tessellator bugs fixed, 8 were still rejected, 2 of them for this reason.
The test shows that XYShape.createIndexableFields rejects a valid polygon whose three holes touch the outer ring and each other only at single shared vertices (a regression from #15739; 10.4.0 accepts it).
/*
* 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.XYPolygon;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestTessellatorChainedTouchingHoles extends LuceneTestCase {
public void testHolesTouchingAtSingleVertices() {
XYPolygon polygon =
new XYPolygon(
new float[] {0, 0, 4, 4, 12, 12, 0},
new float[] {0, 12, 12, 4, 4, 0, 0},
new XYPolygon(new float[] {4, 1, 2, 4}, new float[] {4, 2, 1, 4}),
new XYPolygon(new float[] {3, 4, 3, 3}, new float[] {4, 4, 5, 4}),
new XYPolygon(new float[] {2, 1, 3, 2}, new float[] {6, 5, 4, 6}));
try {
XYShape.createIndexableFields("shape", polygon);
} catch (IllegalArgumentException e) {
throw new AssertionError("expected the valid polygon to be tessellated, got " + e, e);
}
}
}
# in a copy of this finding's repro folder (repro/LFV-755/, 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.TestTessellatorChainedTouchingHoles
- Expected vs actual
- Expected the polygon to be tessellated; actual on main:
IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected.(fromTessellator.earcutLinkedListviasplitEarcut), with-eaand-da.
More files, environment, full output61 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected the valid polygon to be tessellated, got java.lang.IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected.
org.apache.lucene.document.TestTessellatorChainedTouchingHoles.testHolesTouchingAtSingleVertices(TestTessellatorChainedTouchingHoles.java:35)
Caused by: java.lang.IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected.
org.apache.lucene.geo.Tessellator.earcutLinkedList(Tessellator.java:687)
org.apache.lucene.geo.Tessellator.splitEarcut(Tessellator.java:936)
org.apache.lucene.geo.Tessellator.earcutLinkedList(Tessellator.java:683)
org.apache.lucene.geo.Tessellator.tessellate(Tessellator.java:202)
org.apache.lucene.geo.Tessellator.tessellate(Tessellator.java:152)
[...]- The javadoc allows it ("Holes may only touch at one vertex"); JTS accepts the polygon, and every proper subset of its holes tessellates on main.
- 10.4.0 tessellates it exactly (13 triangles, area 75.5); upstream tests require holes touching the shell and another hole to tessellate.
- #15739 set out to fix a wrong hole bridge (#15731), not to reject other polygons.
The full argument345 words
- Contract. The class javadoc (
Tessellator.java:40-52, abridged) 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)", plus a note on overflow with tiny values. Every touch here is one shared vertex. JTS 1.20IsValidOpaccepts the polygon and all 8 hole subsets; an independent check with exact integer arithmetic agrees.XYShape/LatLonShapepromise "indexable fields for polygon geometry", and the strict path rejects this valid polygon as well. - Correct before. 10.4.0 and 10.3.0 return 13 triangles, area 75.5, whose union equals the polygon. Every proper subset of the holes tessellates on main with the right area and full coverage on a sample grid.
- Tests.
TestTessellatorrequires polygons whose holes touch the rest of the polygon at two or more distinct vertices to tessellate with the area matching. IntestComplexPolygon41(:724),57(:897),61(:932) and62(:938) one hole touches both the shell and another hole at different vertices, as hole 1 does here; in53(:863),54(:869) and55(:875) 8 to 11 holes each touch other rings at two or more points. The area is compared with delta 0 for 41, 53-55 and 62, and 3e-11 and 1e-11 for 57 and 61 (:900,:935). The #15731 tests 64 and 65 are not examples: no hole in them touches other rings at two or more points. - History. Upstream fixed every earlier rejection of a valid touching-hole polygon as a bug: #13841, #15205, #15554, #15695, #15731. #15739's PR text and CHANGES entry only describe #15731. The PR text: "We were failing to detect this case and choosing an invalid bridge to eliminate the hole"; the CHANGES entry: "In this case we might fail to detect it and choose a wrong bridge to eliminate the hole" (
lucene/CHANGES.txt:1174-1175); its one review is an LGTM. - The ported library. mapbox/earcut keeps the strict
< 0inlocallyInside.
The devil's-advocate check227 words
The strongest case for "intended" (built by the intended-behaviour checker, the cross-model reviewer and the adjudication): read "Holes may only touch at one vertex" as one contact per hole (hole 1 touches at two); note that Tessellator is @lucene.internal (Tessellator.java:72), that Polygon is @lucene.experimental and warns that self-crossing input "may result in unexpected behavior" (Polygon.java:34, :41), and that the message says "Possible malformed shape"; add that three rings meet at V, and call the collinear edges degenerate input. JTS validity is an outside standard and does not settle how maintainers read the one-vertex rule.
Why it fails: the two-hole control {hole 1, hole 2} has the same two contacts on hole 1 and passes on main; upstream's own tests require holes that touch at two or more distinct vertices to tessellate (41, 53-55, 57, 61, 62); the polygon is not self-crossing; the public XYShape and LatLonShape methods carry no internal tag; a tilted variant with no collinear hole and shell edges still fails; and 10.4.0 returns an exact tessellation, so 10.5 replaced a correct result with a rejection. The cross-model fact check of this doc rated the case moderate (the literal precondition, the two tags, three rings at V) and still found the evidence against it more persuasive; the earlier reviewers rated it weak or rejected it. Verdict: not intended, a regression.
Recommended Strict test in isValidDiagonal's two neighbour checks, inclusive elsewhere (recommended)
In isValidDiagonal, use the strict pre-10.5 test for the two neighbour checks and keep <= 0 everywhere else (+23/-5 lines in Tessellator.java). The repro, TestTessellator (#15731) and all geo and document tests pass; on 9,000 valid lattice polygons (13,500 runs) nothing else changes, and two rejections become exact.
// check next edges are locally visible; use the strict test, since for a diagonal
// between two copies of the same vertex b always lies on the edge (a.previous, a)
|| isLocallyInside(a.previous, b, false) == false
|| isLocallyInside(b.next, a, false) == false
...
private static boolean isLocallyInside(final Node a, final Node b) {
return isLocallyInside(a, b, true);
}
...
if (includeCollinear) {
return areaPrevious <= 0 || areaNext <= 0;
}
return areaPrevious < 0 || areaNext < 0;All options and trade-offs4 options · 988 words
Three scoped variants of #15739's comparison were built and checked; one is recommended. The call-site matrix (all 16 strict/inclusive combinations of the four places that call isLocallyInside) shows why a scoped change is needed: #15731 needs <= 0 in the bridge search (:584), this polygon needs < 0 in isValidDiagonal's neighbour checks (:1209-1210). Add a CHANGES entry under Bug Fixes.
Strict test in isValidDiagonal's two neighbour checks, inclusive elsewhere (recommended) (validated by the checks)
isLocallyInside gets a flag; its two-argument form keeps today's <= 0, and only isLocallyInside(a.previous, b) and isLocallyInside(b.next, a) in isValidDiagonal (:1209-1210) pass false, the pre-10.5 strict test; the ccw branch picks <= 0 or < 0 by the flag. Tessellator.java only, +23/-5 lines, ./gradlew tidy clean.
// check next edges are locally visible; use the strict test, since for a diagonal
// between two copies of the same vertex b always lies on the edge (a.previous, a)
|| isLocallyInside(a.previous, b, false) == false
|| isLocallyInside(b.next, a, false) == false
...
private static boolean isLocallyInside(final Node a, final Node b) {
return isLocallyInside(a, b, true);
}
...
if (includeCollinear) {
return areaPrevious <= 0 || areaNext <= 0;
}
return areaPrevious < 0 || areaNext < 0;Trade-offs
- Smallest behavioural change: the bridge search and the other callers are exactly as on main, so #15731 is untouched; only zero-area cases in the neighbour checks change. That is not limited to zero-length diagonals: any candidate collinear with those edges is now refused there; the sweep found no other change, which is evidence, not proof.
cureLocalIntersectionsandisValidDiagonal's main checks (:1219-1220) keep<= 0, which #15739 introduced there without a test needing it; neither the repro norTestTessellatordepends on that choice, and in the sweep it only changes polygons that are already wrong on main (see the next option).- Cost not benchmarked: the ccw branch now computes both
areaterms before testing them, where stock stops after the first term that passes (||,:1265-1266). Composes with the fix sketch of the sibling bug LFV-757 (which also editsisLocallyInside): with both, both repros andTestTessellatorpass.
What the checks validated
- Gradle in a clean scratch worktree at
a9779ef3c193(removed afterwards): stock fails the repro 2 of 3 and the newTestTessellatortest (1 of 78), seedsDEADBEEF(assertions on) and1234ABCD(off). With the fix:org.apache.lucene.geo.*andorg.apache.lucene.document.*, 77 classes, 695 tests, 33 skipped, 0 failures on both seeds;-Ptests.nightly=truegeo anddocument.*Shape*, 45 classes, 392 tests, 0 failures;checkGoogleJavaFormat,ecjLintMain/Test,forbiddenApisMain/Testpass. - Standalone, shadowed onto the stock jars: repro 3 of 3, the reproducer's own test 3 of 3 (assertions off, seed
0C0FFEE1), upstreamTestTessellator70 of 70 (7 nightly or disabled tests skipped); the repro, the tilted variant and the strict path give the exact area for XY and LatLon; all 972 orderings accepted with the exact area. - Translation validation (9,000 XY lattice polygons, 3 seeds x 3,000; seed 0x249 again in strict mode and, for its first 1,500 polygons, as LatLon), polygon by polygon: identical to stock except poly#389 and poly#2114, which become exact; the same with LFV-210's fix applied.
- Backport: applies unchanged to 10.5.2's
Tessellator.java; on the 10.5.2 jars the repro passes 3 of 3 (assertions on and off; stock 10.5.2 fails 2 of 3) andTestTessellator70 of 70.
The other way round: isLocallyInside is strict again everywhere (the 10.4.0 behaviour), and only fetchHoleBridge (:584) asks for <= 0, matching #15739's own description ("choosing an invalid bridge"): the two-argument form passes false, and the bridge candidate check passes true. +20/-3 lines.
private static boolean isLocallyInside(final Node a, final Node b) {
return isLocallyInside(a, b, false);
}
...
if ((tan < tanMin || (tan == tanMin && p.getX() > connection.getX()))
&& isLocallyInside(p, holeNode, true)) {Trade-offs
- Closest to the long-tested pre-10.5 code, and scoped to what #15739 was about.
- Changes outcomes beyond this bug: in the sweep, 36 cases that main indexes with wrong triangles (the LFV-210 wrong-bridge family) are rejected instead (9-12 per 3,000 XY, 4 of 1,500 LatLon runs; 32 distinct polygons, since the 4 LatLon ones repeat seed 0x249's XY polygons); with LFV-210's fix applied, 1 (poly#1528). No polygon goes from correct to wrong, but documents that index today (wrongly) would fail to index.
What the checks validated
- Gradle (same scratch worktree):
org.apache.lucene.geo.*andorg.apache.lucene.document.*with the regression test, 77 classes, 695 tests, 33 skipped, 0 failures, seedDEADBEEF;checkGoogleJavaFormatpasses. - Standalone: repro 3 of 3, the reproducer's test 3 of 3,
TestTessellator70 of 70, 972 of 972 orderings exact; sweep as above (no polygon from correct to wrong; poly#389 and poly#2114 fixed).
Narrowest: keep <= 0 unless a and b are the same point.
|| isLocallyInside(a.previous, b, isVertexEquals(a, b) == false) == false
|| isLocallyInside(b.next, a, isVertexEquals(a, b) == false) == falseTrade-offs
Special-cases the observed trigger rather than the comparison; checked standalone only (not through gradle).
What the checks validated
Standalone: repro 3 of 3, TestTessellator 70 of 70, 972 of 972 orderings exact; the four non-strict sweep families give the same per-polygon results as the recommended option (stock and with LFV-210's fix); no strict-mode sweep run.
Fixes this polygon, but re-breaks the case #15739 fixed.
Trade-offs
TestTessellator.testComplexPolygon64(#15731) fails withPolygon self-intersection.- In the sweep, 11 cases that main tessellates exactly fail: 10 distinct valid polygons, 9 rejected and 1 with wrong triangles (poly#373 fails both as XY and as LatLon).
What the checks validated
Run by the finder, the independent reproducer and this doc's call-site matrix (70 tests, 1 failure); sweep per polygon.
Downstream: an application that indexes user-supplied polygons may see such a document refused with an IllegalArgumentException; nothing else in the index is affected.
Translation validation: a harness tessellated 9,000 valid polygons with up to four holes on a 13 x 13 grid, many with touching holes, and checked each result (no rejection, no overlaps, exact area). With the other known Tessellator bugs fixed, 8 were still rejected; 2 of them pass with #15739's comparison reverted, and dropping holes from one gave this polygon.
The method in full235 words
Method: translation validation (semi-formal): the stock Tessellator's output on generated inputs is checked against an exact oracle.
- A harness generated JTS-valid polygons with 0-4 holes on a 13 x 13 lattice (3 seeds x 3,000 XY polygons; one seed also with
checkSelfIntersections=trueand, for its first 1,500 polygons, as lat/lon), many of them with holes touching the shell or each other at a vertex. For each it checked: no rejection; every triangle non-degenerate and inside the polygon; triangles pairwise interior-disjoint (both with JTS); total area exactly equal (doubled integer areas); edge flags equal to "this edge lies on a ring". - On stock main most failures belong to already known Tessellator bugs. With their fixes and two sibling candidates' fixes applied, 8 rejections remained: 5 with a hole vertex in the middle of another ring's edge (filed with the known LFV-210 family) and 3 with only vertex touches. Two of those (poly#389, poly#2114) pass with #15739's comparison reverted; the third (poly#2529) does not, so it has another cause, not analysed here.
- Shadowing the versions of
Tessellator.javabefore and after #15739 onto the main jars (an A/B of the two adjacent file versions, not agit bisect) pinned it to #15739; reverting its two hunks separately showed that the comparison change causes it. Dropping holes from poly#2114 gave the three-hole repro. - This doc's work then localised the call site (16 combinations) and traced the zero-length cut.
No prior report found (searched 2026-10-27, re-checked 2026-10-28) in Lucene's GitHub issues, PRs, commits and review comments, open PRs, JIRA, the mailing lists, downstream projects, forums or the web. #15731, #15739 and #15764 are its origin, not reports of it.
Searches6 searches
No public report was found saying that #15739's inclusive comparison makes the Tessellator reject valid polygons. The closest items are the change itself and earlier touching-hole fixes, all merged before it.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream repositories, lists, JIRA, web | 2026-10-27 | the hunt's prior-report tool: 51 queries (for example "Tessellator hole touching another hole", "Unable to Tessellate shape 10.5", isLocallyInside), 152 distinct hits; plus a re-read of the finder's 45-query run | older items or unrelated; no report of this bug |
| GitHub GraphQL and REST search | 2026-10-27 | 76 queries over apache/lucene, Elasticsearch, OpenSearch, Solr, lucenenet, mapbox/earcut and all of GitHub, many restricted to after 2026-02-20 | nothing about Lucene tessellation after #15739 (2026-02-24) |
| Open apache/lucene PRs | 2026-10-27 | changed-file lists of all 408 open PRs | only the old refactors #12036 and #12295 touch Tessellator.java; neither changes isLocallyInside |
| Related items (read) | 2026-10-27 | comments, reviews and cross-references of #15731, #15739, #15764 and the earlier touching-hole issues and PRs | #15731/#15739/#15764 are the origin; #15695/#15700, #15554/#15657, #15205/#15211, #13841/#13980 were fixed before #15739; no activity after 2026-02-24 |
| Mailing lists, JIRA, forums, web | 2026-10-27 | 25 list-archive searches (dev@, java-user@, issues@, builds@), 3 JIRA queries, 12 forum and 12 web searches | no report |
| GitHub issue search (re-check) | 2026-10-28 | repo:apache/lucene tessellat created after 2026-09-01; "Unable to Tessellate" updated after 2026-10-20; the file's commit list | 0 hits; Tessellator.java unchanged since 2026-06-14 |
Introduced by #15739 (faef795a460, merged 2026-02-24; backport 333e6f4e4d95), which made the corner test inclusive to fix #15731; first released in 10.5.0. Pinned by an A/B of the file versions before and after #15739 and by swapping Tessellator.java between the 10.4.0 and 10.5.0 jars; 10.4.0 never got it.
Versions tested7 rows
Introduced by #15739 "Fix tessellator failure when a hole is touching another hole in the middle of a segment" (fixes #15731; by @iverase, approved by @craigtaverner, merged 2026-02-24 as faef795a460 on main, backported the same day to branch_10x as 333e6f4e4d95, milestone 10.5.0). Of its two Tessellator.java hunks, the isLocallyInside comparison (< 0 to <= 0, strict since LUCENE-8396 in 2018) causes this; the eliminateHole bridge-flag hunk does not. 10.4.0 was tagged the day after the merge, from branch_10_4, which never got the backport.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | fails loudly | repro: 2 of 3 fail, with assertions on and off |
| 10.5.0, 10.5.1, 10.5.2 | fails loudly | repro: 2 of 3 fail (10.5.0 and 10.5.2 with assertions on and off) |
| 10.4.0 | not affected | 3 of 3 pass; 13 triangles, area 75.5, union equals the polygon; all 972 variants exact |
| 9.12.2, 9.12.3, 10.0.0, 10.2.2, 10.3.0, 10.3.2 | not affected | 3 of 3 pass |
Tessellator.java at 748570531e0 (#15700) vs faef795a460 (#15739), on the main jars | A/B | the parent passes; #15739 fails |
| 10.4.0 jars + the 10.5.0 Tessellator.java; 10.5.0 jars + the 10.4.0 file | isolation | fails; passes. Only the comparison hunk matters on both bases |
| fix (strict neighbour checks) on main; backported onto the 10.5.2 jars | not affected | repro 3 of 3 pass; TestTessellator 70 of 70 (78 with gradle) |
An independent reproducer with its own exact validity check, a devil's-advocate checker and an adjudication (fresh seed) confirmed it; cross-model reviews (OpenAI GPT-5.6, reading only) agreed with the root cause. An audit re-ran it on main and releases; this doc localised the wrong decision and validated the fix, and fact checks re-checked the doc.
Checks and verdicts356 words
Each check re-ran the failure on main itself; only the cross-model reviews were reading-only (separate runs re-ran the repro).
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Fails 2 of 3 on main (-ea, -da) and 10.5.0-10.5.2; passes on 10.4.0, 10.3.0 and 9.12.3; the plain revert fixes it but breaks testComplexPolygon64. |
| cross-model review (refute lens) | not real (low), on procedure only | OpenAI GPT-5.6, reading only: root cause agreed, its static reading "strongly favors a genuine regression". Separate re-run: 2 of 3 fail. |
| independent reproducer | real | Own test with an exact integer validity check; fails on main, 10.5.0 and 10.5.2; exact coverage on 10.4.0, 10.3.0 and 9.12.3; JTS valid; 810 of 972 orderings rejected. |
| cross-model review (intended lens) | real (medium) | OpenAI GPT-5.6, reading only; a separate run re-ran the repro on main, 10.5.x and older releases. |
| intended-behaviour devil's advocate | real | Every intended argument refuted; tilted variant and strict path fail; 10.4.0 output exact; scale sweep. |
| adjudication | confirmed (high) | Re-ran the reproducer's test with seed 0C0FFEE1: 2 of 3 fail with -ea and -da; passes with the revert and on 10.4.0. |
| credibility audit | keep | Seed A0D17004: main fails (-ea, -da), 10.5.2 fails, 10.4.0 passes; hand-checked the input's validity. |
| this doc | localised; fix validated | Instrumented trace of the zero-length cut; all 16 call-site combinations; the fix through gradle, a 9,000-polygon sweep (13,500 runs) and a 10.5.2 backport. |
| Fact check of this doc (re-ran everything) | holds; three text errors fixed | Re-ran the doc's gradle commands (2 of 3 and 3 of 81 fail, both assertion modes), the repro on main and six releases at a fresh seed, FixCheck (810 of 972 on main, 972 of 972 exact on 10.4.0 and with the fix), the fix through the geo and document packages and the 10.5.2 backport. Found three text errors (two test citations, the residual-rejection count, the sweep's distinct-polygon count); fixed. |
| Cross-model fact check (OpenAI GPT-5.6, reading only) | bug real; 21 accuracy findings | Root cause, regression and fix confirmed; 8 wording errors, 7 overclaims and 6 missing caveats, all handled. |
| Diagram check | pass, no errors |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 382 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,263 words
- Registry title
- Tessellator rejects a valid polygon whose holes touch the outer ring and each other at single vertices with 'Unable to Tessellate shape', because #15739 made the reflex-vertex test in isLocallyInside inclusive ('<= 0'): a regression in 10.5.0 (10.4.0 accepts the polygon)
- Area
- geo / Tessellator hole elimination (polygon indexing for LatLonShape and XYShape)
- Priority
- Low, rank 403: A 10.5.0 regression that loudly rejects a valid polygon with chained vertex-touching holes, a crash with a clear exception for a narrow layout like LFV-762, so it closes the Tessellator group right after that row.
- Impact class
- crash
- Impact note
- Hits applications on 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 or createDocValueField (default codec, IndexWriterConfig and checkSelfIntersections setting; the strict path rejects it too; no scale needed) when two holes share a reflex vertex of the outer ring and a third hole touches one of them at another vertex, all at exact shared vertices: the valid polygon (JTS IsValidOp accepts it, and 10.4.0 tessellates it exactly) 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, no other document, query or index state is affected), depends on hole order and vertex rotation (810 of 972 orderings of the repro are rejected on main, 0 on 10.4.0) and on coordinate scale after lat/lon quantization, and how often real data has this layout was not measured.
- Summary
- You index a valid polygon with holes, for example an L-shaped outer ring (0,0) (0,12) (4,12) (4,4) (12,4) (12,0) with three small triangular holes that touch the ring and each other only at single shared corners, which 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.4.0 does. Instead, since #15739 (first released in 10.5.0), both calls throw IllegalArgumentException 'Unable to Tessellate shape. Possible malformed shape detected.', so the document cannot be indexed, although every proper subset of the holes tessellates. The cause is that #15739 changed the reflex-vertex branch of Tessellator.isLocallyInside from '< 0' to '<= 0', which accepts a candidate exactly collinear with a neighbouring edge; reverting that comparison fixes this polygon but breaks the #15731 polygon #15739 was written for, so a correct fix has to be more selective.
- Introduced
- faef795a460d7cf8270cd9aa53dfd17ca74b76c3 (main, merged 2026-02-24): apache/lucene#15739 'Fix tessellator failure when a hole is touching another hole in the middle of a segment' by iverase (approved by craigtaverner; fixes #15731, reported by roblburris; milestone 10.5.0), backported the same day to branch_10x as 333e6f4e4d95 (identical Tessellator.java blob d0510543dbd). Of its two Tessellator.java hunks, the isLocallyInside ccw-branch change ('area(a, b, a.previous) < 0 || area(a, a.next, b) < 0' to '<= 0 || <= 0', strict since LUCENE-8396 b5ef13330f1, 2018) causes the failure; the eliminateHole bridge-flag hunk does not [exec: Tessellator.java-level bisection on main jars (748570531e0 #15700 passes, faef795a460 fails), the file swapped between the 10.4.0 and 10.5.0 release jars, and each hunk isolated on both; read: git log -S finds only b5ef13330f1 and faef795a460 for the comparison]. (2026-02-24)
- First affected version
- 10.5.0 [exec] (via the branch_10x backport 333e6f4e4d95); 10.4.0, the last release before it, passes [exec] because it was cut from branch_10_4, which never got the backport
- Affected versions
- 10.5.0, 10.5.1, 10.5.2 (the last release before 10.6.0), 10.6.0 (by reading) and main a9779ef3c193 (unreleased 11.0); not fixed upstream (main f7bcc0abd816, branch_10x, branch_10_6 and branch_10_5 still read '<= 0'). [exec] the finder's JUnit fails 2 of 3 (the two indexing methods; the subset control passes) on 10.5.0 (-ea, -da), 10.5.1 (-ea), 10.5.2 (-ea, -da) and main (-ea, -da), and passes 3 of 3 on 9.12.2, 9.12.3, 10.0.0, 10.2.2, 10.3.0, 10.3.2 and 10.4.0 (-ea; 10.4.0 also -da); the independent reproduction's JUnit fails on main, 10.5.2 and 10.5.0 and passes on 10.4.0, 10.3.0 and 9.12.3. [read] every release tag from 9.0.0 to 10.4.0 has the strict '< 0'; 8.x not checked by execution; branch_10_6 (10.6.0, released 2026-10-29) and the unreleased branch_10x and branch_10_5 carry the change (git blob) but were not run.
- 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): a scripted prior-report search (51 queries over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and 6 ingested web queries; 152 distinct hits), a re-read of the finder's run (45 non-web queries), 67 GitHub GraphQL and 9 REST searches (apache/lucene, Elasticsearch, OpenSearch, Solr, lucenenet, mapbox/earcut, all of GitHub, date-restricted after 2026-02-20/24), the changed-file lists of all 408 open apache/lucene PRs (only the old refactors #12036 and #12295 touch Tessellator.java, not isLocallyInside), 25 list-archive searches, 3 JIRA queries, 12 forum and 12 web searches. Related, not reports of this bug: #15739 (the introducing change; its PR body and only review discuss only the #15731 polygon, no comments after the merge), #15731 (the polygon #15739 fixed, which any fix must keep working), #15764 (adds the simpler testComplexPolygon65 for #15731), and the earlier touching-hole fixes #15695/#15700, #15554/#15657, #15205/#15211 and #13841/#13980, all merged before #15739. mapbox/earcut, which Lucene's Tessellator ports, keeps the strict '< 0'. Not fixed upstream: Tessellator.java on main f7bcc0abd816 is byte-identical to a9779ef3c193.
- 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; the repro on the release jars 9.12.2, 9.12.3, 10.0.0, 10.2.2, 10.3.0, 10.3.2, 10.4.0, 10.5.0, 10.5.1 and 10.5.2, and an A/B of the versions ofTessellator.javabefore and after #15739; the fix in the same worktree (repro, the geo and document test packages on two seeds, nightly geo and shape tests, format and lint), all 16 strict/inclusive call-site combinations, a translation-validation sweep of 9,000 lattice polygons (13,500 runs with the strict-mode and LatLon re-runs), and the fix backported onto the 10.5.2 jars.Tessellator.javahas not changed upstream since 2026-06-14 (upstream main20ced71c1e6e, 2026-10-27; read).
- 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), :386-400 (eliminateHole: fetchHoleBridge, the bridge-flag test extended by #15739 at :395 and :397, splitPolygon), :683-687 (splitEarcut failure, the throw), :1251-1268 (isLocallyInside; the ccw branch '<= 0' at :1265-1266); lucene/core/src/java/org/apache/lucene/document/XYShape.java:82-112 and lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:84-114 (createIndexableFields and createDocValueField call Tessellator.tessellate); Tessellator.java identical at a Lucene main checkout ea57360912ed (diff -q)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:307-366, 411-472, 536-592, 617-700 (671, 676, 681-689), 875-876, 909-950, 1205-1229, 1251-1268
- Who is affected
lucene/core/src/java/org/apache/lucene/document/XYShape.java:82-112; LatLonShape.java:84-114; lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:1665-1672 (getX/getY return the input coordinates)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:40-52, 72; lucene/core/src/java/org/apache/lucene/geo/Polygon.java:34, 41lucene/core/src/test/org/apache/lucene/geo/TestTessellator.java:38, 724, 863, 869, 875, 897, 900, 932, 935, 938, 950, 956, 1016-1023lucene/CHANGES.txt:1174, 1234
- TestTessellatorChainedTouchingHoles.java (JUnit test):
repro/LFV-755/src/org/apache/lucene/document/TestTessellatorChainedTouchingHoles.java(38 lines)
Made in parallel from the repro polygon on main a9779ef3c193, with every value parsed from a probe run; the PNG is the summary frame.
assets/LFV-755/bug.png(382 KB)
