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.
- 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.
- 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
INTERSECTSquery. - 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 notDISJOINT(0) forXYShapeandLatLonShape; a small box inside hole A also matches theXYShapedoc-values field. WithcheckSelfIntersections=true:Polygon self-intersection. - 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.
- 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.
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.
- Tessellation:
LatLonShapeandXYShapeindex 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,
eliminateHolesjoins 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.
- 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). - Nothing removes the copies before hole B. #15211 removed the
filterPointscall after each split; duplicates are filtered once after all holes (:364-365). The per-holefilterPoints(result, result.next)(:347) removes nothing here: it tests onlyresult, (0,0), against its two neighbours and stops (:1405-1453). - 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). - 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 requiresisLocallyInside(p, holeNode)(:583-584). That test starts witharea(a.previous, a, a.next)and returns falseif (area == 0)(:1251-1257). Each copy has its twin as a neighbour, so all four fail. - 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. WithcheckSelfIntersections=true,checkIntersectionruns 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.
| case | expected | main |
|---|---|---|
tessellate(polygon, false), twice the triangle area | 169 | 225 (11 triangles, a sum that counts overlaps; (8,4) (2,5) (10,0) twice, see LFV-792) |
| union of the triangles (JTS) | 84.5 | 88.036 = 84.5 + hole A 3.5 + hole B 0.036 |
| each hole alone | 173, 176 | 173, 176 |
XYShape / LatLonShape point (3.75, 6.5): INTERSECTS, DISJOINT | 0, 1 | 1, 0 |
XYShape doc values, box x 3.7-3.8, y 6.4-6.6, INTERSECTS | 0 | 1 |
tessellate(polygon, true) | accepted | Polygon 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 ring | exact | exact |
| 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) | accepted | Unable 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
LatLonShapeorXYShape:createIndexableFieldsandcreateDocValueField(both executed). No custom codec orIndexWriterConfig: the JUnit runs under the test framework's randomized codec, and the diagram probe, a plainmain, 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 passtrueget anIllegalArgumentExceptionfor 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.
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.
/*
* 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));
}
}
}
}
# 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).
More files, environment, full output34 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:<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)- 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 JTSIsValidOpreports it valid. (TheXYPolygon/Polygonconstructors 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.checkPolygonasserts 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 == 0rule inisLocallyInsidedates from LUCENE-8775 (2019); in that version every split was still followed byfilterPoints(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 itslocallyInsidehas 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.
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.
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
isLocallyInsideis also used by ear clipping (:875-876,isValidDiagonal:1205-1225). After the finalfilterPoints(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
< 0comparisons); 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;TestTessellatorwithtestComplexPolygon66added fails 1 of 78 (two seeds). - With the fix:
org.apache.lucene.geo.*andorg.apache.lucene.document.*, 77 classes, 697 tests (33 skipped), 0 failures with seedDEADBEEFand assertions on, and with seed1234ABCDand assertions off;-Ptests.nightly=trueforgeo.*anddocument.*Shape*, 393 tests, 0 failures;checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestpass. 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
TestTessellator70 of 70 on two seeds.
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.
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.
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.
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: 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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@ and commits@; JIRA archive; downstream repositories; the web | 2026-10-27 | the 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 vertex | 61 and 205 distinct hits; none describes this defect |
| GitHub (apache/lucene and three downstream projects) | 2026-10-27 | 16 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 #14974 | only 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 web | 2026-10-27 | all 221 earcut issues and PRs listed, #45, #219, #220 and #221 read; direct list-archive searches; 8 web searches | earcut #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.java | 2026-10-28 | search "Tessellator" sorted by update | nothing after #15731/#15739; the file unchanged upstream since 2026-06-14 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | repro 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 | affected | fail (this doc's repro on 10.4.0 and 10.5.2; the finder's on all four) |
| 9.12.3 | affected | fail; the #15211 backport is its only Tessellator.java change after 9.12.2 |
| 10.3.0, 10.3.1, 10.3.2 | not affected | exact; 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 affected | exact (10.2.1 not run: its Tessellator.java is byte-identical to 10.2.2's) |
4b821699e36 vs d04c5e3530a (Tessellator.java on main jars) | bisect | the 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 | isolation | fail / pass / fail |
fix, scratch worktree at a9779ef3c193 | not affected | repro and 697 geo and document tests pass; nightly and lint pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Translation 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 only | OpenAI 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 agrees | Reading only; agrees that the copies from splitPolygon meet the zero-area return in isLocallyInside. Separate re-run: 4 of 4 fail. |
| independent reproducer | real | Own 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 | keep | Re-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 validated | New 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 fixed | Re-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 fixed | OpenAI 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. |
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 464 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,466 words
- 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 ofTessellator.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.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), :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-1453a 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, 72a Lucene main checkout lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:84, 94-97a Lucene main checkout lucene/core/src/test/org/apache/lucene/geo/TestTessellator.java:916-930, 1016-1023a Lucene main checkout lucene/CHANGES.txt:1174, 1467-1470, 1488, 1494, 2136-2137a 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)
- TestTessellatorSharedVertexLaterHoleBridge.java (JUnit test):
repro/LFV-757/src/org/apache/lucene/document/TestTessellatorSharedVertexLaterHoleBridge.java(53 lines)
Made in parallel from the repro polygon on main a9779ef3c193; the PNG is the summary frame.
assets/LFV-757/bug.png(464 KB)
