All findings · Lucene finding · LFV-366 · shapedocvalues-zero-weight-centroid-null-island

Shape doc values report the centroid (0, 0) for a line or polygon whose length or area is zero after encoding

A shape doc value whose line has all its points in one place, or whose polygon is flatter than one encoding step, returns the centroid (0, 0): 8,319 km from a line at (40, -70), outside its own bounding box. Every release since 9.4.0; queries are unaffected.

Summary

Priority
Low, rank 429The experimental ShapeDocValues centroid comes back as (0,0) only for shapes that have zero length or zero area after encoding, a silent boundary-value geo error comparable to LFV-301.
Impact
silent wrong results
Since
9.4.0 (LUCENE-10654, 2022)introduced 2022-08-09 · about 4 years
Affected
9.4.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
bounded-exhaustive checking of the real jar: the centroid recomputed with the same formula and checked against the bounding box

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

LatLonShape and XYShape doc values store a shape's bounding box and one centroid point. The centroid is the shape's pieces averaged, each weighted by its length (line segments) or area (triangles), computed from the decoded, quantized coordinates: latitude in steps of 4.19e-8 degrees (about 4.7 mm), longitude in steps of 8.38e-8 degrees.

Three panels. 1, the example (executed): new Line({40, 40}, {-70, -70}) stored with LatLonShape.createDocValueField; on a map grid the line's point at lat 40, lon -70 and the returned centroid at (0, 0) on the equator, 8,319 km apart; expected lat 40, lon -70, actual getCentroid() = (0, 0), outside its own one-point bounding box. 2, inside Lucene: each piece's midpoint is multiplied by its length or area, the sum is divided by the total weight only if it is not 0, so at total weight 0 the zero numerator is written as the centroid; createDocValueField(name, Line) types every piece LINE, while the Field[] factory decodes a collapsed piece as a POINT and gets it right. 3, effects: (0, 0) for lines whose points encode alike, XYLines of one repeated point and polygons flat after encoding; stored in the index; getBoundingBox(), relate() and queries not affected; one real piece is enough to be right; fix: the plain mean of the pieces gives (40, -70), the sliver (40, -65) and the XYLine (5, 7).
Left: the repeated-point line at lat 40, lon -70 and its centroid at (0, 0), 8,319 km away. Middle: the weighted sum, divided only when the total weight is not 0. Right: which shapes are hit, what is not affected, and the fix. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Store a line whose two points are the same, for example a track of a device that did not move: LatLonShape.createDocValueField("shape", new Line(new double[] {40, 40}, new double[] {-70, -70})). Line accepts repeated points.
  2. You expect getCentroid(), documented as "the centroid location for the geometry", to be that point: lat 40, lon -70, inside the bounding box the same field returns.
  3. You get (0, 0), 8,319 km away. The centroid averages the segments weighted by their length; the only segment has length 0, so the weighted sum is 0, and the code skips the division by zero and keeps the 0.
  4. The same happens for two points a few millimetres apart (one encoding cell), for a polygon 10 degrees long but thinner than one encoding step (7,503 km or more off), and for an XYLine of repeated points. The other factory, createDocValueField(name, createIndexableFields(name, line)), returns (40, -70) for the same line.
  5. No error: queries and the bounding box are right, and (0, 0) is written into the index, so documents already indexed keep it after a fix.

Who is affected

Applications that store shapes with LatLonShape or XYShape doc-values fields (an experimental API, default codec) and read getCentroid(), for example for a map marker or an average of centroids, when a shape's whole length or area is zero after encoding: repeated-point lines, points in one encoding cell, sliver polygons. One document suffices; the value is stored in the index.

Mechanism, terms and numbers857 words

A shape doc value (LatLonShape.createDocValueField, XYShape.createDocValueField) stores a bounding box and one centroid, returned by getCentroid(). The centroid averages the shape's pieces, each piece's midpoint weighted by its length (segments) or area (triangles), computed from the decoded (quantized) coordinates. When every piece has weight 0, the weighted sum is 0, and ShapeDocValues.buildTree skips the division (if (totalLength != 0), if (totalSignedArea != 0)) and stores the 0: the centroid becomes (0, 0). That happens for a line whose points are all equal, for two points in the same encoding cell (a few millimetres apart), for a polygon whose vertices become collinear after encoding (for example thinner than one latitude step), and for an XYLine of repeated points. A repeated-point line at (40, -70) gets its centroid 8,319 km away, outside the bounding box the same field returns; a 10-degree sliver polygon gets it at least 7,503 km off. The other public factory, createDocValueField(name, createIndexableFields(name, line)), returns the real point for the same line. Queries, relate() and the bounding box are unaffected, nothing throws, and the wrong value is stored in the index. Present in every release since 9.4.0, where the API was added.

Terms

  • Shape doc value: LatLonShape.createDocValueField and XYShape.createDocValueField build a binary doc value holding the shape's pieces as a tree, plus a header with the bounding box, one centroid and the highest dimension (point, line or triangle). getCentroid() and getBoundingBox() read the header. The shape doc-values classes and fields are @lucene.experimental; the factory classes LatLonShape and XYShape are not tagged.
  • Pieces: a line becomes one LINE piece per segment; a polygon is tessellated into TRIANGLE pieces; a point is one POINT piece.
  • Encoding step: LatLonShape stores a latitude in steps of 4.190951585769653E-8 degrees and a longitude in steps of 8.381903171539307E-8 degrees (about 4.7 mm and 9.3 mm at the equator). Encoding rounds down (Math.floor), so points that fall into the same cell are one point in the index.

