To index a cartesian polygon, Lucene first works out which way round its corners go, from a sum of products of coordinate differences. It computes those products in 32-bit float, which overflows for very large coordinates, so a valid polygon gets the wrong direction and is rejected.
XYShape.createIndexableFields(field, polygon)indexes anXYPolygon, a ring of float x/y coordinates (any finite float is allowed), by cutting it into triangles with theTessellator(ear clipping) and indexing each triangle.- Ear clipping must walk the ring in one fixed direction.
XYPolygon's constructor computes the ring's direction once, from the sign of a signed-area sum (below 0: Lucene labels it CCW; otherwise CW), and the Tessellator reverses the ring when needed according to that label. - A float holds magnitudes up to
Float.MAX_VALUE, about 3.4e38. A larger product becomesInfinity,Infinity - Infinityis NaN, and every comparison with NaN is false.
- Index the triangle (0,0), (1,1), (2,1) as an
XYPolygonwithXYShape.createIndexableFields: you get 1 triangle field. Lucene labels the ring CCW, because its winding sum is 1x1 - 1x2 = -1, below 0. - Multiply every coordinate by 2^66 (about 7.4e19). That is exact in float, the constructor accepts every finite float, and neither the shape nor its orientation changes. You expect it to index the same way.
- Instead the two products of the winding sum are 2^132 and 2^133, both above
Float.MAX_VALUE(3.4e38), so in float both areInfinity.Infinity - Infinityis NaN,NaN < 0is false, and the ring is labelled CW. - The Tessellator links the vertices in the order that label implies, so it walks this ring backwards, finds no ear to cut and throws
IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected.The document cannot be built. - It starts at 2^64, the square root of
Float.MAX_VALUE(about 1.8e19): at 2^63 the triangle still indexes. The same triangle listed the other way round still indexes at 2^66, because NaN happens to give it the right label.
Applications that index cartesian polygons with XYShape (default codec and settings, both field factories) whose coordinates differ by about 1.8e19 or more, or that index a whole-plane box at +/-Float.MAX_VALUE listed clockwise. That one document is rejected; the defect is not on the query path, other documents and the index are unaffected, and nothing is silently wrong. Rare.
Mechanism, terms and numbers1,003 words
XYPolygon(float[] x, float[] y, XYPolygon... holes) accepts every finite float coordinate and works out the ring's orientation from a signed-area sum: double windingSum accumulates (x[j] - x[n]) * (y[i] - y[n]) - (y[j] - y[n]) * (x[i] - x[n]), and a sum below 0 is labelled CCW, anything else CW. The accumulator is double, but x and y are float[], so both products are float multiplications. Once coordinate differences reach about sqrt(Float.MAX_VALUE) = 1.8e19 (2^64), the products overflow: in the example triangle both products of a term become Infinity, the term is Infinity - Infinity = NaN, and a NaN sum is labelled CW (infinite terms of opposite sign also give NaN, and a single infinite term can give the wrong sign). Tessellator.tessellate(XYPolygon) links the ring in the order the label implies, so a ring whose correct label is CCW is linked backwards; ear clipping finds no ear and XYShape.createIndexableFields / createDocValueField throw "Unable to Tessellate shape. Possible malformed shape detected." for a valid polygon. A whole-plane box at +/-Float.MAX_VALUE listed clockwise fails the same way, through an overflowing difference. Only that document is affected: no wrong triangulation, index damage or query effect was observed. Present since XY shapes were added (LUCENE-8632, 8.2.0) through 10.6.0 and main. The fix is one statement: cast the differences to double before multiplying, as the lat/lon Polygon already computes the same sum.
- Winding order: the direction a ring's vertices go round.
XYPolygon.getWindingOrder()"Returns the winding order (CW, COLLINEAR, CCW) for the polygon shell" (XYPolygon.java:150-153). Lucene labels a negative sum CCW; with y pointing up such a ring runs clockwise (the example triangle does). - Fan term: the constructor sums, for each vertex pair (j, i) and the last vertex n,
(x[j] - x[n]) * (y[i] - y[n]) - (y[j] - y[n]) * (x[i] - x[n]), twice the signed area of one fan triangle.
- The constructor (
XYPolygon.java:48) checks the first vertex and every vertex the loop visits withcheckVal(:93,:95,:101,:103; the closing vertex must equal the first,:64,:73), which rejects only non-finite values (XYEncodingUtils.java:35-41; range-/+Float.MAX_VALUE,:28-29). double windingSum = 0d;(:98), thenwindingSum += (x[j] - x[numPts]) * (y[i] - y[numPts]) - (y[j] - y[numPts]) * (x[i] - x[numPts]);(:106-107).xandyare thefloat[]parameters: the differences and both products are float; only the term's result is widened.- Triangle (0,0), (s,s), (2s,s): the first term is 0, the second
s*s - s*(2s). Scale 1: -1, label CCW. Scale 2^66:2^132and2^133overflow toInfinityin float, the term is NaN (in double it would be -5.44e39). this.windingOrder = (windingSum < 0) ? CCW : CW;(:113): NaN gives CW.Tessellator.tessellate(XYPolygon, boolean, Monitor)(Tessellator.java:155) converts the coordinates to double (:162-163) and callscreateDoublyLinkedList(..., polygon.getWindingOrder(), ..., WindingOrder.CW)(:160-167), which keeps the vertex order when the label is CW and reverses it otherwise (:227-235). The mislabelled ring is linked backwards.- No vertex of a backwards ring is an ear; after the INIT, CURE and SPLIT passes (
:670-689) the Tessellator throwsIllegalArgumentException("Unable to Tessellate shape. Possible malformed shape detected.")(:687-688; the same message at:206-207when no triangle was produced). XYShape.createIndexableFields(XYShape.java:83-85,:96-106) andcreateDocValueField(:88-90,:109-112) call the Tessellator at:100and:112and pass the exception to the caller, before anyIndexWritercall.
- Overflow needs coordinate differences of about
sqrt(Float.MAX_VALUE)= 1.84e19 (about 2^64) or more. Once a float product (or a difference) overflows, the label can go wrong in three ways:Infinity - Infinityinside one term: both products of a term overflow with the same sign, so the term and the sum are NaN, labelled CW. This is the triangle: its products are 2^126 and 2^127 at scale 2^63 (finite: labelled CCW, indexes) and 2^128 and 2^129 at 2^64 (bothInfinity: labelled CW, rejected).- Infinite terms of opposite sign: a term with one overflowing product is
+Infinityor-Infinity, and two such terms of opposite sign add up to NaN. The notched square (0,0), (0,2^65), (2^65,2^65), (2^20,2^64), (2^65,0) has terms-Infinity,+Infinity,-Infinity, none with both products overflowing; it should be CCW but is labelled CW and rejected, while its exact 2^-64 copy indexes (fact-check probe). - One infinite term that outweighs the rest: a single
+/-Infinityterm carries that term's sign, which can be wrong for the ring. This path also mislabels rings whose correct label is CW. - One overflowing product does not always break the label: the independent reproduction's quadrilateral at 2^63 has two
-Infinityterms, the correct sign, and is labelled right.
- Or a difference overflows by itself: in a whole-plane box from
-Float.MAX_VALUE(-M) to+Float.MAX_VALUE(M) listed(-M,-M), (-M,M), (M,M), (M,-M), the first term starts with(x[0] - x[4]) * (y[1] - y[4]): the y-spanM - (-M)isInfinityin float and0 * Infinityis NaN. That listing is rejected; the opposite listing indexes. - Mainly rings whose correct label is CCW are hit, because NaN falls back to CW. In the intended-behaviour check's search over about 81,000 random star polygons per orientation, 18, 7 and 2 rings whose correct label is CW were labelled CCW at scales 2e19, 3e19 and 5e19, all through a single infinite term (the fact check's replay). A few wrong labels come from a finite but wrongly rounded sum: 8 of the 4,115 rejected reversed generator rings in the generator check run for the read-only cross-model review. So the root cause is the float evaluation, not only the overflow to NaN.
- Holes are linked by their own
getWindingOrder()(Tessellator.java:260) and are affected too (shown in an exploratory run). - Edges parallel to an axis give zero products, so ordinary axis-aligned boxes mostly avoid it.
Exposure and likelihood306 words
- Who is hit. Callers of
XYShape.createIndexableFieldsorXYShape.createDocValueField(both overloads, eithercheckSelfIntersectionsvalue) with the default codec andIndexWriterConfig; nothing to opt into. Also anyone who reads the publicXYPolygon.getWindingOrder(). - What it takes. Coordinate differences of about 1.8e19 or more, mostly in a ring whose correct label is CCW (the exact-trigger list above has the rarer cases), or a whole-plane box at +/-
Float.MAX_VALUElisted clockwise with y up. Typical projected, CAD and game coordinate systems are far below this magnitude. A plausible boundary case for an indexed geometry is such a whole-plane polygon; an ordinary "match everything" query box (XYShape.newBoxQuery) does not use this code. No user report was found since 2019. - What happens. The field factory throws "Unable to Tessellate shape. Possible malformed shape detected.", so that document cannot be built;
getWindingOrder()returns CW for a CCW ring. - What does not happen. No silent wrong triangulation (0 in the study's 51,240 tessellations; every verifier saw only clean exceptions), no index damage, no effect on other documents. Query geometries never read the winding order (
XYShape.newGeometryQuery->XYGeometry.create->Polygon2D), so this winding-label defect is not on the query path. - Scale in the study's harness. Every simple polygon with 3 to 5 vertices on a 4x4 grid, scaled by 2^66, 2^100 and 2^126 (exact): 21,452 of 51,240 tessellations rejected at each scale, none at scale 1, none with the fix. The share reflects the harness's mix of orientations, not a real-world rate.
- Why the tests miss it.
ShapeTestUtil.nextFloatdraws coordinates from the full float range (ShapeTestUtil.java:239-240), but every polygon generator emits the orientation Lucene labels CW, which the NaN fallback gets right, andBaseXYShapeTestCaseretries any polygon whose tessellation throws (BaseXYShapeTestCase.java:228-233). Reversed, 4,115 of 10,530 generator rings that index forwards are rejected; 0 with the fix.
One JUnit test on the public API: build the triangle (0,0), (1,1), (2,1) times 2^66 and call XYShape.createIndexableFields. It fails because the call throws "Unable to Tessellate shape". No index, no randomness, single thread; the same with assertions on and off.
/*
* 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 TestXYShapeLargeTriangle extends LuceneTestCase {
public void testTriangleScaledBy2Pow66CanBeIndexed() {
float s = Math.scalb(1f, 66);
XYPolygon triangle = new XYPolygon(new float[] {0, s, 2 * s, 0}, new float[] {0, s, s, 0});
try {
XYShape.createIndexableFields("shape", triangle);
} catch (IllegalArgumentException e) {
fail("expected: the scaled triangle indexes as at scale 1; actual: " + e.getMessage());
}
}
}
cp TestXYShapeLargeTriangle.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestXYShapeLargeTriangle -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
- Expected
XYShape.createIndexableFieldsreturns the triangle's field, as it does for the same triangle at scale 1.- Observed
IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected., so the test fails, with assertions on and off.
More files, environment, full output254 words
Public API only: XYPolygon and XYShape.createIndexableFields; the exception comes from the Tessellator before any index is touched. The test uses no randomness, so the seed does not matter (5 of 5 iterations with another seed fail too).
The same file fails unchanged on 10.6.0 with assertions on and off. On releases before 9.1.0 LuceneTestCase lives elsewhere; the study used a framework-free main() there.
As the regression test: this same test, for example as a method in TestXYShape.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); the same test also fails on 10.6.0; every release from 8.2.0 to 10.6.0 is affected |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp TestXYShapeLargeTriangle.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestXYShapeLargeTriangle -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, assertions on (trimmed)
TestXYShapeLargeTriangle > testTriangleScaledBy2Pow66CanBeIndexed FAILED
java.lang.AssertionError: expected: the scaled triangle indexes as at scale 1; actual: Unable to Tessellate shape. Possible malformed shape detected.
at __randomizedtesting.SeedInfo.seed([DEADBEEF:AA81DB64071B184D]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.apache.lucene.document.TestXYShapeLargeTriangle.testTriangleScaledBy2Pow66CanBeIndexed(TestXYShapeLargeTriangle.java:30)
# fact-check probe, main 645d4fe14a20, assertions off
scale 2^0: getWindingOrder()=CCW, createIndexableFields: indexes, 1 field(s)
scale 2^63: getWindingOrder()=CCW, createIndexableFields: indexes, 1 field(s)
scale 2^64: getWindingOrder()=CW, createIndexableFields: IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected.
scale 2^66: getWindingOrder()=CW, createIndexableFields: IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected.
scale 2^66 reversed: getWindingOrder()=CW, createIndexableFields: indexes, 1 field(s)
float s*s=Infinity, s*2s=Infinity, difference=NaN, NaN < 0 is false; in double: -5.444517870735016E39
# the same probe with the fix: every row indexes; scales 2^0 to 2^66 are labelled CCW- Any finite float is a valid coordinate, and an exact power-of-two scaling cannot change a ring's "winding order".
- The accumulator is
double; float products came in when the original PR made the parametersfloat[](its author: "Internally we still use doubles"). - Tests miss it: their generators emit the orientation NaN gets right, and failed tessellations are retried.
The full argument294 words
- Any finite float is a valid coordinate.
checkValrejects only non-finite values (XYEncodingUtils.java:34-41); the constructor's other checks are on array lengths, ring closure and nested holes (XYPolygon.java:49-87); upstreamTestXYPolygonrejects only NaN and +/-Infinity (testPolygonNaN,testPolygonPositiveInfinite,testPolygonNegativeInfinite,TestXYPolygon.java:81-116). - The label describes the ring.
getWindingOrder(): "Returns the winding order (CW, COLLINEAR, CCW) for the polygon shell" (XYPolygon.java:150-153; the constructor only ever sets CW or CCW,:113). No javadoc says "scale invariant" in those words; the argument is the meaning of "winding order" plus the accepted range. - Both orientations are supported. "should be CCW, but this will correct" (
Tessellator.java:157-158), and the linking reverses a ring whose label differs (:227-235). The same triangle listed the other way round indexes at 2^66, so the rejected one is not malformed. The rejected triangle even runs clockwise with y up, the order the lat/lonPolygonjavadoc asks for ("Coordinates must be in clockwise order, except for holes",Polygon.java:31); theXYPolygonjavadoc names no order. - History. Inside the introducing PR (apache/lucene-solr#726, LUCENE-8632), commit
4d0c96f40b8("change XYPolygon and XYLine API to accept floats instead of doubles", 2019-06-26) changed the constructor parameters fromdouble[]tofloat[]; they shadow the thendouble[]fields and the loop kept reading them. At its parent1c982b90199the sum was in double. The author (nknize) wrote in the PR: "Internally we still use doubles for the tessellation but the user must pass floats to the Geometry so it's consistent with the encoding." Nobody discussed the winding sum; the PR was squash-merged. LUCENE-9218 (4a54ffb553a, 8.5.0) made the fieldsfloat[]and kept the expression. - The lat/lon twin is double.
Polygoncomputes the same sum ondouble[](Polygon.java:118-134).
The devil's-advocate check283 words
The strongest case for "intended" (built by two of the study's verifiers): the Tessellator class notes say it is "sensitive to overflow (e.g, subatomic values such as E-200 can cause unexpected behavior)" (Tessellator.java:49-50); XYShape warns that vertex values are indexed "with some loss of precision" (XYShape.java:71-72); coordinates near 1e19 are not realistic, and the failure is loud (one document gets an exception), not silent; and the shape test base class retries polygons that fail to tessellate.
Why it fails: the overflow happens in the public XYPolygon constructor, not in the Tessellator, which works in double and tessellates these exact polygons once the label is right; the note is about tiny values and predates XY shapes (it came with the lat/lon shape indexing of LUCENE-8396, b5ef13330f1, 2018); the precision warning is about converting from double, and the XY encoding stores floats exactly (XYEncodingUtils.encode is floatToSortableInt, XYEncodingUtils.java:50-52); "not realistic" and "loud" are impact arguments, not intent: the API accepts the range, Lucene's own random tests draw from it, and the public getWindingOrder() returns a wrong label; and the retry's own comment says it skips shapes the "random polygon generator created" as malformed. On PR #12287 its author, heemin32, pointed out that this retry hides XY tessellation failures, and a reviewer, nknize (the author of XYShape), replied: "That try-catch is intentional. The evil, esoteric / invalid test geometry generation is also intentional so we can maximize our test coverage across the tessellator and Shape indexing." So the retry is a deliberate choice for malformed generated shapes, not a statement that valid shapes may be rejected. The argument is a fair one for low priority, not for intended behaviour.
Recommended A. Compute the winding-sum products in double (one statement)
Cast the coordinate differences to double before multiplying in the winding sum (XYPolygon.java:106-107), as the lat/lon Polygon does: double products of float differences cannot overflow. With it the repro passes with assertions on and off, as do 270 tests in 30 geo and XY-shape test classes and the core module's static checks.
--- a/lucene/core/src/java/org/apache/lucene/geo/XYPolygon.java
+++ b/lucene/core/src/java/org/apache/lucene/geo/XYPolygon.java
@@ -103,8 +103,10 @@ public final class XYPolygon extends XYGeometry {
minY = Math.min(checkVal(y[i]), minY);
maxY = Math.max(y[i], maxY);
// compute signed area
+ // in double: float products of coordinate differences overflow from about 1.8e19
windingSum +=
- (x[j] - x[numPts]) * (y[i] - y[numPts]) - (y[j] - y[numPts]) * (x[i] - x[numPts]);
+ ((double) x[j] - x[numPts]) * ((double) y[i] - y[numPts])
+ - ((double) y[j] - y[numPts]) * ((double) x[i] - x[numPts]);
}
this.minX = minX;
this.maxX = maxX;All options and trade-offs3 options ยท 581 words
Fix A was validated through gradle in a scratch worktree of 645d4fe14a20; the study's finder and the independent reproduction wrote the same change independently, and the intended-behaviour check, the executed cross-model review and a separate run for the read-only cross-model review ran the finder's file. B was not run.
A. Compute the winding-sum products in double (one statement) (validated by the checks) (recommended)
Widen each float difference to double before the multiplication. A difference of two finite floats is at most 2 x Float.MAX_VALUE (6.8e38), so each product is at most about 4.6e77, far below Double.MAX_VALUE (1.8e308). Double arithmetic on the float inputs is also far more precise, which also fixed every rounding case the study found. The comment line is optional.
--- a/lucene/core/src/java/org/apache/lucene/geo/XYPolygon.java
+++ b/lucene/core/src/java/org/apache/lucene/geo/XYPolygon.java
@@ -103,8 +103,10 @@ public final class XYPolygon extends XYGeometry {
minY = Math.min(checkVal(y[i]), minY);
maxY = Math.max(y[i], maxY);
// compute signed area
+ // in double: float products of coordinate differences overflow from about 1.8e19
windingSum +=
- (x[j] - x[numPts]) * (y[i] - y[numPts]) - (y[j] - y[numPts]) * (x[i] - x[numPts]);
+ ((double) x[j] - x[numPts]) * ((double) y[i] - y[numPts])
+ - ((double) y[j] - y[numPts]) * ((double) x[i] - x[numPts]);
}
this.minX = minX;
this.maxX = maxX;Trade-offs
- One statement in the constructor; one double multiply-add per vertex, once per polygon.
getWindingOrder()changes only where the float and double sums disagree in sign (NaN included); the upstream geo and shape suites pass unchanged.- It avoids the float-product overflow but is not an exact orientation predicate: a nearly degenerate ring can still round in double, and a ring whose float sum had rounded to the wrong sign or to 0 gets a new label, so it may be triangulated differently when indexed again.
- Same arithmetic as the lat/lon
Polygon(Polygon.java:126-128).
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seedDEADBEEF, 2026-11-01: without the fix the repro fails with assertions on and off; with it, it passes with assertions on and off. ./gradlew :lucene:core:test --tests 'org.apache.lucene.geo.*' --tests 'org.apache.lucene.document.TestXY*' --tests org.apache.lucene.document.TestShapeDocValueswith the fix: 30 classes (includingTestTessellator, 77 tests,TestXYPolygon, 9,TestXYShape, 5, and the repro), 270 tests, 0 failures, 16 skipped by their own nightly or awaits-fix annotations../gradlew :lucene:core:check -x testwith the fix and the repro as a new test file: passes (formatting, ecj lint, forbidden APIs; Error Prone and eclint did not run, as neither is configured on this host). The full Lucene test suite was not run.- With the fix shadowed into the stock jars, the fact-check probe labels the triangle CCW at 1, 2^63, 2^64 and 2^66 and every case indexes.
- In the study: the finder's harness goes from 21,452 to 0 rejections per scale, the reversed generator rings from 4,115 to 0; the verifiers' and the cross-model reviewer's own tests pass; 27 upstream geo/shape suites pass with this fix and four sibling fixes together (306 passed, 0 failed, 21 aborted by nightly and awaits-fix assumptions).
Derive the ring's direction in Tessellator.tessellate(XYPolygon) from the double coordinates it already builds (Tessellator.java:162-163) instead of trusting polygon.getWindingOrder().
Trade-offs
A larger change in shared code (the lat/lon path uses the same linking), and the public XYPolygon.getWindingOrder() would still return the wrong label.
Tighten checkVal so the products cannot overflow.
Trade-offs
Changes documented behaviour, breaks existing whole-plane boxes at +/-Float.MAX_VALUE, and leaves the rounding cases.
Services that index user-supplied cartesian polygons will report such a valid polygon as malformed at index time, with Lucene's generic "Unable to Tessellate shape" error.
Non-formal: while building a checker that runs the Tessellator on every small simple polygon of a 4x4 grid, an AI tool noticed the float products in the constructor. Scaled by exact powers of two (2^66, 2^100, 2^126), 21,452 of 51,240 tessellations were rejected at each scale; none at scale 1 and none with the sum in double.
The method in full114 words
Method: code reading, then measurement with a bounded-exhaustive translation-validation harness (study unit on the geo Tessellator and shapes, main ea57360912ed, 2026-10-26).
- The harness enumerates every simple polygon with 3 to 5 vertices on a 4x4 grid (12,810 shells, 51,240 tessellations per run) and checks each tessellation for coverage, overlap and area against the polygon.
- Scaled by 2^66, 2^100 and 2^126 (exact in float and double): 29,788 ok and 21,452 rejected with
IllegalArgumentExceptionat each scale; 0 wrong triangulations (no Steiner points, outside or overlapping triangles, or area mismatches). With the winding sum in double: 51,240 ok, 0 rejected. - The finder then wrote a public-API JUnit test; the one-test repro was reduced from it.
No prior report found in GitHub issues, PRs and commits, the JIRA archive, the Lucene mailing lists, downstream projects or the web (126 queries, 2026-10-26; re-checked 2026-11-01). No open PR touches XYPolygon.java. The closest items are the 2019 introduction and test-generator issues; the code is unchanged upstream.
Searches4 searches
Closest items, none a report of this bug: LUCENE-8632 / #9678 and PR lucene-solr#726 (the introduction); LUCENE-9218 / #10258 (float storage, expression unchanged); LUCENE-8850 / #9893 (open; winding-sum robustness for the lat/lon Polygon, in double); #12596, PR #12757 and PR #12287 (XY test generators; #12287 shows the intentional retry in BaseXYShapeTestCase); LUCENE-9470 / #10509 (a query-side numerical error near Float.MAX_VALUE, different code); elastic/elasticsearch#78499 (a cartesian polygon that becomes invalid once rounded to float; these are exact and valid in float). The other "Unable to Tessellate" reports are lat/lon polygons with holes, collinear edges or self-intersections. Gaps: the searches were broad but cannot be exhaustive; GitHub search is phrase-like and does not index diffs; JIRA and list search match words.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene and apache/lucene-solr issues, PRs and commits | 2026-10-26 | a scripted prior-report search (16 issue/PR and 3 commit queries: XYPolygon, getWindingOrder, windingSum, XYShape, "Unable to Tessellate", Float.MAX_VALUE, overflow, NaN, large coordinates) and 32 manual GitHub searches, including the archived lucene-solr repository | no report; 7 medium hits, all read (the introduction and test-generator items) |
| Mailing lists, JIRA archive, downstream, web | 2026-10-26 | dev@, java-user@, issues@ and commits@ (20 scripted queries and 30 direct queries over the full archive range); JIRA text search (XYPolygon 6, XYShape 13, winding 59 issues; 8 read in full); Elasticsearch, OpenSearch (core and geospatial plugin) and Solr; 6 web queries | no report |
| Open PRs and git history | 2026-10-26 | the changed files of all 413 open apache/lucene PRs; git log, blame and pickaxe on XYPolygon.java | no open PR touches XYPolygon.java; the expression was never rewritten after 2019 |
| Re-check on GitHub | 2026-11-01 | GraphQL issue and PR search for XYPolygon, "winding order", "Unable to Tessellate", XYShape, windingSum and getWindingOrder by last update; the files of the 58 open PRs updated since 2026-10-26; the file's upstream commits | nothing new; no open PR touches geo/, XYShape or Tessellator; last upstream commit on the file 2026-06-14 |
Introduced with XY shapes by LUCENE-8632 (0c09481374c, 2019-07-08), first released in 8.2.0 in the sandbox module and in core since 8.5.0. Every release with XY shapes is affected through 10.6.0 (executed on ten releases from 8.2.0 to 10.6.0; bytecode checked for all 57 releases to 10.5.2). Not fixed upstream.
Versions tested6 rows
Introduced by LUCENE-8632 "New XYShape Field and Queries for indexing and searching general cartesian geometries" (#9678, nknize, PR apache/lucene-solr#726): 0c09481374c on master and 81c88e2df30 on branch_8x, both 2019-07-08. The float products come from the PR-internal commit 4d0c96f40b8 (2019-06-26), which changed the parameters to float[] while the loop kept reading them; at its parent 1c982b90199 the sum was in double, but the PR was squash-merged. LUCENE-9218 (4a54ffb553a, 8.5.0) made the fields float[]; LUCENE-9570 (2d6ad2fee6d) reformatted the lines. git blame -w -M -C (ignoring the reformat) attributes the accumulator, the loop, the expression and the label test to 0c09481374c.
Upstream main 2f424fd428da (2026-10-30) has the same line; the file's last upstream commit is 54283150a81 (2026-06-14); branch_10x and branch_10_6 were read with the same line on 2026-10-26.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (gradle and standalone); passes with fix A |
| 10.6.0 | affected | minimal repro fails with assertions on and off (SHA-1-checked jars) |
| 8.5.0 to 10.5.2 (core) | affected | executed 8.5.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2: label CW instead of CCW and "Unable to Tessellate shape"; all 57 releases from 8.2.0 to 10.5.2 have the float products in bytecode (2 fmul, 0 dmul) |
| 8.2.0 to 8.4.1 | sandbox module | executed 8.2.0 (assertions on and off) and 8.4.1: the same failure |
| 8.1.1 and earlier | API absent | no XYPolygon (the repro does not compile) |
| 8.2.0 and 10.5.2 with only the sum in double | fix (shadow) | the repro passes: the defect is the same statement at the first and the latest release |
Confirmed by the study's verifiers and its adjudication, a reviewer from another model family (OpenAI GPT-5.6) that ran its own triangle and judged it real with high confidence, re-runs on two later main commits, and this doc's gradle run. The one-statement fix passed every check it was given.
Checks and verdicts358 words
The study's checks: finder, an executed cross-model refute review, verifiers (one of them a read-only cross-model review), an adjudication; then a follow-up re-run, re-runs on two later main commits, a one-test reduction and this doc's gradle run.
| Check | Verdict | What it found |
|---|---|---|
| finder | fails on main, 10.5.2, 9.12.3 | Public-API JUnit: winding order at scale 1 vs 2^66, and indexing at 2^66; 2 of 2 fail with assertions on and off; pass with the fix; still fail with the four sibling fixes (independent root cause). |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real, high | Its own triangle (1,1), (2,6), (5,2) flips at 2^64 and is rejected while 2^63 works; its test passes with the fix, as do TestXYPolygon and TestXYShape. |
| Independent reproducer | real | Own test with a quadrilateral and no axis-parallel edge: CCW up to 2^63, CW from 2^64; both field factories throw; control passes; its own fix passes 5 of 5, including indexing and a WITHIN query that finds the document. Holes affected too. |
| Works-as-designed lens (OpenAI GPT-5.6, read-only) | real, medium | Rated the intended argument moderate and "not ultimately persuasive". A separate run re-ran the repro and ran the generator check: reversed, 4,115 of 10,530 rings rejected, 0 with the fix. |
| Intended-behaviour devil's advocate | real | Built the strongest intended case and rejected it; whole-plane box rejected when listed clockwise; a ring in the generators' orientation mislabelled at full scale; about 81,000 random star polygons per orientation: every rejection explained by a wrong label. |
| adjudication | confirmed, high | Re-ran the independent reproduction's test (4 of 5 fail with assertions on and off; 5 of 5 pass with the finder's fix) and the finder's (2 of 2 fail). |
| re-runs (follow-up and later main commits) | still reproduces | ea57360912ed, a9779ef3c193 (byte-identical failures) and 645d4fe14a20 (the one-test repro, assertions on and off; passes with the fix). |
| this doc (2026-11-01) | fails on main and 10.6.0; fix passes | Gradle on stock 645d4fe14a20 and with fix A (repro, 30 geo and XY-shape test classes, core static checks); the minimal repro on 10.6.0; the fact-check probe on main and with the fix. |
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 460 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,421 words
- Registry title
- XYPolygon evaluates its winding sum in float, so valid polygons with large finite coordinates (differences of about 1.8e19 or more) can get the wrong winding order, and XYShape rejects them as 'Unable to Tessellate shape'
- Area
- geo / XY shapes (XYPolygon winding order, Tessellator.tessellate(XYPolygon), XYShape.createIndexableFields and createDocValueField)
- Priority
- Low, rank 1134: XYPolygon's float winding sum overflows for coordinate differences of about 1.8e19, so valid huge-coordinate polygons are rejected at indexing.
- Impact class
- crash
- Impact note
- Hits applications that index cartesian polygons with XYShape.createIndexableFields or XYShape.createDocValueField (default codec, IndexWriterConfig and checkSelfIntersections setting; nothing to opt into) when the polygon's coordinates differ by about 1.8e19 or more, or span more than Float.MAX_VALUE as a whole-plane box at +/-Float.MAX_VALUE does: the valid polygon is rejected with IllegalArgumentException 'Unable to Tessellate shape. Possible malformed shape detected.' while its fields are built, so that one document cannot be added, and the public XYPolygon.getWindingOrder() returns the wrong label; mainly rings whose correct label is CCW (clockwise in the usual y-up sense) flip, because NaN falls back to CW, plus a few rings of either orientation through float rounding; holes are affected too, edges parallel to an axis mostly avoid it, and no silent wrong triangulation, index damage, effect on other documents or query-side effect was observed; released since 8.2.0 (sandbox) and 8.5.0 (core).
- Summary
- You build an XYPolygon with large but finite float coordinates, which the constructor accepts (any finite float), for example the triangle (0,0), (2^66,2^66), (2^67,2^66), and index it with XYShape.createIndexableFields; you expect it to index exactly like the same triangle at scale 1, because multiplying every coordinate by a power of two is exact and changes neither the shape nor its orientation. The constructor computes the winding sum with float products, which overflow to Infinity once coordinate differences reach about 1.8e19; Infinity minus Infinity is NaN, and a NaN sum is labelled CW. The Tessellator then links the ring the wrong way round, finds no ear to cut, and rejects the valid polygon with 'Unable to Tessellate shape. Possible malformed shape detected.'
- Introduced
- 0c09481374cab029d57b0f9b45994822c0dcd39b (master; LUCENE-8632 'New XYShape Field and Queries for indexing and searching general cartesian geometries', nknize, apache/lucene-solr#726, migrated issue apache/lucene#9678; branch_8x backport 81c88e2df30428f61fad6129525d839c57e08504, shipped in 8.2.0 in the lucene-sandbox module) [read: git blame -w -M -C and git log -S '(y[j] - y[numPts])' in the history clone attribute the accumulator, the loop, the winding expression and the CCW/CW test to this commit; exec: release boundary, 8.1.1 has no XYPolygon and 8.2.0 fails; replacing only the winding-sum statement with double arithmetic in the release's own XYPolygon.java makes the repro pass on 8.2.0 and 10.5.2]. The float arithmetic came from the PR-internal commit 4d0c96f40b8dd9d2f5c2bb2112e55a81dd47e1cd ('change XYPolygon and XYLine API to accept floats instead of doubles', 2019-06-26): it turned the constructor parameters into float[], which shadow the double[] fields, while the loop kept reading the parameters; at its parent 1c982b90199 the sum was computed in double, and the PR was squash-merged, so no mainline commit had the double version. Later commits kept the expression: 4a54ffb553a (LUCENE-9218, 8.5.0) made the fields float[], 2d6ad2fee6d (LUCENE-9570) only reformatted the two lines. No build at 0c09481374c or its parent was made (the API does not exist before it). (2019-07-08 (0c09481374c on master and 81c88e2df30 on branch_8x, the same day); the PR-internal float change 4d0c96f40b8 has author date 2019-06-26)
- First affected version
- 8.2.0 [exec] (XYShape and XYPolygon in the lucene-sandbox module, @lucene.experimental, through 8.4.1); 8.5.0 [exec] is the first lucene-core release (LUCENE-8621 moved it to core); 8.1.1 and earlier have no XYPolygon [exec: the repro does not compile on 8.1.1]
- Affected versions
- Every release with XYShape through 10.6.0: 8.2.0 to 8.4.1 (sandbox module), 8.5.0 to 10.5.2 (core) and 10.6.0 [exec], and main ea57360912ed. [exec] the finder's JUnit fails 2 of 2 on main (-ea and -da), 10.5.2 (-ea and -da), 9.12.3 (-ea and -da), 10.0.0 and 9.1.0; its framework-free main() fails on 9.0.0, 8.11.4, 8.5.0, 8.4.1 (sandbox) and 8.2.0 (sandbox, -ea and -da); the independent reproduction's JUnit fails 4 of 5 on main (-ea and -da), 10.5.2 and 9.12.3 and its main() on 8.11.4, 8.5.0 and 8.4.1 (sandbox); the intended-behaviour check's tests fail on main and 10.5.2. [read] a bytecode check of all 57 releases from 8.2.0 to 10.5.2 (jars SHA-1-checked against Maven Central) finds the float products (2 fmul, 0 dmul) in the constructor of every one, and upstream main, branch_10x and branch_10_6 have the same line (GitHub contents API, 2026-10-26). Not affected: 8.1.1 and earlier (API absent) [exec]. 10.6.0 (released 2026-10-29): the one-test minimal repro fails on the release jars (SHA-1 checked against Maven Central) with -ea and -da (2026-11-01) [exec]. All runs on aarch64 with JDK 25.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-26, read-only: 126 recorded queries, among them a scripted prior-report search with 51 queries (16 apache/lucene issue/PR, 3 commit, 2 downstream-repository, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and 6 web queries; 61 distinct hits, 0 high overlap, all 7 medium hits read), 32 manual GitHub searches over apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch (core and the geospatial plugin) and Solr, 30 direct list queries over the full archive range, JIRA text searches (XYPolygon 6, XYShape 13 and winding 59 issues; 8 read in full), web searches, the changed files of all 413 open apache/lucene PRs (none touches XYPolygon.java) and the history clone; the finder's, the independent reproduction's and the intended-behaviour check's own searches found nothing either). Context, not this bug: LUCENE-8632/#9678 (the introduction; the author wrote 'Internally we still use doubles for the tessellation'), LUCENE-9218/#10258 (float storage, expression unchanged), LUCENE-8850/#9893 (open; winding-sum robustness for lat/lon Polygon in double), #12596, PR #12757 and PR #12287 (XY test generators; #12287 shows that BaseXYShapeTestCase retries polygons whose tessellation fails on purpose), LUCENE-9470/#10509 (query-side numerical error near Float.MAX_VALUE), elastic/elasticsearch#78499 (the cartesian shape type 'works on the float space'; these polygons are exact and valid in float). Not fixed upstream: the float expression is unchanged on main, branch_10x and branch_10_6.
- Lucene commit tested
- main
645d4fe14a20: the minimal repro with assertions on and off, through gradle and standalone; fix A in a scratch worktree with 30 geo and XY-shape test classes and:lucene:core:check -x test; a fact-check probe of the label and the indexing result at scales 1, 2^63, 2^64 and 2^66, on stock main and with the fix. SHA-1-checked release jars: 10.6.0 (minimal repro, assertions on and off); in the study 8.2.0, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2 (8.1.1 lacks the API). The study ran on mainea57360912edand re-checkeda9779ef3c193.XYPolygon.javais unchanged on upstream main2f424fd428da(2026-10-30, read).
- Finding record
lucene/core/src/java/org/apache/lucene/geo/XYPolygon.java:98-113 (at ea57360912ed) (double windingSum, float products at 106-107, 'windingSum < 0 ? CCW : CW' at 113), 150-153 (getWindingOrder javadoc 'Returns the winding order (CW, COLLINEAR, CCW) for the polygon shell')lucene/core/src/java/org/apache/lucene/geo/XYEncodingUtils.java:28-41 (at ea57360912ed) (MIN/MAX_VAL_INCL = -/+Float.MAX_VALUE; checkVal rejects only non-finite values)lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:40-50 (at ea57360912ed) (class notes incl. 'sensitive to overflow (e.g, subatomic values such as E-200 can cause unexpected behavior)'), 155-212 (tessellate(XYPolygon): coordinates converted to double, ring linked by getWindingOrder(), 'should be CCW, but this will correct'), 206 and 687 (the 'Unable to Tessellate shape' exceptions)lucene/core/src/java/org/apache/lucene/document/XYShape.java:96-113 (at ea57360912ed) (createIndexableFields and createDocValueField call Tessellator.tessellate at 100 and 112)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/XYPolygon.java:48, 93-113, 150-153a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/XYEncodingUtils.java:28-41a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:155-212, 218-244, 246-274 (260), 670-689a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/XYShape.java:83-113
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/geo/ShapeTestUtil.java:239-240; lucene/core/src/test/org/apache/lucene/document/BaseXYShapeTestCase.java:228-233
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/XYEncodingUtils.java:34-41, 50-52; geo/XYPolygon.java:98, 150-153; geo/Tessellator.java:49-50, 157-158, 227-235; geo/Polygon.java:118-134; document/XYShape.java:71-72a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/geo/TestXYPolygon.java:81-116; lucene/core/src/test/org/apache/lucene/document/BaseXYShapeTestCase.java:228-233
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/Polygon.java:126-128; geo/Tessellator.java:162-163
- History
a Lucene clone releases/lucene/10.6.0:lucene/core/src/java/org/apache/lucene/geo/XYPolygon.java:106-107 (read)
- TestXYShapeLargeTriangle.java (JUnit, one test):
repro/LFV-219/src/org/apache/lucene/document/TestXYShapeLargeTriangle.java(33 lines)
Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.
assets/LFV-219/bug.png(460 KB)
