All findings · Lucene finding · LFV-204 · shapefield-encodetriangle-cw-edge-flag-permutation

ShapeField.encodeTriangle moves an edge flag when it flips a clockwise triangle, so LatLonShape and XYShape CONTAINS queries return wrong hits

When ShapeField.encodeTriangle flips a clockwise polygon triangle, it copies the wrong edge's "on the polygon outline" flag, so LatLonShape and XYShape CONTAINS queries silently answer wrong: a box 99.9998% outside a polygon is "contained", a box well inside another is not. Affected since 8.8.0.

Summary

Priority
Medium, rank 37Since 8.8.0 a mislabeled edge flag on flipped triangles gives LatLonShape and XYShape CONTAINS queries false positives and false negatives that are stored in the index (a fix needs reindexing), fairly common for XYShape but rarer for LatLonShape and on a less used relation.
Impact
silent wrong results
Since
8.8.0 (LUCENE-9642, 2021; a regression)introduced 2021-01-04 · about 5 years
Affected
8.8.0 to 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
translation validation + Z3 (semi-formal)

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-03.

The finding in 30 seconds

Lucene stores a polygon as triangles and flags each triangle edge on the polygon's outline; CONTAINS treats crossing a flagged edge as leaving the shape. encodeTriangle makes clockwise triangles counter-clockwise by swapping vertices b and c, but takes the new first edge's flag from old edge b-c instead of c-a: one flag is lost, one duplicated.

Diagram of the bug: the XY polygon (6,0) (-1,7) (-8,8) (0,1) is stored as two triangles with each edge flagged as polygon outline or internal cut; the triangle (-1,7) (-8,8) (0,1) is clockwise in stored coordinates, and flipping it moves a flag, so the outline edge (-1,7)-(-8,8) is stored as internal; a CONTAINS query for a box that crosses that edge, half or even 99.9998% outside, returns a match.
A polygon, its tessellation triangles, and the edge whose outline flag is lost when a clockwise triangle is flipped, so a box crossing it is reported as contained. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index the 4-corner polygon (6,0) (-1,7) (-8,8) (0,1) with XYShape.createIndexableFields. Lucene stores it as 2 triangles and flags each triangle edge as polygon outline or internal cut; CONTAINS uses the flags to tell whether a query leaves the shape.
  2. Ask XYShape.newBoxQuery(..., QueryRelation.CONTAINS, ...) about the 0.5 x 0.5 box centred on the outline edge (-1,7)-(-8,8). You expect 0 hits: half the box is outside.
  3. You get 1 hit. Lucene stores XY coordinates as sortable float bits (0 as 0, 1 as 1,065,353,216, 8 as 1,090,519,040: order kept, distances not), where the triangle (-1,7) (-8,8) (0,1) is clockwise. Flipping it swaps two vertices, and the outline edge (-1,7)-(-8,8) gets the "internal" flag of another edge (ab = bc where ab = ca is right).
  4. The hole is large: the box stretched to 100,000 tall (99.9998% outside) still matches. It also fails the other way: polygon (3,3) (6,0) (8,5) (1,8) does not "contain" the box [3.25,3.75] x [3.75,4.25], at least 0.5 inside every edge, because its internal diagonal got the outline flag.
  5. No error: INTERSECTS still matches, the wrong flags are stored in the index, survive forceMerge(1) and pass CheckIndex, and a fixed Lucene keeps the wrong answers for them until the shapes are reindexed. LatLonShape is hit too, more rarely (thin triangles along densified straight edges).

Who is affected

Anyone who indexes polygons with LatLonShape or XYShape (default codec, createIndexableFields) and runs CONTAINS queries on them, directly or through a downstream geo_shape or shape contains query. XYShape hits it with small ordinary polygons; LatLonShape needs thin triangles, such as densified straight edges. Wrong answers persist until the shapes are reindexed.

Mechanism, terms and numbers899 words

LatLonShape and XYShape index a polygon as tessellation triangles and store, for each triangle edge, a flag that says whether the edge belongs to the polygon's outline. CONTAINS queries ("does the indexed shape fully contain my box, polygon, line or circle?") rely on those flags: crossing a flagged edge means the query leaves the shape, crossing only unflagged edges means it stays inside. ShapeField.encodeTriangle makes a clockwise triangle counter-clockwise by swapping vertices b and c, but then gives the new first edge the flag of the old edge b-c instead of the old edge c-a (ab = bc; at ShapeField.java:221; it should be ab = ca;). Whenever those two flags differ, a polygon edge is stored as internal (CONTAINS then says yes for areas that stick out through it, in one test a box 99.9998% outside) or an internal diagonal as outline (CONTAINS says no for boxes well inside). XYShape triangles can be clockwise in the stored coordinates even for small integer polygons, because the stored values are sortable float bits; LatLonShape triangles become clockwise when quantization flips a thin triangle. The wrong flags are written into the index, survive merges and pass CheckIndex. The line dates from LUCENE-9642, released in 8.8.0 (2021), which changed the swap from a-c to b-c but kept the flag assignment of the a-c swap; every release since is affected, and a one-line fix passes the whole :lucene:core test suite.

