All findings · Lucene finding · LFV-371 · s2-point-query-on-cell-edge-contains-miss

S2PrefixTree: a Contains query for a point misses the document stored at that same point when the point lies on an S2 cell edge, such as the equator

With the experimental S2 prefix tree, Contains(Point(10, 0)) finds 0 documents for a document stored at Point(10, 0), while Intersects finds 1: a point exactly on an S2 cell edge (the equator, some meridians, (0,0)) is missed. Every release since 7.3.0 is affected; a small query-side fix needs no reindexing.

Summary

Priority
Low, rank 421The experimental S2 tree silently misses a point that lies exactly on a cell edge, and only for Contains(point) queries.
Impact
silent wrong results
Since
7.3.0 (LUCENE-8126, 2018)introduced 2018-03-02 · about 8 years
Affected
7.3.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
randomized differential test of RPT against the exact spatial predicate

AI-assisted page: this finding was first signalled by code reading or testing 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

S2PrefixTree cuts the globe into cells, level by level. A point exactly on the border between two cells is stored under one of them, but a Contains query looks in every cell that touches the point and requires the document to be in all of them.

Three-panel diagram. Panel 1, the example: the repro code (Intersects 1 hit, Contains 0 hits) and face 0 drawn as four child cells, with P = (10, 0) on the shared edge of the northern cell .0, where the document is indexed, and the southern cell .1, which holds none of its terms; getNextLevelCells(P) returns both. Panel 2, inside Lucene: indexing uses one cell per level, just north of the equator; the S2 cell has no point case, so FilterCellIterator keeps .0 and .1 (4 children at (0, 0)); ContainsPrefixTreeQuery ANDs the children, .0 gives doc 0 and .1 has no term, so 0 hits; the geohash and quad trees give a point one child. Panel 3, effects, reach and fix: documents indexed exactly at an edge point such as (10, 0), (160, 0), (45, 20) or (0, 0) are lost; CompositeSpatialStrategy returns 1 of 2 true matches; off-edge points, Intersects, IsWithin and the geohash and quad trees are fine; only the experimental S2 tree with the Geo3D context, false negatives only; the fix returns the single S2CellId.fromLatLng child, with no reindexing.
The point (10, 0) sits on the equator, the edge between two cells of face 0: the document is indexed under the northern one, the southern one holds none of its terms, and the Contains query requires the document in both, so it returns 0 hits. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index one document at Point(10, 0), on the equator, with RecursivePrefixTreeStrategy over new S2PrefixTree(ctx, 8) in the Geo3d spatial context, and count an Intersects and a Contains query for that same point.
  2. You expect 1 and 1: Contains means OGC "Covers", and a point covers itself. The geohash and quad trees return 1, and so does the S2 tree for (10, 0.5), just off the equator.
  3. You get 1 and 0. The same at every S2 cell edge point tried: on the equator, on longitudes 0, ±45, ±90, ±135 and 180 near the equator, and at (0,0).
  4. Why: the equator is the border between two S2 cells. Indexing stores the point under just one of them, here the northern one, one cell per level.
  5. But the Contains query takes every cell the point touches, here both, and keeps only documents found in all of them. The other cell holds nothing, so the document drops out. The geohash and quad trees take only the cell the point is stored in.

Who is affected

Applications on the opt-in, experimental S2PrefixTree (Geo3d context only; Solr can select it with prefixTree="s2") that run Contains with a point, through RecursivePrefixTreeStrategy or CompositeSpatialStrategy. Every document stored at exactly that point is silently lost when the point is on a cell edge. The rectangles and circles tested, Intersects and IsWithin are fine.

Mechanism, terms and numbers899 words

With RecursivePrefixTreeStrategy over the experimental S2PrefixTree (Geo3d spatial context), a Contains query whose shape is a point P misses every document indexed as exactly P when P lies on an S2 cell edge: Contains(Point(10, 0)) returns 0 hits for a document at Point(10, 0), while Intersects returns 1, and the geohash and quad trees return 1. Indexing stores a point under one cell per level (S2CellId.fromLatLng), but S2PrefixTreeCell.getNextLevelCells has no point case and returns every child cell that touches P (2 on an edge, 4 at (0,0)); ContainsPrefixTreeQuery ANDs the documents of those children, and the child P was not stored under has none. It affects every release from 7.3.0 to 10.6.0 (10.6.0 by reading) and main, also through CompositeSpatialStrategy. A point case in getNextLevelCells that returns the one child P is stored under, as LegacyCell already does for the other trees, fixes it without reindexing.

Terms

  • Prefix tree (RPT): RecursivePrefixTreeStrategy indexes a shape as the grid cells it covers, one term per cell, from coarse to fine levels. A query walks the same grid from the top and looks the cells up.
  • S2 cells: S2PrefixTree uses S2 geometry: the sphere is projected onto the 6 faces of a cube, the 6 faces are the first level, and each further level splits a cell into 4 children (16 or 64 with arity 2 or 3), the first split running along the face's centre lines. So on the four equatorial faces the face borders (longitudes ±45 and ±135) are cell edges from the first level on, the equator and longitudes 0, ±90 and 180 from the second level on, and (0,0), the centre of face 0, is a corner where 4 cells meet.
  • Contains: SpatialOperation.Contains, the OGC "Covers" predicate: the indexed shape covers the query shape, boundary included; equal shapes count.

