XYShape stores a polygon as triangles, and a point query asks each stored triangle whether it contains the point. That test has a blind spot: the second corner of a clockwise triangle counts as outside. Lucene means to store only counter-clockwise triangles, but for XY it checks the winding on an integer encoding that can flip it, so its own corrective swap can leave a triangle clockwise as floats.
encodeTriangle swaps two corners and stores it clockwise as floats; the point query at (1,2) then misses. Select the still to open it at full size. Generated with AI, checked against the code.- You index the triangle (0,1) (1,2) (3,3) as an
XYPolygonwithXYShape.createIndexableFields(default codec) and runXYShape.newPointQuerywith INTERSECTS at each of its three corners. - You expect 1 hit at every corner, as at the other two corners and at every corner of the same triangle moved by (8, 8), which gives 1, 1, 1; LUCENE-8736 made shape relations include boundary points.
- You get 1, 0, 1: the corner (1,2) misses its own polygon, and DISJOINT at (1,2) returns it. A metre-like hexagon misses its corner (518885, 4395432) the same way.
- Why it is stored clockwise: Lucene makes triangles counter-clockwise but checks this on its integer encoding of the floats, where 0, 1, 2, 3 sit at 0, 254, 256, 257 (times 2^22). The tessellator hands over the triangle counter-clockwise as floats, but on that ruler it looks clockwise (-248), so Lucene swaps two corners: the stored triangle is counter-clockwise as ints (+248) and clockwise as floats (-1).
- Why the corner is lost: at the second corner two of the test's three orientation checks are 0, so the answer falls back to the triangle's own winding, and clockwise means outside. The first and third corners and every edge point still match.
Applications that index polygons with XYShape (projected or cartesian coordinates) using the default codec and run point queries at exact vertex coordinates, such as corners shared by neighbouring shapes. INTERSECTS silently misses the shape and DISJOINT returns it. In one synthetic random-polygon test, 0.07-0.3% of metre-like polygons and about 3% near the origin had such a corner.
Mechanism, terms and numbers1,005 words
An XYShape point query placed exactly on a polygon's corner can miss the polygon. Index the triangle (0,1) (1,2) (3,3) with XYShape.createIndexableFields and query each corner with XYShape.newPointQuery: INTERSECTS gives 1, 0, 1 hits and DISJOINT gives 0, 1, 0, while the same triangle moved by (8, 8) matches at all three corners. Two pieces combine. ShapeField.encodeTriangle makes stored triangles counter-clockwise, but it checks the winding on the encoded ints, and the XY encoding (floatToSortableInt) keeps the order of floats but not their spacing. So the tessellator's counter-clockwise triangle can look clockwise on the ints, and the swap meant to fix it leaves the stored triangle counter-clockwise as ints and clockwise as floats. The query tests the decoded floats with Component2D.pointInTriangle, which rejects the second corner of a clockwise triangle and accepts everything else. Every release from 8.5.0 to 10.5.2 and main is affected, with the default codec and no error. A one-function fix in pointInTriangle (accept when all three orientations are >= 0 or all <= 0) fixes it without reindexing.
- Triangles:
XYShape.createIndexableFieldstessellates a polygon into triangles and stores each one with its three corners, called a, b and c in the code. - Winding: a triangle is counter-clockwise (CCW) when its signed area (the determinant) is positive, clockwise (CW) when it is negative.
GeoUtils.orient(a, b, c)returns +1, -1 or 0. - The XY encoding: XY coordinates are floats and are stored as
NumericUtils.floatToSortableInt(x). This keeps the order of floats and is exact, but it is not linear: within one power-of-two range it is a plain scaling, and from one power-of-two range to the next the scale halves or doubles.
ShapeField.encodeTriangle takes the corners in the tessellator's order, rotates them so that the smallest x comes first (ShapeField.java:157-182 at a9779ef3c193), and then ("change orientation if CW", :214-228) swaps b and c when GeoUtils.orient of the encoded ints is -1. On the encoded ruler the values 0, 1, 2 and 3 sit at 0, 254, 256 and 257 (in units of 2^22): the encodings of 0 and 1 are 254 x 2^22 apart, those of 1 and 2 are 2^23 apart, and those of 2 and 3 are 2^22 apart. The tessellator hands over the triangle as (1,2) (0,1) (3,3); after the rotation it is a = (0,1), b = (3,3), c = (1,2).
| corners a, b, c | determinant | winding | |
|---|---|---|---|
| handed over, after the rotation: floats | (0,1) (3,3) (1,2) | (3-0)(2-1) - (1-0)(3-1) = +1 | counter-clockwise |
| the same on the encoded ints (units of 2^22) | (0,254) (257,257) (254,256) | 257 x 2 - 254 x 3 = -248 | clockwise: the swap fires |
| stored after the swap: encoded ints | (0,254) (254,256) (257,257) | 254 x 3 - 257 x 2 = +248 | counter-clockwise |
| stored after the swap: floats, as the query decodes them | (0,1) (1,2) (3,3) | (1-0)(3-1) - (3-0)(2-1) = -1 | clockwise |
The swap meant to normalise the winding is what makes the float triangle clockwise. On the lattice {0..5}^2 every one of the 6,768 tessellated triangles reaches encodeTriangle counter-clockwise as floats, and the 240 that end up stored clockwise as floats all got there through this swap. In metre-like data the same happens at 2^19 = 524,288 or 2^22 = 4,194,304: below 524,288 one unit holds 32 float steps, above it 16. The repro's hexagon has x from 517,494 to 539,509, and its triangle at the missed corner goes through the same swap.
XYShapeQuery decodes each stored triangle to floats and calls intersectsTriangle (XYShapeQuery.java:93-99); for a point this is Component2D.pointInTriangle (Point2D.java:83-95).
int a = orient(x, y, aX, aY, bX, bY);
int b = orient(x, y, bX, bY, cX, cY);
if (a == 0 || b == 0 || a < 0 == b < 0) {
int c = orient(x, y, cX, cY, aX, aY);
return c == 0 || (c < 0 == (b < 0 || a < 0));
}
return false;At p = corner b the point lies on both edges that meet there, so a == 0 and b == 0, and the method returns c >= 0, where c = orient(b, c, a) is the triangle's own winding: true for a counter-clockwise triangle, false for a clockwise one. At corners a and c two orientations are 0 as well and the method returns true for either winding; edge and interior points are accepted for either winding too. An exhaustive comparison on the stock jar over every triangle and point of the integer grid {-4..4}^2 (43,046,721 cases) found that the stock method and the winding-independent fix disagree in exactly the 247,824 cases where p is corner b of a clockwise triangle. At the level of signs the two differ only for (a, b, c) = (0, 0, -1) of the 27 sign triples; reading that as "corner b of a clockwise triangle" assumes exact orientations, which holds here (every product is a small exact integer) but not for GeoUtils.orient in general.
- A polygon's corner is missed only if every stored triangle that touches it misses it: always for a lone triangle, and in the repro's hexagon too, where only one of its four stored triangles touches the missed corner.
- INTERSECTS misses the document; DISJOINT (no triangle intersects) returns it. CONTAINS (
Point2D.withinTriangle, same test) also answers differently per corner, but its correct boundary answer is debatable. - The experimental
XYShapeDocValues.relate(Component2D)also decodes XY triangles to floats and callsintersectsTriangle(ShapeDocValues.java:919-934), so with a point component it likely misses the same corner (not executed); the fix covers it too. - LatLonShape is not affected in the scope checked: its decoding is a positive linear scaling of the encoded ints, so stored triangles decode with their encoded, counter-clockwise winding (0 of 281,203 grid triangles stored clockwise as decoded doubles).
Exposure and likelihood326 words
- Who. Applications that index polygons with
XYShape(building and floor plans, CAD drawings, game maps, projected GIS data in metres) and runXYShape.newPointQuery, ornewGeometryQuerywith anXYPoint, with INTERSECTS or DISJOINT at a coordinate that equals a polygon vertex exactly: corners shared by neighbouring shapes, a click snapped to a vertex, or a re-query with stored vertex coordinates. - Default configuration. Public, non-experimental API, no special setting. The codec does not matter (the triangle encoding is in the field's bytes, the test runs at query time): the repro (Lucene's randomized test configuration) fails under every randomized test codec it met, and the independent reproducer's JUnit and the diagram probe, both with a plain
new IndexWriterConfig(), fail with the default codec (Lucene104in the adjudication's run). - Silent. No exception and no assertion; the same with assertions off.
- How often (the finder's random-polygon probe; not re-run by the verifiers): polygons with a missed corner were 0.30% (60 of 20,000 triangles) and 0.16% (32 of 19,562 hexagons) in projected-metre ranges, 0.07% (13 of 18,933) in a narrower metre window, 0.15% (29 of 18,937) around 1,000-1,900, and about 3% (590 of 19,878; 650 of 19,533) around the origin. With the fix, 0 (re-run for the hexagon range and the lattice triangles only). On the lattice {-2..2}^2, 8 of 2,148 triangles miss a corner. These rates come from one synthetic generator (random star-shaped polygons, fixed seed, checked on their stored triangles), not from real data: they show that the miss is not confined to toy coordinates, not how often real queries hit it.
- Blast radius. A miss affects only the documents that hold a polygon with such a corner, and only point queries at exactly that corner; the index itself is not changed.
- Not affected. LatLonShape (scope check); points indexed with
XYPointField, which stores no triangles. - Rare in practice. Nobody has reported it since 8.5.0 (2020); the registry rates the likelihood "rare" and the priority low.
The test shows that an XYShape INTERSECTS point query placed exactly on vertex (1,2) of the indexed clockwise triangle (0,1) (1,2) (3,3) does not find the polygon.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.document;
import org.apache.lucene.document.ShapeField.QueryRelation;
import org.apache.lucene.geo.XYPolygon;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestXYShapePointQueryAtPolygonVertex extends LuceneTestCase {
public void testPointQueryAtVertexOfClockwiseTriangle() throws Exception {
XYPolygon triangle = new XYPolygon(new float[] {0, 1, 3, 0}, new float[] {1, 2, 3, 1});
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
for (Field f : XYShape.createIndexableFields("shape", triangle)) {
doc.add(f);
}
w.addDocument(doc);
try (DirectoryReader reader = DirectoryReader.open(w)) {
IndexSearcher searcher = new IndexSearcher(reader);
// (1,2) is a vertex of the triangle: expected 1 hit, actual 0
assertEquals(
"INTERSECTS point query at vertex (1,2)",
1,
searcher.count(
XYShape.newPointQuery("shape", QueryRelation.INTERSECTS, new float[] {1, 2})));
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-764/, 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.TestXYShapePointQueryAtPolygonVertex
- Expected vs actual
- Expected: 1 hit (a polygon's vertices belong to it). Actual: 0 hits.
More files, environment, full output39 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: INTERSECTS point query at vertex (1,2) expected:<1> but was:<0>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.document.TestXYShapePointQueryAtPolygonVertex.testPointQueryAtVertexOfClockwiseTriangle(TestXYShapePointQueryAtPolygonVertex.java:43)- The point-query javadoc promises every complying shape, and LUCENE-8736 made the box and polygon relations with triangles "correctly include boundary points".
- The test handles both windings everywhere else: the interior, every edge and the first and third corners of a clockwise triangle match.
- Moving the same triangle by exactly (8, 8) makes the miss disappear.
The full argument245 words
- Contract.
XYShape.newPointQuery: "create a query to find all indexed shapes that comply the QueryRelation with the provided point" (XYShape.java:236-239). CHANGES for LUCENE-8736: "Point in Polygon now correctly includes boundary points. Box and Polygon relations with triangles have also been improved to correctly include boundary points." - Written for both windings. The method compares signs (
a < 0 == b < 0,c < 0 == (b < 0 || a < 0)) instead of testing all three >= 0, and its javadoc states no precondition: "Compute whether the given x, y point is in a triangle; uses the winding order method". - Upstream tests pass clockwise triangles to
Component2D.TestLatLonShape.testTriangleCrossingPolygonVerticescallsComponent2D.intersectsTriangleon (-5,0) (10,0) (-5,-15), which is clockwise, and expects true. - Position should not matter. The exact translation by (8, 8) makes the miss disappear; the independent reproduction's metre-like triangle is fixed by a (+10000, +10000) move.
- Precedent. LUCENE-8973 (#10016, fixed 2019): "XYShape encoding is not lineal and shapes loose their spatial relationship. XYRectangle2D should work then on the float space." A review comment on #1017 (2022): "XY queries should work on the float space."
- Why tests missed it. The random point-query check draws random points, which rarely land exactly on a stored vertex, and validates them with the same
Component2Dcode; and the TRIANGLE branch ofBaseShapeEncodingTestCasecomputes its "original" and "encoded" predicates from the same original coordinates, so it cannot see a change caused by the encoding.
The devil's-advocate check210 words
The strongest case for "works as designed": Component2D is @lucene.internal, and ShapeField documents that triangles are stored counter-clockwise (since LUCENE-8581, 2018; LUCENE-9642 moved the check after the corner rotation), so a clockwise triangle would be a broken precondition, not a predicate bug.
Why it fails: the counter-clockwise guarantee holds only for the encoded ints, not for the floats the query actually tests; Lucene's own winding swap turns a triangle the tessellator emitted counter-clockwise into a clockwise float triangle, for a valid public XYPolygon (the reversed ring stores the same triangle); no precondition is documented; and the public result changes under an exact translation. The fallback argument, that boundary answers are best effort because GeoUtils.orient "does not yet have the floating point tricks to be exact", fails too: every product here is a small exact integer, so the miss is branch logic, not rounding. The XYShape precision WARNING covers double to float only; these coordinates are already floats, which the XY encoding keeps exactly. Maintainers may still see a decoded clockwise triangle as an encoding-side precondition violation and prefer to fix it there or with the narrowest predicate change; the fixes section lists both. Verdict: not intended (the intended-behaviour check, high confidence; the cross-model reviews rated the intended-behaviour argument weak).
Recommended Make pointInTriangle independent of the winding (one function, no reindexing)
Make Component2D.pointInTriangle independent of the winding: accept the point when its three orientation tests are all >= 0 or all <= 0. This changes exactly one answer, the second corner of a clockwise triangle, and needs no reindexing. The repro and the whole lucene/core test suite (8,892 tests) pass with it.
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
int a = orient(x, y, aX, aY, bX, bY);
int b = orient(x, y, bX, bY, cX, cY);
- if (a == 0 || b == 0 || a < 0 == b < 0) {
- int c = orient(x, y, cX, cY, aX, aY);
- return c == 0 || (c < 0 == (b < 0 || a < 0));
- }
- return false;
+ int c = orient(x, y, cX, cY, aX, aY);
+ // inside or on the boundary for either winding order
+ return (a >= 0 && b >= 0 && c >= 0) || (a <= 0 && b <= 0 && c <= 0);
} else {All options and trade-offs5 options · 1,137 words
One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF); the two alternatives were not run. The zero-size box query is a user-side workaround checked on the two repro shapes only.
Make pointInTriangle independent of the winding (one function, no reindexing) (validated by the checks) (recommended)
In Component2D.pointInTriangle, inside the bounding-box check, compute all three orientations and accept the point when they are all >= 0 or all <= 0. The independent reproducer's own fix has the same logic (only the comment differs).
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
int a = orient(x, y, aX, aY, bX, bY);
int b = orient(x, y, bX, bY, cX, cY);
- if (a == 0 || b == 0 || a < 0 == b < 0) {
- int c = orient(x, y, cX, cY, aX, aY);
- return c == 0 || (c < 0 == (b < 0 || a < 0));
- }
- return false;
+ int c = orient(x, y, cX, cY, aX, aY);
+ // inside or on the boundary for either winding order
+ return (a >= 0 && b >= 0 && c >= 0) || (a <= 0 && b <= 0 && c <= 0);
} else {Trade-offs
- Exactly one answer changes: the two return statements differ only for the sign triple (0, 0, -1) of the 27, which in exact arithmetic means the point is the second corner of a clockwise triangle (stock false, fix true); on every grid triangle and point that is the only difference. Counter-clockwise triangles get the same answers as before.
- Other callers.
pointInTriangleis also called fromLine2D,Rectangle2D,Circle2D,Polygon2DandSpatialQuery.SpatialQuery's two calls test encoded triangles, which are counter-clockwise or degenerate in encoded space (asserted bydecodeTriangle), so in exact arithmetic their answers cannot change (GeoUtils.orientrounds when encoded ints are far apart, so a near-degenerate encoded triangle was not ruled out; not audited). For the float-space callers the change can only turn "outside" into "inside" when the tested point is exactly the second corner of a clockwise decoded triangle, the answer they already give at the other two corners; their user-visible effects were not audited separately. - No reindexing and no format change: the stored triangles stay as they are.
- Cost: the fix computes the third orientation on every call inside the bounding box, where the stock code can reject after two; no benchmark was run.
pointInTriangleis shared by several geometries, so measure it, or take the narrower variant below.
What the checks validated
- Stock main
a9779ef3c193(scratch worktree, gradle, seed DEADBEEF): the repro fails 3 of 3 with assertions on and off; the closest suites (TestPoint2D,TestXYShape,TestXYPolygonShapeQueries,TestXYMultiPolygonShapeQueries,TestXYPointShapeQueries,TestXYMultiPointShapeQueries) pass: 29 tests, 4 skipped. - With the fix: the repro passes 3 of 3 with assertions on and off; the same closest suites pass (29 tests, 4 skipped); every geo and shape suite of
lucene/core(thegeopackage,document*Shape*,TestXY*andTestLatLon*,searchTestXY*andTestLatLon*; 54 classes with the repro) passes: 587 tests, 40 skipped, 0 failures; the whole:lucene:core:testpasses: 8,892 tests, 322 skipped, 0 failures (run after the fix runs in the same worktree; its command line was not recorded, but every compile task in its log is up to date with the fix build, so it ran the fixed sources with the repro);ecjLintMain,ecjLintTestandcheckGoogleJavaFormatpass, so the repro file is formatted as Lucene requires. - After the comment-only edit of the repro (2026-10-28): it fails 3 of 3 on stock main and passes 3 of 3 with the fix, assertions on and off, and google-java-format 1.37.0 leaves it unchanged;
fix-with-test.diffwas regenerated and passesgit apply --check. - Exhaustive predicate comparison on the stock jar: the fix differs from the stock method only at the second corner of a clockwise triangle (247,824 of 43,046,721 grid cases on {-4..4}^2); at the sign level only for (0, 0, -1) of the 27 sign triples.
- Earlier hunt runs: the finder's random-polygon probe finds 0 missed corners with the fix in the two settings re-run with it (the lattice triangles and the hexagon range); the independent reproducer ran 355 upstream geo and shape tests with the same logic (0 failures), and the finder 584 together with another patch of its unit (40 skipped, 0 failures).
Narrower variant that keeps the early reject (checked at the sign level only) (validated by the checks)
Keep the stock code and also accept when a == 0 && b == 0 in the inner return: return c == 0 || (a == 0 && b == 0) || (c < 0 == (b < 0 || a < 0));.
Trade-offs
Same answers as the recommended fix at the sign level, without computing the third orientation where the stock code rejects early; harder for a reader to see that it is winding-independent.
What the checks validated
Over all 27 sign triples it gives the same answers as the recommended fix (sign-enum.txt). Not run in Lucene.
Make ShapeField.encodeTriangle choose the winding of XY triangles from the float coordinates, so stored triangles decode counter-clockwise.
Trade-offs
encodeTriangle is shared with LatLon and works on ints; decodeTriangle asserts the encoded-space invariant, and the 3-bit reconstruction code relies on it ("Triangles are encoded with CCW orientation and might be rotated to limit the number of possible reconstructions to 2^3"), and a triangle that is counter-clockwise as floats can be clockwise as ints, so this is an encoding change. Existing indexes keep their clockwise triangles until reindexed. It touches the same swap block as LFV-204 (edge flags); test both together.
In XYShapeQuery (and other XY decoders), swap b and c, and the edge flags, when the decoded float triangle is clockwise.
Trade-offs
No reindexing, but it fixes only the call sites that decode, costs one orientation test per visited triangle, and the edge-flag swap must be right for withinTriangle (CONTAINS).
Workaround for users today: a zero-size box query at the point (not a fix) (validated by the checks)
Query XYShape.newBoxQuery(field, relation, x, x, y, y) instead of newPointQuery at an exact vertex.
Trade-offs
Checked on two shapes only; a box query goes through a different relation code path.
What the checks validated
On main, for both repro shapes: INTERSECTS [1, 1, 1] and [1, 1, 1, 1, 1, 1], DISJOINT all 0, where the point query gives [1, 0, 1] and [1, 1, 1, 0, 1, 1]. Not an exhaustive check.
Downstream engines and applications that index cartesian shapes with XYShape and use its point queries inherit this; it would matter for access or geofencing decisions only where those rest on point queries at exact polygon vertices.
A metamorphic test indexed every triangle of a small integer lattice, plus random samples of larger lattice shapes, as-is and under 22 exact transforms, and compared the hits of all four query relations. With registered geo fixes applied, most remaining mismatches were point queries at polygon corners; a probe showed that exactly the triangles stored clockwise miss their second corner.
The method in full217 words
Method: metamorphic bounded-exhaustive translation validation of the stock main jar (semi-formal tier), in a hunt unit on orientation-preserving transforms of geo shapes.
- Every document and query of a 6 x 6 integer-lattice family (9,156 documents, including all 6,768 non-degenerate lattice triangles, and 1,631 queries) was indexed and queried as-is and under 22 exact transforms: quarter-turn rotations, reflections with vertex reversal, cyclic vertex rotation, translations by +-2^k and reversal. All four relations were run on the BKD, doc-values, point and point doc-values paths, and the correct side of each mismatch was decided by an exact DE-9IM reference plus the closed-set convention.
- With the fixes of the registered geo bugs shadowed in, most of the remaining mismatches, 80,128, were point or multi-point queries at polygon vertices; with this bug's fix also shadowed in, none of those remained, and the rest came from separate findings, mainly circle queries after ±2^23 translations. A component-level probe then showed that exactly the 240 lattice triangles stored clockwise in float space miss their second corner (0 with the fix).
- The finding was reduced to a public-API JUnit and checked by an independent reproducer, an intended-behaviour devil's advocate, reading-only reviews by a model from another family (OpenAI GPT-5.6), an adjudication, a history, prior-report and security follow-up, and an audit. Found 2026-10-27 on main
a9779ef3c193.
No prior report found (2026-10-27): four prior-report tool runs, 55 GitHub searches across Lucene and downstream projects, JIRA, the mailing lists, every CI failure mail of the XY shape tests, the changed files of all 408 open PRs and git history. The closest items treat XY geometry in encoded space as a bug in general, not this case.
Searches4 searches
Verdict new: no report by anyone. Keyword search cannot prove absence: GitHub search does not index review comments repository-wide, and list search returns capped results. The nearest items are listed below; none of them is this bug.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene and downstream repos), dev@, java-user@, issues@, commits@, the JIRA archive, the web | 2026-10-27 | the hunt's prior-report tool: the finder's run plus three variants (identifiers, symptoms, mechanism), 51 queries each | no report; nearest: #9680 (LUCENE-8634, a 2019 aside that triangles "might change orientation through encoding", LatLon; asked in passing whether that matters, not followed up) |
| apache/lucene, Elasticsearch, OpenSearch, OpenSearch geospatial, Solr, Apache SIS | 2026-10-27 | 55 manual GitHub GraphQL searches (34 on apache/lucene, 21 downstream); 21 geo issue threads and 3 JIRA issues read in full; review and issue comments of 19 geo PRs (17 in the follow-up's comment grep; #12036 and #12757 re-read by this doc's fact check, no relevant hit) | context only: #10016 (LUCENE-8973, XY relations must run in float space; fixed for XYRectangle2D), #1017 (same principle, 2022), #10681 (LUCENE-9642, the orientation check moved after the corner rotation), #9782 (LUCENE-8736, boundary points included) |
| all 408 open apache/lucene PRs; code search for the predicate's exact expression | 2026-10-27 | changed-file lists; GitHub code search | no open PR touches the involved files except the refactoring #12036, which keeps the logic; copies of the predicate in forks and downstream code, none with a fix |
| JIRA archive, mailing lists, CI failure mails | 2026-10-27 | 13 JQL queries, 27 list queries, all 32 failure mails of the TestXY*ShapeQueries classes read in full | no report; the failure mails are CONTAINS box failures, out-of-memory errors and one circle-radius exception, none at a vertex |
Introduced in 8.5.0 by ff365a0abf23 (LUCENE-8903, 2020-01-15), which added the XYShape point query; the predicate's sign test dates from 2018 and the encoded-space winding check from 2018-2019. Executed: all 52 releases 8.5.0-10.5.2 and main fail the same way; 8.4.1 has no such query.
Versions tested5 rows
Introduced by ff365a0abf23 "LUCENE-8903: Add LatLonShape point query (#762)" (apache/lucene-solr PR #762 by iverase, merged 2020-01-15; branch_8x f56f51fc3ebc). It added XYShape.newPointQuery, the first public path that tests a stored vertex with pointInTriangle; its parent has no such API. The commit itself was not built: the executed bracket is 8.4.1 (compile error) to 8.5.0 (fails).
Older ingredients: the pointInTriangle sign test, unchanged since LUCENE-8396 (b5ef13330f1, 2018-07-14; moved into Component2D by LUCENE-8746); the counter-clockwise normalisation on encoded ints in encodeTriangle (LUCENE-8581, d185ba99de0, 2018-12-18, harmless for the linear LatLon encoding); the sortable-float XY encoding (LUCENE-8632, 0c09481374c, 2019-07-08, sandbox in 8.2.0). Main has no geo or shape change since, and no open PR touches the involved files.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | fails 3 of 3 (gradle and the classpath runner, assertions on and off); passes with the fix |
| 10.0.0 to 10.5.2 (all 12 releases) | affected | fails 3 of 3 with the same three messages |
| 9.0.0 to 9.12.3 (all 21 releases) | affected | fails 3 of 3 |
| 8.5.0 to 8.11.4 (19 releases) | first release 8.5.0 | fails 3 of 3 (8.5.0 also with assertions off) |
| 8.4.1 (core, and sandbox) | API absent | compile error: no XYShape point query; other query types at the vertex answer correctly |
An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing; reading-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause; an adjudication and an audit re-ran it with new seeds, and this doc's gradle runs reproduce it on a9779ef3c193.
Checks and verdicts414 words
Every executing check wrote or re-ran a repro on stock main, and every control passed. The cross-model reviews could not execute code here, so all execution evidence comes from the hunt's own runs.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Metamorphic lattice sweep, then the 3-test public-API JUnit: fails on main (-ea, -da), 10.5.2, 9.12.3, 8.11.4 and 8.5.2; passes with the fix. |
| cross-model review, refute lens (OpenAI GPT-5.6, reading only) | not real, low (procedural) | Could not execute anything, and it may not vote real without its own run; on substance root cause agrees, inputs in contract, intended argument weak. A separate re-run failed 3 of 3. |
| independent reproducer | real | Own JUnit: 8 of 2,148 lattice triangles miss a corner, matching an independent model 8 of 8; metre-like triangle with a translated control; fails on main (-ea, -da), 10.5.2, 9.12.3; its own fix passes 355 upstream geo/shape tests. |
| cross-model review, intended lens (the cross-model review, OpenAI GPT-5.6, reading only) | real, medium | Re-derived the arithmetic (float determinant -1, encoded positive); intended argument weak. Separate re-runs fail on main and 10.5.2 and pass with the fix. |
| intended-behaviour devil's advocate | real, high confidence not intended | No precondition documented; upstream tests pass clockwise triangles; LUCENE-8736 and LUCENE-8973 support the expectation; its public-API probe shows the encode/decode round trip turns orient +1 into -1. |
| adjudication | confirmed, high | Re-ran the independent reproduction's JUnit (-ea) and the finder's (-da) with seed C0FFEE12: 3 of 3 fail each; duplicate of none (nearest LFV-204, a different fix site). |
| credibility audit | keep | Re-ran with seeds A0D17001-3: 3 of 3 fail with -ea and -da, 0 with the fix. |
| this doc | reproduced; fix validated | Gradle in a scratch worktree at a9779ef3c193: the repro fails 3 of 3 (assertions on and off) and passes 3 of 3 with the fix; with the fix 587 geo and shape tests and the whole :lucene:core:test (8,892 tests) pass; the exhaustive predicate comparison and the workaround probe ran on the stock jar. |
| independent fact and diagram checks | bug stands; text errors fixed | A fact check that re-ran the repro four ways (classpath runner, released jars, the brief's gradle commands, the fix), a fact check by a reviewer from another model family (OpenAI GPT-5.6), and a diagram check. They found no error in the bug or the fix, and a set of text errors (the indexing step, two history remarks, search counts, scope words), all fixed. |
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 557 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,384 words
- Registry title
- XYShape point queries can miss a polygon at one of its own vertices: Component2D.pointInTriangle rejects the second vertex (b) of a clockwise triangle, and ShapeField.encodeTriangle makes XY triangles counter-clockwise only on their sortable-int encodings, so a stored triangle can decode clockwise; an INTERSECTS point query at that vertex misses the polygon and DISJOINT returns it (since 8.5.0)
- Area
- geo / XYShape point queries (Component2D.pointInTriangle; ShapeField.encodeTriangle orientation for XY)
- Priority
- Low, rank 400: Default XYShape INTERSECTS/DISJOINT point queries give silent wrong answers only for a query placed exactly on a clockwise-decoded polygon vertex, an exact-geometry boundary case that belongs below the two silent Tessellator rows and above the loud Tessellator crash rows LFV-762 and LFV-755.
- Impact class
- silent wrong results
- Impact note
- Hits applications on 8.5.0 through 10.5.2 or main that index polygons with the default XYShape.createIndexableFields (default codec and IndexWriterConfig, no scale needed) and run XYShape point or multi-point queries whose coordinate equals a polygon vertex exactly, when every stored triangle at that vertex holds it in position b and decodes clockwise, which can happen when a triangle's x or y range crosses zero or a power-of-two boundary: INTERSECTS silently misses the polygon and DISJOINT silently returns it (CONTAINS also answers differently per vertex, but its correct boundary answer is debatable); the finder measured such a vertex in about 0.07-0.3% of random polygons in metre-like and 1000-1900 coordinate ranges and about 3% of random polygons around the origin (not re-run by the verifiers); LatLonShape is not affected in the scope checked (its decoding is linear) [exec on main and all 52 releases 8.5.0-10.5.2].
- Summary
- You index the clockwise triangle (0,1) (1,2) (3,3) as an XYPolygon with XYShape.createIndexableFields and run XYShape.newPointQuery at each of its corners; you expect every corner to match the polygon (INTERSECTS) and none to be DISJOINT from it, as happens for the same triangle moved by (8,8) and for every point of its edges. Instead the INTERSECTS query at (1,2) finds nothing and the DISJOINT query at (1,2) returns the document: ShapeField.encodeTriangle checks the winding on the sortable-int encodings of the floats, which are ordered like the floats but not evenly spaced, so the stored triangle decodes clockwise, and Component2D.pointInTriangle reports the second vertex of a clockwise triangle as outside. The same happens with projected metre-like coordinates, for example at vertex (518885,4395432) of a hexagon whose x range crosses 2^19.
- Introduced
- ff365a0abf23d89fd0eba3fbb707f0fc4f2316ce on master: LUCENE-8903 'Add LatLonShape point query' (apache/lucene-solr PR #762 by iverase, merged 2020-01-15), which added XYShape.newPointQuery (XYShapePointQuery, then Point2D.relateTriangle, then Component2D.pointInTriangle), the first public path that evaluates pointInTriangle at a stored vertex; branch_8x backport f56f51fc3ebc58bc69d8c381af9d6ef6671619b5 [read for the commit: at ff365a0abf23^ XYShape.java has no newPointQuery; exec for the release bracket: the repro does not compile on 8.4.1 and fails 3 of 3 on 8.5.0; the commit and its parent were not built]. Older latent ingredients [read, pickaxe and log]: the pointInTriangle sign test, unchanged since b5ef13330f1 (LUCENE-8396, 2018-07-14, then Tessellator.pointInTriangle; copied into EdgeTree by 893f5cda574, LUCENE-8654, and moved into Component2D by 68a3886b977, LUCENE-8746); the counter-clockwise normalisation of encoded ints in encodeTriangle (d185ba99de0, LUCENE-8581, 2018-12-18, harmless for the linear LatLon encoding); and the non-affine XY sortable-float encoding that reached it with 0c09481374c (LUCENE-8632, 2019-07-08, sandbox in 8.2.0) (2020-01-15 (master ff365a0abf23 and branch_8x f56f51fc3ebc); latent ingredients 2018-07-14 (pointInTriangle), 2018-12-18 and 2019-07-08 (encoded-space orientation, XY encoding))
- First affected version
- 8.5.0 [exec] (tag commit 2020-03-12; the same release moved XYShape from sandbox to core, LUCENE-8621). 8.4.1 has no XYShape point query: the repro does not compile against 8.4.1 core or 8.4.1 sandbox [exec]; a limited probe found no earlier public path (line, polygon and box queries touching the triangle only at the vertex answer correctly on 8.4.1 sandbox, 8.5.0 and main) [exec]
- Affected versions
- [exec] the finder's JUnit (seed DEADBEEF, -ea) fails 3 of 3 with the same three messages on all 52 releases from 8.5.0 to 10.5.2 (8.5.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2) and on main a9779ef3c193, and also with -da on 8.5.0 and main; the independent reproduction's own JUnit fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check's replay fails on 10.5.2. Not applicable: 8.4.1 and earlier (no XYShape point query; 8.3.1 and 8.4.1 are compile errors) [exec]. [read] main a9779ef3c193 has no geo or shape change since the history base 7cecf795236, upstream main a95c754ee00 (2 commits ahead) touches no geo or shape file, and no open PR touches Component2D.java, ShapeField.java, Point2D.java, XYShapeQuery.java or XYEncodingUtils.java. branch_10_6 (10.6.0, released 2026-10-29) and the unreleased branch_10x were not executed.
- 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-27, read-only; the prior-report record, verdict new): the finder's scripted prior-report search plus three follow-up runs (identifier, symptom and mechanism phrasings, 51 queries each over GitHub issues/PRs, commits, downstream repositories, mailing lists, JIRA and the web; 42, 60, 159 and 176 distinct hits, none high), 55 manual GitHub GraphQL searches (34 on apache/lucene, 21 on Elasticsearch, OpenSearch, OpenSearch geospatial, Solr and Apache SIS), GitHub code search for the predicate's exact expression (100 copies in forks and downstream code, no fixed variant), the changed files of all 408 open apache/lucene PRs, review comments of 19 geo PRs, 13 JIRA JQL queries, 27 mailing-list queries plus all 32 CI failure mails of the TestXY*ShapeQueries classes read in full, git pickaxe on the predicate, and the intended-behaviour check's extra searches. Closest items, none a report of this bug: LUCENE-8634 (#9680, a 2019 aside by jpountz that triangles 'might change orientation through encoding', LatLon, before the CCW invariant, judged no problem), LUCENE-8973 (#10016, fixed 2019: XY relations must be computed in float space, fixed for XYRectangle2D only) and the same principle in #1017 (2022), LUCENE-9642 (#10681, made encodeTriangle produce CCW triangles in encoded space), LUCENE-8581/8583, LUCENE-9197, LUCENE-9226, LUCENE-8736 (the boundary-inclusion contract), LUCENE-9139/9470/9595/10514 (other XY or CONTAINS defects), the open refactoring PR #12036 (keeps the logic) and Elasticsearch #144684 (docs: INTERSECTS includes boundary points). Gaps: GitHub search does not index review comments repo-wide and was partly rate-limited (redone through GraphQL); list search returns capped results; Elasticsearch's copy of the predicate was found but not analysed for impact.
- Lucene commit tested
- main
a9779ef3c193(2026-10-26): this doc's gradle runs (stock and fixed, assertions on and off) and classpath runs; the hunt's runs of the same JUnit on all 52 releases from 8.5.0 to 10.5.2 (Maven Central jars) and on 8.4.1 core and sandbox (compile error: no XYShape point query). OpenJDK 25.0.3 on Linux aarch64.
- Finding record
lucene/core/src/java/org/apache/lucene/geo/Component2D.java:250-278 (pointInTriangle; javadoc 'uses the winding order method', the sign tests at :270-272)lucene/core/src/java/org/apache/lucene/document/ShapeField.java:139-144 (javadoc 'Triangles are encoded with CCW orientation'), :214-226 ('change orientation if CW' on the encoded ints), :365-366 (decodeTriangle asserts CCW in encoded space)lucene/core/src/java/org/apache/lucene/geo/XYEncodingUtils.java:50-63 (encode and decode via NumericUtils sortable-int); lucene/core/src/java/org/apache/lucene/document/XYShapeQuery.java:93-99 (leaf check decodes to floats, calls intersectsTriangle); lucene/core/src/java/org/apache/lucene/geo/Point2D.java:83-95 and :162 (intersectsTriangle and withinTriangle call pointInTriangle)lucene/core/src/java/org/apache/lucene/document/XYShape.java:236-246 (newPointQuery javadoc 'find all indexed shapes that comply the QueryRelation with the provided point')lucene/CHANGES.txt:6480-6484 (LUCENE-8736, boundary points included) and :5505-5506 (LUCENE-9642, CCW triangles in ShapeField)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/geo/Component2D.java:250-278 (pointInTriangle; the sign tests at :268-272)lucene/core/src/java/org/apache/lucene/document/ShapeField.java:81-93 (tessellator corner order), :139-144 ('Triangles are encoded with CCW orientation'), :157-182 (smallest x first), :214-228 ('change orientation if CW' on the encoded ints), :365-366 (decodeTriangle asserts CCW on the ints)lucene/core/src/java/org/apache/lucene/geo/XYEncodingUtils.java:50-52 (encode = floatToSortableInt), :60-63 (decode)lucene/core/src/java/org/apache/lucene/document/XYShapeQuery.java:93-99; lucene/core/src/java/org/apache/lucene/geo/Point2D.java:83-95, :162lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:163-164, :919-934; lucene/core/src/java/org/apache/lucene/geo/GeoEncodingUtils.java:58-65, :90-97, :121-147
- Who is affected
lucene/core/src/java/org/apache/lucene/document/XYShape.java:236-246 (newPointQuery)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/document/XYShape.java:71-72 (WARNING), :236-239 (newPointQuery javadoc)lucene/core/src/java/org/apache/lucene/geo/Component2D.java:27 (@lucene.internal), :250 (javadoc), :268-272lucene/core/src/java/org/apache/lucene/geo/GeoUtils.java:198-214 (orient; :203 'does not yet have the floating point tricks to be exact')lucene/core/src/test/org/apache/lucene/document/TestLatLonShape.java:701-711 (clockwise triangle, expects true)lucene/core/src/test/org/apache/lucene/document/BaseShapeEncodingTestCase.java:564-596; lucene/core/src/test/org/apache/lucene/document/BaseSpatialTestCase.java:545-590
- Suggested fix
lucene/core/src/java/org/apache/lucene/document/SpatialQuery.java:882, :998; lucene/core/src/java/org/apache/lucene/geo/Line2D.java:137, :245; lucene/core/src/java/org/apache/lucene/geo/Rectangle2D.java:116, :233; lucene/core/src/java/org/apache/lucene/geo/Circle2D.java:102, :211; lucene/core/src/java/org/apache/lucene/geo/Polygon2D.java:194, :336lucene/core/src/java/org/apache/lucene/document/ShapeField.java:214-228, :365-366
- TestXYShapePointQueryAtPolygonVertex.java (JUnit test):
repro/LFV-764/src/org/apache/lucene/document/TestXYShapePointQueryAtPolygonVertex.java(51 lines)
assets/LFV-764/bug.png(557 KB)
