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.
- You index one document at
Pt(180, 10)withRecursivePrefixTreeStrategyovernew PackedQuadPrefixTree(SpatialContext.GEO, 8)and search Intersects, or Contains, with that same point. - 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,
QuadPrefixTreeat +180, and a rectangle query [179, 180] x [9, 11] all give 1. - You get 0 hits, and
CompositeSpatialStrategyover the same tree also gets 0. No error. - 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].
- 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.
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.
- Prefix tree (grid):
PackedQuadPrefixTreesplits 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.
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.
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.
| point, tree levels | indexed leaf | looked-up leaf | same |
|---|---|---|---|
| (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, 8 | west edge | west edge | yes |
| (180, 10) with the fix, 1, 2, 8 | west edge | west edge | yes |
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.
- RPT Intersects with a point (
RecursivePrefixTreeStrategy.java:185-188, 216-237): withpointsOnly=trueit builds aTermQueryfrom the index-side iterator and finds the document; withpointsOnly=falseit builds anIntersectsPrefixTreeQuery, which descends withgetNextLevelCells, and misses. - RPT Contains (
ContainsPrefixTreeQuery.java:106-142) descends withgetNextLevelCells: misses with eitherpointsOnlyvalue. RPT IsWithin finds the document. CompositeSpatialStrategyoptimized Intersects, the default, buildsIntersectsRPTVerifyQuery(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 haspointsOnly=false.
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, orCompositeSpatialStrategyover it, onPackedQuadPrefixTreewith a geoSpatialContext, 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 eitherpointsOnlyvalue; 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 haspointsOnly=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 withQuadPrefixTree. - Opt-in, experimental.
SpatialPrefixTreeFactorypicks geohash for a geo context unlessprefixTreenames another tree (quad,packedQuad,s2, or a factory class name) (SpatialPrefixTreeFactory.java:45-66); the tree, RPT,ContainsPrefixTreeQueryand the composite strategy are@lucene.experimental. Otherwise defaultIndexWriterConfig, codec and searcher, any index size. A tree built by the factory from WKT input (26 levels,Intersects(POINT(180 10))parsed bySpatialArgsParser) misses too, also withnormWrapLongitude=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=trueit ran Intersects on point documents only; a second probe covers Contains and IsWithin withpointsOnly=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_shapequadtreetree to it in a geo context with RPTpointsOnly=falseby 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
pointsOnlyvalue 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).
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).
/*
* 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))));
}
}
}
}
# 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-eaand-da.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: 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)- 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;
QuadPrefixTreefinds 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 aPointquery 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)andContains.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 theSpatialContext.GEOworld bounds. spatial4j keeps x = 180: WKTPOINT(180 10)gives x = 180, and withnormWrapLongitude=truenormX(180)and evennormX(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
testPackedQuadPointsOnlyBugindexes one point on a packed-quad tree and expects 1 hit for Intersects with that same point (non-geo world, one level,pointsOnly=true, theTermQuerypath 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
getCellused 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)withSpatialContext.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.
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 withpointsOnly=falsegives 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 itspointsOnlyTermQuerypath 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 arePackedQuadCell.getSubCellandLegacyPrefixTree.getDistanceForLevel(world centre, x = 0, unaffected). The survey covers Lucene's spatial-extras only: code outside Lucene that calls the publicgetCelland 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)andPt(-180, 10)as different points. Maintainers may prefer a wider policy (normalize point longitudes in one place, or let the index followgetCell); 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
getCellfor 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;
TestRandomSpatialOpFuzzyPrefixTreewith 20 iterations passes, 180 tests;ecjLintMain,ecjLintTestandcheckGoogleJavaFormatof 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 (
pointsOnlyfalse 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.
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.
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.
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.
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).
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 comparedCompositeSpatialStrategy(optimized and general path),RecursivePrefixTreeStrategyandTermQueryPrefixTreeStrategywithSpatialOperation.evaluateon the stored shapes, over geohash, quad and packed-quad trees, levels,distErrPct, prune andprefixGridScanLevelsettings. - 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
QuadPrefixTreetoo). 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 theQuadPrefixTreecontrol (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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene and downstream repositories), dev@, java-user@, issues@, commits@, the JIRA archive, the web | 2026-10-27 | the hunt's prior-report tool, four runs (identifiers, strategies, origin and Solr, downstream): 58, 51, 51 and 59 queries | no 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 GitHub | 2026-10-27 | manual GitHub GraphQL searches (PackedQuadPrefixTree, packedQuad, + 180 / dateline / antimeridian; PackedQuad: 0); issue threads #9800, #8896, #8910, #7481, #13305 and lucene-solr#824 read | context 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 web | 2026-10-27 | 15 manual JIRA queries, 40 list-archive queries, 13 web queries | no report |
| all 409 open apache/lucene PRs; upstream main and branch_10x | 2026-10-27 | changed-file lists; read of getCell on upstream main a95c754ee003 and branch_10x | no 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 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | fails 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 | affected | fail 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 | affected | fail 3 of 6; a factory-built tree with version=8.2.0 passes |
| 8.3.0, 8.11.4 | first release 8.3.0 | fail 3 of 6; a factory-built tree with version=8.2.0 passes |
| 8.2.0, 7.7.3 | not affected | pass 6 of 6: getCell still used the indexer's relate-based rule |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Lattice 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, high | Own 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 | real | Own 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 weak | Own 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 | real | No 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, high | Re-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 | keep | Follow-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 validated | Gradle 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). |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 528 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,079 words
- 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.
- 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-503lucene/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-83lucene/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-134lucene/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:38lucene/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
- TestPackedQuadPlus180Point.java (JUnit test):
repro/LFV-705/src/org/apache/lucene/spatial/prefix/TestPackedQuadPlus180Point.java(56 lines)
assets/LFV-705/bug.png(528 KB)
![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.](../assets/LFV-705/bug.png)