Terms

  • Tessellation triangle: LatLonShape and XYShape cut a polygon into triangles and index each one as a point field. The tessellator labels each triangle edge as part of the polygon's outline or as an internal cut (Tessellator.Triangle.isEdgefromPolygon, Tessellator.java:1724).
  • Edge flags: three booleans ab, bc, ca stored with each triangle (bits 3-5 of the packed value, ShapeField.java:275-277, read back at :367-369; only a triangle that collapses to a line or a point gets merged flags, :374-400). Each one "represent[s] if edge ab|bc|ca belongs to original shape" (ShapeField.java:434-441).
  • CONTAINS: QueryRelation.CONTAINS matches a document whose shape fully contains the query geometry. Per triangle, crossing a flagged edge gives NOTWITHIN and crossing only unflagged edges gives CANDIDATE (Component2D.java:101-115; LatLon boxes: SpatialQuery.java:947-1001; XY boxes: Rectangle2D.java:180; a circle query gives NOTWITHIN on a flagged edge and CANDIDATE when its centre lies in the triangle, Circle2D.java:196-214); a document matches if a triangle is CANDIDATE and none is NOTWITHIN (SpatialQuery.java:591-645).

The bug

  1. encodeTriangle first rotates the triangle so that the minimum-x vertex comes first (ShapeField.java:157-212). That shifts the flags cyclically with their edges, which is correct.
  2. If the triangle is then clockwise (GeoUtils.orient(...) == -1, :215), it swaps vertices b and c (:216-227). The new vertex order is a, old c, old b, so the new edge a-b is the old edge c-a, the new b-c is the old b-c reversed, and the new c-a is the old a-b.
  3. The code sets ab = bc; (:221, under the comment "ab becomes bc"), leaves bc alone and sets ca = old ab. The first assignment should be ab = ca;. So the old c-a flag is lost and the old b-c flag is stored on two edges, whenever the two differ. In a grid check, 912 of 1,824 clockwise inputs and 0 of 1,824 counter-clockwise inputs decode with a wrong flag.
  4. An outline edge that loses its flag fails open: a query crossing it is treated as staying inside (CONTAINS false positive). An internal diagonal that gains the flag fails closed (CONTAINS false negative).

When a triangle is clockwise at encode time

  • XYShape: easily, even for small integer polygons (no rate on real data was measured). Coordinates are stored as NumericUtils.floatToSortableInt (XYEncodingUtils.java:50-52), which keeps the order of values but not their distances: 0 is stored as 0, 1 as 1,065,353,216, 3 as 1,077,936,128, 6 as 1,086,324,736 and 8 as 1,090,519,040. Both triangles of the polygon (6,0) (-1,7) (-8,8) (0,1), and the triangle (1,8) (3,3) (6,0) of the polygon (3,3) (6,0) (8,5) (1,8), are counter-clockwise in float coordinates and clockwise in the stored ones. In the first polygon the outline edge (-1,7)-(-8,8) is stored as internal; in the second the diagonal (6,0)-(1,8) is stored as outline.
  • LatLonShape: when quantization (4.19e-8 degrees latitude, 8.38e-8 longitude, LatLonShape.java:72-74) flips a thin, nearly collinear triangle, for example along a densified straight edge.

The numbers

