Background: LatLonShape and XYShape store a polygon as the triangles of its tessellation, one indexed field and one doc-values entry per triangle. When ear clipping cannot find an ear, the Tessellator's cure step looks for a small local self-intersection, cuts that triangle off and carries on.
- You index the ring
POLYGON((6 3, 0 6, 4 0, 3 4, 5 1, 3 2, 6 3))withXYShape.createIndexableFields(orLatLonShape, lat = y, lon = x). Its edge (4,0)-(3,4) crosses the edge (5,1)-(3,2). ThePolygonand internalTessellatorjavadocs exclude self-crossing rings, but the defaultcheckSelfIntersections = falseaccepts it. - Lucene stores a polygon as triangles. Ear clipping gets stuck on this ring, so a clean-up step ("cure") cuts off the small triangle at a crossing, (3,2) (4,0) (3,4) with area 1, and clips the rest into 2 more: 3 triangles, areas summing to 9.5. You expect 3 indexed triangles.
- You get 4: the cure step calls the same
tessellation.add(...)twice in a row, with only a comment between the two calls, so (3,2) (4,0) (3,4) is stored twice (areas summing to 10.5). No error. - The shape doc values follow the duplicate:
numberOfTerms()is 4 for 3 triangles, andgetCentroid()is (3.0, 3.254), which weights that triangle twice, instead of (2.965, 3.386), the area-weighted centroid of the 3 distinct triangles. No search result changed: 129,472 box queries give the same counts with and without the duplicate. - On valid polygons, non-zero-area duplicates were found only together with another Tessellator bug (a hole touching the outer ring, LFV-210), whose fix removes them. Every release since 8.3.0 is affected; 8.2.0 added the triangle once. Deleting the first call fixes it.
Applications that index polygons with LatLonShape or XYShape on the default path (checkSelfIntersections = false) and receive self-intersecting rings; on valid polygons, non-zero-area duplicates were found only together with LFV-210. Each cured triangle is indexed twice, and the experimental shape doc values over-count it in numberOfTerms() and getCentroid(). No search result changed in the sweeps run.
Mechanism, terms and numbers724 words
LatLonShape and XYShape index a polygon as the triangles that Tessellator.tessellate returns: one indexed Triangle field and one doc-values triangle per list entry. When ear clipping gets stuck on a ring that crosses itself a little, the Tessellator's cure step, cureLocalIntersections, cuts off the small triangle at the crossing and continues. It appends that triangle with two identical tessellation.add(...) calls (Tessellator.java:890 and :892, only a comment between them), so the shape is indexed with one triangle twice. The ring POLYGON((6 3, 0 6, 4 0, 3 4, 5 1, 3 2, 6 3)), accepted by the default checkSelfIntersections = false, is tessellated into 3 distinct triangles but indexed as 4, because (3,2) (4,0) (3,4) is stored twice; XYShapeDocValuesField.numberOfTerms() is 4 and getCentroid() is (3.0, 3.254), the area-weighted centroid of the 4 entries with the cut-off triangle counted twice, instead of (2.965, 3.386), that of the 3 distinct triangles. No query result changed in the executed box-query sweeps (129,472 queries, the same counts with and without the duplicate). The second call was left by a 2019 edit (LUCENE-8620, first released in 8.3.0); 8.2.0 added the triangle once, as earcut, which the class ports, does. On valid polygons, non-zero-area duplicates were found only together with LFV-210 (a hole touching the outer ring), whose fix removes them; with it fixed, 3 of about 12 million valid lattice polygons still store a zero-area cure triangle twice, through LFV-793. Deleting line 890 fixes it: the repro passes, and the geo and shape test suites pass with assertions on and off.
- Tessellation:
Tessellator.tessellatecuts a polygon into triangles by ear clipping: it repeatedly cuts off a corner triangle (an ear) that lies inside the ring. Each triangle becomes one indexedTrianglefield (createIndexableFields) and one doc-values triangle (createDocValueField). - Cure step: when no ear is found, the Tessellator first filters points and retries, then calls
cureLocalIntersections(Tessellator.java:671-679), which looks for a node whose previous edge intersects the edge after the next node (a crossing, or some touching configurations), cuts off that small triangle, removes the two nodes in between and lets ear clipping continue. It is meant for rings with small self-crossings.
- In
cureLocalIntersections(Tessellator.java:854-903), fora = node.previous,nextNode = node.next,b = nextNode.next, edge (a, node) crosses edge (nextNode, b) (:862-878). For the example a = (4,0), node = (3,4), nextNode = (5,1), b = (3,2): edge (4,0)-(3,4) crosses (5,1)-(3,2) at (3.571, 1.714), one of the ring's 3 crossings. - It computes the three edge flags (
:880-889) and then adds the triangle (a, node, b) twice:
tessellation.add(new Triangle(a, abFromPolygon, node, bcFromPolygon, b, caFromPolygon)); // :890
// Return the triangulated vertices to the tessellation // :891
tessellation.add(new Triangle(a, abFromPolygon, node, bcFromPolygon, b, caFromPolygon)); // :892Nothing changes between the calls; a traced copy of the class shows the list growing to 1 at line 890 and to 2 at line 892.
- It removes (3,4) and (5,1) (
:895-896), and ear clipping finishes the ring (3,2) (6,3) (0,6) (4,0) with 2 more triangles. createIndexableFieldsandcreateDocValueFieldturn every list entry into one stored triangle (XYShape.java:93-130,LatLonShape.java:95-132), so the cut-off triangle is stored twice.ShapeDocValuesweights each triangle's centroid by its area (ShapeDocValues.java:428-453,:262-290) and writes the centroid into the doc value at index time (:491-493).
| expected (3 triangles) | stock main | line 890 deleted | |
|---|---|---|---|
| indexed triangles (distinct) | 3 (3) | 4 (3): (3,2) (4,0) (3,4) twice | 3 (3) |
| sum of the indexed triangles' areas | 9.5 | 10.5 | 9.5 |
numberOfTerms() | 3 | 4 | 3 |
getCentroid() | (2.9649, 3.3860) | (3.0, 3.2540) | (2.9649, 3.3860) |
| box queries (129,472) | same counts and result hash in both columns |
The 3 triangles are (0,6) (3,2) (6,3) with area 7.5, (0,6) (4,0) (3,2) with area 1 and (3,2) (4,0) (3,4) with area 1. LatLonShape gives the same 4 fields for 3 triangles. The stock centroid (3, 205/63) is exactly the area-weighted centroid of the 4 entries; (169/57, 193/57) is that of the 3 distinct triangles. Because the ring crosses itself, (3,2) (4,0) (3,4) and (0,6) (3,2) (6,3) overlap by 6/13, so 9.5 is a sum of triangle areas, not the area of one region. With checkSelfIntersections = true the ring is rejected ("Polygon self-intersection at lat=1.7142857142857142 lon=3.5714285714285716").
Exposure and likelihood555 words
- Who. Applications that index polygons through
LatLonShapeorXYShapecreateIndexableFieldsorcreateDocValueField(the overloads without the flag passcheckSelfIntersections = false; defaultIndexWriterConfigand codec; one document is enough) and receive self-intersecting rings (a small self-crossing, or a ring that touches or overlaps itself). That is the input the cure step exists for: in #11767 a maintainer wrote "Small self intersections are a reality" and called the step a "best effort" clean-up. - Effect. Per cured triangle: one extra indexed triangle (
PointValues.sizeone higher),ShapeDocValuesField.numberOfTerms()one higher, and that triangle's area counted twice ingetCentroid(). No exception, and no search result changed in the executed sweeps: 129,472 box queries on this ring, and the finder's 57,800 point-index plus 43,350 doc-values box queries over two other witnesses, give identical counts with and without the duplicate. Only XY box queries were run; the duplicate is an exact copy of a triangle that is already indexed, so every per-triangle relation it produces is one its twin already produces, and no other query is expected to change. - Default vs opt-in. Indexing is the default path. The doc-values effects need
createDocValueField;ShapeDocValuesFieldis@lucene.experimental, exists since 9.4.0, and nothing else in Lucene reads itsgetCentroid(). - Valid input. Non-zero-area duplicates on valid input were found only together with LFV-210 (a hole touching the outer ring makes the hole bridge pick the wrong vertex):
POLYGON((4 2, 3 0, 1 2, 0 0, 0 4, 4 2), (2 1, 3 2, 2 2, 2 1))indexes 8 triangles, 7 distinct, with (0,0) (3,0) (2,1), which lies outside the polygon, twice, also withcheckSelfIntersections = true. LFV-210's one-line fix alone makes this and all 9 known valid witnesses exact, with no cure triangle. Its centroid error is mostly LFV-210's (exact (1.6667, 2.0222); stock (1.5833, 1.4722); duplicate removed (1.5714, 1.6349)). With LFV-210's fix and two other Tessellator fix sketches applied, 3 of about 12 million valid lattice polygons still store a zero-area cure triangle twice (one extra entry and term, no centroid effect); all 3 hit LFV-793, where thefilterPointscall after a hole merge drops a vertex copy that the next hole needs. These are search results, not a proof. - Scale. No cure triangle occurred in the upstream geo test corpus: 411 polygons (real-world polygons from issues included), each tessellated with
checkSelfIntersectionsoff and on (822 tessellations), entered the cure step 43 times and never had a triangle cut off. That corpus is a test set, not prevalence data. Common in synthetic data: of 300,000 random hole-free rings on a 7x7 lattice, main accepts 66,208 and 5,524 of those index a triangle twice; 8.2.0, on the same seeded inputs, stores none twice (it accepts 64,940; the accepted counts differ for other reasons). Hole-free valid rings never reached it: in the exhaustive bound of 64,962,384 small lattice rings, all 1,241,798 rings with a cure triangle are JTS-invalid (self-intersecting; not all self-crossing: 12,696 self-touching rings with no proper crossing reach it too). Each cure triangle takes two nodes out of the ring, and splits and hole bridges add two node copies each, so the number of cure triangles stays linear in the polygon's size. - Persistence. The duplicate triangle and the centroid are written at index time; documents indexed before a fix keep them until reindexed, which is harmless for searches.
The test shows that XYShape.createIndexableFields on the ring POLYGON((6 3, 0 6, 4 0, 3 4, 5 1, 3 2, 6 3)) (self-crossing, accepted by the default path) indexes the triangle cut off by Tessellator.cureLocalIntersections twice (TestTessellatorCureAddsTriangleTwice#testCuredTriangleIndexedOnce).
/*
* 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 java.util.HashSet;
import java.util.Set;
import org.apache.lucene.geo.XYPolygon;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
public class TestTessellatorCureAddsTriangleTwice extends LuceneTestCase {
public void testCuredTriangleIndexedOnce() {
// self-crossing ring POLYGON((6 3, 0 6, 4 0, 3 4, 5 1, 3 2, 6 3)), repaired by the cure step
XYPolygon polygon =
new XYPolygon(new float[] {6, 0, 4, 3, 5, 3, 6}, new float[] {3, 6, 0, 4, 1, 2, 3});
Field[] triangles = XYShape.createIndexableFields("f", polygon);
Set<BytesRef> distinct = new HashSet<>();
for (Field triangle : triangles) {
distinct.add(triangle.binaryValue());
}
assertEquals(
"indexed triangles (expected: each distinct triangle once)",
distinct.size(),
triangles.length);
}
}
# in a copy of this finding's repro folder (repro/LFV-792/, 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.TestTessellatorCureAddsTriangleTwice
- Expected vs actual
- Expected: each triangle indexed once (3 fields); actual: 4 fields, 3 distinct (
expected:<3> but was:<4>), with -ea and -da.
More files, environment, full output40 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: indexed triangles (expected: each distinct triangle once) expected:<3> but was:<4>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.document.TestTessellatorCureAddsTriangleTwice.testCuredTriangleIndexedOnce(TestTessellatorCureAddsTriangleTwice.java:36)- Until 8.2.0 the cure step added the triangle once; the 2019 edit LUCENE-8620 added a second, identical call.
- Earcut, which the class ports, adds it once;
numberOfTerms()is documented to count "tessellated triangles". - The cure step exists for self-intersecting rings; "unexpected behavior" for them does not cover storing a triangle twice.
The full argument285 words
- History: a regression from a copy/paste edit. Until 8.2.0 the method ended with
// Return the triangulated vertices to the tessellationandtessellation.add(new Triangle(a, node, b));. LUCENE-8620 (apache/lucene-solr#771,62001b9b965on master,252421bb77con branch_8x, 2019-09-09) added edge flags; in this method it inserted a new add above the comment and rewrote the old add below it into the same call. None of the PR's review comments touches the cure step. Instrumented 8.2.0 and 8.3.0 Tessellators cut off the same triangles, with 1 and 2 adds each. - The algorithm it ports adds it once. The class is "inspired by mapbox's earcut algorithm" (
Tessellator.java:34); earcut'scureLocalIntersectionspushes the cut-off triangle once (read in the earcut source by the devil's advocate, the follow-up and the fact check, which read earcut's currentsrc/earcut.js). - The contract is a mesh. "Computes a triangular mesh tessellation for a given polygon" (
Tessellator.java:32);numberOfTerms()returns "the number of terms (tessellated triangles) for this shape" (ShapeDocValuesField.java:84);getCentroid()"retrieves the centroid location for the geometry" (:97). UpstreamTestTessellator.checkPolygonasserts that the tessellation's area equals the polygon's (TestTessellator.java:1016-1019); in the upstream geo test corpus the cure step never cuts off a triangle, and #11767 and #756 note that the method is not exercised by the tests, so no test could notice. - The cure step exists for this input. It cuts off a triangle only where the edge before a node and the edge after the next node intersect (
Tessellator.java:865-878;linesIntersectalso counts some touching configurations); for hole-free rings every such case found is invalid, self-intersecting input. The default path accepts such rings, and #11767 defends the step as a best-effort clean-up of them.
The devil's-advocate check292 words
The strongest case (the devil's advocate and the adjudication): the only standalone trigger is an invalid, self-intersecting ring, which the javadoc excludes ("No self intersections", Tessellator.java:45; "The polygon must not be self-crossing, otherwise may result in unexpected behavior", Polygon.java:34), and checkSelfIntersections = true rejects rings with a proper crossing such as this one; upstream tests even expect some self-crossing rings to be rejected with the flag off (TestTessellator.java:137-151, 823-861); in #11767 a maintainer proposed removing the method ("As we claim we only support valid polygons, I think it is safe to remove the method entirely"); on valid input the duplicate only doubles one of LFV-210's wrong triangles (or, with LFV-210 fixed, a zero-area one in polygons that hit LFV-793); and no search result changed in the sweeps. The strict flag does not reject every invalid ring that reaches the cure step: a self-overlapping ring POLYGON((4 4, 1 1, 4 1, 2 0, 0 1, 2 1, 4 4)) and 202 small lattice rings with no proper edge crossing, such as POLYGON((0 0, 1 1, 1 3, 0 0, 1 2, 2 0, 0 0)), are accepted with checkSelfIntersections = true and still store a triangle twice (the fact check's runs).
Why it fails as an intent argument: "unexpected behavior" leaves the region of a self-intersecting ring open, not whether the output lists one triangle twice, which is wrong under any reading (4 terms for 3 distinct triangles, and a centroid that weights one triangle twice). Nothing in the code, the history or earcut gives a reason for two adds, and the method added one from 7.5.0 to 8.2.0. What survives is impact: minor, rare in the data tested, no search change observed. Verdict: a defect, low priority.
Recommended Delete the first of the two identical adds
Delete the first of the two identical tessellation.add(...) calls in Tessellator.cureLocalIntersections (line 890, -1 line), which restores the 8.2.0 behaviour. The repro passes, and the geo and shape test suites pass with assertions on and off (366 tests in 45 suites, none failing). Shapes indexed before the fix keep the duplicate until reindexed; searches are unaffected either way.
boolean caFromPolygon =
(b.next == a) ? b.isNextEdgeFromPolygon : isEdgeFromPolygon(a, b, mortonOptimized);
- tessellation.add(new Triangle(a, abFromPolygon, node, bcFromPolygon, b, caFromPolygon));
// Return the triangulated vertices to the tessellation
tessellation.add(new Triangle(a, abFromPolygon, node, bcFromPolygon, b, caFromPolygon));All options and trade-offs3 options · 425 words
One option was built and validated; the alternatives are noted for the discussion.
Remove Tessellator.java:890. The remaining call, under the comment "Return the triangulated vertices to the tessellation", carries the same edge flags, so nothing else changes; the other triangles of the example are unchanged. This restores the method as it was from 7.5.0 to 8.2.0 (with the flags) and as earcut has it. Add the repro class (or a TestTessellator case asserting no repeated triangle) as a regression test; upstream's checkPolygon area oracle does not apply to self-crossing rings.
boolean caFromPolygon =
(b.next == a) ? b.isNextEdgeFromPolygon : isEdgeFromPolygon(a, b, mortonOptimized);
- tessellation.add(new Triangle(a, abFromPolygon, node, bcFromPolygon, b, caFromPolygon));
// Return the triangulated vertices to the tessellation
tessellation.add(new Triangle(a, abFromPolygon, node, bcFromPolygon, b, caFromPolygon));Trade-offs
- None for searches: the 129,472-query sweep gives the same counts with and without the duplicate.
- Existing indexes keep the duplicate triangle and the stored centroid until reindexed; worth one CHANGES line.
- It does not fix LFV-210, which owns the valid-input trigger (a different line); fixing either leaves the other.
What the checks validated
- Gradle, scratch worktree at
a9779ef3c193: on stock the 3 repro tests fail with assertions on (seed DEADBEEF) and off (5EED0792); with the fixorg.apache.lucene.geo.*,org.apache.lucene.document.*Shape*and the new class pass with assertions on and off: 45 geo and shape suites (392 test cases: 366 run, 26 skipped, of which 24 nightly-only, 1 awaits-fix, 1 other), includingTestTessellator(77 cases, 7 skipped),TestShapeDocValues(7),TestLatLonShape(25),TestXYShape(5) and the polygon, line, point and doc-values shape-query suites.:lucene:core:check -x test(google-java-format, ecjLint, forbiddenApis) passes. The full:lucene:coretest suite was not run. - Standalone runner: the class passes with the fix shadowed (assertions on and off); the 129,472-query sweep prints the same counts and result hash as stock.
- Earlier stages: the finder's, the independent reproducer's and the registry re-runs pass with the same deletion, as do
TestTessellator,TestShapeDocValues,TestXYShapeandTestLatLonShape(106 tests).
Also removes the duplicate, but changes which self-crossing rings tessellate at all: they would go straight to the split stage or be rejected. Not run; a separate decision with its own tests.
Trade-offs
A behaviour change for inputs the default path accepts today; #11767 has not settled it.
Hides the symptom for the two shape classes.
Trade-offs
Costs a set per shape and leaves Tessellator.tessellate's own output wrong for other callers.
Downstream relevance: a product that aggregates shape doc-values centroids or triangle counts over user-supplied polygons counts the cured triangle twice for that shape; no search result changed in the executed sweeps, and other documents are unaffected.
While modelling which inputs reach the Tessellator's cure and split stages, the finder read the cure step and saw two identical add calls. Exhaustive and random polygon searches (about 65 million small lattice rings, millions of valid polygons with touching holes) then showed who reaches it: self-intersecting rings and, for a non-zero-area duplicate, valid polygons only together with LFV-210.
The method in full199 words
Method: code reading inside a reachability model, witnessed by bounded search (semi-formal tier).
- The finder of a hunt unit built a probe harness on the Tessellator's monitor hook to model which inputs reach its CURE and SPLIT stages, and noticed the two identical
tessellation.addcalls while readingcureLocalIntersections(2026-10-27). - Witnesses and reach came from tools: every ring with 4 or 5 vertices on 3x3 and 4x4 lattices, 6 on 4x4 and 5x5, and 7 on 4x4 (64,962,384 rings; every one that has a cure triangle is JTS-invalid, and not all are self-crossing: 12,696 self-touching rings reach it too), random JTS-valid lattice polygons with touching holes (about 4 million on stock, 12 million with the known Tessellator fixes applied), 1.3 million valid floating-point LatLon polygons, the upstream geo test corpus (411 polygons, 822 tessellations) and an exact rational area and centroid oracle. A reduction harness shrank the valid witness.
- The follow-up's history stage found this ring, which every release from 8.2.0 on accepts, with a seeded search over random hole-free rings.
- Then an independent reproducer with its own witnesses, reviews by a model from another family (OpenAI GPT-5.6, reading only), an intended-behaviour devil's advocate, an adjudication and a credibility audit.
No prior report found (2026-10-27): three runs of the prior-report tool (55, 61 and 73 hits over GitHub issues, PRs and commits, downstream projects, the mailing lists, JIRA and the web), manual searches and the changed files of 408 open PRs. Closest: open #11767, which asks whether the cure step should exist at all.
Searches5 searches
Nobody reports the double add or its effects. Removing the method, as #11767 proposes, would also remove the duplicate. The earcut code examined (its current src/earcut.js and the versions the reviewers read) pushes the cut-off triangle once, so the earcut prior art of LFV-210 does not cover it.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, lucene-solr) | 2026-10-27 | three prior-report tool runs (16 GitHub and 3 commit queries each; the follow-up's two runs also had 6 web queries); 19 manual searches including the comment text "Return the triangulated vertices" and "remove small local self-intersections"; git log -S on the add line | no report; the add line comes only from 62001b9b965 / 252421bb77c and was never removed |
| Related items (read in full) | 2026-10-27 | #11767, lucene-solr#771 / LUCENE-8620, #11785 / #11786, #13980, #1017, #11690, #1066, #9580, #756 | #11767 (open) asks whether the cure step should exist, nobody mentions the double add; #771 introduced it, no comment on it; the others touch the method's condition or signature, or the doc-values work (whose test derives its expected centroid from the same tessellation) |
| Open PRs | 2026-10-27 | changed files of all 408 open apache/lucene PRs | #12036 and #12295 touch Tessellator.java; neither touches the adds |
| Downstream and code search | 2026-10-27 | Elasticsearch, OpenSearch, OpenSearch geospatial, Solr (7 searches); all of GitHub (5); a code search for the comment line (34 repositories) | no report; the comment appears only in copies or ports |
| Mailing lists and JIRA archive | 2026-10-27 | 20 queries over dev@, java-user@, issues@ and commits@; 4 JIRA-archive queries | no report |
Introduced by LUCENE-8620 (apache/lucene-solr#771, 2019-09-09), first released in 8.3.0; 8.2.0 added the triangle once. A version-independent check fails on 37 releases from 8.3.0 to 10.5.2 (36 of them with this ring) and on main, and passes on 8.2.0. The doc-values effects exist since 9.4.0. Not fixed upstream.
Versions tested5 rows
Introduced by LUCENE-8620 "Update Tessellator logic to label if triangle edges belongs to the original polygon" (apache/lucene-solr#771 by iverase, merged 2019-09-09; 62001b9b965 on master, 252421bb77c on branch_8x), first released in 8.3.0. In cureLocalIntersections it inserted a new add above the comment and rewrote the old single add tessellation.add(new Triangle(a, node, b)) below it into the same call. The cure step itself dates from LUCENE-8396 (2018: b5ef13330f1 on master, first released in 8.0.0; the branch_7x commit bc29580378d shipped in 7.5.0) and added the triangle once until 8.2.0. The LUCENE-9570 reformat (2d6ad2fee6d, 2020) made the two lines byte-identical.
The boundary is at release level: the only Tessellator commit between the 8.2.0 and 8.3.0 tags is 252421bb77c, and instrumented 8.2.0 and 8.3.0 Tessellators cut off the same triangles with 1 and 2 adds each. All 47 release tags from 8.3.0 to 10.5.2 carry both adds; 7.5.0 to 8.2.0 carry one. In 8.3.x and 8.4.x the classes lived in lucene-sandbox, in lucene-core from 8.5.0. The release runs (a sample of the tags: 8.3.0, 8.3.1, 8.4.0, 8.4.1, 8.5.0, 8.5.2, 8.6.3, 8.7.0, 8.8.2, 8.9.0, 8.10.1, 8.11.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.3.0, 9.4.0, 9.4.2, 9.5.0, 9.6.0, 9.7.0, 9.8.0, 9.9.2, 9.10.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0, 10.5.0, 10.5.1 and 10.5.2, plus 8.6.0 with an earlier version of the main()) used JDK 25, outside the JDKs the 8.x releases support; the failing path is plain Java, and 8.2.0 and the control pass on the same JVM. Not fixed: upstream main 20ced71c1e6e (2026-10-27), branch_10x and branch_10_6 still have both adds.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's class 3 of 3 fail (assertions on and off, standalone and gradle); the version-independent main(), the finder's and the reproducer's tests fail |
| 10.5.2, 9.12.3, 9.4.0 | affected | this doc's class 3 of 3 fail (doc values exist from 9.4.0) |
| 8.3.0 to 10.5.2 (37 release runs) | main() | this ring 4 triangles for 3 (36 runs; the 8.6.0 run used an earlier version of the main() without this ring); the valid touching-hole polygon 8 for 7 (XY and LatLon) |
| 8.2.0 | main() | this ring 3 triangles; the valid polygon 7 (its cure triangle, LFV-210's wrong one, appears once) |
fix on a9779ef3c193 | not affected | the 3 repro tests pass; 45 geo and shape suites (392 test cases: 366 run, 26 skipped, of which 24 nightly-only, 1 awaits-fix, 1 other) pass, assertions on and off; same query results as stock |
An independent reproducer with its own witnesses, cross-model reviews (reading only), a devil's advocate and an adjudication (confirmed, high) found it real and rated it minor; a credibility audit re-ran it. This doc's runs re-confirmed it on a ring every release since 8.2.0 accepts and validated the fix through gradle.
Checks and verdicts393 words
Every executing check wrote or re-ran its own test on stock main; every control passed. No cross-model review could execute code.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-API JUnit: 4 of 4 fail on main (assertions on and off), 10.5.2 and 9.12.3 (on 9.12.3 one of the four is the rejection of its other ring, not this bug); with line 890 deleted 4 of 4 pass and 106 upstream tests pass; with only LFV-210's fix only the self-crossing test fails; 57,800 point-index and 43,350 doc-values box queries unchanged by the duplicate. |
| cross-model reviews, refute and intended lenses | not real on procedure only (low) / real (medium) | An independent reviewer from another model family (OpenAI GPT-5.6), reading only: agreed on the root cause, found the inputs in contract and the intended argument weak; the refute verdict is procedural (it could not run a test). Both separate runs re-ran the finder's test: 4 of 4 fail. |
| Independent reproducer | real | Own witnesses and JUnit: 6 of 7 fail on main (assertions on and off), 10.5.2 and 9.12.3, the control passes; 7 of 7 pass with line 890 deleted; the strict path duplicates its valid witness too. |
| Cross-model root-cause review | uncertain (procedural) | Reading only; agreed on the root cause. Separate runs: the finder's test fails on main, 10.5.2 and 9.12.3 and passes with the fix; the duplicated valid-witness triangle lies outside the polygon. |
| Intended-behaviour devil's advocate | real; impact framing disputed | No intent argument survives; on all 9 valid witnesses LFV-210's fix alone gives exact tessellations, so the valid-input centroid error is mostly LFV-210's; recommends minor. |
| adjudication | confirmed (high), minor | Re-ran the independent reproduction's test (seed 5EED1234): 6 of 7 fail with assertions on and off, 7 of 7 pass with the fix. |
| credibility audit | keep | Re-ran the finder's test on main with assertions on and off (4 of 4 fail) and the independent reproduction's with assertions off (6 of 7 fail). |
| this doc's runs | reproduced; fix validated | A new class on a ring every release from 8.2.0 accepts: 3 of 3 fail on main, 10.5.2, 9.12.3 and 9.4.0, pass with the fix; 45 geo and shape suites (392 test cases: 366 run, 26 skipped, of which 24 nightly-only, 1 awaits-fix, 1 other) pass with the fix through gradle; 129,472 box queries unchanged. |
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 517 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,426 words
- Registry title
- Tessellator.cureLocalIntersections adds each triangle it cuts off twice, so LatLonShape and XYShape index that triangle twice and the shape doc-values centroid counts its area twice
- Area
- geo / Tessellator cure step (polygon indexing for LatLonShape and XYShape, shape doc values)
- Priority
- Low, rank 432: This Tessellator duplicate-triangle bug has a rare trigger and leaves query results unchanged, only adding one triangle to the index and skewing the experimental ShapeDocValues centroid, and it needs either an out-of-contract self-crossing ring or a polygon already hit by LFV-210.
- Impact class
- minor
- Impact note
- Hits applications on 8.3.0 to 10.6.0 (10.6.0 by reading) and main that index polygons through the default LatLonShape or XYShape createIndexableFields or createDocValueField (default codec and IndexWriterConfig, no scale) whenever the Tessellator's cure step cuts off a triangle, which on its own happens only for self-crossing rings (excluded by the Polygon and Tessellator javadoc, but accepted with the default checkSelfIntersections=false) and on valid polygons only together with the registered bug LFV-210 (a hole touching the shell, whose one-line fix removes it); the effect is one extra indexed triangle per cured triangle, ShapeDocValuesField.numberOfTerms one too high and that triangle's area counted twice in the experimental getCentroid(), while query results are unchanged (57,800 box queries gave identical counts with and without the duplicate, finder's check) [exec on main, 10.5.2, 9.12.3 and 36 releases from 8.3.0].
- Summary
- You index a polygon with LatLonShape or XYShape (createIndexableFields or createDocValueField) and expect each triangle of its tessellation to be stored once. When the Tessellator's cure step (cureLocalIntersections) cuts off a triangle to repair a small local self-intersection, it appends that same triangle twice (two identical tessellation.add calls at Tessellator.java:890 and :892, left by LUCENE-8620 in 8.3.0), so the shape carries one extra indexed triangle, ShapeDocValuesField.numberOfTerms is one too high and getCentroid() counts that triangle's area twice; query results do not change. On its own the cure step fires only for self-crossing rings, which the javadoc excludes but the default checkSelfIntersections=false path accepts (for example POLYGON((6 3, 0 6, 4 0, 3 4, 5 1, 3 2, 6 3)), duplicated on every release from 8.3.0), and a valid polygon reaches it only when it already hits LFV-210 (a hole touching the shell), whose fix removes it.
- Introduced
- 62001b9b9651e54b54f73352801061d40da75168 on master (apache/lucene-solr#771 'LUCENE-8620: Update Tessellator logic to label if triangle edges belongs to the original polygon', by iverase, merged 2019-09-09; issue LUCENE-8620, migrated as apache/lucene#9666), backported to branch_8x as 252421bb77c (2019-09-09), the commit that shipped first (8.3.0). In cureLocalIntersections it inserted a new tessellation.add above the comment '// Return the triangulated vertices to the tessellation' and rewrote the old single add 'tessellation.add(new Triangle(a, node, b))' below it into the same call [read: git log -S on the call, git blame -w -M -C -C on main; exec: release boundary 8.2.0 passes and 8.3.0 fails, and instrumented 8.2.0 and 8.3.0 Tessellators cut off the same cure triangles with 1 vs 2 adds each; the only Tessellator commit between the two tags is 252421bb77c; no commit-level build of the 2019 tree]. The LUCENE-9570 reformat (2d6ad2fee6d, 2020-12-23) made the two lines byte-identical. The cure step itself dates from LUCENE-8396 (b5ef13330f1, 7.5.0) and added the triangle once until 8.2.0. (2019-09-09 (master 62001b9b965 and branch_8x 252421bb77c))
- First affected version
- 8.3.0 [exec] (8.2.0 is the last unaffected release [exec]; in 8.3.0-8.4.1 the classes shipped in lucene-sandbox, in lucene-core from 8.5.0; the doc-values numberOfTerms and centroid effect exists from 9.4.0, when ShapeDocValuesField was added)
- Affected versions
- [exec] the portable main() repro (one triangle indexed twice for the valid touching-hole polygon through XYShape and LatLonShape and for the self-crossing ring POLYGON((6 3, 0 6, 4 0, 3 4, 5 1, 3 2, 6 3))) fails on 36 releases: 8.3.0, 8.3.1, 8.4.0, 8.4.1, 8.5.0, 8.5.2, 8.6.3, 8.7.0, 8.8.2, 8.9.0, 8.10.1, 8.11.0, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.3.0, 9.4.0, 9.4.2, 9.5.0, 9.6.0, 9.7.0, 9.8.0, 9.9.2, 9.10.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0, 10.5.0, 10.5.1 and 10.5.2, and on main a9779ef3c193 (-ea and -da); it passes on 8.2.0. The finder's JUnit fails 4 of 4 on main (-ea and -da), 10.5.0 and 10.5.2; on 9.4.0, 9.12.3, 10.0.0 and 10.4.0 only 3 of its 4 failures are this bug, because those releases reject its ring POLYGON((6 0, 6 4, 5 1, 6 5, 6 1, 2 6, 2 0, 6 0)) with 'Unable to Tessellate shape' (accepted, and duplicated, only from 10.5.0 through #15739); on 9.3.0 it does not compile (no shape doc values yet). The independent reproduction's JUnit fails 6 of 7 on main (-ea and -da), 10.5.2 and 9.12.3. [read] all 47 release tags 8.3.0..10.5.2 in the history clone carry both adds and 7.5.0..8.2.0 carry one. Still unfixed on upstream main 20ced71c1e6e (2026-10-27), branch_10x and branch_10_6, so 10.6.0 has both adds (by reading).
- 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, code reading
- Upstream and prior reports
- none found (2026-10-27, read-only; the prior-report record, verdict new): the finder's scripted prior-report search (16 GitHub, 3 commit, 2 downstream, 20 mailing-list and 4 JIRA queries; 55 distinct hits) and the follow-up's run (the same kinds plus 6 web queries; 73 distinct hits), manual gh searches on apache/lucene, all of GitHub and downstream repositories (Elasticsearch, OpenSearch, OpenSearch geospatial, Solr), a code search for the comment line (34 repositories, all verbatim copies or ports), the changed files of all 408 open apache/lucene PRs, the Tessellator.java history (git log -S), full reads of #11767, lucene-solr#771 and LUCENE-8620, and web queries. Closest items, none a report of this bug: #11767 (open since 2022-09-13: whether cureLocalIntersections should exist at all; nobody mentions the double add), lucene-solr#771 / LUCENE-8620 (the introducing change; no comment mentions it), #11785 and #11786 (reorder the method's condition; the adds untouched), #13980 (signature only), #9580 (2018, predates it), #756 (collinear bridges), #1017, #11690 and #1066 (the ShapeDocValues work; TestShapeDocValues derives its expected centroid from the same tessellation, so it cannot see the duplicate), #12036 and #12295 (the only open PRs touching Tessellator.java; neither touches the adds). Not fixed upstream: main 20ced71c1e6e (2026-10-27), branch_10x and branch_10_6 still have both adds. Gaps: GitHub search reads only the first page per query and list search is keyword-based.
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27 and 28) through the standalone JUnit runner (assertions on and off) and through gradle in a scratch worktree, stock and with the fix; the 10.5.2, 9.12.3 and 9.4.0 release jars; earlier stages ran a version-independentmain()on 8.2.0 and on 37 releases from 8.3.0 to 10.5.2 (36 of them with this ring), instrumented 8.2.0 and 8.3.0 Tessellators, and the finder's and the independent reproducer's JUnit classes on main, 10.5.2 and 9.12.3. JDK 25.0.3 on Linux aarch64 only (for 8.x outside the JDKs those releases support).
- Finding record
lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:31-52 (class javadoc: 'Computes a triangular mesh tessellation', 'Requires valid polygons: No self intersections'), :72 (@lucene.internal), :855-903 (cureLocalIntersections; identical tessellation.add calls at :890 and :892, removeNode at :895-896)lucene/core/src/java/org/apache/lucene/geo/Polygon.java:30-36 ('The polygon must not be self-crossing, otherwise may result in unexpected behavior')lucene/core/src/java/org/apache/lucene/document/XYShape.java:93-131 and LatLonShape.java:95-131 (createIndexableFields and createDocValueField: one field or doc-values triangle per tessellation entry; checkSelfIntersections javadoc)lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:49 (@lucene.experimental), :262-290 and :428-453 (centroid = sum of area-weighted triangle centroids / total area); ShapeDocValuesField.java:38 (@lucene.experimental)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:671-679, 854-903 (862-878, 880-892, 895-896) at a9779ef3c193lucene/core/src/java/org/apache/lucene/document/XYShape.java:93-130, LatLonShape.java:95-132, ShapeDocValues.java:262-290, 428-453, 491-493
- Who is affected
lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:85-92, XYShape.java:83-90 (default checkSelfIntersections=false); ShapeDocValuesField.java:38 (@lucene.experimental); ShapeDocValues.java:491-493lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:895-896 (removeNode), 936-939 (both halves restart at INIT), 1175-1177 (splitPolygon copies two nodes)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:32, 34, 45, 890-892; Polygon.java:34; lucene/core/src/java/org/apache/lucene/document/ShapeDocValuesField.java:84, 97lucene/core/src/test/org/apache/lucene/geo/TestTessellator.java:1011-1019lucene/core/src/test/org/apache/lucene/geo/TestTessellator.java:137-151, 823-861 (self-crossing rings rejected even with checkSelfIntersections=false)
- TestTessellatorCureAddsTriangleTwice.java (JUnit test):
repro/LFV-792/src/org/apache/lucene/document/TestTessellatorCureAddsTriangleTwice.java(41 lines)
Made in parallel with this doc from an executed trace of this example (a copy of Tessellator with print statements, main a9779ef3c193: the cure cut, the list size after lines 890 and 892, the remaining ears, the stored fields, the doc values and a box-query sweep). The PNG is the summary still; the caption describes the executed example.
assets/LFV-792/bug.png(517 KB)