What happens, step by step (main a9779ef3c193, lucene/core/src/java/org/apache/lucene/document/)

  1. createDocValueField(name, Line) types every segment LINE, also a segment whose two ends encode to the same point (LatLonShape.java:153-174; XYShape.java:151-172 for XYLine). createDocValueField(name, Polygon) types every tessellated triangle TRIANGLE (LatLonShape.java:110-132).
  2. The TreeNode constructor weights each piece's midpoint by its length or area, computed from the decoded encoded corners (ShapeDocValues.java:439-454). Equal corners give length 0, collinear corners area 0, so the weighted midpoint is 0.
  3. buildTree divides by the total weight only if it is not 0, for one piece (:191-204) and for several (:265-288, if (totalLength != 0), if (totalSignedArea != 0)). Otherwise the 0 numerator stays as the centroid.
  4. The writer encodes that centroid into the doc value (:491-493); the field and any value read back from the index decode it (:72-93, LatLonShapeDocValues.java:58-63, XYShapeDocValues.java:58-64), so (0, 0) is persisted.
  5. The Field[] factory decodes each indexed triangle with ShapeField.resolveTriangleType (ShapeField.java:371, :374-399), which turns a triangle with all corners equal into a POINT; a single POINT piece keeps its decoded corner (ShapeDocValues.java:434-436) and several are averaged without weights (:266-268). So that route gets lines right, but for the sliver polygon it keeps a zero-area TRIANGLE and also gives (0, 0), in both winding orders.
  6. One real segment is enough to make the total non-zero: a line (40, -70) (40, -70) (41, -69) gets the right centroid (40.5, -69.5). XYShape encodes a float exactly (XYEncodingUtils.encode is floatToSortableInt), so an XYLine triggers it only when all its points are identical; XYPolygons were not bounded (the three collinear or rounding-collinear ones tried were rejected by the tessellator). A LatLonShape polygon could also collapse along a meridian or a slanted line; only the latitude-thin sliver was executed.
  7. relate() skips the stored centroid and the highest dimension before it walks the tree (ShapeDocValues.java:767-770), so shape doc-values queries do not use the wrong value.

Examples (executed on main a9779ef3c193)

inputbounding boxgetCentroid()distance
control: Line (40, -70) to (41, -69)lat 40 to 41, lon -70 to -69lat 40.49999997, lon -69.500000050 km
Line (40, -70) to (40, -70)one point, lat 39.99999996, lon -70.00000002(0.0, 0.0)8,319 km
Line (40, -70) to (40.000000001, -70), one encoding cellthe same point(0.0, 0.0)8,319 km
Line of 4 equal pointsthe same point(0.0, 0.0)8,319 km
Polygon (40, -70) (40, -60) (40.000000001, -65)lat 39.99999996, lon -70.00000002 to -60.00000003(0.0, 0.0), through both factories7,907 km from its middle, 7,503 km from its nearest point
XYLine (5, 7) to (5, 7)x 5 to 5, y 7 to 7XYPoint(0.0, 0.0)
the repeated-point Line via createDocValueField(name, createIndexableFields(name, line))the same pointlat 39.99999996, lon -70.000000020 km
the repeated-point Line indexed and read back from BinaryDocValuesthe same point(0.0, 0.0)8,319 km

Distances are great-circle distances on a sphere of radius 6371.0088 km. A doc-values box query around (40, -70) still matches the repeated-point line, and one around (0, 0) does not.

