All findings · Lucene finding · LFV-705 · packedquad-geo-plus180-point-index-query-cell-mismatch

PackedQuadPrefixTree in a geo context stores a point at longitude +180 in the -180 edge cell but looks it up in the +180 edge cell, so a query for that same point misses the document

With the opt-in PackedQuadPrefixTree in a geo context, a document at longitude +180 is not found by a query for the same point: Pt(180, 10) gets 0 hits from RPT Intersects and Contains (default settings) and from CompositeSpatialStrategy, 1 at -180. Affected since 8.3.0; a small fix needs no reindexing.

Summary

Priority
Low, rank 422The experimental PackedQuadPrefixTree silently misses a point at exactly longitude +180, a boundary-value point miss with the same profile as LFV-316 and LFV-371.
Impact
silent wrong results
Since
8.3.0 (LUCENE-8755, 2019)introduced 2019-08-20 · about 7 years
Affected
8.3.0 to 10.5.2 and main
Status
documented
Found by
bounded differential check against exact geometry

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

A prefix tree files each indexed shape under grid cells, and a query looks it up by the same cells. Here the two sides pick different cells for one point: indexing files (180, 10) at the west edge of the map, the point query looks at the east edge. Both edges are longitude 180, but they are different cells.

Diagram of the bug: the geo world split into quad cells, with the point (180, 10) on the right edge. Indexing keeps the first cell in Z order that contains the point; because +180 and -180 are the same meridian, that is the west cell, down to the leaf [-180, -178.59] x [9.84, 10.55]. The point query compares 180 with each cell's mid-point, goes east at every level and ends in the leaf [178.59, 180] x [9.84, 10.55], which holds no document: 0 hits instead of 1. The same point at -180 is filed and found in the west leaf.
The world grid at the antimeridian: indexing files (180, 10) in the west edge cell because the geo wrap makes it contain +180 and it comes first in Z order; the point query walks mid-points east to the +180 edge cell and finds nothing. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index one document at Pt(180, 10) with RecursivePrefixTreeStrategy over new PackedQuadPrefixTree(SpatialContext.GEO, 8) and search Intersects, or Contains, with that same point.
  2. You expect 1 hit: a point intersects and contains itself, and a point query has zero distance error, so it is matched at the tree's full depth, where the point was indexed. The same point at -180, QuadPrefixTree at +180, and a rectangle query [179, 180] x [9, 11] all give 1.
  3. You get 0 hits, and CompositeSpatialStrategy over the same tree also gets 0. No error.
  4. Why: +180 and -180 are the same meridian, so both top cells, [-180, 0] x [0, 90] and [0, 180] x [0, 90], contain (180, 10). Indexing keeps the first in Z order, the west one, and ends at the -180 edge: leaf [-180, -178.59] x [9.84, 10.55].
  5. The query walks mid-points instead (since 8.3.0): 180 is east of every mid-point, so it ends at the +180 edge, [178.59, 180] x [9.84, 10.55], a cell with no document in it.

Who is affected

Applications that index geo points with RPT, or CompositeSpatialStrategy over it, on the opt-in PackedQuadPrefixTree and search for a point at exactly longitude +180: that document is silently missing. Such data is uncommon; no report was found since 8.3.0. Other longitudes, RPT Intersects with pointsOnly=true and geohash are unaffected; rectangle and circle queries showed no such misses.

Mechanism, terms and numbers903 words

With PackedQuadPrefixTree in a geo SpatialContext, a document indexed at longitude exactly +180 is not found by a query for that same point. For one document at Pt(180, 10) and an 8-level tree, RPT Intersects (with pointsOnly=false), RPT Contains and CompositeSpatialStrategy Intersects return 0 hits instead of 1; the same point at -180, QuadPrefixTree and a rectangle query that touches +180 find it. Indexing and querying use two different point-to-cell rules. PrefixTreeIterator stores the point in the first cell in Z order whose rectangle relate()s to it, and a geo rectangle treats +180 as -180, so the point lands in the cells at the -180 edge. A point query descends with getCell, a mid-point walk that LUCENE-8755 introduced in 8.3.0, and that walk sends x = +180 to the cells at the +180 edge. Every release from 8.3.0 to 10.5.2 and main is affected, with no error and the index intact; 8.2.0 passes. A small fix in getCell (one if: treat +180 as -180 in a geo context) makes the query look where the document is, needs no reindexing, and passes the repro and the module's tests.

Terms

  • Prefix tree (grid): PackedQuadPrefixTree splits the world into 4 cells per level (a quad tree) and stores each cell as a term. RecursivePrefixTreeStrategy (RPT) indexes a shape as the terms of the cells it touches; a point is stored as one path of cells, from the top level down to a leaf at the tree's maximum level.
  • Z order: the four children of a cell are visited as NW, NE, SW, SE.
  • Geo wrap: in SpatialContext.GEO, longitude +180 and -180 are the same meridian, and a spatial4j rectangle that starts at -180 relates to x = +180 as if it were -180.

Step 1: indexing keeps the first cell that relates

PackedQuadPrefixTree.getTreeCellIterator (PackedQuadPrefixTree.java:183-190 at a9779ef3c193) returns a PrefixTreeIterator. Its hasNext (:468-503) walks the children in Z order, keeps the first whose rectangle relate()s to the shape (:476), and for a Point stops at the first leaf (:489-491). On main, [-180,0] x [0,90] relate Pt(180,10) is CONTAINS, and so is [0,180] x [0,90]. NW comes first, so the point goes west, and in every later level the westmost child again relates first.

