relate() picks a helper by the other shape's class and maps its spatial3d answer with one shared switch. The Geo3d-shape helper and the rectangle helper ask the question from opposite sides, so the switch is right for one and backwards for the other.
relate() picks, the one mapping that turns the rectangle helper's answer around, the SerializedDVStrategy query, and a PackedQuad cell that holds a small circle taken to lie inside it. Select the still to open it at full size. Generated with AI, checked against the code.- You use the Geo3d spatial context and ask how a 10-degree circle at (0, 0) relates to
ctx.getWorldBounds()and to a 2x2 degreeRectangleImplat its centre. - You expect WITHIN (the circle lies inside the world) and CONTAINS (the circle covers the small box). Those are the answers for the same two boxes built by
ctx.getShapeFactory().rect(...). - You get the reverse, CONTAINS and WITHIN. So
SerializedDVStrategyIsWithin(world bounds)finds 0 of 1 indexed circles, andContains(world bounds)finds it. PackedQuadPrefixTreecells are plain rectangles too. A quarter-world cell that contains a half-degree query circle is taken to lie inside it, soIntersects(circle(1, 1, 0.5))matches a point at (100.3, 37.7). 20 such queries over 20,000 points return 99,972 hits; the exact answer is 3, the fixed build returns 5.- Why: the rectangle helper asks spatial3d how this shape relates to the rectangle, then applies the mapping written for the opposite question. No error. A regression since 7.1.0 (2017); 7.0.1 answers correctly.
Users of the opt-in Geo3d spatial context who also use PackedQuadPrefixTree (wrong cells for small and medium circles and polygons), run SerializedDVStrategy IsWithin or Contains with the world bounds or a hand-built rectangle, or call relate() with one. The common setup (geohash, quad or S2 trees, factory or WKT shapes, Intersects) was not shown to be affected.
Mechanism, terms and numbers760 words
In the opt-in Geo3d spatial context (Geo3dSpatialContextFactory, @lucene.experimental), relating a Geo3d shape to a spatial4j Rectangle that is not itself a Geo3d shape returns WITHIN where the answer is CONTAINS and CONTAINS where it is WITHIN; DISJOINT and INTERSECTS are right. A 10-degree circle at (0, 0) is CONTAINS ctx.getWorldBounds() and WITHIN a 2x2 degree RectangleImpl at its centre, while the same boxes built by the Geo3d shape factory give WITHIN and CONTAINS. Geo3dShape.relate(Shape) maps the answers of two helpers with one switch, but the two helpers ask spatial3d the question from opposite sides. Lucene builds such rectangles itself (the Geo3d context's world bounds, every PackedQuadPrefixTree cell), so SerializedDVStrategy IsWithin / Contains queries with the world bounds return the opposite set (and CompositeSpatialStrategy IsWithin misses), and RecursivePrefixTreeStrategy over PackedQuadPrefixTree returns documents far outside small query circles and polygons. Nothing throws. It came in with LUCENE-7951 in 7.1.0 (2017) and fails on every release tested since and on main.
- Spatial context: spatial4j's
SpatialContextdecides how shapes are built and related.Geo3dSpatialContextFactory(opt-in,@lucene.experimental) makes one whose shapes wrap spatial3d (Geo3d) shapes inGeo3dShape. a.relate(b): howarelates tob: WITHIN (a lies inside b), CONTAINS (a covers b), INTERSECTS or DISJOINT.SpatialOperation.IsWithinandContainstestindexedShape.relate(queryShape)for WITHIN and CONTAINS (SpatialOperation.java:84, 134).- Plain rectangle: a spatial4j
RectangleImpl, not built by the Geo3d shape factory. The Geo3d context'sctx.getWorldBounds()is one, and so is everyPackedQuadPrefixTreecell (PackedQuadPrefixTree.java:400).ctx.getShapeFactory().rect(...)and WKT envelopes build aGeo3dRectangleShapeinstead, which is aGeo3dShape.
Geo3dShape.relate(Shape)(Geo3dShape.java:54-75) sends aGeo3dShapeargument to one helper (:58), any otherRectangleto a second (:59) and aPointto a third (:60), then maps the helper's spatial3dGeoAreaconstant with one switch (:66-74):GeoArea.CONTAINS -> WITHINfor non-points,GeoArea.WITHIN -> CONTAINS.- spatial3d's contract (
GeoArea.java:34-38, 46-50):CONTAINS= "The referenced shape CONTAINS this area";getRelationship(shape)'s "return value is how the GeoShape relates to the GeoArea, not the other way around". - The Geo3d-shape helper returns
shape.getRelationship(s.shape)(:77-79): the area is this shape and the answer describes the other one. "The other contains this" means "this is WITHIN the other", so the switch is right. - The rectangle helper builds a
GeoAreafrom the rectangle and returnsgeoArea.getRelationship(shape)(:81-92): the area is the rectangle and the answer already describes this shape. The same switch turns it around: WITHIN and CONTAINS come back swapped. - For a shape that is neither a point nor a rectangle, spatial4j's
RectangleImpl.relate(shape)returnsshape.relate(this).transpose()(spatial4j 0.8), sorectangle.relate(circle)is swapped too.
- Exact predicates.
SerializedDVStrategychecksIsWithin/Containsfor every document, so withctx.getWorldBounds()or a hand-built rectangle as the query shape it returns the opposite set.CompositeSpatialStrategychecks them only for the candidates of an RPT query: itsIsWithin(world bounds)matched 0 of 3 documents, while itsContains(world bounds)gave the correct 0, because it narrowsContainswith an RPTContainsquery first (CompositeSpatialStrategy.java:139-142). - PackedQuad prefix tree. When indexing, a cell reported WITHIN the shape becomes a leaf (
PackedQuadPrefixTree.java:476-483); when querying,IntersectsPrefixTreeQuerycollects every document under a cell reported WITHIN the query shape (IntersectsPrefixTreeQuery.java:71-73). A quarter-world cell that really contains a small circle or polygon is reported WITHIN it, so a 1-degree circle document is indexed as that one quarter-world cell instead of 351 cells, and a half-degree query circle matches every point in its quarter of the world. Rectangles from the Geo3d factory or WKT are not affected: spatial4j relates a cell to anyRectanglewith its own rectangle code (a factory rectangle query of the same extent gave 0 false hits). - Not affected, as tested. Geohash and quad trees build their cells with the Geo3d factory, so RPT over them in the Geo3d context is right (the geohash control gives 0 false hits). No effect was shown for S2 trees or heatmaps, whose world cell and default input shape are
ctx.getWorldBounds().
| call or query | expected | main | fix |
|---|---|---|---|
circle(0, 0, 10).relate(ctx.getWorldBounds()) | WITHIN | CONTAINS | WITHIN |
| the same box from the Geo3d factory (control) | WITHIN | WITHIN | WITHIN |
circle.relate(new RectangleImpl(-1, 1, -1, 1, ctx)) | CONTAINS | WITHIN | CONTAINS |
SerializedDVStrategy, 1 indexed circle: IsWithin / Contains(world bounds) | 1 / 0 | 0 / 1 | 1 / 0 |
RPT over PackedQuadPrefixTree(ctx, 12), point (100.3, 37.7): Intersects(circle(1, 1, 0.5)) | 0 | 1 | 0 |
the same over GeohashPrefixTree(ctx, 6) (control) | 0 | 0 | 0 |
Exposure and likelihood387 words
- Who is hit. Applications using
Geo3dSpatialContextFactory(opt-in,@lucene.experimental) that relate Geo3d shapes to rectangles the Geo3d factory did not build:RecursivePrefixTreeStrategyoverPackedQuadPrefixTree(the non-defaultprefixTree=packedQuad; a geo context defaults to geohash): wrong cells at index time and at query time for small and medium circles, polygons and other shapes that are neither points nor rectangles (factory and WKT rectangles take spatial4j's own rectangle code and are not affected). Only false positives (0 false negatives in the probe).SerializedDVStrategywithIsWithinorContainsandctx.getWorldBounds()or a hand-builtRectangleImplas the query shape: the opposite set (in a 342-document probeContains(world bounds)matched all 342 circles andIsWithinnone).CompositeSpatialStrategyIsWithin(world bounds)matched 0 of 3 documents; itsContains(world bounds)gave the correct 0, because it narrowsContainswith an RPTContainsquery first.- Direct
relate()calls: a Geo3d shape related to such a rectangle, and the rectangle related to a Geo3d shape that is neither a point nor a rectangle (spatial4j answers those two kinds with its own code). - Also, in one constructed case: code that wraps a spatial3d shape in
Geo3dShapeinside the standardSpatialContext.GEO, as the 7.0 upstream tests did. There the geohash cells are plain rectangles, and a point at (40, 40) matchesIntersectsof a 5-degree circle at (22.5, 22.5) on 7.1.0 and later, not on 7.0.1.
- Scale of the PackedQuad effect (
PackedQuadPrefixTree(12), the factory's default SPHERE model, 20 randomIntersectscircle queries over 20,000 random points, hits summed over the 20 queries):
| radius | main | exact | fix |
|---|---|---|---|
| 0.5 deg | 99,972 | 3 | 5 |
| 2 deg | 95,129 | 101 | 101 |
| 10 deg | 80,442 | 2,365 | 2,396 |
| 30 deg | 63,678 | 21,293 | 21,724 |
| 60 deg | 96,389 | 94,813 | 96,389 |
The fixed build's surplus is RPT's normal grid approximation. At distErrPct 0.025, circle documents of 0.5, 5 and 20 degrees are indexed as 1 term each instead of 109, 1,151 and 1,072, and the over-coarse cells stay in the index until it is rebuilt (code reading, not run).
- Silent. No exception; the index opens and answers queries normally (its integrity was not checked further); other filter clauses and deletions still apply (0 documents of another tenant and 0 deleted documents returned in the probe).
- How likely. Rare: it takes the opt-in context plus a second uncommon choice, and no report was found since 7.1.0 (2017).
The test shows that in the Geo3d spatial context Geo3dShape.relate(Rectangle) swaps WITHIN and CONTAINS for a rectangle that is not a Geo3d shape, here the context's own ctx.getWorldBounds().
/*
* 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.spatial4j;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.shape.Shape;
import org.locationtech.spatial4j.shape.SpatialRelation;
public class TestGeo3dShapeRelateWorldBounds extends LuceneTestCase {
public void testCircleIsWithinWorldBounds() {
SpatialContext ctx = new Geo3dSpatialContextFactory().newSpatialContext();
Shape circle = ctx.getShapeFactory().circle(0, 0, 10);
// expected: a 10-degree circle is WITHIN the world bounds; actual: CONTAINS
assertEquals(SpatialRelation.WITHIN, circle.relate(ctx.getWorldBounds()));
}
}
# in a copy of this finding's repro folder (repro/LFV-673/, 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.spatial4j.TestGeo3dShapeRelateWorldBounds
# the module(s) spatial-extras also need their third-party runtime jars
- Expected vs actual
- Expected
circle(0, 0, 10).relate(ctx.getWorldBounds())to beWITHIN; actualCONTAINS(expected:<WITHIN> but was:<CONTAINS>), 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:<WITHIN> but was:<CONTAINS>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.spatial.spatial4j.TestGeo3dShapeRelateWorldBounds.testCircleIsWithinWorldBounds(TestGeo3dShapeRelateWorldBounds.java:30)- The same box gives CONTAINS from the Geo3d factory and WITHIN as a
RectangleImpl; point sampling agrees with the factory. - Both contracts allow only a weaker answer (INTERSECTS, OVERLAPS) in place of WITHIN or CONTAINS, never the opposite one.
- Before 7.1.0 the same call was mapped the right way: the same test sources pass on 7.0.1 and fail on 7.1.0.
The full argument289 words
- Self-contradiction. For a 10-degree circle at (0, 0), the boxes (-1..1, -1..1), (-30..30, -30..30) and the world give CONTAINS, WITHIN, WITHIN from the Geo3d factory and WITHIN, CONTAINS, CONTAINS as
RectangleImpl, on SPHERE and WGS84; disjoint and crossing boxes agree. The factory path agrees with exact point membership (0 contradictions in 10,000 random pairs). - Contracts. spatial4j
Shape.relate: "Describe the relationship between the two objects. For example: this is WITHIN other, this CONTAINS other ...", with INTERSECTS the only allowed approximation of WITHIN or CONTAINS.GeoArea.getRelationship(GeoArea.java:46-60): the "return value is how the GeoShape relates to the GeoArea, not the other way around"; OVERLAPS may replace WITHIN "if the shape intersects with the area at even a single point" and CONTAINS only for a shape made of several overlapping subshapes; the opposite nested answer is "not permissible".IsWithinandContainsare documented as OGC CoveredBy and Covers (SpatialOperation.java:75, 124). - History. Before LUCENE-7951 (
035523ba7a8),relate(Rectangle)made the samegeoArea.getRelationship(shape)call and mapped WITHIN to WITHIN and CONTAINS to CONTAINS. The commit added the Geo3d-shape helper and one shared mapping written for it, and in the same change moved the rect-relation tests to the Geo3d context, whose rectangles take the other branch. - On purpose. On lucene-solr#246 the author wrote "I have added the possibility to relate no geo3d rectangles. I was trying to avoid that but I see that for the time being it is necessary.", and on #9000 noted that the Geo3d world bounds are a spatial4j
RectangleImpl. - Tests. No spatial-extras test relates a Geo3d shape to a
RectangleImplor the world bounds, andTestGeo3dRptuses only geohash, quad and S2 grids. No test, CHANGES entry or discussion accepts the swapped answer.
The devil's-advocate check138 words
The strongest case for "intended" (an intended-behaviour checker): every class involved is @lucene.experimental; the Geo3d context is meant to produce only Geo3d shapes, so a RectangleImpl is foreign input; getWorldBounds() is documented only as the coordinate extent, so it could be read as metadata rather than a query shape; and both contracts allow approximate answers.
Why it fails: experimental status covers API changes, not reversed answers; Lucene creates these rectangles itself (PackedQuad cells, the world bounds, the S2 world cell, the heatmap default input), and the rectangle branch was added for them; getWorldBounds() is a public, non-deprecated Rectangle that Lucene's heatmap code uses as a shape; both approximation clauses allow only a weaker answer, never the opposite nested one. Verdict: not intended, a defect (the adjudication and independent checks; the cross-model reviewer rated the intended-behaviour argument weak).
Recommended A: transpose the answer in relate(Rectangle)
In relate(Rectangle), transpose spatial3d's answer (CONTAINS to WITHIN, WITHIN to CONTAINS) into the direction the shared mapping expects, as before 7.1.0: +8/-1 in Geo3dShape.java. The repros, the spatial-extras module checks and the differential family that found the bug pass. PackedQuad indexes built with the bug keep the wrong cells until reindexed (code reading).
// geoArea.getRelationship(shape) says how this shape relates to the rectangle; the caller
// expects how the rectangle relates to this shape (as relate(Geo3dShape) returns): transpose.
final int relationship = geoArea.getRelationship(shape);
return switch (relationship) {
case GeoArea.CONTAINS -> GeoArea.WITHIN;
case GeoArea.WITHIN -> GeoArea.CONTAINS;
default -> relationship;
};All options and trade-offs3 options ยท 630 words
Two options were built and validated in a scratch worktree at a9779ef3c193 with gradle (JDK 25) and with the standalone runner against this page's test, the finder's, the independent reproducer's and the devil's advocate's repros and differential family G1 (patched class shadowed). They gave the same answer on 40,000 random shape and box pairs; that is a sample, not a proof, since the two directions of getRelationship may legitimately differ where the contract allows OVERLAPS. Either needs a regression test; this page's test is one.
Keep the spatial3d computation of 7.0 and earlier (GeoBBox.getRelationship(shape)) and swap CONTAINS and WITHIN before the shared mapping, so both helpers hand it an answer about the other shape. The finder's fix, with the comment shortened to fit the line limit.
// geoArea.getRelationship(shape) says how this shape relates to the rectangle; the caller
// expects how the rectangle relates to this shape (as relate(Geo3dShape) returns): transpose.
final int relationship = geoArea.getRelationship(shape);
return switch (relationship) {
case GeoArea.CONTAINS -> GeoArea.WITHIN;
case GeoArea.WITHIN -> GeoArea.CONTAINS;
default -> relationship;
};Trade-offs
- Smallest change; restores the 7.0 answers exactly (a 7.1.0 port makes the history tests pass there).
- Leaves two helpers that ask from opposite sides, so the class still needs the comment to stay right.
- Indexes built on Geo3d plus PackedQuad while the bug was present keep their over-coarse cells until reindexed (code reading, not run): worth a CHANGES or MIGRATE line.
What the checks validated
- Gradle, scratch worktree at
a9779ef3c193, seed DEADBEEF, with this page's test added::lucene:spatial-extras:checkpasses: 27 suites, 121 tests (the module's 117 plus the 4 new), 5 skipped, 0 failures, and the format, ecjLint and forbiddenApis checks. The finder's original comment had one 102-character line that fails the format check, hence the rewording. - Standalone, fix shadowed: this page's test 4/4 (assertions on and off), the finder's 3/3, the independent reproducer's 4/4, its PackedQuad probe 1/1, the devil's advocate's 2/2; the differential harness's family G1 gives 0 contradictions on all three paths (its other families, unrelated findings, report the same counts with and without the fix).
- Earlier, by the hunt: the module's 117 tests with all six fixes of the same hunt unit applied (fix A in the finder's original wording among them;
:test, not:check); a 7.1.0 port makes the history tests pass on 7.1.0.
Build a GeoBBox from the rectangle and return shape.getRelationship(bbox), the computation a Geo3dRectangleShape already gets, so the same box gives the same answer in both forms by construction. +5/-5 (GeoAreaFactory.makeGeoArea with four bounds is GeoBBoxFactory.makeGeoBBox anyway, so its import goes).
GeoBBox bbox =
GeoBBoxFactory.makeGeoBBox(
shape.getPlanetModel(),
r.getMaxY() * DistanceUtils.DEGREES_TO_RADIANS,
r.getMinY() * DistanceUtils.DEGREES_TO_RADIANS,
r.getMinX() * DistanceUtils.DEGREES_TO_RADIANS,
r.getMaxX() * DistanceUtils.DEGREES_TO_RADIANS);
return shape.getRelationship(bbox);Trade-offs
- Removes the second direction from the class; plain rectangles use the path the existing rect-relation tests cover.
- Switches plain rectangles from
GeoBBox.getRelationship(shape)toshape.getRelationship(bbox). Both gave the same answer on all 40,000 random pairs of a comparison probe (circles and convex polygons, SPHERE and WGS84), but that is a sample, not a proof; B would want broader testing (degenerate and composite shapes) before it is called equivalent. - The same reindex note as A.
What the checks validated
- Gradle, the same worktree and test:
:lucene:spatial-extras:checkpasses: 27 suites, 121 tests, 5 skipped, 0 failures, and the format, ecjLint and forbiddenApis checks. - Standalone, fix shadowed: the same repros all pass (4/4, 3/3, 4/4, 1/1, 2/2); family G1 of the differential harness gives 0 contradictions.
Let each helper return a SpatialRelation, as before 7.1.0, instead of sharing one switch.
Trade-offs
Larger diff, same behaviour as A; makes the direction explicit per branch.
An application that used an IsWithin or PackedQuad Intersects result as a region boundary, for example for visibility, would include documents far outside the region; other filter clauses and deletions still apply.
Randomized differential testing of the spatial4j bridge: a harness checked each relate() answer against exact spatial3d membership of 2,000 sampled points. With a Geo3d shape as the argument 0 of 10,000 pairs contradicted; with the world bounds 999 of 1,000 and with plain rectangles 179 of 2,863, every one a WITHIN or CONTAINS answer.
The method in full157 words
Method: randomized differential and model-based testing (non-formal; the finder tagged it semi-formal), in a hunt unit on the spatial4j bridge to spatial3d.
- The harness
Geo3dBridgeDiff(family G1) generated random and boundary shapes on SPHERE and WGS84 (seed 5EED220) and required thatA.relate(B)never contradict exact spatial3d membership of 2,000 sampled points per pair. B was a Geo3d shape, the same box as a plainRectangleImpl, orctx.getWorldBounds(). - On stock main: 0 contradictions on the Geo3d-shape path, 999 of 1,000 against the world bounds and 179 of 2,863 against plain rectangles, all WITHIN or CONTAINS; 0 with the fix. Re-run for this doc with the same numbers.
- Reading
Geo3dShape.relategave the cause. The swap had been noted earlier, untriaged, as a side lead in the verification of a different spatial3d bug. - Verifiers widened the reach: an independent reproducer found the PackedQuad false positive, and the devil's advocate showed the flip with rectangles that Lucene itself creates.
No prior report found (2026-10-27): 127 tool queries over GitHub, commits, mailing lists, JIRA and the web, 23 manual GitHub searches and the files of all 409 open PRs. Closest: #9000 (LUCENE-7951), the change that introduced it, which never discusses the relation direction.
Searches4 searches
None of the hits is this bug. The only earlier mention is the hunt's own side lead.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene); downstream repositories | 2026-10-27 | two runs of the prior-report tool: 45 and 82 queries (identifiers such as Geo3dShape, Geo3dSpatialContextFactory, PackedQuadPrefixTree, getWorldBounds; symptoms such as Geo3d Within, SerializedDVStrategy IsWithin); 939 distinct hits in the second, positive control #9000 at rank 1 | no report; related: #9000, #9134, #9174, #9038, #9102, #8990, #9568, #11550 |
| GitHub, manual | 2026-10-27 | 23 phrasings across apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch and spatial4j | nothing matching |
| Open PRs | 2026-10-27 | changed files of all 409 open apache/lucene PRs | only #12295 touches Geo3dShape.java, an instanceof refactor that keeps the mapping |
| JIRA and mailing lists | 2026-10-27 | 6 JQL queries (LUCENE, SOLR); dev@ and java-user@ lucene, users@ and dev@ solr, solr-user@ | two 2019 solr-user threads on Geo3D returning too many hits, diagnosed there as clockwise polygons |
Introduced in 7.1.0 by LUCENE-7951 (035523ba7a86 on main, 85491401d54c on branch_7x, 2017-09-20), which routed the rectangle helper through a mapping written for the new Geo3d-shape helper. 6.6.6 and 7.0.1 pass the same tests; every release run from 7.1.0 to 10.5.2 and main fail. Not fixed upstream.
Versions tested6 rows
Introduced by LUCENE-7951 "Spatial4j implementations using Geo3d" (#9000): code from lucene-solr#246 by @iverase, committed by @dsmiley on 2017-09-20 as 035523ba7a86 on main (first released in 8.0.0) and the identical cherry-pick 85491401d54c on branch_7x, which shipped in 7.1.0. Later commits on these lines (0673c57ecf4, d66d9549d7c, 2cbf261032d, dfd536186fe / #15889) change no behaviour; a source scan of the 63 local release tags from 7.1.0 to 10.5.2 shows the same swapped path (8.8.2 to 8.11.4 are not tagged locally; 8.11.4 ran from its release jar). main has the swapped mapping in the switch-pattern form of #15889; branch_10x, branch_10_5, branch_10_6 and branch_10_3 have it in the 10.x form: different text, same behaviour.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test fails 4 of 4, assertions on and off (gradle and standalone) |
| 10.5.2, 9.12.3 | affected | this doc's test fails 4 of 4; the finder's 3 of 3 |
| 8.11.4, 7.7.3, 7.1.0 | affected | the finder's test fails 3 of 3; the history tests fail 2 of 3 and 1 of 2 |
| 7.1.0 with only Geo3dShape transposed | isolation | the history tests pass, so other 7.1.0 changes are not the cause |
| 7.0.1, 6.6.6 | not affected | the history tests pass (the old, correct mapping); the Geo3d spatial context does not exist yet |
| fix A or B on main | not affected | every listed repro passes; 0 contradictions in differential family G1 |
Reproduced by the finder, an independent reproducer, two executed reviews by another model family, a devil's advocate, the adjudication, the follow-up and a credibility audit. All rated it real; they widened the reach to PackedQuad and asked for narrower scope wording.
Checks and verdicts237 words
Each check ran its own tests on main a9779ef3c193 (the cross-model reviews on ea57360912ed, same spatial-extras and spatial3d code).
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate, corrected later | Differential harness and a 3-test JUnit (fails 3 of 3, assertions on and off); first claimed RPT was not affected. |
| refute review (OpenAI GPT-5.6, executed) | real, high | Own direction test fails exactly the two plain-rectangle nesting cases; own SerializedDV test gives [0, 2] instead of [2, 0]; all six own tests pass with the fix. |
| Independent reproducer | real, wider | Own test fails 4 of 4 on main, 10.5.2, 9.12.3, 8.11.4; Composite IsWithin(world bounds) 0 of 3; found the PackedQuad false positive. |
| Intended lens (OpenAI GPT-5.6, executed) | real, high | Intended-behaviour argument weak; own tests fail on main and pass with the fix. |
| Devil's advocate | real, high | Built the strongest intended case and showed why it fails; flip shown with library-made rectangles only (PackedQuad cell, S2 world cell). |
| adjudication | confirmed, high | Re-ran the finder's, the independent reproduction's and the PackedQuad tests; asked to name PackedQuad and to claim nothing for geohash, quad, S2 or heatmaps. |
| Follow-up | regression in 7.1.0 | Release boundary with one same-source test; scale and security probe; prior-report search. |
| credibility audit | keep | Re-ran the repro and the PackedQuad probe; asked not to say 'every query' and to quote the fixed build next to 99,972. |
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 443 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,506 words
- Registry title
- Geo3dShape.relate(Rectangle) swaps WITHIN and CONTAINS when the rectangle is not a Geo3d shape (such as ctx.getWorldBounds() or a PackedQuadPrefixTree cell), so IsWithin/Contains on stored shapes give the opposite answer and RecursivePrefixTreeStrategy over PackedQuadPrefixTree returns documents far outside the query shape (regression in 7.1.0)
- Area
- spatial-extras / spatial4j bridge to spatial3d (Geo3dShape.relate(Rectangle) and its shared GeoArea-to-SpatialRelation mapping), reached through SpatialOperation IsWithin/Contains on SerializedDVStrategy and CompositeSpatialStrategy, RecursivePrefixTreeStrategy cell traversal over PackedQuadPrefixTree, and any spatial4j relate() against ctx.getWorldBounds()
- Priority
- Medium, rank 202: Geo3dShape.relate silently swaps WITHIN and CONTAINS, but it needs the opt-in Geo3d context plus a second uncommon choice such as PackedQuadPrefixTree, so it follows the broader buffered-line row LFV-678.
- Impact class
- silent wrong results
- Impact note
- Applications that use the opt-in, @lucene.experimental Geo3d spatial context (Geo3dSpatialContextFactory), or that wrap spatial3d shapes in Geo3dShape inside the standard spatial4j geo context as code written before 7.1 did, get WITHIN and CONTAINS swapped with no error whenever a Geo3d shape is related to a spatial4j rectangle that the Geo3d shape factory did not build: with RecursivePrefixTreeStrategy over the non-default PackedQuadPrefixTree small and medium circle and polygon queries and documents are affected, not rectangles from the Geo3d factory or WKT (20 random half-degree circle queries over 20,000 points returned 99,972 hits where the exact answer is 3), SerializedDVStrategy IsWithin/Contains queries with ctx.getWorldBounds() or a hand-built rectangle return the opposite set (CompositeSpatialStrategy IsWithin matched 0 of 3; its Contains gave the correct 0), and direct relate() calls are wrong; the common setup (the Geo3d context with the default geohash tree, or quad or S2, WKT- or factory-built shapes, the Intersects predicate) was not shown to be affected, DISJOINT and INTERSECTS answers are correct, nothing throws, ordinary scale, every release tested from 7.1.0 to 10.5.2.
- Summary
- You use the Geo3d spatial context and relate a Geo3d shape to a spatial4j rectangle that the Geo3d shape factory did not build, for example a 10-degree circle against the context's own ctx.getWorldBounds(), or run a SerializedDVStrategy IsWithin(ctx.getWorldBounds()) query over that circle, and expect WITHIN (the circle lies inside the world) and one hit. Instead relate() returns CONTAINS and the query matches nothing, while Contains(world bounds) matches the circle: Geo3dShape.relate(Rectangle) asks spatial3d how this shape relates to the rectangle but then applies the mapping written for the opposite question (how the other shape relates to this one), so the two nested answers come out swapped, whereas the same box built by the Geo3d factory relates correctly. Lucene builds such rectangles itself, so RecursivePrefixTreeStrategy over PackedQuadPrefixTree, whose cells are plain spatial4j rectangles, treats a cell that contains a small query circle as lying inside the circle and returns every document in that cell, for example a point at (100.3, 37.7) for a half-degree circle at (1, 1).
- Introduced
- 035523ba7a86773500c053129f65c9a6dc4fd384 (main) and its identical branch_7x cherry-pick 85491401d54c59e1593a5c9f13dca7bfcdda31a2 (the commit that shipped in 7.1.0): LUCENE-7951 'Spatial4j implementations using Geo3d' (issue apache/lucene#9000), code from apache/lucene-solr#246 by GitHub iverase (closed unmerged 2017-09-29), committed by GitHub dsmiley [read: git show in the full-history clone: the commit added relate(Geo3dShape) and replaced relate(Rectangle)'s own mapping (GeoArea.WITHIN -> WITHIN, CONTAINS -> CONTAINS) with the shared mapping (CONTAINS -> WITHIN, WITHIN -> CONTAINS) while keeping the geoArea.getRelationship(shape) call; exec: release boundary with one same-source test, 7.0.1 passes, 7.1.0 fails, and 7.1.0 with only Geo3dShape transposed passes; no commit-level bisection]. A regression; later commits on these lines (0673c57ecf4, d66d9549d7c, 2cbf261032d, dfd536186fe / #15889) are behaviour-neutral. (2017-09-20 (both commits); first release 7.1.0 (tag 2017-10-16))
- First affected version
- 7.1.0 [exec: the follow-up's same-source relate test fails 2 of 3 and its same-source RPT test 1 of 2, the finder's JUnit fails 3 of 3; with only Geo3dShape transposed (shadowed) both same-source tests pass]; 7.0.1 and 6.6.6 pass the same-source tests [exec] (old, correct mapping; the finder's JUnit does not compile there because Geo3dSpatialContextFactory and the generic Geo3dShape<T> first appear in 7.1.0)
- Affected versions
- Every release from 7.1.0 to 10.6.0 (10.6.0 by reading: the doc reads branch_10_6) and main a9779ef3c193; not fixed upstream. [exec, JDK 25, SHA-1-checked release jars] the finder's JUnit fails 3 of 3 on 7.1.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main (-ea; main also -da); the independent reproduction's JUnit fails 4 of 4 on 8.11.4, 9.12.3, 10.5.2 and main (-ea; main also -da); the independent reproduction's PackedQuad RPT probe fails on 10.5.2 and main; the follow-up's same-source tests fail on 7.1.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main (main with -ea and -da) and pass on 6.6.6 and 7.0.1. [read] a source scan of 63 local release tags from 7.1.0 to 10.5.2 shows the same swapped path (8.8.2-8.11.4 are not tagged locally; 8.11.4 was executed from its release jar); 6.6.6, 7.0.0 and 7.0.1 have the old correct mapping; main, branch_10x and branch_10_3 have the same behaviour (main in the #15889 switch-pattern form, the branches in the 10.x form), and the only open PR touching the file (#12295) is a refactor that keeps the mapping.
- 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
- non-formal
- Upstream and prior reports
- none found (searched 2026-10-27, read-only; the prior-report record, verdict new): two scripted prior-report searches (45 queries with the web part pending, then 82 queries over GitHub issues and PRs on apache/lucene, commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev, java-user, issues and commits lists, JIRA and the web: 939 distinct hits, the positive control #9000 at rank 1, none about this defect), 23 manual GitHub search phrasings across apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and locationtech/spatial4j, the changed files of all 409 open apache/lucene PRs (only #12295 touches Geo3dShape.java, an instanceof refactor that keeps the mapping), upstream commits by path on main, branch_10x, branch_10_3 and branch_9x, 6 manual JIRA queries (LUCENE and SOLR) and the Lucene and Solr user and dev lists. Nothing reports or discusses Geo3dShape.relate(Rectangle) returning swapped WITHIN/CONTAINS for a rectangle that is not a Geo3d shape, or its IsWithin/Contains or PackedQuad symptoms. Closest: the introducing issue LUCENE-7951 / #9000 and its PR lucene-solr#246 (they note that the Geo3d world bounds are a plain RectangleImpl and add the non-Geo3d rectangle path on purpose, but never discuss the relation direction), LUCENE-8086 / #9134 with lucene-solr#288 (a later refactor, mapping unchanged), LUCENE-8126 / #9174 (S2PrefixTree, whose world cell is ctx.getWorldBounds()), and two 2019 solr-user threads about Geo3D returning too many hits, diagnosed there as clockwise polygons. Gaps: GitHub search reads only the first page per query; the web index covers few deep list and JIRA pages.
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27) with gradle in a scratch worktree and with the standalone JUnit runner, assertions on and off; the 10.5.2 and 9.12.3 release jars (assertions on and off). The hunt, verifiers and judge ran on maina9779ef3c193(the cross-model reviews onea57360912ed, whose spatial-extras and spatial3d sources are the same); the follow-up ran two same-source test classes on 6.6.6, 7.0.1, 7.1.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main. Fixes A and B were each validated with gradle (the new test, the whole spatial-extras module and its checks) and with the standalone runner against the listed repros and the differential family that found the bug.
- Finding record
lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dShape.java:39 (at a9779ef3c193) (@lucene.experimental), :54-75 (relate(Shape): dispatch to three helpers and one shared mapping, :66-74 GeoArea.CONTAINS -> WITHIN and GeoArea.WITHIN -> CONTAINS for non-points), :77-79 (relate(Geo3dShape): this.shape.getRelationship(other.shape)), :81-92 (relate(Rectangle): GeoAreaFactory.makeGeoArea(rect).getRelationship(this.shape))lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoArea.java:33-65 (at a9779ef3c193) (CONTAINS = 'The referenced shape CONTAINS this area', WITHIN = 'The referenced shape IS WITHIN this area'; getRelationship: 'return value is how the GeoShape relates to the GeoArea, not the other way around'; OVERLAPS may replace WITHIN or CONTAINS, the opposite nested answer is 'not permissible')lucene/spatial-extras/src/java/org/apache/lucene/spatial/query/SpatialOperation.java:84, (at a9779ef3c193) :134 (Contains and IsWithin evaluate indexedShape.relate(queryShape) == CONTAINS / WITHIN)lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/PackedQuadPrefixTree.java:49 (at a9779ef3c193) (@lucene.experimental), :400 (cells are new RectangleImpl); lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/S2PrefixTreeCell.java:179 (world cell shape = ctx.getWorldBounds()); lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/HeatmapFacetCounter.java:107 (default input shape = ctx.getWorldBounds()); lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/SpatialPrefixTreeFactory.java:50-58 (default tree geohash for a geo context; packedQuad is an explicit choice); lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dSpatialContextFactory.java:28 (@lucene.experimental)
- The finding in 30 seconds
lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dShape.java:54-105 (at a9779ef3c193)lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoArea.java:34-65 (at a9779ef3c193)lucene/spatial-extras/src/java/org/apache/lucene/spatial/query/SpatialOperation.java:84, (at a9779ef3c193) 134lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dShapeFactory.java:164-172; (at a9779ef3c193) Geo3dRectangleShape.java:37lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/PackedQuadPrefixTree.java:400, (at a9779ef3c193) 476-483; IntersectsPrefixTreeQuery.java:71-73; tree/FilterCellIterator.java:48; tree/S2PrefixTreeCell.java:179; HeatmapFacetCounter.java:107lucene/spatial-extras/src/java/org/apache/lucene/spatial/composite/CompositeSpatialStrategy.java:138-155 (at a9779ef3c193)spatial4j 0.8 jar: javap -c RectangleImpl.relate(Shape) (other.relate(this).transpose())
- Who is affected
lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/SpatialPrefixTreeFactory.java:55-58 (at a9779ef3c193)
- Why it looks unintended
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoArea.java:46-65 (at a9779ef3c193)lucene/spatial-extras/src/java/org/apache/lucene/spatial/query/SpatialOperation.java:75-87, (at a9779ef3c193) 124-137lucene/spatial-extras/src/test/org/apache/lucene/spatial/spatial4j/TestGeo3dRpt.java:57-62 (at a9779ef3c193)
- Suggested fix
lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoAreaFactory.java:37-44 (at a9779ef3c193)
- TestGeo3dShapeRelateWorldBounds.java (JUnit test):
repro/LFV-673/src/org/apache/lucene/spatial/spatial4j/TestGeo3dShapeRelateWorldBounds.java(32 lines)
A step-by-step picture made in parallel with this doc; every relation, count and hit on screen is parsed from its own executed probe (main a9779ef3c193 with assertions on and off, and with the fix shadowed).
assets/LFV-673/bug.png(443 KB)
