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.
- 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})).Lineaccepts repeated points. - 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. - 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.
- 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
XYLineof repeated points. The other factory,createDocValueField(name, createIndexableFields(name, line)), returns (40, -70) for the same line. - 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.
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.
- Shape doc value:
LatLonShape.createDocValueFieldandXYShape.createDocValueFieldbuild 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()andgetBoundingBox()read the header. The shape doc-values classes and fields are@lucene.experimental; the factory classesLatLonShapeandXYShapeare not tagged. - Pieces: a line becomes one
LINEpiece per segment; a polygon is tessellated intoTRIANGLEpieces; a point is onePOINTpiece. - Encoding step:
LatLonShapestores 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.
createDocValueField(name, Line)types every segmentLINE, also a segment whose two ends encode to the same point (LatLonShape.java:153-174;XYShape.java:151-172forXYLine).createDocValueField(name, Polygon)types every tessellated triangleTRIANGLE(LatLonShape.java:110-132).- The
TreeNodeconstructor 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. buildTreedivides 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.- 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. - The
Field[]factory decodes each indexed triangle withShapeField.resolveTriangleType(ShapeField.java:371,:374-399), which turns a triangle with all corners equal into aPOINT; a singlePOINTpiece 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-areaTRIANGLEand also gives (0, 0), in both winding orders. - 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).
XYShapeencodes afloatexactly (XYEncodingUtils.encodeisfloatToSortableInt), so anXYLinetriggers 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). ALatLonShapepolygon could also collapse along a meridian or a slanted line; only the latitude-thin sliver was executed. 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.
| input | bounding box | getCentroid() | distance |
|---|---|---|---|
control: Line (40, -70) to (41, -69) | lat 40 to 41, lon -70 to -69 | lat 40.49999997, lon -69.50000005 | 0 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 cell | the same point | (0.0, 0.0) | 8,319 km |
Line of 4 equal points | the 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 factories | 7,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 7 | XYPoint(0.0, 0.0) | |
the repeated-point Line via createDocValueField(name, createIndexableFields(name, line)) | the same point | lat 39.99999996, lon -70.00000002 | 0 km |
the repeated-point Line indexed and read back from BinaryDocValues | the 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
LatLonShapeorXYShapedoc values (createDocValueField, default codec andIndexWriterConfig) and readgetCentroid(), at index time or from a value read back withLatLonShape.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 shapegetCentroid()outside the getters. The shape doc-values classes and fields are@lucene.experimental; the factory classesLatLonShapeandXYShapeare 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 saysLINEorTRIANGLE. 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,156relate()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.
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).
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);
}
}
# 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.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: 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)- 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.
LineandXYLinecheck 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 theLinefactory 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
LINEorTRIANGLE. - 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 ingetCentroid()). 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.
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). TestShapeDocValuesand the sixTest*ShapeDVQueriesclasses pass: 37 tests, 6 of them nightly-only and skipped, and all 37 with-Ptests.nightly=true. Every test class oforg.apache.lucene.documentpasses (506 tests, 26 of them skipped).checkGoogleJavaFormat,ecjLintMainandecjLintTestforlucene:corepass.- 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.
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.
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.
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.
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.
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.
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
LineorPolygon. 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
ShapeDocValueswere 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs, commits and downstream code (apache/lucene; OpenSearch, Elasticsearch, Solr) | 2026-10-26 | a 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, XYShapeDocValuesField | no 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 items | 2026-10-26 | all issue comments, review comments and review summaries of #1017 and #11690, grep for centroid, degenerate, zero, collinear; every later shape doc-values issue and PR | centroid weighting, coordinate space and overflow discussed; zero-length and zero-area shapes never |
| Open apache/lucene PRs | 2026-10-26 | PRs 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 JIRA | 2026-10-26 | dev@, java-user@, issues@ and commits@; the JIRA archive | only notification mirrors of the items above and the 9.4.0 announcement; JIRA has only LUCENE-10654 (the feature) |
| Web and other libraries | 2026-10-26 | 9 web searches | nothing on Lucene's code; shapely#107 (GEOS returns an empty centroid for a zero-length line) and locationtech/jts#193 as context |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this repro 6 of 8 fail with -ea and -da (gradle and classpath runner); the finder's JUnit 5 of 5 |
| 10.5.2 | affected | this 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 | affected | this 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 | affected | the finder's JUnit 5 of 5 fail |
| 9.3.0 | no API | the doc-values factories do not exist; the repro does not compile |
main a9779ef3c193 + fix | fix check | 8 of 8 pass with option A and with option B (gradle and shadowed classes) |
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.
| Check | Verdict | What it found |
|---|---|---|
| Independent reproducer | real | Own 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 | real | Built 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 confidence | Re-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 | keep | Re-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 reproduces | The finder's JUnit fails 5 of 5 on main a9779ef3c193 with the same messages. |
| Doc fact checks (2026-10-28) | real; text corrected | An 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 493 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,372 words
- 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 mainea57360912ed, 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.
- 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-68lucene/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-64lucene/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-69lucene/core/src/java/org/apache/lucene/geo/Line.java:53-87, (at a9779ef3c193) XYLine.java:47-79lucene/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
- TestShapeDocValuesRepeatedPointCentroid.java (JUnit test):
repro/LFV-366/src/org/apache/lucene/document/TestShapeDocValuesRepeatedPointCentroid.java(16 lines)
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.
assets/LFV-366/bug.png(493 KB)
![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).](../assets/LFV-366/bug.png)