Step 1: indexing stores a point under one cell per level

S2PrefixTree.getTreeCellIterator (S2PrefixTree.java:141-155 at a9779ef3c193) handles a Point by computing S2CellId.fromLatLng(S2LatLng.fromDegrees(lat, lon)).parent(...) and emitting that cell and its ancestors: exactly one cell per level. For a point on an edge, fromLatLng puts it into one of the touching cells.

Step 2: the Contains query ANDs the children it is given

For Contains, RecursivePrefixTreeStrategy.makeQuery builds a ContainsPrefixTreeQuery (RecursivePrefixTreeStrategy.java:199-201). Its visitor asks cell.getNextLevelCells(queryShape) (ContainsPrefixTreeQuery.java:120) and computes "the AND of all child results" (:111): a child with no indexed term makes the result empty and stops the loop (:123-124, :137); otherwise the next child is searched only among the documents found so far (:138).

Step 3: the S2 query side has no point case

S2PrefixTreeCell.getNextLevelCells (S2PrefixTreeCell.java:156-173) builds all children and returns new FilterCellIterator(cells.iterator(), shapeFilter). FilterCellIterator keeps every cell whose relation to the filter intersects() (FilterCellIterator.java:48-53), and a closed cell whose border passes through the point intersects it. The geohash and quad trees special-case a point and return only the sub-cell it falls in (LegacyCell.java:134-143):

if (shapeFilter instanceof Point point) {
  LegacyCell cell = getSubCell(point);
  cell.shapeRel = SpatialRelation.CONTAINS;
  return new SingletonCellIterator(cell);
}

Step 4: the result

Of the touching children, only the one the point was stored under has terms for the document, so the AND is empty. For Point(10, 0) this happens at the second level, where the equator splits face 0 into a northern and a southern half: the query side gets 2 children there. A probe with the arity-1 S2 tree at its maximum depth (31 levels) measured 2 children for 11 points on the equator, the face borders and the face-centre meridians and 4 for (0,0); all 12 miss with Contains and are found by Intersects, while (1,10), (46,20), (10,0.001) and (91,10) get 1 child per level and are found by both. A trace of the stock visitor on the repro's index shows it asks the world cell, face 0 and the .0 side down to level 7 for children and never .1 or anything below it: after .0's leaf term the term enumeration is used up, so seek(.1) is false and the loop breaks (ContainsPrefixTreeQuery.java:144-145, :123-124, :137).

What is not affected

  • Intersects: for a point query makeQuery takes the grid-aligned fast path (RecursivePrefixTreeStrategy.java:185-187): with pointsOnly a single TermQuery on the indexing cell (:218-229), else a visiting query that ORs over the cells it visits, so extra cells only add hits.
  • IsWithin: RPT passes no buffer (queryBuffer -1, :191-198), so the query walks the cells with no point filter (WithinPrefixTreeQuery.java:74).
  • Rectangle and circle documents (the area shapes tested): one that covers P is indexed under every cell it intersects, including all the touching ones, so it is still found. Polygons, lines and shape collections were not tested.
  • Shallow trees: the edge must exist at the tree's depth. With new S2PrefixTree(ctx, 1) (faces only) the equator is not an edge yet and (10, 0) and (0, 0) are found; from 2 levels on they are missed. (45, 20), on a face edge, is missed from 1 level on.
  • The two sides disagree: the index stores an edge point under one cell, the query follows every touching cell. The recommended fix makes the query side follow the index side, so it needs no reindexing; changing the index side instead is the other option.