Exposure and likelihood286 words
  • Who is hit. Applications that index LatLonShape or XYShape doc values (createDocValueField, default codec and IndexWriterConfig) and read getCentroid(), at index time or from a value read back with LatLonShape.createLatLonShapeDocValues(BytesRef) / XYShape.createXYShapeDocValues(BytesRef). The introducing PR's author said the centroid and bounding box were added for downstream aggregations. Lucene itself has no caller of the shape getCentroid() outside the getters. The shape doc-values classes and fields are @lucene.experimental; the factory classes LatLonShape and XYShape are not tagged.
  • What it takes. One document whose whole length or area is zero after encoding: a line of repeated points or of points in one encoding cell built with createDocValueField(name, Line) / (name, XYLine), or a polygon that is flat after encoding (for example thinner than one latitude step) through either factory. Deterministic, with assertions on and off.
  • What goes wrong. That document's centroid is (0, 0), an ordinary-looking location, so a caller cannot tell it from a real shape there; getHighestDimensionType() still says LINE or TRIANGLE. Anything computed from it is off by thousands of kilometres.
  • Persistent. The value is stored in the doc value and read back unchanged, so a writer fix leaves documents already indexed at (0, 0) until they are re-indexed, unless a read-time fallback is added too.
  • Not affected. Shape doc-values queries (relate() skips the centroid; the hunt's 499,269,156 relate() checks found 0 mismatches), the bounding box, other documents, the indexed shape and its queries. Nothing throws and nothing costs more.
  • How likely. Rare: the experimental API, a reader of getCentroid(), and a zero-length or zero-area shape. Such shapes are legal input; no report was found in the searches (see Prior reports), four years after 9.4.0.

Minimal repro

The test shows that LatLonShape.createDocValueField("f", new Line(new double[] {40, 40}, new double[] {-70, -70})).getCentroid() returns (0, 0) for a line whose points coincide, because the length-weighted centroid skips the division when the total length is 0 (TestShapeDocValuesRepeatedPointCentroid#testCentroidOfLineWithRepeatedPoint).

TestShapeDocValuesRepeatedPointCentroid.java (JUnit test)16 lines
package org.apache.lucene.document;

import org.apache.lucene.geo.Line;
import org.apache.lucene.geo.Point;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestShapeDocValuesRepeatedPointCentroid extends LuceneTestCase {

  public void testCentroidOfLineWithRepeatedPoint() {
    Line line = new Line(new double[] {40, 40}, new double[] {-70, -70});
    Point centroid = LatLonShape.createDocValueField("f", line).getCentroid();
    // expected: the line's only point (40, -70) within encoding error; actual: (0, 0)
    assertEquals("latitude", 40, centroid.getLat(), 1e-6);
    assertEquals("longitude", -70, centroid.getLon(), 1e-6);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-366/, 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.TestShapeDocValuesRepeatedPointCentroid
Expected vs actual
Expected: (40, -70) within encoding error, the line's only point; actual: latitude expected:<40.0> but was:<0.0> (the centroid is (0, 0)), with -ea and -da.

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

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: latitude expected:<40.0> but was:<0.0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:555)
       org.apache.lucene.document.TestShapeDocValuesRepeatedPointCentroid.testCentroidOfLineWithRepeatedPoint(TestShapeDocValuesRepeatedPointCentroid.java:13)

Why it looks unintended

  • Documented as "the centroid location for the geometry"; (0, 0) lies outside the shape's own bounding box.
  • The other public factory returns (40, -70) for the same line, and every input here is legal.
  • The zero checks only avoid dividing by zero; nothing documents (0, 0) as "no centroid".
The full argument221 words
  • The contract. "retrieves the centroid location for the geometry" (ShapeDocValuesField.java:97-98, LatLonShapeDocValuesField.java:65-69, XYShapeDocValuesField.java:65-69). (0, 0) is not a location of these geometries and lies thousands of kilometres outside the bounding box the same object returns.
  • Legal inputs. Line and XYLine check only null arrays, equal lengths, at least 2 points and the value range (Line.java:53-87, XYLine.java:47-79). The tessellator accepts the sliver polygon.
  • Lucene disagrees with itself. The Field[] factory returns (40, -70) for the same line that the Line factory maps to (0, 0).
  • No fallback is defined. No javadoc, CHANGES entry or test names (0, 0) as a sentinel, and the stored highest dimension still says LINE or TRIANGLE.
  • History. The guards came with the feature (d7fd48c9502c, #1017) and never changed. The #1017 review discussed centroid weighting at length (multi-polygon weighting, encoded versus original coordinates, overflow), never zero-length or zero-area shapes.
  • Common practice. JTS's Centroid, which a #1017 reviewer used as the reference, documents "Zero-length lines are treated as points" (class javadoc) and computes degenerate geometry "using their effective dimension" (a code comment in getCentroid()). Elasticsearch fixed the same kind of defect in its own, separate centroid code in 2020 by treating zero-area polygons as lines and zero-length lines as points (elastic/elasticsearch#52303, fixed by #52500; also #55851 / #58027).
The devil's-advocate check276 words

The strongest case for intended behaviour (the hunt's intended-behaviour checker; the cross-model reviews rated it moderate): the API is @lucene.experimental and #1017 was merged as progress over perfection; a length- or area-weighted centroid of a shape with no length or area is 0/0, undefined, and the guards show the zero case was considered; the upstream test's reference centroid substitutes a denominator of 1 (TestShapeDocValues.java:133, totalSignedArea = totalSignedArea == 0d ? 1 : totalSignedArea;), which also gives (0, 0); a line of identical points is not a valid OGC LineString; the encoding is documented as lossy; and GEOS returns an empty centroid for a zero-length line (shapely#107).

Why it fails: an undefined weighted average still has a well-established fallback, the effective dimension (JTS, Elasticsearch), and Lucene's own Field[] factory already gives it for lines. GEOS's empty centroid is the other precedent, but a caller can detect it; the guard here returns an ordinary location, so callers cannot. No test checks this input against an independent expectation: the centroid test (TestShapeDocValues.java:79-87) compares with a reference that copies the implementation's formula, zero case included (:133), so a zero-area polygon would pass there with (0, 0); the zero-area skip that #13743 added (area(p) != 0, the bounding-box area, :59, :261-263) is in the bounding-box test (:57-66). Lucene neither documents nor enforces OGC validity, and two triggers are valid geometry: two distinct points a few millimetres apart, and a non-collinear sliver polygon the tessellator accepts. The documented precision loss is tens of nanodegrees (about 42 of latitude and 84 of longitude); (0, 0) is thousands of kilometres off.

Suggested fix

Recommended Fall back one dimension, piece by piece, when the total length or area is zero

When the total length or area is zero, fall back one dimension: each piece counts as its edges, or as its corners if they have no length (+42 lines in ShapeDocValues). Exact for lines, points and one flat triangle, not for flat polygons of several triangles. Validated: the repro passes 8 of 8; the shape doc-values tests pass.

All options and trade-offs5 options · 1,272 words

Option A (recommended) and option B were each applied in a scratch worktree at a9779ef3c193 and run through gradle, and checked as shadowed classes on the main jars; B is the hunt's fix, which an independent reproducer also wrote. C, D and E were not run. None repairs documents already indexed except D. The tests show that A and B remove (0, 0) and keep the existing tests passing; none checks the centroid of a flat polygon cut into several triangles. Workaround (executed): building line doc values with createDocValueField(name, createIndexableFields(name, line)) gives the right centroid for the line triggers (testFactoriesAgree), not for the sliver polygon.

Fall back one dimension, piece by piece, when the total length or area is zero (validated by the checks) (recommended)

Each of the four zero-weight branches in buildTree (ShapeDocValues.java:191-204, :265-288) calls a new helper instead of keeping 0 (+42 lines). Every piece of the zero-weight shape counts as its edges, length-weighted, and if no edge has a length either, every piece counts as its corners, averaged. A line is stored as the flat triangle (a, b, a) and a point as (a, a, a), and the edges of a flat piece cover it twice, so half a piece's perimeter is the length that piece covers and one formula serves all three types. For a line, a point and a polygon tessellated into one flat triangle this is the effective-dimension answer of JTS and of the Elasticsearch fix. Results: the repeated-point, one-cell and 4-point lines get lat 39.99999996, lon -70.00000002; the sliver polygon gets lon -65.00000007 (its middle) through both factories; the XYLine gets (5.0, 7.0); the controls are unchanged.

      if (totalLength != 0) {
        root.midX /= totalLength;
        root.midY /= totalLength;
+     } else {
+       setDegenerateCentroid(root, tessellation);
      }
...
+  private void setDegenerateCentroid(TreeNode node, List<ShapeField.DecodedTriangle> tessellation) {
+    Encoder encoder = getEncoder();
+    double length = 0, lengthX = 0, lengthY = 0;
+    double pointX = 0, pointY = 0;
+    for (ShapeField.DecodedTriangle t : tessellation) {
+      double ax = encoder.decodeX(t.aX), ay = encoder.decodeY(t.aY);
+      double bx = encoder.decodeX(t.bX), by = encoder.decodeY(t.bY);
+      double cx = encoder.decodeX(t.cX), cy = encoder.decodeY(t.cY);
+      double ab = Math.hypot(ax - bx, ay - by);
+      double bc = Math.hypot(bx - cx, by - cy);
+      double ca = Math.hypot(cx - ax, cy - ay);
+      // edge midpoints weighted by edge length, halved because the edges cover the shape twice
+      length += 0.5d * (ab + bc + ca);
+      lengthX += 0.25d * ((ax + bx) * ab + (bx + cx) * bc + (cx + ax) * ca);
+      lengthY += 0.25d * ((ay + by) * ab + (by + cy) * bc + (cy + ay) * ca);
+      pointX += ax + bx + cx;
+      pointY += ay + by + cy;
+    }
+    if (length != 0) {
+      node.midX = lengthX / length;
+      node.midY = lengthY / length;
+    } else {
+      node.midX = pointX / (3d * tessellation.size());
+      node.midY = pointY / (3d * tessellation.size());
+    }
+  }

Trade-offs

  • Behaviour change: only shapes whose total length or area is zero after encoding get a new centroid; every other shape keeps its centroid bit for bit, because only the four branches that keep 0 today change.
  • Not exact for a flat polygon of several triangles: it also counts the interior diagonals the triangles share, so its answer depends on how the tessellator cuts the polygon. A sliver of 11 distinct vertices (lon -70 to -60 and back inside one latitude cell) is cut into 2 triangles sharing the diagonal from lon -69.9 to -60: A gives lon -64.9751, B -64.9667, the length-weighted centroid of the polygon's own ring -65.00000002. Counting only the edges the tessellator flags as polygon edges gives the ring's answer there (recomputed by hand), but createDocValueField(name, Polygon) passes the first edge's flag for the second edge too (LatLonShape.java:125, XYShape.java:123), so that variant needs that line fixed as well (not run as a patch). The helper's javadoc and comment ("effective dimension", "the edges cover the shape twice") hold per piece; reword them if you use the patch.
  • Inside the box: the result is a weighted average of the shape's own decoded corners. On 800,000 random repeated-point lines of 2, 3, 4 and 7 points it was exactly the point every time.
  • Cost: one extra pass over the pieces, only for degenerate shapes. No format or API change.
  • Existing data: documents already indexed keep (0, 0) until they are re-indexed (see option D).

What the checks validated

  • Gradle in a scratch worktree at a9779ef3c193, seed DEADBEEF: the repro passes 8 of 8 with assertions on and off (stock: 6 of 8 fail).
  • TestShapeDocValues and the six Test*ShapeDVQueries classes pass: 37 tests, 6 of them nightly-only and skipped, and all 37 with -Ptests.nightly=true. Every test class of org.apache.lucene.document passes (506 tests, 26 of them skipped). checkGoogleJavaFormat, ecjLintMain and ecjLintTest for lucene:core pass.
  • As a shadowed class on the main jars: this repro 8 of 8 (assertions on and off), the independent reproducer's JUnit 10 of 10 and the finder's 5 of 5.

Unweighted mean of the highest dimension's pieces (the hunt's fix) (validated by the checks)

TreeNode also keeps each piece's plain midpoint (segment) or corner average (triangle); when the total length or area is 0, the centroid is the plain average of those (+34 lines). The finder and the independent reproducer wrote it separately. It gives the same results as option A on every input above; for a single line the two always agree, because a zero-length line is one point.

Trade-offs

For a flat polygon cut into several triangles it gives every triangle the same weight however long it is, so its answer depends on how the tessellator cuts the polygon (on the 11-vertex sliver cut into 2 triangles: lon -64.9667 against -64.9751 for option A and -65.00000002 for the polygon's ring); for one flat triangle it gives the average of the three corners, not the middle of the segment the triangle covers (by the patch's code). It adds two fields to every TreeNode.

What the checks validated

Gradle, the same worktree and seed: the repro passes 8 of 8 with assertions on and off, and the closest suites pass (37 tests, 6 of them nightly-only and skipped); as a shadowed class 8 of 8. In the hunt it also passed the finder's and the reproducer's JUnits and the 31 upstream shape doc-values tests.

Fall back to the bounding-box centre

When the total is 0, set the centroid to the middle of the encoded bounding box (about six lines).

Trade-offs

Not run. Always inside the box and exact for every line trigger, but for a degenerate shape that is not straight (an L-shaped sliver, say) it is not the shape's centroid.

Read-time repair for documents already indexed (in addition to A, B or C)

When the decoded stored centroid lies outside the stored bounding box, computeCentroid() (LatLonShapeDocValues.java:58-63, XYShapeDocValues.java:58-64) returns the box centre instead.

Trade-offs

Not run. Repairs old documents without re-indexing, but it is a heuristic: it would also move centroids that are only one encoding step outside the box (the hunt counted 1,116 such non-degenerate shapes) to the box centre, it cannot repair a (0, 0) that lies inside the box, and it would mask other writer bugs.

Document the behaviour

A javadoc sentence that shapes with zero length or area after encoding get the centroid (0, 0).

Trade-offs

Not recommended. Callers still cannot tell the value from a real location, and the two factories still disagree.

Downstream note

OpenSearch's GeoShapeDocValue builds shape doc values with the Field[] factory (right for repeated-point lines, not for sliver polygons) and copies getCentroid(); no caller of that copy was found in a read-only code search.

How it was found

Bounded-exhaustive checking of the real jar: 63,276,054 shape doc values built from sequences of factory pieces, each decoded independently, its centroid recomputed with the same formula and checked against its bounding box, which exposed this bug. 376,260 had zero total weight, 54,182 of them a centroid outside the box. A public-API JUnit confirmed it on main and releases.

The method in full235 words

Method: semi-formal, bounded-exhaustive checking of the stock jar (hunt unit on the shape doc-values tree, 2026-10-26, main ea57360912ed).

  • A harness enumerated every sequence of up to 4 of 61 pieces on four 2x2 coordinate grids (three LatLon, one XY), up to 3 of 186 pieces on a 3x2 LatLon grid, and 500,000 random trees of 1 to 64 pieces: points (a, a, a), lines (a, b, a) including a = b, and canonical triangles including collinear ones; these are sequences of pieces, not necessarily the pieces of one valid Line or Polygon. 63,276,054 trees in all, run once with assertions on and once off.
  • For each tree it decoded the bytes with an independent reference decoder, checked 499,269,156 relate() calls against a brute-force relation (0 mismatches), and recomputed the centroid with the same formula plus a check that the centroid lies inside the bounding box.
  • 376,260 trees had zero total weight; 54,182 of them had the centroid (0, 0) outside the box (the rest have boxes that contain (0, 0)). 8 of 8 seeded mutants of ShapeDocValues were killed, so the checks are not vacuous.
  • The finder then wrote a JUnit on the public factories and confirmed it on main, 10.5.2, 9.12.3 and 9.4.0. The harness also counted 1,116 shapes with non-zero weight whose centroid lies one encoding step outside the box; that was not triaged and is not part of this bug.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): about 120 tool queries over GitHub, commits, downstream code, the Lucene lists and JIRA, 31 GitHub search phrasings (58 searches) across Lucene, OpenSearch, Elasticsearch and Solr, 9 web searches and the introducing PR's threads. Precedent in other code: Elasticsearch fixed the same kind of bug in its own centroid code in 2020 (elastic/elasticsearch#52303).

Searches5 searches

No hit is the same bug. Related, not this bug: #1017 / #1064 / #11690 (the introducing change; its review discussed centroid weighting but never zero-length or zero-area shapes), #11829 / #12124 and #13531 / #13743 (test-generator fixes for random test polygons), #11973 / #11974 (a serialization flag), #11752 / #11753 (relate() made public), #11702 (multi-value doc values). The open PRs that touch these classes change other code: #13948, a 67-file change that reads binary doc values as a RandomAccessInput, touches only BaseShapeDocValuesQuery.java and BinaryRangeDocValues.java among the shape and document classes; #12036 touches only Tessellator.java and GeoUtils.java; neither touches the centroid code. Precedent in another code base: elastic/elasticsearch#52303 (fixed by #52500, 2020) and #55851 / #58027, the same kind of defect in Elasticsearch's own geo_shape centroid code, fixed by treating zero-area polygons as lines and zero-length lines as points, two years before Lucene's code existed; the author and a reviewer of #1017 were asked for input there. That is not a report of Lucene's code, so the row says "new"; read as a partial overlap it would be "known". Limits: keyword search only; GitHub's search rate limit was saturated (GraphQL searches covered the same index); private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, commits and downstream code (apache/lucene; OpenSearch, Elasticsearch, Solr)2026-10-26a scripted prior-report search, three runs (45, 24 and 51 queries), plus 22 GraphQL issue/PR phrasings in apache/lucene and 9 more, each run in 4 downstream repositories (OpenSearch, OpenSearch geospatial, Elasticsearch, Solr), 58 searches: ShapeDocValues centroid, getCentroid, zero-length line, zero area, degenerate, (0, 0), LatLonShapeDocValuesField, XYShapeDocValuesFieldno report; #1017 is the closest Lucene hit (introduced the code); elastic/elasticsearch#52303 / #52500 and #55851 / #58027 are precedent in other code
The introducing PR and later shape doc-values items2026-10-26all issue comments, review comments and review summaries of #1017 and #11690, grep for centroid, degenerate, zero, collinear; every later shape doc-values issue and PRcentroid weighting, coordinate space and overflow discussed; zero-length and zero-area shapes never
Open apache/lucene PRs2026-10-26PRs touching ShapeDocValues.java, LatLonShape.java, XYShape.java and the doc-values classes#13948 (67 files; among the shape and document classes only BaseShapeDocValuesQuery and BinaryRangeDocValues) and #12036 (Tessellator and GeoUtils); none touches the centroid
Mailing lists and JIRA2026-10-26dev@, java-user@, issues@ and commits@; the JIRA archiveonly notification mirrors of the items above and the 9.4.0 announcement; JIRA has only LUCENE-10654 (the feature)
Web and other libraries2026-10-269 web searchesnothing on Lucene's code; shapely#107 (GEOS returns an empty centroid for a zero-length line) and locationtech/jts#193 as context

History

Present since the API was added: LUCENE-10654 (#1017, d7fd48c9502c, 2022-08-09), first released in 9.4.0. Executed and affected: 9.4.0, 9.12.3, 10.0.0, 10.5.2 and main; 9.3.0 has no such API. Not a regression; not fixed upstream.

Versions tested6 rows

Introduced by d7fd48c9502c on main (2022-08-09) and its branch_9x backport ddf0d0acf4e4 (the same day, the copy that shipped first), "LUCENE-10654: Add new ShapeDocValuesField for LatLonShape and XYShape" by @nknize (PR #1017, backport #1064, issue #11690). git blame attributes every line of the defect to it: the zero guards (ShapeDocValues.java:191-204, :265-288), the weights (:439-454), the Line factory (LatLonShape.java:153-174) and computeCentroid (LatLonShapeDocValues.java:58-63). First released in 9.4.0 (CHANGES, New Features: "LUCENE-10654: Add new ShapeDocValuesField for LatLonShape and XYShape."). The later commits to ShapeDocValues.java, fad3108b27b (#11974, a serialization flag) and de3b294be44 (#12655, formatting), do not touch the centroid. Upstream main 20ced71c1e6 (2026-10-27), branch_10x and branch_10_6 have the same ShapeDocValues.java, LatLonShape.java and XYShape.java as a9779ef3c193, so 10.6.0 is very likely affected (not executed). The releases between the executed ones are inferred from git log.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis repro 6 of 8 fail with -ea and -da (gradle and classpath runner); the finder's JUnit 5 of 5
10.5.2 affectedthis repro 6 of 8 fail with -ea and -da; the finder's 5 of 5 and the reproducer's 7 of 10
9.12.3, 9.4.0 affectedthis repro 6 of 8 fail; the finder's JUnit 5 of 5 (9.12.3 also with -da); the reproducer's 7 of 10
10.0.0 affectedthe finder's JUnit 5 of 5 fail
9.3.0 no APIthe doc-values factories do not exist; the repro does not compile
main a9779ef3c193 + fix fix check8 of 8 pass with option A and with option B (gradle and shadowed classes)

Independent checks

An independent reproducer wrote its own 10-test JUnit (7 fail, 3 controls pass), a devil's advocate looked for an intended reading (real), read-only cross-model reviews agreed on the root cause, an adjudication confirmed it with high confidence, and a credibility audit kept it; re-run on main a9779ef3c193 since.

Checks and verdicts346 words

The checks ran on main ea57360912ed (where every file involved is identical) unless stated, some also on releases, with assertions on and off. The cross-model reviews could only read code (they could not run Java), so their verdicts on reality are procedural; separate runs re-ran the repro (5 of 5 fail). No cross-model review that executes code was done.

Each independent check and its verdict
CheckVerdictWhat it found
Independent reproducer realOwn public-API JUnit from the claim alone, in another package: 7 of 10 fail on main (-ea, -da), 10.5.2, 9.12.3 and 9.4.0; 3 controls pass; showed the stored value reads back as (0, 0); its own shadow fix makes all 10 pass.
Intended-behaviour devil's advocate realBuilt the strongest intended case (experimental, 0/0, the test helper, OGC validity, lossy encoding) and showed why each part fails; probed both winding orders, both factories and the round trip.
Cross-model reviews (OpenAI GPT-5.6, read-only) intended lens real (medium); refute lens not real (low, procedural)The reviewer could not run code; the lenses agreed on the root cause and that the inputs are in contract, and rated the intended-behaviour argument moderate.
Adjudication confirmed, high confidenceRe-ran the reproducer's JUnit (-ea, -da: 7 of 10 fail, controls pass) and checked the root cause by hand; not a duplicate.
Registration check and credibility audit keepRe-runs on main with fresh seeds fail as before and pass with the fix shadowed; the audit re-ran the finder's JUnit with -ea and -da (5 of 5 fail).
Main refresh still reproducesThe finder's JUnit fails 5 of 5 on main a9779ef3c193 with the same messages.
Doc fact checks (2026-10-28) real; text correctedAn independent fact check re-ran the repro through gradle (assertions on and off), the classpath runner on main, 10.5.2 and 9.4.0, both fixes and its own probe: all as stated. A read-only cross-model fact check (OpenAI GPT-5.6) agreed the bug is real and found wording errors and that option A is not exact for flat polygons of several triangles; the fixes are listed.

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,372 words

Finding record

LFV-366, snapshot of 2026-11-06

Registry title
ShapeDocValues getCentroid() returns (0, 0), far outside the shape's own bounding box, when the shape's total encoded length or area is zero (a line whose points coincide after encoding, or a polygon thinner than one encoding step)
Area
document / shape doc values (ShapeDocValues centroid; LatLonShapeDocValuesField and XYShapeDocValuesField getCentroid)
Priority
Low, rank 429: The experimental ShapeDocValues centroid comes back as (0,0) only for shapes that have zero length or zero area after encoding, a silent boundary-value geo error comparable to LFV-301.
Impact class
silent wrong results
Impact note
Applications that store shapes in LatLonShape or XYShape doc-values fields (an experimental API; default codec and IndexWriterConfig) and read getCentroid() get (0, 0) for any shape whose whole length or area is zero after encoding: a line whose points are all repeated or lie within one encoding step (about 4.2e-8 degrees of latitude, 8.4e-8 of longitude), built with createDocValueField(name, Line) or createDocValueField(name, XYLine), or a polygon whose vertices become collinear after encoding, through either factory; one document suffices, -ea and -da, every release since 9.4.0; the wrong value is stored in the index (a fix needs re-indexing or a read-time fallback), while queries, relate() and the bounding box are not affected.
Summary
You build a doc-values shape with LatLonShape.createDocValueField or XYShape.createDocValueField and ask for its centroid; you expect getCentroid(), documented as 'the centroid location for the geometry', to be a point of the shape, inside the bounding box that the same object returns. For a line whose points coincide after encoding (new Line({40, 40}, {-70, -70}), or two points closer than one encoding step) or a polygon thinner than one encoding step, Lucene returns Point(0.0, 0.0) instead, 7,500 km or more from the shape in these examples: the centroid is weighted by length or area, and when the total weight is zero the code skips the division and keeps the zero numerator. The other public factory, createDocValueField(name, createIndexableFields(name, line)), returns the real point for the same line.
Introduced
d7fd48c9502c567e4760a011fa99b1a491fea2cb (main) and its branch_9x backport ddf0d0acf4e4443ddea37bb855dead7bed5cc1a2 (the copy that shipped in 9.4.0), 'LUCENE-10654: Add new ShapeDocValuesField for LatLonShape and XYShape' by nknize (PR apache/lucene#1017, backport #1064, issue #11690) [read: git blame attributes every relevant line (ShapeDocValues.java:191-204, 265-288, 439-454; LatLonShape.java:153-174; LatLonShapeDocValues.java:58-63) to d7fd48c9502c, and the guards and weighting are identical at d7fd48c9502, ddf0d0acf4e, 9.4.0, 10.5.2 and main; exec: 9.3.0 has no such API, 9.4.0 fails]. Not a regression: present since the feature was added; the later commits to ShapeDocValues.java (fad3108b27b, #11974, a header serialization flag; de3b294be44, formatting) do not touch it (2022-08-09 (d7fd48c9502c and its backport ddf0d0acf4e))
First affected version
9.4.0 [exec] (tag 2022-09-30, the first release with ShapeDocValuesField; CHANGES 9.4.0 'LUCENE-10654: Add new ShapeDocValuesField for LatLonShape and XYShape'); 9.3.0 has no such API [exec: the repro does not compile]
Affected versions
Every release from 9.4.0 to 10.6.0 (10.6.0 by reading) and main ea57360912ed; not fixed upstream. [exec] the finder's JUnit fails 5 of 5 on 9.4.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da) (finder and follow-up; the intended-behaviour check repeated 10.5.2 and 9.4.0); the independent reproduction's own JUnit fails 7 of 10 (its 3 controls pass) on main (-ea and -da), 10.5.2, 9.12.3 and 9.4.0. [read] 9.4.1 to 10.5.1 are inferred: git log shows no commit that changes the centroid code; upstream main at cf398bfb142c (2026-10-26) has no change to it and no open PR touches it (the prior-report record). 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 could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, read-only, the prior-report record, verdict new: three scripted prior-report searches (the finder's pass, 45 queries over GitHub issues/PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@ and JIRA; a list and JIRA pass, 24 queries; a third pass, 51 queries plus ingested web results, 57 distinct hits), 22 GraphQL issue/PR phrasings in apache/lucene and 9 in OpenSearch, Elasticsearch and Solr, 9 web searches, direct reads of the introducing PR's review threads and of later shape doc-values issues, and the local git history); no issue, PR, JIRA entry, commit, list thread or web page reports Lucene's ShapeDocValues centroid for zero-length or zero-area shapes. Not fixed upstream: upstream main cf398bfb142c has no change to the centroid code and no open PR touches it (#13948 changes only BaseShapeDocValuesQuery, #12036 only Tessellator). Related, not this bug: #1017 / #11690 / #1064 (LUCENE-10654, introduced the code; the review discussed centroid weighting at length but never zero-measure shapes), #11829 / #12124 and #13531 / #13743 (test-generator fixes for self-crossing and zero-area random test polygons; no production change), #11973 / #11974 (a serialization flag bug in the same file); precedent in another code base: elastic/elasticsearch#52303 (fixed by #52500, 2020) and #55851 / #58027, the same defect class in Elasticsearch's own, separate geo_shape centroid code, fixed there by treating zero-area polygons as lines and zero-length lines as points, two years before Lucene's code existed.
Lucene commit tested
main a9779ef3c193, run 2026-10-28: the JUnit through gradle in a fresh scratch worktree with only the repro added and through the classpath runner, with assertions on and off; the release jars 10.5.2 (assertions on and off), 9.12.3 and 9.4.0; the fix and the alternative fix through gradle and as shadowed classes. The hunt, its checks and the history runs used main ea57360912ed, where every source file involved is identical, and 9.3.0 (no such API), 9.4.0, 9.12.3, 10.0.0 and 10.5.2.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:49 (@lucene.experimental), :191-204 (single component: divide by length or area only if non-zero), :269-288 (several components: same for totalLength and totalSignedArea), :439-454 (midpoints weighted by encoded-space length or area), :491-493 (centroid encoded and written), :768 (relate skips the centroid and highest dimension)
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:72-74 (stated encoding precision), :153-174 (createDocValueField(Line) types every segment LINE); XYShape.java:151-172 (the same for XYLine); LatLonShapeDocValues.java:58-63 and XYShapeDocValues.java:58-63 (computeCentroid decodes the stored value)
  • lucene/core/src/java/org/apache/lucene/document/ShapeDocValuesField.java:38 (@lucene.experimental), :97-98 ('retrieves the centroid location for the geometry'); LatLonShapeDocValuesField.java:52, :65-69; XYShapeDocValuesField.java:51, :65-68
  • lucene/core/src/test/org/apache/lucene/document/TestShapeDocValues.java:133 ('totalSignedArea == 0d ? 1 : totalSignedArea', the test reference)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:72-74 (at a9779ef3c193) (encoding steps), :110-132 (polygon factory), :153-174 (Line factory), :222-236 (Field[] factory)
  • lucene/core/src/java/org/apache/lucene/document/XYShape.java:151-172 (at a9779ef3c193) (XYLine factory)
  • lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:49 (at a9779ef3c193) (@lucene.experimental), :72-93 (constructors decode the stored centroid), :191-204, :265-288 (zero guards), :439-454 (weights), :491-493 (centroid written), :767-770 (relate skips the centroid)
  • lucene/core/src/java/org/apache/lucene/document/ShapeField.java:371, (at a9779ef3c193) :374-399 (resolveTriangleType); LatLonShapeDocValues.java:58-63; XYShapeDocValues.java:58-64
  • lucene/core/src/java/org/apache/lucene/geo/XYEncodingUtils.java:50-52 (at a9779ef3c193) (encode = floatToSortableInt)
  • lucene/core/src/java/org/apache/lucene/geo/GeoEncodingUtils.java:52-64, (at a9779ef3c193) :84-96 (encodeLatitude, encodeLongitude: Math.floor, 'rounding down'); ShapeDocValues.java:434-436 (a POINT piece keeps its decoded corner)
Who is affected
  • lucene/core/src/java/org/apache/lucene/document/ShapeDocValuesField.java:38, (at a9779ef3c193) :107 (getHighestDimensionType); LatLonShapeDocValuesField.java:52; XYShapeDocValuesField.java:51; LatLonShapeDocValues.java:32; XYShapeDocValues.java:32 (@lucene.experimental); LatLonShape.java:79 and XYShape.java:77 (class declarations, no such tag)
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:350, (at a9779ef3c193) XYShape.java:280 (create*ShapeDocValues(BytesRef))
  • Lucene main a9779ef3c193: grep of lucene/*/src/java for getCentroid() (only the shape getters and unrelated vector-quantization methods)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/document/ShapeDocValuesField.java:97-98; (at a9779ef3c193) LatLonShapeDocValuesField.java:65-69; XYShapeDocValuesField.java:65-69
  • lucene/core/src/java/org/apache/lucene/geo/Line.java:53-87, (at a9779ef3c193) XYLine.java:47-79
  • lucene/core/src/test/org/apache/lucene/document/TestShapeDocValues.java:57-66 (at a9779ef3c193) (bounding-box test with the area skip at :59), :79-87 (centroid test, no skip), :100-135 (reference centroid, :133), :261-263 (area() is the bounding-box area)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:119-128, (at a9779ef3c193) XYShape.java:117-126 (createDocValueField(name, Polygon) passes isEdgefromPolygon(0) for the second edge)
History
  • lucene/CHANGES.txt:3958 (at a9779ef3c193) (9.4.0), :3966 (LUCENE-10654)
  • a Lucene clone: git diff --quiet a9779ef3c193 against origin/main 20ced71c1e6, origin/branch_10x 1309534296e and origin/branch_10_6 2763db11b0a for ShapeDocValues.java, LatLonShape.java and XYShape.java (identical), 2026-10-28

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 same example (a repeated-point line at (40, -70) and the sliver polygon (40, -70) (40, -60) (40.000000001, -65)); its numbers come from an executed probe on main a9779ef3c193. Its fix panel shows option B (the plain mean of the pieces); the recommended option A gives the same three values on these inputs.

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.