One document per index, default IndexWriterConfig (LuceneTestCase picks the JUnit's codec from the seed; a framework-free main() with the default codec gives the same encoding and XY results). Encode/decode of the clockwise triangle (0,0) (0,10) (10,0) with only edge (0,10)-(10,0) flagged gives (0,0) (10,0) (0,10) with flags [true,true,false] instead of [false,true,false]. XY polygon (6,0) (-1,7) (-8,8) (0,1): CONTAINS for the box x [-4.75,-4.25], y [7.25,7.75] is 1 (expected 0), and stays 1 when the box is 100,000 tall (99.9998% outside), as a polygon or line query and for circles of radius 0.1 and 0.2 on the edge. XY polygon (3,3) (6,0) (8,5) (1,8): CONTAINS for [3.25,3.75] x [3.75,4.25] is 0 (expected 1). LatLon polygon with a densified north-east edge: CONTAINS is 1 for a box whose north-east corner is about 0.007 degrees outside, and still 1 with the box extended 10 degrees (about 1,100 km) north. INTERSECTS is 1 in every case.

Exposure and likelihood308 words
  • Who. Applications that index polygons with LatLonShape.createIndexableFields or XYShape.createIndexableFields (the default indexing path, default codec) and query them with QueryRelation.CONTAINS and a box, polygon, line or circle. By reading their default branches (not tested), Elasticsearch and OpenSearch pass a geo_shape query with relation contains to LatLonShape.newGeometryQuery, and Elasticsearch passes a shape contains query to XYShape.newGeometryQuery.
  • Default or opt-in. The indexing path is the default; CONTAINS is an opt-in relation, used less than INTERSECTS.
  • Not affected. INTERSECTS, WITHIN and DISJOINT do not read the flags; point CONTAINS ("which shapes contain this point") ignores them (in the security probe CONTAINS equalled INTERSECTS in all 7,600 point checks, and none of the 6,000 XY checks differed from the exact answer); Lucene's doc-values shape queries reject CONTAINS; points and lines are encoded with all flags set.
  • How often. Unmeasured on real data. Edge-biased searches (boxes centred on edges and diagonals, so not population rates): for XYShape, 1 of 298 random convex quadrilaterals with integer vertices in [-8,8] and 9 of 100 with mixed-magnitude coordinates gave a wrong CONTAINS answer (6 false positives and 37 false negatives in 9,471 box checks); for LatLonShape, the finder reported 3,455 of 19,859 generated star polygons (half with one densified edge) with at least one wrong flag (raw output not kept).
  • What the damage looks like. Silent (no exception, INTERSECTS still matches); large (a box 99.9998% outside matched); both directions (false positives and false negatives, so MUST_NOT CONTAINS exclusions can fail open); and persistent: the wrong flags are in the index, survive forceMerge(1), pass CheckIndex, and a fixed Lucene reading a 10.5.2 index still gives the wrong answers for the old documents, also after forceMerge(1). Current readers and merges do not repair them (executed for two XY polygons written by 10.5.2); reindexing the shapes from their source geometry is the practical repair.

Minimal repro

The test shows that ShapeField.encodeTriangle, when it flips a clockwise triangle to counter-clockwise, moves the "edge belongs to the shape" flag of the old edge b-c onto the new first edge (the old edge c-a), so after decodeTriangle the wrong geometric edges are flagged (index-free; this is the root cause of the LatLonShape/XYShape CONTAINS wrong hits).

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

import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestEncodeTriangleClockwiseEdgeFlag extends LuceneTestCase {

  public void testClockwiseTriangleKeepsEdgeFlag() {
    byte[] bytes = new byte[7 * Integer.BYTES];
    // clockwise (0,0) (0,10) (10,0): expected only (0,10)-(10,0) flagged, actual also (0,0)-(10,0)
    ShapeField.encodeTriangle(bytes, 0, 0, false, 10, 0, true, 0, 10, false);
    ShapeField.DecodedTriangle t = new ShapeField.DecodedTriangle();
    ShapeField.decodeTriangle(bytes, t);
    List<String> flagged = new ArrayList<>();
    if (t.ab) flagged.add(edge(t.aX, t.aY, t.bX, t.bY));
    if (t.bc) flagged.add(edge(t.bX, t.bY, t.cX, t.cY));
    if (t.ca) flagged.add(edge(t.cX, t.cY, t.aX, t.aY));
    assertEquals("flagged edges of " + t, List.of(edge(0, 10, 10, 0)), flagged);
  }

  private static String edge(int x1, int y1, int x2, int y2) {
    boolean ordered = x1 < x2 || (x1 == x2 && y1 <= y2);
    return ordered
        ? "(" + x1 + "," + y1 + ")-(" + x2 + "," + y2 + ")"
        : "(" + x2 + "," + y2 + ")-(" + x1 + "," + y1 + ")";
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-204/, 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.TestEncodeTriangleClockwiseEdgeFlag
Expected vs actual
Expected for the clockwise triangle (0,0) (0,10) (10,0) with only edge (0,10)-(10,0) flagged: only that edge flagged after decode; actual: expected:<[(0,10)-(10,0)]> but was:<[(0,0)-(10,0), (0,10)-(10,0)]>, with -ea and -da.

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

More files, environment, full output44 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: flagged edges of 0, 0 10, 0 0, 10 [true,true,false] expected:<[(0,10)-(10,0)]> but was:<[(0,0)-(10,0), (0,10)-(10,0)]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.apache.lucene.document.TestEncodeTriangleClockwiseEdgeFlag.testClockwiseTriangleKeepsEdgeFlag(TestEncodeTriangleClockwiseEdgeFlag.java:35)

Why it looks unintended

  • Each DecodedTriangle flag "represent[s] if edge ab|bc|ca belongs to original shape" (ShapeField.java:434-441).
  • Lucene's verifyEncodingPermutations asserts all six vertex orders encode alike, with one flag pattern; with all 8, 18 tests fail on stock, 0 with the fix.
  • The wrong assignment is a carry-over from the a-c swap used before 8.8.0, where it (ab = bcFromShape) was right.
The full argument347 words
  • The contracts. DecodedTriangle.ab/bc/ca (ShapeField.java:434-441): "represent if edge ab|bc|ca belongs to original shape". The encodeTriangle javadoc (:139-144): "Triangles are encoded with CCW orientation and might be rotated"; re-orienting and rotating change the vertex order, not the edges. Component2D.WithinRelation (Component2D.java:101-115) defines CONTAINS through those per-edge flags. CHANGES 8.3.0 (LUCENE-8620): "Tessellator labels the edges of the generated triangles whether they belong to the original polygon. This information is added to the triangle encoding."
  • Lucene's own test states the invariant. BaseShapeEncodingTestCase.verifyEncodingPermutations (:314-349) asserts [a,b,c] == [c,a,b] == [b,c,a] == [c,b,a] == [b,a,c] == [a,c,b], passing each flag with its own edge, but only with the single false on edge c-a (:321), where the swap happens to be harmless. With the false moved to edge a-b or b-c, the invariant fails 6 of 15 checks on stock and 0 of 15 with the fix; run over all 8 flag patterns, 9 tests in each of TestLatLonShapeEncoding and TestXYShapeEncoding fail on stock and pass with the fix.
  • Why the tests missed it. The other encoding tests use all-true flags; the randomized CONTAINS oracles compute the expected answer from the same indexed, decoded triangles, so they share the bug. The unmodified upstream geo and shape suites pass on stock (27 classes); in a 45-class run that adds the new tests, the only failures are the 22 new assertions.
  • History. In 8.3.0 to 8.7.0 the clockwise branch swapped a and c with ab = bcFromShape; bc = abFromShape; ca = caFromShape (8.7.0 ShapeField.java:122-132), correct for that swap. LUCENE-9642 (8.8.0) changed the swap to b-c to fix an orientation problem with degenerate triangles, but kept the a-c flag assignment (ab = bcFromShape became ab = bc) and added the comment "ab becomes bc" that describes it; 8.7.0 has no such comment. Its issue and review never mention edge flags; the test it added encodes all-true flags.
  • Precedent. #12020 / #12022, a flag error in decodeTriangle with the same CONTAINS false-positive symptom, was accepted and fixed as a bug (9.5.0, 8.11.3).
The devil's-advocate check294 words

The strongest case: "the comment ab becomes bc says it is meant; CONTAINS is approximate anyway because vertices are quantized; and CONTAINS is defined through the flags, so whatever the flags say is the answer."

Why it fails: the comment does not show a design decision. LUCENE-9642 wrote it in the same commit as the bug, and it restates the flag assignment that was right for the old a-c swap; the current code never uses the old ca flag at all and stores the bc flag on two edges, which no per-edge design can mean. Quantization does not explain it: XY encoding is exact for floats; the false-positive box sticks out 0.21 to 0.28 units past the outline edge and the false-negative box stays at least 0.51 units inside every edge; the LatLon corner is about 0.007 degrees out (five orders of magnitude above the quantum), and the false positive reaches 100,000 units (XY) and 10 degrees (LatLon) outside. The flags have their own contract, which the encoder breaks without any query. The inputs are legal: simple, counter-clockwise polygons (checked with exact rational arithmetic) that index with checkSelfIntersections=true through the public API.

What a maintainer could still raise: the public CONTAINS javadoc is thin (QueryRelation.CONTAINS is "used for CONTAINS Queries", ShapeField.java:126), and an upstream test leaves a dateline CONTAINS query commented out as "Not supported due to encoding" (TestLatLonShape.java:388); false negatives could be called conservative (the strongest of these, rated moderate by the cross-model fact check); and no report was found in almost six years. None of these covers wrong flags on exact XY coordinates: the per-edge flag contract is broken before any query runs, and the rejected box lies at least 0.51 units inside. Verdict: not intended, a defect.

Suggested fix

Recommended ab = ca in the clockwise branch, plus a stronger encoding test (validated)

In the clockwise branch of ShapeField.encodeTriangle, write ab = ca; instead of ab = bc; and correct the two comments; make BaseShapeEncodingTestCase.verifyEncodingPermutations try all 8 flag patterns. Validated: the repro and 393 shape and geo tests pass, and so do all 8,869 :lucene:core tests. Shapes already indexed keep their wrong flags until reindexed.

       final boolean tempBool = ab;
-      // aX and aY do not change, ab becomes bc
-      ab = bc;
+      // aX and aY do not change, the new ab edge (a-c) is the old ca edge
+      ab = ca;
       bX = cX;
       bY = cY;
-      // bc does not change, ca becomes ab
+      // bc does not change (same edge, reversed), the new ca edge (b-a) is the old ab edge
       cX = tempX;
       cY = tempY;
       ca = tempBool;
All options and trade-offs4 options ยท 625 words

One fix was built and validated; the other options are alternatives or explain why no read-side repair exists.

ab = ca in the clockwise branch, plus a stronger encoding test (validated) (recommended)

In ShapeField.encodeTriangle (ShapeField.java:214-228), the new edge a-b after the b-c swap is the old edge c-a, so it takes the old ca flag. The test part turns verifyEncodingPermutations into a loop over all 8 flag patterns, each checked by a private overload that passes every flag with its own edge in all six vertex orders (+29/-12 lines in BaseShapeEncodingTestCase.java; the public signature and its 9 callers are unchanged). A second patch file also adds the repro as a new test class.

       final boolean tempBool = ab;
-      // aX and aY do not change, ab becomes bc
-      ab = bc;
+      // aX and aY do not change, the new ab edge (a-c) is the old ca edge
+      ab = ca;
       bX = cX;
       bY = cY;
-      // bc does not change, ca becomes ab
+      // bc does not change (same edge, reversed), the new ca edge (b-a) is the old ab edge
       cX = tempX;
       cY = tempY;
       ca = tempBool;

Trade-offs

  • No query-time cost; the encoded layout and the index format are unchanged.
  • It does not repair existing indexes: shapes indexed by 8.8.0 or later keep their wrong flags (executed for two XY polygons: a 10.5.2 index read by the fixed jar, also after forceMerge(1)). The CHANGES entry should tell CONTAINS users to reindex.
  • No benchmark was run (one assignment changes).

What the checks validated

  • Scratch worktree at ea57360912ed, JDK 25, gradle: on stock, the repro fails 4 of 4 (assertions on and off) and the strengthened permutation test fails 18 tests; the 45 shape and geo classes (393 tests) fail exactly those 22.
  • With the fix: the same 393 tests pass (seed DEADBEEF with assertions on, seed CAFEBABE with assertions off); the whole :lucene:core suite passes (8,869 tests as gradle counts them, 0 failures, 322 skipped; one seed); :lucene:core:check -x test passes, and tidy leaves the fix unchanged.
  • Through the repro harness: the finder's test, an independent reproducer's 6-test JUnit, the 5-test security probe and the triangle dump pass; 27 upstream geo and shape suites pass; a Z3 check of a hand-written model of the rotate/flip step shows that the fixed step keeps every flag on its own edge for all distinct-vertex inputs, whatever the orientation test returns (stock: a counterexample).
  • All runs used one JDK (OpenJDK 25.0.3) and one platform (Linux aarch64).

Rewrite the branch as an explicit edge permutation (untested)

Name the three old flags first (oldAB, oldBC, oldCA) and assign ab = oldCA; bc = oldBC; ca = oldAB; next to the vertex swap, so the mapping is visible.

Trade-offs

Same behaviour as the recommended fix, a slightly larger diff. A straight revert to the 8.7.0 order (re-orient, then rotate) would bring back the LUCENE-9642 problem: rotating after re-orienting could undo the orientation of a degenerate triangle. Any other order must keep the stored triangle counter-clockwise.

Repair old segments at read time (not possible per triangle)

The stored bits do not say whether a triangle was flipped, and the lost c-a flag is not stored anywhere.

Trade-offs

decodeTriangle and the queries cannot correct a stored triangle on its own. Rebuilding the outline from all triangles of a document was not explored; reindexing from the source geometry is the practical repair.

Interim advice for applications (no Lucene change)

Do not base access or compliance decisions on shape CONTAINS alone; re-check containment exactly against the original geometry for the returned candidates.

Trade-offs

Extra work per hit, and it catches only false positives; false negatives stay missing.

Downstream note

Whoever chooses the query geometry can pick an area that crosses only the mislabeled edge, and the yes is stable, so it can be reused. That matters where an application decides access, licensing or geofence compliance with CONTAINS. Out of scope of SECURITY.md (lines 28, 50): no documented untrusted-input guarantee, no parser or limit involved; no exploit known.

How it was found

Semi-formal translation validation: the finder ran the stock encodeTriangle and decodeTriangle over every vertex triple of a small grid with all 8 flag patterns (125,000 checks; 25,776 wrong flags, all on clockwise inputs), read the cause off the code and checked the one-line fix on a Z3 model of the rotate/flip step. Search harnesses then found the CONTAINS failures.

The method in full281 words

Method: bounded-exhaustive translation validation of the stock jar's ShapeField.encodeTriangle and decodeTriangle against exact per-edge flag semantics (the hunt's "semi-formal" tier), a hunt unit (geo tessellator and shapes), 2026-10-25, main ea57360912ed.

  • Small scope: every ordered vertex triple over {-2..2}^2 with all 8 flag patterns, 125,000 checks: 25,776 flag mismatches, all on clockwise inputs (exactly half of the clockwise inputs). Extended scope: an 11-value set including the int extremes, 14,172,488 checks: 3,394,812 flag mismatches, all clockwise. The per-edge flag oracle applies to the triples with three distinct vertices. The extended run also counted 16,680 inputs at the int extremes whose decoded triangle has the wrong orientation by exact integer arithmetic, a separate effect, most likely of GeoUtils.orient computing in doubles (GeoUtils.java:203); it is not this bug and was not analysed further.
  • The root cause was then read from the code. An SMT check (Z3) of the flag invariant on a hand-written model of the rotate/flip step (a proof about the model, not the compiled Java), with the orientation result left free, is satisfiable (a counterexample) for the stock code and unsatisfiable for the one-line fix.
  • Search harnesses over XY and LatLon polygons found the end-to-end CONTAINS false positives and false negatives and minimised the LatLon case; the finder then wrote the JUnit test.
  • Before filing it as a candidate, a reviewer from another model family (OpenAI GPT-5.6) tried to refute it and reproduced it with its own XY tests. Then an independent reproducer, a works-as-designed review, an intended-behaviour devil's advocate and an adjudication checked it; a follow-up traced its history over 35 runs, searched for prior reports and probed the security reach; this doc's brief validated the fix with gradle.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26): GitHub issues, PRs and code of Lucene, Elasticsearch, OpenSearch and Solr, five mailing lists, the JIRA archive, all 413 open Lucene PRs, the advisory database and the web. Closest: #12020/#12022 and #11973/#11974, earlier flag bugs at other code sites.

Searches5 searches

Verdict new. No issue, PR, commit, list thread, JIRA entry, downstream report or advisory describes the wrong first-edge flag, the "ab becomes bc" comment, or CONTAINS errors from flipped triangles. Nothing is fixed upstream: no open PR touches ShapeField.java, and upstream main, branch_10x and branch_10_6 still read ab = bc.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr), downstream repositories2026-10-26three scripted prior-report searches (identifiers and code text; symptoms; history) and about 65 GraphQL searches, including the exact strings "swap b with c", "ab becomes bc" and "change orientation if CW"; elastic/elasticsearch, opensearch-project/OpenSearch, opensearch-project/geospatial, apache/solrno report; 0 hits for the exact strings; the closest items are listed below
Related items (read)2026-10-26the threads of the closest hits#12020/#12022 (decodeTriangle kept the wrong flag for flat polygons; same symptom, different code, fixed in 9.5.0 and 8.11.3); #11973/#11974 (ShapeDocValues serialization flag bug); #10681/lucene-solr#2154 (the introducing change, no flag discussion); #9666 (LUCENE-8620, added the flags and CONTAINS)
Open PRs2026-10-26the changed-file lists of all 413 open apache/lucene PRsnone touches ShapeField.java; the geo-touching open PRs do not change the encoder
Mailing lists and JIRA archive2026-10-26dev@, java-user@, issues@, commits@ (60 queries) and builds@ by hand; 12 JIRA-archive queriesno report; builds@ CONTAINS test failures all predate the regression (2020)
Advisories and web2026-10-26GitHub advisory database for lucene-core and lucene-spatial-extras; 12 web searches0 advisories; nothing relevant