Exposure and likelihood323 words
  • Who. Applications that use RecursivePrefixTreeStrategy, or CompositeSpatialStrategy (which pre-filters Contains with RPT's own Contains query, CompositeSpatialStrategy.java:139-142), over S2PrefixTree, and run Contains (alias "Covers") with a point as the query shape.
  • Opt-in. S2PrefixTree is @lucene.experimental and works only with a context whose shape factory is an S2ShapeFactory, i.e. the Geo3d context (constructor check, S2PrefixTree.java:83-85; CHANGES for LUCENE-8126: "It can only be used currently with Geo3D spatial context"). SpatialPrefixTreeFactory defaults to geohash for a geo context and selects S2 for prefixTree="s2"; Solr's RPT field types build their tree through that factory and parse Contains (code reading, not run). A GitHub code search found no use of S2PrefixTree in Elasticsearch or OpenSearch.
  • What is lost. Documents indexed as exactly the query point, when that point lies on an S2 cell edge that exists at the tree's depth: on the equator, and in the band around the equator on the face borders (longitudes ±45, ±135) and the face-centre meridians (0, ±90, 180), and (0,0), a common placeholder coordinate (all of these tried; multi-valued documents not tested). Continuous coordinates rarely land exactly on an edge; round-number and placeholder data does. With CompositeSpatialStrategy, a point document and a rectangle around it, Contains(p) returns 1 of the 2 true matches.
  • Silent, deterministic, false negatives only. No exception, the same with assertions off, per document, so at any index size. In 600 random Contains(point) queries (300 per tree, 2 trees, 400 random documents per tree), the stock hits were always a subset of the fixed hits.
  • How likely. Contains(point) against point data is an equality test that users would usually write as Intersects; the realistic case is a mixed-shape field asked "which shapes cover this location" when the location equals a stored point on a cell edge. No prior report was found in the recorded searches, although the code dates from 7.3.0 (2018); the registry rates the likelihood rare and the priority low.

Minimal repro

The test shows that with RecursivePrefixTreeStrategy over S2PrefixTree (Geo3d context), a Contains query with point P misses the document indexed with P when P is on an S2 cell edge (here Point(10, 0) on the equator).

TestS2PointOnCellEdgeContains.java (JUnit test)58 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.tree;

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.RecursivePrefixTreeStrategy;
import org.apache.lucene.spatial.query.SpatialArgs;
import org.apache.lucene.spatial.query.SpatialOperation;
import org.apache.lucene.spatial.spatial4j.Geo3dSpatialContextFactory;
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 TestS2PointOnCellEdgeContains extends LuceneTestCase {

  public void testContainsPointOnEquator() throws Exception {
    SpatialContext ctx = new Geo3dSpatialContextFactory().newSpatialContext();
    RecursivePrefixTreeStrategy strategy =
        new RecursivePrefixTreeStrategy(new S2PrefixTree(ctx, 8), "geo");
    Point p = ctx.getShapeFactory().pointXY(10, 0);
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter writer = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      for (Field f : strategy.createIndexableFields(p)) {
        doc.add(f);
      }
      writer.addDocument(doc);
      try (DirectoryReader reader = DirectoryReader.open(writer)) {
        // expected: Contains(p) finds the one document indexed with p; actual: 0 hits
        assertEquals(
            1,
            new IndexSearcher(reader)
                .count(strategy.makeQuery(new SpatialArgs(SpatialOperation.Contains, p))));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-371/, 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.tree.TestS2PointOnCellEdgeContains
# the module(s) spatial-extras also need their third-party runtime jars
Expected vs actual
Expected: 1 hit (Contains is OGC Covers, and Contains.evaluate(p, p) is true); actual (-ea and -da): 0 hits, "expected:<1> but was:<0>".

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

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.tree.TestS2PointOnCellEdgeContains.testContainsPointOnEquator(TestS2PointOnCellEdgeContains.java:51)

Why it looks unintended

  • Contains is documented as OGC "Covers", Contains.evaluate(p, p) is true, and RPT has an explicit Contains branch (LUCENE-4645).
  • The tree disagrees with itself: indexing stores a point in one cell per level, the query follows every touching cell. Other trees special-case points.
  • Upstream's random RPT test fails on any missed Contains match, but runs only quad and geohash trees.
The full argument289 words
  • Contract. SpatialOperation.Contains: "Meets the 'Covers' OGC definition (boundary-neutral)", evaluate = indexedShape.relate(queryShape) == CONTAINS || indexedShape.equals(queryShape) (SpatialOperation.java:75-87). ContainsPrefixTreeQuery: "Finds docs where its indexed shape CONTAINS the query shape. For use on RecursivePrefixTreeStrategy". RecursivePrefixTreeStrategy.makeQuery has an explicit Contains branch and throws UnsupportedSpatialOperation only for other operations (:199-203). CHANGES: "LUCENE-4645: Added support for the 'Contains' spatial predicate for RecursivePrefixTreeStrategy".
  • Approximation may only add matches. SpatialArgs: the error "effectively inflates the size of the shape but should not shrink it", and it is 0 for a point.
  • Upstream tests assert this for the other trees. TestRandomSpatialOpFuzzyPrefixTree.testContains ensures "true-positive matches (if the predicate on the raw shapes match then the search should find those same matches)", sometimes queries with "an existing indexed shape" (when it is a point or a rectangle), and uses power-of-two boundaries for "the prospect of edge conditions we want to test"; it runs only the quad and geohash trees. The only S2 RPT test, TestGeo3dRpt, runs only Intersects.
  • The tree disagrees with itself. Its index side stores a point in one cell per level; its query side follows every touching cell. In LUCENE-8126 (#9174) the tree's author wrote that S2 "Cells at the same level are disjoint" (in a comment about leaf pruning, not about queries). The legacy trees make the query side follow the index side for points (LegacyCell.java:134-143).
  • History. The introducing commit added the S2 cell class without the point case and tested S2 with Intersects only; no review or JIRA comment discusses Contains or edge points.
  • Precedent. LUCENE-4770 (#5835): a false negative from an index-side versus query-side cell mismatch in the legacy trees' Intersects (TermQueryPrefixTreeStrategy over indexed non-point shapes), fixed as a bug in 4.2.
The devil's-advocate check225 words

The strongest case for "works as designed" (the reading-only cross-model reviews rated it moderate; one of them, the refute lens, returned "not real" at low confidence for a procedural reason, as it could not run the repro, while its static analysis agreed on the root cause): PrefixTreeStrategy's class javadoc, which RPT inherits, says "Only SpatialOperation#Intersects is supported" (PrefixTreeStrategy.java:52-53); S2 cells are closed, so a point on an edge really does touch several cells, and Cell#getNextLevelCells only promises the cells "optionally filtered by shapeFilter"; and S2PrefixTree, RPT and ContainsPrefixTreeQuery are all @lucene.experimental.

The answer: the "Only Intersects" sentence was written on 2012-09-24 (84c5a4addeb), six months before LUCENE-4645 added Contains to RPT (8b573c413d4, 2013-04-03), and makeQuery, the ContainsPrefixTreeQuery javadoc and the CompositeSpatialStrategy javadoc ("All predicates are supported except for the BBox* ones, and Disjoint") all say Contains works. Closed cells would justify "several touching cells" only if the index side did the same, and it does not. @lucene.experimental is about API stability, not missing results. A maintainer could still prefer an index-side fix, a Contains-only fix or a documentation change. Verdict: not intended (the intended-behaviour devil's advocate, high confidence, and the adjudication). Expect the "Only Intersects" sentence to be quoted back; answer with LUCENE-4645 and the dates, and consider fixing that sentence in the same patch.

Suggested fix

Recommended Point case in S2PrefixTreeCell.getNextLevelCells (one method, no reindexing)

Give S2PrefixTreeCell.getNextLevelCells a point case that returns the one child the point is indexed under (the same S2CellId.fromLatLng cell as indexing), as LegacyCell does for the geohash and quad trees. One method, no reindexing. The repro and the whole spatial-extras test suite pass with it.

   @Override
   public CellIterator getNextLevelCells(Shape shapeFilter) {
+    if (shapeFilter instanceof Point point) {
+      // A point gets the one child cell it is indexed under (see S2PrefixTree.getTreeCellIterator),
+      // not every child that touches it: on a cell edge 2 to 4 children touch the point.
+      int childLevel = cellId == null ? 0 : cellId.level() + tree.arity;
+      S2CellId child =
+          S2CellId.fromLatLng(S2LatLng.fromDegrees(point.getY(), point.getX())).parent(childLevel);
+      S2PrefixTreeCell cell = new S2PrefixTreeCell(tree, child);
+      cell.shapeRel = SpatialRelation.CONTAINS;
+      return new SingletonCellIterator(cell);
+    }
     S2CellId[] children;
All options and trade-offs4 options · 897 words

One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF) and through the scope probes; the alternatives were not run. The workaround was checked in the scope probe only.

Point case in S2PrefixTreeCell.getNextLevelCells (one method, no reindexing) (validated by the checks) (recommended)

For a point filter, return a SingletonCellIterator of the child cell S2CellId.fromLatLng(point).parent(childLevel) with shapeRel CONTAINS: the cell the index side uses, and the pattern of LegacyCell. This is the finder's fix rewritten with imports instead of fully qualified names and with a comment; the logic is the same (+12 lines). It changes every S2 traversal that filters by a point, not only Contains (see the trade-offs); faceting with a point shape was not tested.

   @Override
   public CellIterator getNextLevelCells(Shape shapeFilter) {
+    if (shapeFilter instanceof Point point) {
+      // A point gets the one child cell it is indexed under (see S2PrefixTree.getTreeCellIterator),
+      // not every child that touches it: on a cell edge 2 to 4 children touch the point.
+      int childLevel = cellId == null ? 0 : cellId.level() + tree.arity;
+      S2CellId child =
+          S2CellId.fromLatLng(S2LatLng.fromDegrees(point.getY(), point.getX())).parent(childLevel);
+      S2PrefixTreeCell cell = new S2PrefixTreeCell(tree, child);
+      cell.shapeRel = SpatialRelation.CONTAINS;
+      return new SingletonCellIterator(cell);
+    }
     S2CellId[] children;

Trade-offs

  • No reindexing, no format change: the index side already writes the single fromLatLng path.
  • The other point callers take the single path too: the visiting queries (AbstractVisitingPrefixTreeQuery.java:245), i.e. RPT's Intersects without pointsOnly, the Intersects pre-filter of CompositeSpatialStrategy and PrefixTreeFacetCounter with a point shape. They then visit 1 cell per level instead of 2 to 4. An area document that covers the point is still indexed under that cell: in the probe the Intersects answers did not change. The facet counter with a point shape was not probed. Indexing never passes a point, and IsWithin passes no filter.
  • Tests: the upstream tests never ran Contains on S2, so their passing shows only that nothing else broke; add the repro (or an S2 grid in a Contains test) with the fix.
  • Javadoc: consider fixing PrefixTreeStrategy's stale "Only SpatialOperation#Intersects is supported" sentence in the same patch.
  • Valid depths assumed: like the index side, the point case computes parent(childLevel) and assumes a depth S2 supports; S2PrefixTree does not check maxLevels against getMaxLevels(arity) (the base constructor only asserts it is positive, SpatialPrefixTree.java:38-42). This predates the patch.

What the checks validated

  • Stock a9779ef3c193 (scratch worktree, gradle, seed DEADBEEF): the repro fails 1 of 1 with assertions on and off; the closest suites (TestS2PrefixTree, TestGeo3dRpt, TestRandomSpatialOpFuzzyPrefixTree, TestSpatialPrefixTree, TestCompositeStrategy) pass: 19 tests, 1 skipped.
  • With the fix: the repro passes with assertions on and off; the same closest suites pass (19 tests, 1 skipped); the whole :lucene:spatial-extras:test with the repro in place passes (118 tests, 5 skipped, 0 failures); TestS2PrefixTree, TestGeo3dRpt and TestRandomSpatialOpFuzzyPrefixTree with 5 iterations and a random seed pass (80 tests, 5 skipped); ecjLintMain, ecjLintTest and checkGoogleJavaFormat pass. The repro's class javadoc first failed checkGoogleJavaFormat on its wrapping; after a comment-only edit (formatted with ./gradlew tidy, which left the fix unchanged) the edited repro fails on stock and passes with the fix, assertions on and off. fix.diff and fix-with-test.diff pass git apply --check on a clean tree.
  • Scope probe (main jars, stock and with the fixed class): 6 edge points and 1 off-edge point, 8 documents each (the point, 6 rectangles that have the point on an edge (4) or at a corner (2), and a circle around it), Intersects, Contains and IsWithin, SPHERE and WGS84, 4 tree shapes. Stock: Contains misses 6 documents in each configuration (48 misses; the probe lists the first 40, all edge-point documents, and each count of 6 matches the 6 edge points). Fixed: 0 misses, and the Intersects and IsWithin lines are identical to stock.
  • Earlier hunt runs with the same logic: the finder's and the independent reproducer's JUnits pass; the security probe (600 random Contains(point) queries, 300 per tree) finds 0 misses; the four upstream S2/RPT suites passed 17 of 17 and 170 of 170 over 10 iterations.

Point case in ContainsPrefixTreeQuery (not run)

For a point query, descend only the cells of getTreeCellIterator.

Trade-offs

Fixes Contains for any tree, but changes a query shared by all trees to work around one tree's cell iterator, and leaves the S2 Intersects traversal visiting extra cells.

Index a point under every cell it touches (not run)

Make the index side match the query side instead.

Trade-offs

Gives the index side closed-cell semantics, but needs reindexing and writes up to 4x the terms for edge points. The pointsOnly Intersects fast path would still work: it looks up the single getTreeCellIterator cell (RecursivePrefixTreeStrategy.java:218-229), which stays among the indexed terms.

Workaround for users today: query Intersects(P) instead of Contains(P) for a point (not a fix) (validated by the checks)

In the OGC sense "covers P" and "intersects P" are the same predicate for a point: a shape covers a point exactly when the point lies in the closed shape.

Trade-offs

Only for point query shapes; checked on the probe's shapes only.

What the checks validated

In the scope probe, with point, rectangle and circle documents, the two predicates had identical sets of true document-query pairs in all 8 configurations, and stock Intersects missed none of them.

Downstream note

Downstream engines that expose the S2 tree with Contains (Solr can select it with prefixTree="s2", by code reading) inherit the miss; it only ever drops matches.

How it was found

A randomized differential test compared RecursivePrefixTreeStrategy's hits with the exact spatial predicate over random points, rectangles and circles, and, among other findings, flagged Contains on the point (160, 0) with the S2 tree only. Probes then showed that the tested points exactly on S2 cell edges miss while nearby off-edge points are found; reading getNextLevelCells gave the cause.

The method in full187 words

Method: a randomized differential test (non-formal tier; the finder tagged it semi-formal) in a hunt unit on spatial-extras.

  • The harness indexed random rectangles, circles and points snapped to a 5- or 10-degree grid (150 documents and 60 queries in each of two S2 configurations) and reported every document that SpatialOperation.evaluate says should match but the query missed. Among its findings it flagged Contains, indexed Point(160, 0), query the same point, on S2PrefixTree only; the same run also reported other S2 Within and area-shape Contains misses, which are not part of this report.
  • A point probe then showed that S2 Contains misses the tested points on S2 cell edges ((160,0), (10,0), (45,20)), while off-edge points and the geohash and quad trees find them; the finder found the cause by reading S2PrefixTreeCell.getNextLevelCells and confirmed it with a public-API JUnit on the stock main jar.
  • It was then checked by an independent reproducer, an intended-behaviour devil's advocate, reading-only reviews by a model from another family (OpenAI GPT-5.6), an adjudication, a history, prior-report and security follow-up, and an audit. Found 2026-10-26 on main ea57360912ed; re-checked on a9779ef3c193.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): 186 logged search records (180 individual queries) over GitHub issues, PRs and code, JIRA, the Lucene and Solr mailing lists and the web, plus the changed files of all 411 open PRs. The closest item, LUCENE-4770, is an older index-side versus query-side cell mismatch in other trees, fixed as a bug.

Searches3 searches

Verdict new: the recorded searches found no report by anyone. Keyword search cannot prove absence: closed unmerged PRs are covered by text search only, and private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr, downstream repos), dev@, java-user@, issues@, commits@, the JIRA archive, the web2026-10-26the hunt's prior-report tool, three runs (57, 30 and 24 queries)no report; the tree's own issue #9174 / LUCENE-8126 has no Contains or edge-point discussion in its 49 JIRA comments (48 on the GitHub mirror)
apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, all of GitHub2026-10-2650 GitHub GraphQL searches; 9 JIRA searches over LUCENE and SOLR; 10 list searches including the Solr user listscontext only: #5835 (LUCENE-4770, index/query cell mismatch in the legacy trees, fixed in 4.2), #8896 (LUCENE-7845, the Intersects-only point fast path), #6126 (LUCENE-5062, a different Contains false negative), #5710 (LUCENE-4645, Contains support), SOLR-13467 (Solr bundles S2)
all 411 open apache/lucene PRs; upstream blobs2026-10-26 and 2026-10-28changed-file lists; blob comparison of the three files on upstream main and the release branchesno open PR touches S2PrefixTreeCell.java or ContainsPrefixTreeQuery.java (#14599 and #12295 touch S2PrefixTree.java with a dependency switch and a refactor); the files are unchanged on upstream main 20ced71c1e6

History

Introduced with the S2 tree itself, LUCENE-8126 (master e3032dd3fcc2, branch_7x backport 1e86af061e41, 2018-03-02), first released in 7.3.0. Executed: 7.3.0 through 10.5.2 and main fail; 7.2.1 has no S2 tree. Not fixed upstream.

Versions tested7 rows

Introduced by LUCENE-8126 "New spatial prefix tree (SPT) based on google S2 geometry" (GitHub #9174; review PR apache/lucene-solr#302, committed directly), by iverase, 2018-03-02: master e3032dd3fcc2 (first released in 8.0.0) and the branch_7x backport 1e86af061e41 (identical tree sources; first released in 7.3.0). Both halves of the mismatch come from this commit: git blame (ignoring the 2021 reformat) attributes every line of getNextLevelCells and getTreeCellIterator to it. ContainsPrefixTreeQuery (LUCENE-4645, 2013) and LegacyCell's point case are older. Not a regression.

Not fixed upstream: the three files are byte-identical on upstream main 20ced71c1e6 (2026-10-27); on branch_10x and branch_10_6 the two S2 files are identical too. Releases 7.4.0-7.7.2, 8.1.0-8.11.3, 9.1.0-9.12.2 and 10.1.0-10.5.1 are inferred affected from the unchanged code.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedfails 1 of 1 (gradle, assertions on and off); passes with the fix
main ea57360912ed affectedfails 1 of 1 (assertions on and off); the 96-configuration JUnit misses 96 of 96
10.0.0, 10.5.2 affectedfails 1 of 1
9.0.0, 9.12.3 affectedfails 1 of 1
8.0.0, 8.11.4 affectedfails 1 of 1 (8.0.0: first release of the master commit)
7.3.0, 7.3.1, 7.7.3 first release 7.3.0fails 1 of 1; the 96-configuration JUnit also fails on 7.3.0 with its controls passing
7.2.1 API absentcompile error: no S2PrefixTree class (the only error)

Independent checks

An independent reproducer (96 of 96 configurations) and an intended-behaviour devil's advocate (80 of 80 queries) reproduced it with their own tests, controls passing; reading-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause; an adjudication and an audit re-ran it with new seeds, and this doc's gradle runs reproduce it on a9779ef3c193.

Checks and verdicts352 words

Every executing check wrote or re-ran a repro on stock main, and every control passed. The cross-model reviews could not execute code, so all execution evidence comes from the hunt's own runs.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateRandom differential, point probe, then the public-API JUnit: fails on main (-ea, -da), 10.5.2 and 9.12.3; passes with the fix.
cross-model review, refute lens (OpenAI GPT-5.6, reading only) not real, low (procedural)Could not execute anything, and it may not vote real without its own run; on substance the root cause agrees, the inputs are in contract and the intended argument is moderate. A separate re-run failed 1 of 1.
independent reproducer realOwn JUnit: 96 of 96 S2 configurations miss on main (-ea, -da), 10.5.2, 9.12.3 and 8.11.4; off-edge and geohash/quad controls pass; 3 of 3 pass with the fix; area shapes, Intersects and IsWithin unaffected.
cross-model review, intended lens (the cross-model review, OpenAI GPT-5.6, reading only) real, mediumIntended argument moderate (base-class javadoc, closed cells, generic Cell contract) against the explicit Contains branch, the Covers definition and the legacy trees' point case; a separate re-run failed 1 of 1.
intended-behaviour devil's advocate real, high confidence not intendedFive intended readings tried, none survives; probe: 80 of 80 Contains misses on grid-line points, 0 off-line and 0 for geohash/quad; CompositeSpatialStrategy returns 1 of 2 true matches.
adjudication confirmed, highRe-ran the independent JUnit (-ea, -da: 1 of 3 fail, controls pass), the finder's JUnit (1 of 1 fail) and the independent JUnit with the fix (3 of 3 pass); not a duplicate.
credibility audit keepRe-ran with seeds A4D17001-2: 1 of 1 fails with -ea and -da.
this doc reproduced; fix validatedGradle in a scratch worktree at a9779ef3c193: the repro fails (assertions on and off) and passes with the fix; with the fix the whole spatial-extras module (118 tests) passes, and lint and format pass after a comment-only edit of the repro's javadoc; the scope probes ran on the stock jars and with the fixed class.

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

Finding record

LFV-371, snapshot of 2026-11-06

Registry title
S2PrefixTree: a Contains query whose shape is a point P misses a document indexed with that same point P when P lies on an S2 cell edge (the equator, an S2 face edge, (0,0)), because the query side follows every child cell that touches P while indexing puts P in one cell per level
Area
spatial-extras / prefix trees (S2PrefixTree and S2PrefixTreeCell under RecursivePrefixTreeStrategy; Contains through ContainsPrefixTreeQuery)
Priority
Low, rank 421: The experimental S2 tree silently misses a point that lies exactly on a cell edge, and only for Contains(point) queries.
Impact class
silent wrong results
Impact note
Applications that use RecursivePrefixTreeStrategy, or CompositeSpatialStrategy (which pre-filters with it), over the @lucene.experimental S2PrefixTree, which works only with the Geo3d spatial context (Solr's RPT field types can select it with prefixTree="s2", by code reading), and run a Contains query whose shape is a point silently lose every document indexed as exactly that point when the point lies on an S2 cell edge, such as any point on the equator, (45,20) on a face edge, (90,10) on a face-centre meridian or the common placeholder (0,0), at any index size and with every arity, depth, planet model, pointsOnly and pruneLeafyBranches setting tried; released since 7.3.0; false negatives only, while Intersects, IsWithin and indexed area shapes that cover the point are unaffected.
Summary
You index a document with point P through RecursivePrefixTreeStrategy over S2PrefixTree in the Geo3d spatial context and search with SpatialOperation.Contains and the same point P, expecting the document, because Contains is documented as the OGC 'Covers' predicate and its evaluate(P, P) is true, RecursivePrefixTreeStrategy builds a ContainsPrefixTreeQuery for it, a point query is resolved at full precision, and the geohash and quad trees do return the document. When P lies exactly on an S2 cell edge, for example Point(10, 0) on the equator, Contains finds 0 documents while Intersects finds 1. Indexing puts P in one cell per level (S2PrefixTree.getTreeCellIterator, from S2CellId.fromLatLng), but at query time S2PrefixTreeCell.getNextLevelCells has no point case and returns every child cell that touches P (2 on an edge, 4 at (0,0)), and ContainsPrefixTreeQuery requires the document to be in all of them.
Introduced
e3032dd3fcc28570c5f9d2dab4961b5b07555912 (master, first released in 8.0.0) and its branch_7x backport 1e86af061e41f1a7df1740f34ef58a1110626d96 (identical prefix/tree sources; the copy that shipped first, in 7.3.0), 'LUCENE-8126: New spatial prefix tree (SPT) based on google S2 geometry' (GitHub issue #9174; review PR apache/lucene-solr#302, closed without a GitHub merge and committed directly), by GitHub iverase [read: git blame -w --ignore-rev 2cbf261032dc in the history clone attributes every line of S2PrefixTreeCell.getNextLevelCells and S2PrefixTree.getTreeCellIterator to e3032dd3fcc2; exec: 7.2.1 has no S2PrefixTree, 7.3.0 fails]. Both halves of the mismatch (the query side without a point case and the one-cell-per-level index side) come from this commit; ContainsPrefixTreeQuery (LUCENE-4645, 8b573c413d4, 2013) and LegacyCell's point special case predate it. Not a regression (2018-03-02 (master e3032dd3fcc2 and branch_7x 1e86af061e41))
First affected version
7.3.0 [exec] (the first release with S2PrefixTree, from the branch_7x backport; the master commit first shipped in 8.0.0 [exec]); 7.2.1 has no such API [exec: the repro fails to compile only on the missing S2PrefixTree class]
Affected versions
Every release from 7.3.0 to 10.6.0 (10.6.0 by reading) and main ea57360912ed; not fixed upstream. [exec] the finder's JUnit fails 1 of 1 (Contains 0 hits after the Intersects control finds 1) on 7.3.0, 7.3.1, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea; main also -da) (follow-up; releases up to 9.0.0 with only the LuceneTestCase import changed, 7.x with the JUnitCore runner); the independent reproduction's 96-configuration JUnit fails on main (-ea and -da), 10.5.2, 9.12.3, 8.11.4 and 7.3.0 with its controls passing. [read] no later commit changes either method (only the LUCENE-9570 reformat touches their lines); releases 7.4.0-7.7.2, 8.1.0-8.11.3, 9.1.0-9.12.2 and 10.1.0-10.5.1 are inferred from the unchanged code; upstream main e1c4184cb983 (2026-10-26) and branch_10x 1309534296e3 (2026-10-24) have S2PrefixTreeCell.java, S2PrefixTree.java and ContainsPrefixTreeQuery.java byte-identical to the tested commit. All runs on aarch64 with JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-26, read-only, the prior-report record, verdict new: 186 logged queries: three scripted prior-report searches (57, 30 and 24 queries over GitHub issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web), 50 read-only GitHub GraphQL searches over apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch and all of GitHub, 9 JIRA searches over LUCENE and SOLR, 10 mailing-list searches including the Solr user lists, a scan of the file lists of all 411 open apache/lucene PRs, an upstream blob check and the local git history; plus the finder's, the independent reproduction's and the intended-behaviour check's earlier searches); no hit is the same bug. Not fixed upstream: S2PrefixTreeCell.java, S2PrefixTree.java and ContainsPrefixTreeQuery.java are byte-identical on upstream main e1c4184cb983 (2026-10-26) and branch_10x 1309534296e3 (2026-10-24); no open PR touches S2PrefixTreeCell.java or ContainsPrefixTreeQuery.java (#14599 and #12295 touch S2PrefixTree.java with a dependency switch and a pattern-matching refactor only). Related items, not reports of this bug: #9174 / LUCENE-8126 (introduced the tree; no Contains or edge-point discussion in its 49 comments), #5835 / LUCENE-4770 (an index-side versus query-side cell mismatch in the legacy trees' Intersects, fixed as a bug in 4.2), #8896 / LUCENE-7845 (the point fast path, Intersects only), #6126 / LUCENE-5062 (a different Contains false negative), #5710 / LUCENE-4645 (RPT Contains support), SOLR-13467 (Solr bundles S2 for prefixTree="s2").
Lucene commit tested
main a9779ef3c193 (2026-10-26): this doc's gradle runs in a scratch worktree (stock and fixed, assertions on and off, the closest suites and the whole spatial-extras module) and its scope probes on the main jars (stock and with the fixed class); the hunt's runs of the same JUnit on main ea57360912ed and on 7.3.0, 7.3.1, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (Maven Central jars; 7.2.1 has no S2 tree); and three small probes made while resolving the fact checks (the factory-built tree's depth, the miss against tree depth, and a trace of which cells the stock Contains visitor asks for children). OpenJDK 25.0.3 on Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/S2PrefixTreeCell.java:155-173 (getNextLevelCells: all children wrapped in a FilterCellIterator, no Point case)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/S2PrefixTree.java:39 (@lucene.experimental), :81-86 (S2ShapeFactory, i.e. the Geo3d context, required), :140-157 (getTreeCellIterator: a Point gets one S2CellId.fromLatLng cell per level)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/LegacyCell.java:134-143 (getNextLevelCells: a Point gets a SingletonCellIterator of getSubCell(point))
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/ContainsPrefixTreeQuery.java:41 (@lucene.experimental), :106-142 (ContainsVisitor.visit ANDs the doc sets of every returned child; acceptContains narrowed at :138)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/RecursivePrefixTreeStrategy.java:185-202 (makeQuery: Intersects point fast path, explicit Contains branch at :199-201), :211-237 (isGridAlignedShape and makeGridShapeIntersectsQuery)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/query/SpatialOperation.java:75-87 (Contains: 'Meets the "Covers" OGC definition (boundary-neutral)', evaluate = relate CONTAINS or equals)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/query/SpatialArgs.java:60-61, :117-120, :133-136 (point distance error 0; the error 'should not shrink' a shape)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/PrefixTreeStrategy.java:52-53 ('Only SpatialOperation#Intersects is supported', written 2012-09-24 in 84c5a4addeb)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeSpatialStrategy.java:40-41 ('All predicates are supported except for the BBox* ones, and Disjoint') and :139-142 (Contains pre-filters with RPT's own Contains query)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/SpatialPrefixTreeFactory.java:59 ('s2' selects S2PrefixTree)
  • lucene/CHANGES.txt:7564 (LUCENE-8126, 'It can only be used currently with Geo3D spatial context') and :14016 (LUCENE-4645, Contains support for RecursivePrefixTreeStrategy)
The finding in 30 seconds
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/S2PrefixTree.java:141-155 (getTreeCellIterator: one fromLatLng cell per level for a Point)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/S2PrefixTreeCell.java:156-173 (getNextLevelCells: all children through FilterCellIterator, no Point case)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/FilterCellIterator.java:43-57 (keeps cells whose relation intersects())
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/LegacyCell.java:134-143 (Point case: SingletonCellIterator of getSubCell(point))
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/ContainsPrefixTreeQuery.java:106-142 (ContainsVisitor.visit: AND of the children)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/RecursivePrefixTreeStrategy.java:178-237 (makeQuery: point fast path for Intersects, IsWithin with -1, explicit Contains branch)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/WithinPrefixTreeQuery.java:74, :171-174 (no filter when queryBuffer is -1)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/ContainsPrefixTreeQuery.java:144-145 (seek is false once the terms are used up)
Who is affected
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/S2PrefixTree.java:39 (@lucene.experimental), :81-94 (S2ShapeFactory required)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/SpatialPrefixTreeFactory.java:44-59 (default geohash for geo; 's2' selects S2PrefixTree)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeSpatialStrategy.java:139-142 (Contains pre-filter is RPT's Contains)
  • lucene/CHANGES.txt:7580-7583 (LUCENE-8126)
Why it looks unintended
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/query/SpatialOperation.java:75-87
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/ContainsPrefixTreeQuery.java:36-43; lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/RecursivePrefixTreeStrategy.java:199-203
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/query/SpatialArgs.java:60-61, :117-120, :133-136
  • lucene/spatial-extras/src/test/org/apache/lucene/spatial/prefix/TestRandomSpatialOpFuzzyPrefixTree.java:72-74, :98-101, :138-141, :345-350, :363-364; lucene/spatial-extras/src/test/org/apache/lucene/spatial/spatial4j/TestGeo3dRpt.java:62, :101, :123, :129, :167
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/PrefixTreeStrategy.java:52-53; lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/Cell.java:96-109; lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeSpatialStrategy.java:40-41
  • lucene/CHANGES.txt:14459 (LUCENE-4770 under 4.2.0 Bug Fixes)
Suggested fix
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/AbstractVisitingPrefixTreeQuery.java:244-246; lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/IntersectsRPTVerifyQuery.java:169; lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/PrefixTreeFacetCounter.java:135-136; lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/RecursivePrefixTreeStrategy.java:124-126, :218-229
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/SpatialPrefixTree.java:38-42 (only asserts maxLevels > 0); prefix/tree/S2PrefixTree.java:102-104 (getMaxLevels)
Prior reports
  • a Lucene clone origin/main 20ced71c1e6, origin/branch_10x 1309534296e, origin/branch_10_6 2763db11b0a (blob comparison, 2026-10-28)

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.