All findings · Lucene finding · LFV-219 · xypolygon-float-winding-overflow

XYPolygon computes its winding order in float, so XYShape rejects valid polygons with very large coordinates

XYPolygon decides which way a ring turns from float products, which overflow once coordinates differ by about 1.8e19; then, for example, Infinity - Infinity is NaN and the ring gets the wrong label. XYShape then rejects valid polygons, like the triangle (0,0), (1,1), (2,1) times 2^66, as "Unable to Tessellate shape" (since 8.2.0).

Summary

Priority
Low, rank 1134XYPolygon's float winding sum overflows for coordinate differences of about 1.8e19, so valid huge-coordinate polygons are rejected at indexing.
Impact
crash
Since
8.2.0 (LUCENE-8632, 0c09481374c, 2019-07-08; sandbox module through 8.4.1, core since 8.5.0)introduced 2019-07-08 · about 7 years
Affected
8.2.0-10.6.0 and main
Status
documented
Found by
non-formal: code reading while building a Tessellator checker, then measured with it

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: how XYShape indexes a polygon

  1. XYShape.createIndexableFields(field, polygon) indexes an XYPolygon, a ring of float x/y coordinates (any finite float is allowed), by cutting it into triangles with the Tessellator (ear clipping) and indexing each triangle.
  2. 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.
  3. A float holds magnitudes up to Float.MAX_VALUE, about 3.4e38. A larger product becomes Infinity, Infinity - Infinity is NaN, and every comparison with NaN is false.
Diagram of the bug. The triangle (0,0), (1,1), (2,1) is indexed with XYShape.createIndexableFields at scale 1 and with every coordinate multiplied by 2^66. At scale 1 the winding-sum term 1x1 - 1x2 = -1 is below 0, the ring is labelled CCW and it indexes as 1 triangle. At 2^66 the float products 2^132 and 2^133 are above Float.MAX_VALUE (3.4e38) and become Infinity; Infinity - Infinity = NaN; NaN < 0 is false, so the ring is labelled CW. The Tessellator walks the ring backwards, finds no ear and throws IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected. With the products computed in double the term is -5.44e39 and the ring indexes.
The triangle (0,0), (1,1), (2,1) at scale 1 and times 2^66: the same winding-sum term gives -1 (CCW, indexes) in one case and Infinity - Infinity = NaN (CW, rejected) in the other. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index the triangle (0,0), (1,1), (2,1) as an XYPolygon with XYShape.createIndexableFields: you get 1 triangle field. Lucene labels the ring CCW, because its winding sum is 1x1 - 1x2 = -1, below 0.
  2. 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.
  3. 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 are Infinity. Infinity - Infinity is NaN, NaN < 0 is false, and the ring is labelled CW.
  4. 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.
  5. 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.

Who is affected

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.

Terms

  • 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 bug, step by step (main 645d4fe14a20)

  1. The constructor (XYPolygon.java:48) checks the first vertex and every vertex the loop visits with checkVal (: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).
  2. double windingSum = 0d; (:98), then windingSum += (x[j] - x[numPts]) * (y[i] - y[numPts]) - (y[j] - y[numPts]) * (x[i] - x[numPts]); (:106-107). x and y are the float[] parameters: the differences and both products are float; only the term's result is widened.
  3. 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^132 and 2^133 overflow to Infinity in float, the term is NaN (in double it would be -5.44e39).
  4. this.windingOrder = (windingSum < 0) ? CCW : CW; (:113): NaN gives CW.
  5. Tessellator.tessellate(XYPolygon, boolean, Monitor) (Tessellator.java:155) converts the coordinates to double (:162-163) and calls createDoublyLinkedList(..., 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.
  6. No vertex of a backwards ring is an ear; after the INIT, CURE and SPLIT passes (:670-689) the Tessellator throws IllegalArgumentException("Unable to Tessellate shape. Possible malformed shape detected.") (:687-688; the same message at :206-207 when no triangle was produced).
  7. XYShape.createIndexableFields (XYShape.java:83-85, :96-106) and createDocValueField (:88-90, :109-112) call the Tessellator at :100 and :112 and pass the exception to the caller, before any IndexWriter call.

Exact trigger

  • 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 - Infinity inside 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 (both Infinity: labelled CW, rejected).
    • Infinite terms of opposite sign: a term with one overflowing product is +Infinity or -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 +/-Infinity term 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 -Infinity terms, 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-span M - (-M) is Infinity in float and 0 * Infinity is 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.createIndexableFields or XYShape.createDocValueField (both overloads, either checkSelfIntersections value) with the default codec and IndexWriterConfig; nothing to opt into. Also anyone who reads the public XYPolygon.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_VALUE listed 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.nextFloat draws 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, and BaseXYShapeTestCase retries 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.

Minimal repro

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.

TestXYShapeLargeTriangle.java (JUnit, one test)33 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.document;

import org.apache.lucene.geo.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());
    }
  }
}
Command
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.createIndexableFields returns 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.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
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)

Observed output

# 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

Why it looks unintended

  • 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 parameters float[] (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. checkVal rejects 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); upstream TestXYPolygon rejects 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/lon Polygon javadoc asks for ("Coordinates must be in clockwise order, except for holes", Polygon.java:31); the XYPolygon javadoc 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 from double[] to float[]; they shadow the then double[] fields and the loop kept reading them. At its parent 1c982b90199 the 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 fields float[] and kept the expression.
  • The lat/lon twin is double. Polygon computes the same sum on double[] (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.

Suggested fix

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, seed DEADBEEF, 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.TestShapeDocValues with the fix: 30 classes (including TestTessellator, 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 test with 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).

B. Let the Tessellator compute the orientation itself (not run)

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.

Not recommended: reject coordinates above sqrt(Float.MAX_VALUE)

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.

Downstream note

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.

How it was found

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 IllegalArgumentException at 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene and apache/lucene-solr issues, PRs and commits2026-10-26a 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 repositoryno report; 7 medium hits, all read (the introduction and test-generator items)
Mailing lists, JIRA archive, downstream, web2026-10-26dev@, 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 queriesno report
Open PRs and git history2026-10-26the changed files of all 413 open apache/lucene PRs; git log, blame and pickaxe on XYPolygon.javano open PR touches XYPolygon.java; the expression was never rewritten after 2019
Re-check on GitHub2026-11-01GraphQL 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 commitsnothing new; no open PR touches geo/, XYShape or Tessellator; last upstream commit on the file 2026-06-14

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (gradle and standalone); passes with fix A
10.6.0 affectedminimal repro fails with assertions on and off (SHA-1-checked jars)
8.5.0 to 10.5.2 (core) affectedexecuted 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 moduleexecuted 8.2.0 (assertions on and off) and 8.4.1: the same failure
8.1.1 and earlier API absentno 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

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder fails on main, 10.5.2, 9.12.3Public-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, highIts 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 realOwn 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, mediumRated 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 realBuilt 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, highRe-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 reproducesea57360912ed, 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 passesGradle 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,421 words

Finding record

LFV-219, snapshot of 2026-11-06

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 main ea57360912ed and re-checked a9779ef3c193. XYPolygon.java is unchanged on upstream main 2f424fd428da (2026-10-30, read).

Lucene and upstream sources

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-153
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/XYEncodingUtils.java:28-41
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:155-212, 218-244, 246-274 (260), 670-689
  • a 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-72
  • a 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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.