Step 2: the point query walks mid-points

A point query descends with LegacyCell.getNextLevelCells(Point) (LegacyCell.java:134-143), which calls getSubCell(point), here PackedQuadPrefixTree.getCell(p, level + 1) (PackedQuadPrefixTree.java:342-345). getCell (:90-128) compares the point with each cell's mid-point using QuadPrefixTree.battenberg (QuadPrefixTree.java:223-235): xmid >= xp goes west, otherwise east. 180 is greater than every mid-point, so the walk goes east at every level.

The two leaves (executed on main, print-only probe)

point, tree levelsindexed leaflooked-up leafsame
(180, 10), 1[-180, 0] x [0, 90][0, 180] x [0, 90]no
(180, 10), 2[-180, -90] x [0, 45][90, 180] x [0, 45]no
(180, 10), 8[-180, -178.59375] x [9.84375, 10.546875][178.59375, 180] x [9.84375, 10.546875]no
(-180, 10), 1, 2, 8west edgewest edgeyes
(180, 10) with the fix, 1, 2, 8west edgewest edgeyes

Tree depth (executed at depths 1 to 8 for every strategy and operation; the scope below is at 8 and 26 levels). Contains misses at every depth, a 1-level tree included: ContainsPrefixTreeQuery starts at the world cell and descends with getNextLevelCells(point) (ContainsPrefixTreeQuery.java:88-89, 120), which picks the east level-1 cell. RPT Intersects (pointsOnly=false) finds the document on a 1-level tree, because the visiting queries clamp the scan level to maxLevels - 1 (AbstractVisitingPrefixTreeQuery.java:61) and scan the world cell with relate(), and misses from 2 levels on (the independent reproducer: 30 of 36 misses over levels 1, 2, 5, 12, 20, 29 and six latitudes, the north pole included; the south pole misses too in this doc's probes). The optimized composite Intersects, which uses RPT's default scan level maxLevels - 4 (RecursivePrefixTreeStrategy.java:62-63), finds the document up to 4 levels and misses from 5 on.

Which queries take the mid-point walk

  • RPT Intersects with a point (RecursivePrefixTreeStrategy.java:185-188, 216-237): with pointsOnly=true it builds a TermQuery from the index-side iterator and finds the document; with pointsOnly=false it builds an IntersectsPrefixTreeQuery, which descends with getNextLevelCells, and misses.
  • RPT Contains (ContainsPrefixTreeQuery.java:106-142) descends with getNextLevelCells: misses with either pointsOnly value. RPT IsWithin finds the document.
  • CompositeSpatialStrategy optimized Intersects, the default, builds IntersectsRPTVerifyQuery (CompositeSpatialStrategy.java:122-134), a visiting descent: misses with either value, as does composite Contains. Composite IsWithin, Equals, Overlaps and non-optimized Intersects take their candidates from RPT Intersects, so they miss only when the composite's RPT has pointsOnly=false.

Why QuadPrefixTree and other longitudes are fine

QuadPrefixTree got the same mid-point getCell in the same commit, but it also indexes points with getCell (LegacyPrefixTree.java:62-83): one rule on both sides. Inside the world both rules break ties the same way (west on an x tie, north on a y tie) and the geo bounds are exact in binary, so only the wrap at +180 separates them. An SMT check (Z3, exact rationals) found the rules agree in every cell without the wrap and, on the level-1 split of the geo world, differ only at x = +180. Executed controls: -180, Math.nextDown(180), 179.9, 0 and 90 are found.

Exposure and likelihood464 words
  • Who. Applications that index points with RecursivePrefixTreeStrategy, or CompositeSpatialStrategy over it, on PackedQuadPrefixTree with a geo SpatialContext, and search for a point exactly at longitude +180.
  • Exactly which queries miss (independent reproducer and this doc's probes, 8 and 26 levels; for smaller trees see the depth note in "What"): RPT Intersects with pointsOnly=false; RPT Contains with either pointsOnly value; the default (optimized) composite Intersects and composite Contains with either value; composite IsWithin, Equals, Overlaps and non-optimized Intersects only when the composite's RPT has pointsOnly=false.
  • Not affected: RPT Intersects with pointsOnly=true, RPT IsWithin, TermQueryPrefixTreeStrategy, x = -180, Math.nextDown(180), QuadPrefixTree, geohash. Rectangle and circle queries showed no packed-quad-specific misses in the finder's lattice sweep; its exact-boundary misses (such as a line exactly on a circle's rim) occur in the same numbers with QuadPrefixTree.
  • Opt-in, experimental. SpatialPrefixTreeFactory picks geohash for a geo context unless prefixTree names another tree (quad, packedQuad, s2, or a factory class name) (SpatialPrefixTreeFactory.java:45-66); the tree, RPT, ContainsPrefixTreeQuery and the composite strategy are @lucene.experimental. Otherwise default IndexWriterConfig, codec and searcher, any index size. A tree built by the factory from WKT input (26 levels, Intersects(POINT(180 10)) parsed by SpatialArgsParser) misses too, also with normWrapLongitude=true.
  • Silent. No exception, no assertion, and the index is intact; the same with assertions off.
  • Measured scope. This doc's side-effect probe: points at x = +/-180 for six latitudes, rectangles and circles touching or crossing the antimeridian, queried with points at +/-180 and two rectangles, three operations, RPT and composite, 8 and 26 levels. Of 552 combinations that should match, stock main misses 60, every one a document at exactly +180 queried with the same point. Its queries are points and rectangles (no circles), and with pointsOnly=true it ran Intersects on point documents only; a second probe covers Contains and IsWithin with pointsOnly=true (see the workaround).
  • Downstream (code reading only, not executed): Solr exposes the tree as prefixTree="packedQuad"; two downstream search engines map their legacy geo_shape quadtree tree to it in a geo context with RPT pointsOnly=false by default. Whether they rewrite +180 first was not checked.
  • How likely. Rare: it needs a document exactly on +180 and a point query at exactly that spot; no report of it was found since 8.3.0 (2019). Registry likelihood "rare", priority low.
  • Workaround today: write longitude +180 as -180 on both sides, before indexing and before querying. On stock main a document at -180 queried with the same point is found in all 216 combinations tried: RPT with either pointsOnly value and the composite strategy (optimized and general, either value), each with Intersects, Contains and IsWithin, at six latitudes and 8 and 26 levels (the same set at +180: 144 misses).

Minimal repro

The test shows that a document indexed at longitude +180 with RecursivePrefixTreeStrategy over PackedQuadPrefixTree(SpatialContext.GEO, 8) is not found by an Intersects query with that same point (indexing stores it in the -180 edge cell, the point query looks in the +180 edge cell).

TestPackedQuadPlus180Point.java (JUnit test)56 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.spatial.prefix;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
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.spatial.prefix.tree.PackedQuadPrefixTree;
import org.apache.lucene.spatial.query.SpatialArgs;
import org.apache.lucene.spatial.query.SpatialOperation;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.shape.Point;

public class TestPackedQuadPlus180Point extends LuceneTestCase {

  public void testIntersectsSamePointAtPlus180() throws Exception {
    RecursivePrefixTreeStrategy strategy =
        new RecursivePrefixTreeStrategy(new PackedQuadPrefixTree(SpatialContext.GEO, 8), "geo");
    Point p = SpatialContext.GEO.getShapeFactory().pointXY(180, 10);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      for (Field f : strategy.createIndexableFields(p)) {
        doc.add(f);
      }
      w.addDocument(doc);
      try (DirectoryReader r = DirectoryReader.open(w)) {
        // the document at Pt(180,10) must match Intersects(Pt(180,10)): expected 1 hit, got 0
        assertEquals(
            1,
            new IndexSearcher(r)
                .count(strategy.makeQuery(new SpatialArgs(SpatialOperation.Intersects, p))));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-705/, 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:$LUCENE/lucene/spatial-extras/build/libs/lucene-spatial-extras-$V.jar
CP=$CP:$LUCENE/lucene/spatial3d/build/libs/lucene-spatial3d-$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.spatial.prefix.TestPackedQuadPlus180Point
# the module(s) spatial-extras also need their third-party runtime jars
Expected vs actual
Expected 1 hit (a point intersects itself); actual 0 (expected:<1> but was:<0>), with -ea and -da.

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

More files, environment, full output34 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: 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.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.spatial.prefix.TestPackedQuadPlus180Point.testIntersectsSamePointAtPlus180(TestPackedQuadPlus180Point.java:49)

Why it looks unintended

  • A point intersects and contains itself, and a point query has zero distance error.
  • Reviewing the change that introduced this, its committer (dsmiley) named "exact point lookup of itself should round-trip" as a use case that must keep working.
  • The same tree finds the point written as -180; QuadPrefixTree finds it at +180.
The full argument363 words
  • Contract. SpatialArgs#getDistErrPct / #getDistErr: the error "effectively inflates the size of the shape but should not shrink it" (SpatialArgs.java:118-119, 134-135). PrefixTreeStrategy#setDistErrPct: "Points don't use this as they are always indexed at the configured maximum precision" (PrefixTreeStrategy.java:102-103), and for a Point query the distance error is 0 (SpatialArgs.java:60-61), so the query runs at the full depth (QuadPrefixTree.java:117-119). SpatialOperation.Intersects.evaluate(p, p) and Contains.evaluate(p, p) are true (the repro asserts it). CompositeSpatialStrategy: "RPT acts as an index to the precision available in SDV" (CompositeSpatialStrategy.java:38).
  • Legal input. Pt(180, y) is inside the SpatialContext.GEO world bounds. spatial4j keeps x = 180: WKT POINT(180 10) gives x = 180, and with normWrapLongitude=true normX(180) and even normX(540) are 180. No Lucene javadoc asks callers to rewrite +180 as -180 (spatial4j's javadoc was not checked).
  • Stated intent. In the review of apache/lucene-solr#824 (the PR behind LUCENE-8755), dsmiley, who then committed the change, asked to keep the old tokenization available because otherwise "some use-cases with existing indexes may break, like exact point lookup of itself should round-trip". Upstream testPackedQuadPointsOnlyBug indexes one point on a packed-quad tree and expects 1 hit for Intersects with that same point (non-geo world, one level, pointsOnly=true, the TermQuery path this bug does not break; TestRandomSpatialOpFuzzyPrefixTree.java:143-152); the randomized checks fail with "Should have matched" when an indexed shape is missed (:416).
  • Precedent. In 2017 a packed-quad "index a point, search by the same point" failure with a different cause was fixed as a bug (LUCENE-7845, fixed by LUCENE-7859, 38741ece587).
  • The rules used to agree. Before 8.3.0 getCell used the same "first relating cell in Z order" search as the iterator. LUCENE-8755 replaced it with the mid-point walk to fix an exception at high-precision cell edges in a non-geo world, and left the iterator unchanged; neither the issue nor the PR mentions the dateline or longitude 180.
  • Why the tests missed it. The randomized packed-quad tests run in a non-geo [0, 256] x [-128, 128] world (TestRandomSpatialOpFuzzyPrefixTree.java:97-112, factory.geo = false); the only geo packed-quad test, TestDistanceStrategy (PackedQuadPrefixTree(ctx, 25) with SpatialContext.GEO, TestDistanceStrategy.java:43, 55), never uses longitude +180.
The devil's-advocate check161 words

The strongest case for "works as designed": +180 and -180 are the same meridian and spatial4j's rectangle relate() deliberately treats them as one, so storing the point at the -180 edge is defensible; an application could normalize to -180; and the classes are @lucene.experimental, with approximate filtering.

Why it fails: no Lucene documentation states a normalization requirement, and spatial4j's own normalization keeps +180; the query uses the very same Point that was indexed, so the library disagrees with itself, not with the caller; a point query has zero distance error, so no documented approximation explains a miss; the change's committer named exact point round trip as something that must keep working; and the same tree finds -180 while QuadPrefixTree finds +180. @lucene.experimental lowers the severity, not the fact that a document cannot find itself. The intended-behaviour devil's advocate found no documented, accepted or misuse argument, and the intended-lens reviewer from another model family (OpenAI GPT-5.6) rated the "intended" argument weak.

Suggested fix

Recommended Query side: look up +180 where indexing put it (one if in getCell, no reindexing)

In PackedQuadPrefixTree.getCell, treat x = +180 as -180 when the context is geo, so the query looks in the cell where indexing put the point. One if, no reindexing, no format change. The repro and the whole spatial-extras test suite (123 tests) pass; around the antimeridian it removes all 60 misses and adds none.

--- a/lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/PackedQuadPrefixTree.java
+++ b/lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/PackedQuadPrefixTree.java
@@ -93,6 +93,11 @@ public class PackedQuadPrefixTree extends QuadPrefixTree {
     double currentYmid = ymid;
     double xp = p.getX();
     double yp = p.getY();
+    if (ctx.isGeo() && xp == 180) {
+      // Geo rectangles treat longitude +180 as -180: PrefixTreeIterator (used to index points) puts
+      // such a point in the cell at the -180 edge, so look it up there too.
+      xp = -180;
+    }
     long term = 0L;
All options and trade-offs4 options ยท 1,073 words

One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF) and through classpath probes; the alternatives were not run.

Query side: look up +180 where indexing put it (one if in getCell, no reindexing) (validated by the checks) (recommended)

At the start of PackedQuadPrefixTree.getCell, map x = +180 to -180 when ctx.isGeo(). Point queries then descend into the -180 edge cells, where PrefixTreeIterator stored the point. The finder wrote this fix; the independent reproducer, the cross-model reviewers and the adjudication ran it.

--- a/lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/PackedQuadPrefixTree.java
+++ b/lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/PackedQuadPrefixTree.java
@@ -93,6 +93,11 @@ public class PackedQuadPrefixTree extends QuadPrefixTree {
     double currentYmid = ymid;
     double xp = p.getX();
     double yp = p.getY();
+    if (ctx.isGeo() && xp == 180) {
+      // Geo rectangles treat longitude +180 as -180: PrefixTreeIterator (used to index points) puts
+      // such a point in the cell at the -180 edge, so look it up there too.
+      xp = -180;
+    }
     long term = 0L;

Trade-offs

  • No reindexing: stored terms do not change, so existing indexes are found again at once.
  • Same answers as the existing TermQuery shortcut. With pointsOnly=true, RPT Intersects already builds its term from the index-side iterator, so on stock main a +180 query matches both a +180 and a -180 document. After the fix RPT with pointsOnly=false gives the same answers: hits for (doc x, query x) = (+,+), (+,-), (-,+), (-,-) go from 0, 1, 0, 1 to 1, 1, 1, 1. A -180 document returned for a +180 query is an RPT candidate: RPT is documented as "a fast approximate spatial search filter" (PrefixTreeStrategy.java:39-40), and its pointsOnly TermQuery path already returns it today; the composite strategy checks the exact geometry and returns only exact matches (0 for mixed signs, before and after).
  • A public method changes its answer at one coordinate: getCell(Point, int) is public; in a geo context it now returns the -180 edge cell for x = +180. Its callers inside spatial-extras are PackedQuadCell.getSubCell and LegacyPrefixTree.getDistanceForLevel (world centre, x = 0, unaffected). The survey covers Lucene's spatial-extras only: code outside Lucene that calls the public getCell and relies on the east cell for +180 would get the west cell.
  • A special case for one meridian, matching what the indexer already does; only the geo wrap can make the two rules disagree. Cost: one comparison per call.
  • A design choice for maintainers: spatial4j's geo rectangles treat +180 and -180 as one meridian, while exact point relations treat Pt(180, 10) and Pt(-180, 10) as different points. Maintainers may prefer a wider policy (normalize point longitudes in one place, or let the index follow getCell); this fix is the smallest change that keeps existing indexes working, not the only defensible design.
  • Same method as another hunt fix: LFV-316 also changes getCell for a different cause (floating-point mid-point drift with non-power-of-two non-geo bounds); with its fix applied this bug still reproduces (3 of 6), so offer the two together or in sequence.

What the checks validated

  • Stock (scratch worktree, gradle, seed DEADBEEF): the repro fails 3 of 6 with assertions on and off; the 8 closest suites (TestRandomSpatialOpFuzzyPrefixTree, TestDistanceStrategy, TestCompositeStrategy, TestSpatialPrefixTree, TestHeatmapFacetCounter, TestTermQueryPrefixGridStrategy, TestPortedSolr3, TestQueryEqualsHashCode) pass, 35 tests.
  • With the fix: the repro passes 6 of 6 with assertions on and off; the 8 closest suites pass, 35 tests, 0 failures; the whole spatial-extras test suite with the repro in place passes, 123 tests, 5 skipped, 0 failures; TestRandomSpatialOpFuzzyPrefixTree with 20 iterations passes, 180 tests; ecjLintMain, ecjLintTest and checkGoogleJavaFormat of the module pass.
  • Around the antimeridian (classpath probe, main jars with the fixed class shadowed): of 552 combinations that should match (points at +/-180 for six latitudes, rectangles and circles touching or crossing +/-180, the whole world; point and rectangle queries; Intersects, Contains, IsWithin; RPT and composite; 8 and 26 levels), stock misses 60 and the fix 0. No rectangle or circle document is missed before or after. Not covered: circle queries, and the heatmap and facet code at +180 (the module's heatmap and facet tests pass with the fix).
  • Every strategy, operation and depth (resolution probe, main jars with the fixed class shadowed): doc = query at x = +/-180 for RPT (pointsOnly false and true) and the composite strategy (optimized and general, either value), Intersects, Contains and IsWithin, six latitudes, 8 and 26 levels: 432 of 432 found (stock: 144 misses, all at +180); depths 1 to 8 at (180, 10): every combination found.
  • Cells: with the fix the looked-up leaf equals the indexed leaf for (180, 10) and (-180, 10) at 1, 2, 3 and 8 levels.
  • Earlier hunt runs: the finder's repro passes 6 of 6 with the fix shadowed (seeds DEADBEEF, 5EEDF00D, A0D17004), the independent reproducer's 10 tests pass 10 of 10, the cross-model reviewers' own tests pass, and a clean worktree ran the whole module with the repro: 123 tests, 3 failures stock, 0 with the fix.

Index side: store a point along the getCell path (needs a reindex)

Make PrefixTreeIterator store a Point along the mid-point path (or pick the east cell for +180), so both sides use one rule, as QuadPrefixTree does.

Trade-offs

One rule everywhere, but existing indexes keep the -180 cells and must be reindexed; until then the pointsOnly TermQuery shortcut, which builds its term with the same iterator, would start missing the old documents too. It also changes the stored terms of new documents. Not run.

Fall back to the old relate-based search at the wrap only

In getCell, use the old buildNotRobustly search (still in the class) when ctx.isGeo() and x = +180.

Trade-offs

Same answers as the recommended fix by construction (it is the indexer's rule), more code. Not run.

Workaround for users today (not a fix): write +180 as -180 on both sides

Rewrite longitude +180 as -180 before indexing and before querying. On stock main a document at -180 queried at -180 is found in all 216 combinations tried (RPT with either pointsOnly value, the composite strategy optimized and general with either value; Intersects, Contains, IsWithin; six latitudes; 8 and 26 levels).

Trade-offs

Querying at -180 alone finds a +180 document with RPT, but not with the composite strategy, whose exact check treats Pt(180, 10) and Pt(-180, 10) as different points; existing +180 documents need reindexing for the composite.

Downstream note

Solr exposes this tree as prefixTree="packedQuad" and two downstream search engines map their legacy geo_shape quadtree to it in a geo context, so their users can hit this miss with data exactly on +180 unless the longitude is rewritten first (not checked).

How it was found

A bounded differential check compared the strategies with exact geometry on a lattice of points, rectangles and circles in the geo context (including +/-180 and the poles) over three tree types. Beyond exact-boundary cases that the quad tree shows equally, the extra packed-quad misses were point queries at +180; a cell probe and an SMT check located the two rules.

The method in full247 words

Method: bounded differential check of the stock main jars against exact geometry (semi-formal tier), in a hunt unit on spatial-extras composite and prefix-tree queries.

  • A harness indexed a lattice of points, rectangles and circles in SpatialContext.GEO (including longitude +/-180 and the poles) and compared CompositeSpatialStrategy (optimized and general path), RecursivePrefixTreeStrategy and TermQueryPrefixTreeStrategy with SpatialOperation.evaluate on the stored shapes, over geohash, quad and packed-quad trees, levels, distErrPct, prune and prefixGridScanLevel settings.
  • Both quad trees showed the same exact-boundary misses (for example a line lying exactly on a circle's rim, which RPT Contains misses with QuadPrefixTree too). On top of those, the packed-quad tree had extra RPT and composite false negatives and term-query vs RPT disagreements on points, for most classes exactly 192 more than the QuadPrefixTree control (the number of +180 point-query cases), and every printed example was a point query at x = +180; the control had none of these extras.
  • A cell-level diagnosis printed the two different leaves, and an SMT check (Z3, exact rationals) showed the index-side and query-side point-to-cell rules agree in every cell without the longitude wrap and, on the level-1 split of the geo world, differ only at x = +180.
  • The finding was reduced to a 6-method public-API JUnit and checked by an independent reproducer, an intended-behaviour devil's advocate, reviews by a model from another family (OpenAI GPT-5.6) that ran their own tests, an adjudication, a history, prior-report and security follow-up, and an audit. Found 2026-10-27 on main a9779ef3c193.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): four prior-report tool runs, GitHub searches across Lucene, Solr, spatial4j and downstream projects, JIRA, the mailing lists, the web and the changed files of all 409 open PRs (one list capped by GitHub). The nearest items are the change that introduced it and a 2017 packed-quad point-lookup bug with a different cause.

Searches4 searches

Verdict new: no report by anyone. Keyword search cannot prove absence; GitHub search does not index review comments repository-wide. Nearest items, none of them this bug: #9800 (LUCENE-8755) and apache/lucene-solr#824, the change that introduced it (non-geo floating-point robustness only); #8896 / #8910 (LUCENE-7845 / LUCENE-7859, 2017), a packed-quad "index a point, search the same point" failure with a different cause, fixed before this bug existed; #13305 (open), an RPT Contains test failure with rectangles near +/-180 on the geohash tree; locationtech/spatial4j#85 (2015), why geo relate treats +/-180 as equal.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene and downstream repositories), dev@, java-user@, issues@, commits@, the JIRA archive, the web2026-10-27the hunt's prior-report tool, four runs (identifiers, strategies, origin and Solr, downstream): 58, 51, 51 and 59 queriesno report; every post-2019 hit is unrelated (commits, number noise on "180", other geo_shape dateline items, an unanswered 2023 question)
apache/lucene, apache/lucene-solr, apache/solr, locationtech/spatial4j, downstream repositories, all of GitHub2026-10-27manual GitHub GraphQL searches (PackedQuadPrefixTree, packedQuad, + 180 / dateline / antimeridian; PackedQuad: 0); issue threads #9800, #8896, #8910, #7481, #13305 and lucene-solr#824 readcontext only: #9800 / lucene-solr#824 (origin), #8896 / #8910 (precedent), #13305 (geohash, rectangles), spatial4j#85 (why +/-180 relate as equal)
JIRA (LUCENE, SOLR), mailing lists (lucene and solr domains), the web2026-10-2715 manual JIRA queries, 40 list-archive queries, 13 web queriesno report
all 409 open apache/lucene PRs; upstream main and branch_10x2026-10-27changed-file lists; read of getCell on upstream main a95c754ee003 and branch_10xno open PR touches PackedQuadPrefixTree.java or QuadPrefixTree.java as far as the file lists show (one very large PR's list stops at the 3,000-file API limit; 411 open PRs re-checked on 2026-10-28, same result); getCell unchanged upstream

History

Introduced in 8.3.0 by LUCENE-8755 (26628b2717a, 2019-08-20), which gave point queries a faster mid-point getCell while indexing kept the relate-based iterator. Executed: 7.7.3 and 8.2.0 pass; 8.3.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main fail. A version switch kept the old rule until 10.0.0.

Versions tested5 rows

Introduced by 26628b2717a "LUCENE-8755: Spatial-extras quad and packed-quad trees now index points a little faster, and also fix an edge case bug. Fixes #824" (apache/lucene#9800; PR apache/lucene-solr#824, by chenkovsky, closed and committed directly by dsmiley on 2019-08-20; branch_8x c09ddcb7180). It replaced getCell's relate-based search (build / checkBattenberg, the indexer's rule) with the mid-point walk and did not touch PrefixTreeIterator, unchanged since LUCENE-6422 (9d1c2019a8a, 2015). git blame -w attributes main's getCell body and battenberg to it.

Version switch. From 8.3.0 to 9.12.x, SpatialPrefixTreeFactory with version < 8.3.0 kept the old getCell; flipping only that argument turns the failure off and on inside one release. 1ddb01044a0 ("remove unnecessary backward compatibility", 2021-11-02, first in 10.0.0) removed it, so from 10.0.0 on every PackedQuadPrefixTree is affected. Between 8.2.0 and 8.3.0 only c09ddcb7180 and LUCENE-8758 touch spatial-extras sources; both brackets ran with spatial4j 0.7. Releases between the executed ones were not run (no semantic change to the involved code). Upstream main and branch_10x still have the same getCell.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedfails 3 of 6 (gradle and the classpath runner, assertions on and off); passes 6 of 6 with the fix
10.0.0, 10.5.2 affectedfail 3 of 6; the factory's old-rule switch is gone, so the version=8.2.0 tree fails too
9.0.0, 9.12.3 affectedfail 3 of 6; a factory-built tree with version=8.2.0 passes
8.3.0, 8.11.4 first release 8.3.0fail 3 of 6; a factory-built tree with version=8.2.0 passes
8.2.0, 7.7.3 not affectedpass 6 of 6: getCell still used the indexer's relate-based rule

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing; reviews by a model from another family (OpenAI GPT-5.6) ran their own tests and agreed; an adjudication and an audit re-ran it with new seeds, and this doc's gradle runs reproduce it and validate the fix.

Checks and verdicts396 words

Every check wrote or re-ran a repro on stock main; every control passed, and every fix run passed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateLattice sweep, cell diagnosis and Z3 check, then the 6-method JUnit: fails 3 of 6 on main (-ea, -da), 10.5.2, 9.12.3 and 8.3.0, passes on 8.2.0 and with the fix; with LFV-316's fix still 3 of 6.
cross-model review, refute lens (OpenAI GPT-5.6) real, highOwn test fails only for exact +180 (seeds DEADBEEF, C0FFEE, BADC0DE, -da; 3 of 9 over three iterations); Math.nextDown(180) and pointsOnly controls pass; the fix passes it and the existing packed-quad suites.
independent reproducer realOwn 10-test JUnit from the claim: 4 fail on main (-ea, -da), 10.5.2 and 9.12.3; prints both leaves; level sweep 30 of 36; 8.2.0 passes, 8.3.0 fails; narrowed the composite scope; the finder's fix passes 10 of 10.
cross-model review, intended lens (the cross-model review, OpenAI GPT-5.6) real, high; intended argument weakOwn test fails 2 of 6 on main, 9.12.3 and 10.5.2; a cell probe shows the two leaf tokens; 8.2.0 passes, 8.3.0 fails. A separate run re-ran both tests on a9779ef3c193 and with the fix (12 of 12 pass).
intended-behaviour devil's advocate realNo documented, accepted or misuse argument. Own probe with the factory-built tree, WKT and SpatialArgsParser: 0 hits, also with normWrapLongitude=true; fails on main and 10.5.2.
adjudication confirmed, highRe-ran the independent reproduction's test (seeds 1D236A01 -ea, 1D236A02 -da: 4 of 10 fail), the finder's repro (1D236A03: 3 of 6) and the independent reproduction's test with the fix (1D236A04: 10 of 10 pass).
Follow-up and audit keepFollow-up re-run (seed 5EEDF00D) and an independent credibility audit (seed A0D17004): 3 of 6 fail with -ea and -da, 6 of 6 pass with the fix.
this doc reproduced; fix validatedGradle in a scratch worktree: stock 3 of 6 fail (assertions on and off); with the fix the repro, the 8 closest suites and the whole module pass; the antimeridian probe goes from 60 misses to 0. After the fact checks, a resolution probe ran every strategy and operation at depths 1 to 8 and over the +/-180 cross product (stock and fixed), and the reworded repro was re-run (3 of 6 fail stock, 6 of 6 with the fix, format check passes).

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

Finding record

LFV-705, snapshot of 2026-11-06

Registry title
PackedQuadPrefixTree in a geo context indexes a point at longitude +180 under the -180 edge cell, but point queries look in the +180 edge cell, so RPT and CompositeSpatialStrategy Intersects/Contains with that same point miss the document
Area
spatial-extras / prefix trees: PackedQuadPrefixTree point-to-cell lookup (RecursivePrefixTreeStrategy and CompositeSpatialStrategy point queries)
Priority
Low, rank 422: The experimental PackedQuadPrefixTree silently misses a point at exactly longitude +180, a boundary-value point miss with the same profile as LFV-316 and LFV-371.
Impact class
silent wrong results
Impact note
Applications that index points with RecursivePrefixTreeStrategy, or CompositeSpatialStrategy over it, on the opt-in, @lucene.experimental PackedQuadPrefixTree in a geo SpatialContext (any maxLevels >= 2; Solr exposes it as prefixTree=packedQuad) silently lose a document stored at exactly longitude +180 when they query that same point: RPT Intersects with pointsOnly=false, RPT Contains with either pointsOnly value, the default (optimized) composite Intersects and Contains with either value, and composite IsWithin/Equals/Overlaps when the composite's RPT is not pointsOnly; nothing throws and the index is intact, while RPT pointsOnly=true Intersects, RPT IsWithin, TermQueryPrefixTreeStrategy, the rectangle and circle queries of the finder's sweep, x=-180, Math.nextDown(180), QuadPrefixTree and geohash are unaffected; default IndexWriterConfig, codec and searcher, any index size, -ea and -da, every release from 8.3.0 to 10.5.2 and main.
Summary
You index a point at longitude +180, for example Pt(180, 10), with RecursivePrefixTreeStrategy over PackedQuadPrefixTree(SpatialContext.GEO, 8) and query Intersects or Contains with that same point; the document must match, since a point intersects and contains itself and the prefix-tree approximation may only add matches (SpatialArgs: the error 'should not shrink' the shape). Instead the query returns 0 hits, and so does CompositeSpatialStrategy's Intersects: indexing keeps the first cell in Z order whose rectangle relate()s to the point, and a geo rectangle treats +180 as -180, so the point is stored in the cells at the -180 edge, while the point query's cell lookup (PackedQuadPrefixTree.getCell, a mid-point walk since LUCENE-8755 in 8.3.0) sends x=+180 to the cells at the +180 edge. The same point at -180, QuadPrefixTree, and a rectangle query touching +180 still find the document.
Introduced
26628b2717a73235db56fde94f7f5b64cbc5b8b2 (LUCENE-8755 'QuadPrefixTree robustness: can throw exception while indexing a point at high precision', apache/lucene#9800, from apache/lucene-solr#824, which was closed and committed directly; branch_8x backport c09ddcb7180ccc4abaef25f654ffc053421353a4, shipped in 8.3.0) [exec: the candidate repro passes 6/6 on 8.2.0 and fails 3/6 on 8.3.0, and inside 8.3.0-9.12.3 flipping only SpatialPrefixTreeFactory's version argument (old vs new getCell) turns the failure off and on; read: git blame -w attributes main's getCell body and QuadPrefixTree.battenberg to this commit, the indexing iterator PrefixTreeIterator is unchanged since LUCENE-6422 (9d1c2019a8a), and between 8.2.0 and 8.3.0 only the backport c09ddcb7180 and LUCENE-8758 (f3b2358bc73, unused fields) touch spatial-extras sources] (2019-08-20 (commit date; authored 2019-08-07))
First affected version
8.3.0 [exec] (8.2.0 and 7.7.3 pass [exec])
Affected versions
8.3.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2 and main a9779ef3c193; never fixed (getCell is unchanged on upstream main a95c754ee003 and branch_10x, read-only check 2026-10-27). [exec] the candidate repro fails 3/6 on 8.3.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da) and passes 6/6 on 7.7.3 and 8.2.0; the independent reproduction's own JUnit fails on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3, and the independent reproduction's framework-free main() passes on 8.2.0 and fails on 8.3.0; the GPT reviewers' own tests fail on main, and the intended-lens reviewer's also on 10.5.2 and 9.12.3, while its 8.x probe passes on 8.2.0 and fails on 8.3.0. [read] the releases in between (no semantic change to the involved code). In 8.3.0-9.12.x a tree built through SpatialPrefixTreeFactory with version < 8.3.0 keeps the old getCell and is not affected (the switch was removed by 1ddb01044a0 in 10.0.0); the public constructor and the default factory version are affected.
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; also reached by Z3
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-27, read-only: four scripted prior-report searches (58, 51, 51 and 59 queries; 78, 231, 45 and 81 distinct hits) over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web; manual GitHub GraphQL searches in apache/lucene, apache/lucene-solr, apache/solr, locationtech/spatial4j and downstream repositories (including repo:apache/lucene PackedQuad: 0); JIRA LUCENE and SOLR searches; 40 lists.apache.org queries; 13 web queries; and the file lists of all 409 open apache/lucene PRs, none of which touches PackedQuadPrefixTree.java or QuadPrefixTree.java; the finder's, the independent reproduction's and the intended-behaviour check's searches agree). Closest items, none of them this bug: #9800 (LUCENE-8755, the change that introduced it; its discussion covers non-geo floating-point robustness only) and its PR apache/lucene-solr#824; #8896/#8910 (LUCENE-7845/LUCENE-7859, a 2017 PackedQuad point self-lookup failure with a different cause, fixed before this bug existed); #13305 (open, an RPT Contains test failure with rectangles near +/-180 on GeohashPrefixTree). Not fixed upstream: getCell is unchanged on upstream main a95c754ee003 and branch_10x.
Lucene commit tested
main a9779ef3c193 (2026-10-28): this doc's gradle runs in a scratch worktree (stock and fixed, assertions on and off, the module's tests) and its classpath probes (cells, side effects, mixed signs); the hunt's runs on 7.7.3, 8.2.0, 8.3.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (Maven Central jars). OpenJDK 25.0.3 on Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/PackedQuadPrefixTree.java:49 (@lucene.experimental), :90-128 (getCell mid-point walk), :183-190 (getTreeCellIterator -> PrefixTreeIterator), :468-503 (PrefixTreeIterator.hasNext: first relating Z-order cell, a Point stops at its first leaf)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/QuadPrefixTree.java:223-235 (battenberg: xmid >= xp goes west)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/LegacyCell.java:134-143 (getNextLevelCells(Point) -> getSubCell)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/RecursivePrefixTreeStrategy.java:216-237 (pointsOnly TermQuery shortcut via getTreeCellIterator, else IntersectsPrefixTreeQuery)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/ContainsPrefixTreeQuery.java:106-142 (Contains descends with getNextLevelCells)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeSpatialStrategy.java:122-134 (optimized Intersects -> IntersectsRPTVerifyQuery)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/query/SpatialArgs.java:117-135 ('inflates the size of the shape but should not shrink it')
  • SECURITY.md (In-scope reports; out-of-scope reports filed publicly)
The finding in 30 seconds
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/PackedQuadPrefixTree.java:90-128, 183-190, 342-345, 452-503
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/QuadPrefixTree.java:223-235; prefix/tree/LegacyCell.java:134-143; prefix/tree/LegacyPrefixTree.java:62-83
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/RecursivePrefixTreeStrategy.java:179-207, 216-237; prefix/ContainsPrefixTreeQuery.java:106-142; prefix/AbstractVisitingPrefixTreeQuery.java:61; composite/CompositeSpatialStrategy.java:122-134
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/ContainsPrefixTreeQuery.java:88-89, 120; prefix/RecursivePrefixTreeStrategy.java:62-63; composite/CompositeSpatialStrategy.java:128-134
Who is affected
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/SpatialPrefixTreeFactory.java:45-58; prefix/tree/PackedQuadPrefixTree.java:49; prefix/RecursivePrefixTreeStrategy.java:40; prefix/ContainsPrefixTreeQuery.java:41; composite/CompositeSpatialStrategy.java:43
Why it looks unintended
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/query/SpatialArgs.java:118-119, 134-135; prefix/PrefixTreeStrategy.java:102-103; composite/CompositeSpatialStrategy.java:38
  • lucene/spatial-extras/src/test/org/apache/lucene/spatial/prefix/TestRandomSpatialOpFuzzyPrefixTree.java:97-112, 143-152, 416; TestDistanceStrategy.java:43, 55
Suggested fix
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/PackedQuadPrefixTree.java:130 (buildNotRobustly), :342-345; prefix/tree/LegacyPrefixTree.java:37-47; prefix/RecursivePrefixTreeStrategy.java:216-237

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

Repro files

About the diagrams

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