History

A regression from LUCENE-9642 (#10681, lucene-solr#2154, merged 2021-01-04, released in 8.8.0). 8.3.0 to 8.7.0 are correct; every release tested from 8.8.0 to 10.5.2, and main, fails 4 of 4. Not fixed upstream.

Versions tested10 rows

Introduced by LUCENE-9642 "Rotate triangle points before checking triangle orientation (#2154)" (iverase): ce667fdd2f94 on the main line (first released in 9.0.0) and its branch_8x backport fd2294f585db, shipped in 8.8.0 (merged 2021-01-04). It fixed a real problem (for a degenerate triangle, rotating after re-orienting could undo the orientation and trip a decode assertion) by moving the orientation check after the rotation and swapping b and c instead of a and c, but kept the flag assignment of the a-c swap (ab = bcFromShape became ab = bc), which is right only for that swap, and added a comment describing it, "ab becomes bc" (8.7.0 has no such comment). The edge flags date from 8.3.0 (LUCENE-8620: main-line 62001b9b965, first released in 9.0.0, and its branch_8x backport 252421bb77c, shipped in 8.3.0) and CONTAINS from 8.4.0; both were mapped correctly through 8.7.0.

Commit isolation: on 8.7.0 jars, replacing only ShapeField by the 8.8.0 version (whose diff is exactly fd2294f585db) makes all 4 tests fail; on 8.8.0 jars the one-line fix makes all 4 pass. All 35 release tags from 8.8.0 to 10.5.2 in the history clone have the bug (8.8.2 to 8.11.4 are not tags in the clone; the jar runs cover them); 10.6.0 (released 2026-10-29) was not executed.

Versions and branches executed, with results
Version or commitVerdictResult
8.2.0 and earlier no edge flagsthe encoding probe does not compile (no flag parameters)
8.3.0 flags addedencoding-only probe: 0 of 8 flag patterns wrong
8.4.0 to 8.7.0 last correct: 8.7.08.4.0 and 8.5.0: a framework-free main() (the encoding check and the two XY CONTAINS cases) passes; 8.6.0 (-ea) and 8.7.0 (-ea, -da): the 4-test JUnit passes
8.7.0 + 8.8.0 ShapeField only commit isolation4 of 4 fail
8.8.0 first affected4 of 4 fail with -ea and -da; with the one-line fix, 4 of 4 pass
8.8.1 to 8.11.4 (10 releases) affected4 of 4 fail each
9.0.0, 9.1.0, 9.12.3, 10.0.0 affected4 of 4 fail (9.12.3 with -ea and -da); the other 9.x and 10.x tags have the same code (read)
10.5.2 latest 10.5.x release4 of 4 fail with -ea and -da
main ea57360912ed affected4 of 4 fail with -ea and -da (harness and gradle)
main + the fix fixthe repro, 393 shape and geo tests and all 8,869 :lucene:core tests pass

Independent checks

Real, high confidence, from an independent reproducer (own test, own one-line fix), a cross-model reviewer (OpenAI GPT-5.6), an intended-behaviour devil's advocate and an adjudication. Every executed check fails on stock and passes with the fix.

Checks and verdicts439 words

All checks ran on main ea57360912ed unless stated.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateTranslation validation (125,000 and 14,172,488 checks), Z3 proof of the fix, JUnit fails 4 of 4 with -ea and -da and passes with the fix; independent of the four sibling bugs (fails with their fixes, passes with all five).
Refute review (OpenAI GPT-5.6, executed) real, high confidenceIts own public-API XY tests fail on main with -ea and -da; root cause agreed; the fix fixes its tests and 42 relevant upstream tests; intended-behaviour argument weak.
Independent reproducer realOwn 6-test JUnit with different inputs fails 6 of 6 on main (-ea, -da), 10.5.2 and 9.12.3; its framework-free variant fails on 8.8.0 and passes on 8.7.0; its own one-line fix fixes both test sets.
Works-as-designed review (OpenAI GPT-5.6, reading only) real, high confidenceCould not execute code; agreed on the root cause and rated the intended argument weak. A separate run re-ran the repro: fails 4 of 4 with -ea and -da, passes with the fix.
Intended-behaviour devil's advocate real, high confidenceReplay fails 4 of 4; the upstream permutation invariant fails 6 of 15 on stock and 0 of 15 with the fix; exact-rational ground truth and checkSelfIntersections=true for all three polygons.
Adjudication confirmed, high confidenceRe-ran the finder's and the independent reproduction's repros (fail on stock, pass with the fix) and checked the root cause and the history by hand.
Follow-up: re-runs, history, prior reports, security probe 35 history runs; new; out of scope of SECURITY.mdRe-ran both repros on main (-ea, -da); 35 history runs with commit isolation; prior-report search (new); a 5-test security probe on main and 10.5.2 (3 fail-open tests fail, 2 limit tests pass, all pass with the fix).
Brief: gradle validation of the fix fix validatedScratch worktree at ea57360912ed: stock 22 failures (repro + strengthened test) in 393 shape and geo tests; fix 0; full :lucene:core 8,869 tests 0 failures; core static checks pass; an index written by 10.5.2 stays wrong under the fixed jar.
Fact checks of this page and its diagram bug confirmed; text correctedA fact check re-ran the repro (harness and gradle, assertions on and off), the fix, the strengthened upstream test and its own encode/decode grid and LatLon dump; a second came from an independent reviewer from another model family (OpenAI GPT-5.6) with read-only file access; a diagram check re-ran the diagram's probe. All confirmed the bug, the fix and the history; their corrections of distances, the comment's origin, scope and counts are applied.

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

Finding record

LFV-204, snapshot of 2026-11-03

Registry title
ShapeField.encodeTriangle puts the wrong edge flag on the first edge when it flips a clockwise triangle, so LatLonShape and XYShape CONTAINS queries return wrong hits
Area
document / geo shape fields (ShapeField triangle encoding; LatLonShape and XYShape CONTAINS queries)
Priority
Medium, rank 37: Since 8.8.0 a mislabeled edge flag on flipped triangles gives LatLonShape and XYShape CONTAINS queries false positives and false negatives that are stored in the index (a fix needs reindexing), fairly common for XYShape but rarer for LatLonShape and on a less used relation.
Impact class
silent wrong results
Impact note
Hits applications that index polygons with LatLonShape or XYShape (default codec, default createIndexableFields) and query them with QueryRelation.CONTAINS (box, polygon, line or circle query geometry; downstream geo_shape/shape 'contains' queries map onto these queries [read]) whenever a tessellation triangle is clockwise in encoded space and its rotated bc and ca flags differ: fairly common for XYShape, whose sortable-float encoding is order-preserving but not linear, rarer for LatLonShape (near-collinear vertices that flip under quantization, for example densified straight edges); no scale is needed, false positives and false negatives both occur, the wrong flags are stored in the index and survive merges (so a fix needs reindexing of affected shapes), and INTERSECTS, WITHIN, DISJOINT and point CONTAINS are not affected; released since 8.8.0.
Summary
You index a polygon with LatLonShape or XYShape and run a CONTAINS query that asks whether the polygon fully contains a box (or another query shape); you expect a match exactly when the box lies inside the polygon. When ShapeField.encodeTriangle turns one of the polygon's clockwise tessellation triangles counter-clockwise, it swaps two vertices but gives the new first edge the 'belongs to the polygon boundary' flag of the wrong old edge, so a real boundary edge can lose its flag and an internal diagonal can gain one. CONTAINS then silently says yes for boxes that stick out of the polygon (in one executed case a box 99.9998% outside it) and no for boxes well inside it.
Introduced
ce667fdd2f9453f34c8dc393600565c425021fd1 (main line, first released in 9.0.0) and its branch_8x backport fd2294f585db65c6bb093ebde6a51ddd6cb986a5 (shipped in 8.8.0), the same patch: LUCENE-9642 'Rotate triangle points before checking triangle orientation (#2154)' by iverase (PR apache/lucene-solr#2154, issue #10681) [read: git log -L on the clockwise branch lists only ce667fdd2f9 (plus the file's creation for the comment line), and pickaxe searches for 'ab = bc;', 'abFromShape' and 'ab becomes bc' find only 62001b9b965 and ce667fdd2f9; exec: on 8.7.0 jars, replacing only ShapeField with the 8.8.0 version (whose 8.7.0-to-8.8.0 diff is exactly fd2294f585db) makes all 4 repro tests fail, and on 8.8.0 jars the one-line fix makes all 4 pass]; the commit replaced the correct a<->c swap (ab = old bc, bc = old ab, ca = old ca, from LUCENE-8620 #771, 62001b9b965, released in 8.3.0) with a b<->c swap but kept 'ab = bc' and the comment 'ab becomes bc' (2021-01-04 (LUCENE-9642 / lucene-solr#2154 merged; PR opened 2020-12-17))
First affected version
8.8.0 [exec] (release tag 2021-01-29; on the main line 9.0.0, tag 2021-12-06); 8.3.0 to 8.7.0 map the flags correctly [exec: 8.3.0 encoding-only probe, 8.4.0, 8.5.0, 8.6.0 and 8.7.0 pass; read at all 12 tags]; 8.2.0 and earlier have no edge flags (the encoding probe does not compile on 8.2.0) [exec]
Affected versions
8.8.0 to 8.11.4, 9.0.0 to 9.12.3, 10.0.0 to 10.5.2, 10.6.0 (by reading) and main ea57360912ed. [exec] the canonical repro (or its import-only legacy copy) fails 4 of 4 on all 12 8.x releases from 8.8.0 to 8.11.4, on 9.0.0, 9.1.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da) (follow-up history runs); the independent reproduction's own JUnit fails 6 of 6 on main (-ea, -da), 10.5.2 and 9.12.3, and its framework-free variant fails on 8.8.0 and passes on 8.7.0; the security probe fails the same way on main and 10.5.2. [read] every release tag in the history clone from 8.8.0 to 10.5.2 (35 tags) has the b<->c swap with 'ab = bc'; 8.8.2 to 8.11.4 are not in the clone and are covered by execution only. Not affected: 8.3.0 to 8.7.0 [exec]. Not executed: 10.6.0 (released 2026-10-29); upstream main, branch_10x and branch_10_6 still read 'ab = bc' [read, GitHub API, prior-report record].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, follow-up prior-report record verdict new: three scripted prior-report searches (identifiers and code text; symptoms; history) with together 35 GitHub issue/PR, 5 commit, 4 downstream-repository, 60 mailing-list (dev@, java-user@, issues@, commits@) and 12 JIRA-archive queries plus web, 60, 247 and 51 distinct hits; about 65 manual GitHub searches in apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, opensearch-project/geospatial and apache/solr; a builds@ list search; the changed-file lists of all 413 open apache/lucene PRs; the GitHub advisory database; 12 web searches; the local git history); no issue, PR, commit, list thread, JIRA entry, downstream report or advisory describes the wrong first-edge flag in encodeTriangle's clockwise branch, its 'ab becomes bc' comment, or CONTAINS errors from re-oriented triangles; not fixed upstream: no open PR touches ShapeField.java, and upstream main, branch_10x and branch_10_6 still read 'ab = bc'; related, not this bug: #10681 / lucene-solr#2154 (LUCENE-9642, the introducing change; its issue and two-comment review never mention edge flags), #12020 / #12022 (flat polygons: decodeTriangle kept the wrong flag when collapsing a degenerate triangle; same CONTAINS false-positive symptom, different code and fix, released in 9.5.0 and 8.11.3; the repro fails on main, which contains it), #11973 / #11974 (a ShapeDocValues serialization flag bug, different site), #9666 (LUCENE-8620, which added the flags and CONTAINS); gaps: GitHub search is token-based and GitHub's search rate limit was saturated (two tool runs used reduced GitHub budgets, compensated by GraphQL searches), JIRA and list coverage is through the archives' own search, private security reports cannot be searched
Lucene commit tested
main ea57360912ed (2026-10-24) through the repro harness (assertions on and off) and gradle; the Maven Central jars of 8.2.0 to 8.11.4 (selected releases), 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2; the introducing commit isolated on the 8.7.0 and 8.8.0 jars; the fix in a scratch worktree at ea57360912ed (repro, 393 shape and geo tests, the full :lucene:core suite and its static checks).

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/document/ShapeField.java:139-144 (encodeTriangle javadoc: CCW orientation, may be rotated), :214-228 (clockwise branch: 'swap b with c', ':220 ab becomes bc', :221 'ab = bc;'), :434-441 (DecodedTriangle ab/bc/ca: 'represent if edge ... belongs to original shape')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/geo/Component2D.java:101-115 (WithinRelation CANDIDATE / NOTWITHIN defined through edges that do or do not belong to the original shape); lucene/core/src/java/org/apache/lucene/document/SpatialQuery.java:591-645, 947-1001 (CONTAINS per-document decision and EncodedRectangle.withinTriangle)
The finding in 30 seconds
  • a Lucene main checkout @ ea57360912ed lucene/core/src/java/org/apache/lucene/document/ShapeField.java:139-144, 157-228, 275-277, 367-369, 434-441
  • a Lucene main checkout @ ea57360912ed lucene/core/src/java/org/apache/lucene/geo/Component2D.java:101-115; document/SpatialQuery.java:591-645, 947-1001
  • a Lucene main checkout @ ea57360912ed lucene/core/src/java/org/apache/lucene/geo/XYEncodingUtils.java:50-52; geo/Tessellator.java:1724; document/LatLonShape.java:72-74
  • a Lucene main checkout @ ea57360912ed lucene/core/src/java/org/apache/lucene/geo/Rectangle2D.java:180-236, Circle2D.java:172-215; document/ShapeField.java:374-400 (resolveTriangleType)
Who is affected
  • a Lucene main checkout @ ea57360912ed lucene/core/src/java/org/apache/lucene/document/LatLonShapeQuery.java:165-208, XYShapeQuery.java:147-191, LatLonShapeBoundingBoxQuery.java:160-205 (among queries only contains() uses the flags; ShapeDocValues.java:550-558 reads them to serialize doc values); geo/Point2D.java:148-166; document/BaseShapeDocValuesQuery.java:47; document/ShapeField.java:66-78
Why it looks unintended
  • a Lucene main checkout @ ea57360912ed lucene/core/src/java/org/apache/lucene/document/ShapeField.java:139-144, 214-228, 434-441; geo/Component2D.java:101-115; lucene/CHANGES.txt:6203-6204
  • a Lucene main checkout @ ea57360912ed lucene/core/src/test/org/apache/lucene/document/BaseShapeEncodingTestCase.java:314-349
  • a Lucene main checkout @ ea57360912ed lucene/core/src/java/org/apache/lucene/document/ShapeField.java:126; lucene/core/src/test/org/apache/lucene/document/TestLatLonShape.java:388

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

Repro files

About the diagrams

The PNG is the summary card. Every value on screen was printed by an executed probe on the main jars (public API) or comes from the repro or the code; the diagram notes list each part with its sources, and a diagram check re-ran the probe.

Built 2026-11-03 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.