All findings · Lucene finding · LFV-675 · geo3drectangleshape-reset-keeps-old-bounds

Geo3dRectangleShape.reset() keeps the old minX/maxX/minY/maxY, so BBoxStrategy shape values and overlap-ratio scores are wrong in the Geo3d spatial context

In the opt-in Geo3d spatial context, Rectangle.reset() never updates getMinX()..getMaxY(), so BBoxStrategy, which resets one rectangle per document, reads every document's box back as (0, 0, 0, 0). A box equal to the query scores 0.0 instead of 1.0, in every release since 7.1.0.

Summary

Priority
Low, rank 435It only affects scores and shape values, never matching, when two rarely combined experimental pieces are used together, the same scoring-only BBoxStrategy class as the Low row LFV-369, so it moves to Low just ahead of LFV-369 because every document is affected.
Impact
silent wrong results
Since
7.1.0 (2017), LUCENE-7951, which added the Geo3d spatial4j classesintroduced 2017-09-20 · about 9 years
Affected
every release from 7.1.0 to 10.5.2, and main: run on 7.1.0, 7.7.3, 8.0.0, 8.9.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main
Status
documented
Found by
a model of the spatial4j bridge comparing the coordinates a reset shape reports with those of a freshly built one

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

A spatial4j Rectangle can be reused: reset(minX, maxX, minY, maxY) overwrites it in place. The Geo3d rectangle keeps its coordinates in four fields, and its reset replaces only the inner spatial3d box. BBoxStrategy reuses one rectangle each time it reads a segment's values, so every document reads back as that rectangle's first value, (0, 0, 0, 0).

Result frame. Geo3d context, BBoxStrategy, documents P (10, 20, 30, 40) and Q (15, 25, 35, 45), query P. rect(-50, ...).reset(10, ...) leaves getMinX() at -50.0 instead of 10.0; the shape values of P and Q are (0, 0, 0, 0) instead of their boxes; the overlap scores are 0.0 and 0.0 instead of 1.0 and 0.25; makeQuery still hits P and Q, and the distances from (15, 35) stay 0.00 and 6.38. The 4-line fix makes every row equal spatial4j's own SpatialContext.GEO.
The two indexed boxes and the point (0, 0) that every document reads back as, the four lines reset is missing, and the executed results next to the expected ones: wrong getters, shape values and overlap scores; matching and distances unaffected. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You use the Geo3d spatial context (Geo3dSpatialContextFactory) with BBoxStrategy and index two boxes: P = rect(10, 20, 30, 40) and Q = rect(15, 25, 35, 45).
  2. You read each document's box with makeShapeValueSource() and expect P and Q back. You get (0, 0, 0, 0) for both.
  3. You rank by makeOverlapRatioValueSource(rect(10, 20, 30, 40), 0.5) and expect P = 1.0 (it equals the query) and Q = 0.25 (it covers a quarter of it). You get 0.0 and 0.0, and explain says "No intersection".
  4. Why: BBoxStrategy creates one rect(0, 0, 0, 0) each time it reads a segment's values (getValues) and calls reset(...) for each document, and Geo3dRectangleShape.reset rebuilds its inner spatial3d box but never assigns the four fields the getters return. Directly: after rect(-50, -40, -30, -20).reset(10, 20, 30, 40) the rectangle equals rect(10, 20, 30, 40), yet getMinX() is still -50.
  5. No error, with assertions on or off. Lucene never resets the rectangles it queries with or indexes, so matching and the index are fine; distances use the rebuilt inner box. The default spatial4j context returns P, Q, 1.0 and 0.25. Every release since 7.1.0 (2017) has it.

Who is affected

Applications that use the opt-in Geo3d spatial context with BBoxStrategy and read its shape values or rank by its overlap ratio, and code that calls reset on a Geo3d rectangle. There every stored box reads back as (0, 0, 0, 0). Matching with freshly built query boxes, distances, the default spatial4j context and Lucene's core geo types are not affected.

Mechanism, terms and numbers878 words

In the opt-in Geo3d spatial context (Geo3dSpatialContextFactory, @lucene.experimental), Geo3dRectangleShape.reset(minX, maxX, minY, maxY) rebuilds the wrapped spatial3d GeoBBox but never assigns the four fields that getMinX()..getMaxY(), getWidth(), getHeight() and getCrossesDateLine() return. The reset rectangle equals the new box but reports the old coordinates. BBoxStrategy's shape-value source reuses one rect(0, 0, 0, 0) per getValues call and resets it for every document, so makeShapeValueSource() reports (0, 0, 0, 0) for every document and makeOverlapRatioValueSource() scores against that point: a box equal to the query scores 0.0 instead of 1.0. No exception. Lucene's own matching and indexing never reset a rectangle, so with freshly built rectangles they are unaffected, and distances use the rebuilt inner box; only a rectangle that application code resets itself and then queries with or indexes carries the stale bounds there. It came in with LUCENE-7951 in 7.1.0 (2017) and is in every release since and on main; a 4-line fix (assign the four fields in reset) passes every repro and the module's tests, and should land together with, or after, the fix for the class's separate getCrossesDateLine bug.

Terms

  • Spatial context: spatial4j's SpatialContext builds and relates shapes. Geo3dSpatialContextFactory (opt-in, @lucene.experimental) makes one whose shapes wrap spatial3d (Geo3d) shapes; its rectangles are Geo3dRectangleShape.
  • Rectangle.reset: spatial4j's "Expert: Resets the state of this shape given the arguments. This is a performance feature to avoid excessive Shape object allocation". A caller keeps one rectangle and overwrites it instead of allocating a new one.
  • BBoxStrategy (@lucene.experimental): indexes a box as four numbers (and a dateline flag). makeShapeValueSource() reads each document's box back from doc values; makeOverlapRatioValueSource(queryBox, proportion) scores each document by how much its box and the query box overlap (1.0 when they are equal).

The bug

  1. Geo3dRectangleShape keeps minX, maxX, minY, maxY as fields (Geo3dRectangleShape.java:39-42); Geo3dShapeFactory.rect passes the caller's raw bounds into them (Geo3dShapeFactory.java:163-173). getMinX()..getMaxY() return them (:132-150), and getWidth(), getHeight() and getCrossesDateLine() are computed from them (:118-130, :152-155).
  2. reset (:100-111) builds a new GeoBBox into the wrapped shape, clears the center and boundingBox caches, and returns. It never writes the four fields.
  3. So the object contradicts itself: equals and the rectangle's own relate use the new GeoBBox (Geo3dShape.java:55-79, :160-164), and so does getCenter(), but the getters return the old box, and so does any code that reads them (spatial4j's RectangleImpl.relate(Rectangle) reads the other rectangle's getters).
  4. BBoxValueSource creates one rect(0, 0, 0, 0) in each getValues(LeafReaderContext) call (BBoxValueSource.java:55-56, "// reused"; each call reads one segment, and a segment can get several calls) and resets it to each document's four doc values (:74). In the Geo3d context every document read through it therefore reports (0, 0, 0, 0).
  5. BBoxOverlapRatioValueSource.score computes the overlap height from the target's getMinY()/getMaxY() (BBoxOverlapRatioValueSource.java:148-156): for the query (10, 20, 30, 40) and the target (0, 0, 0, 0) it is min(40, 0) - max(30, 0) = -30, so the score is 0 with "No intersection".

The numbers (executed on main, assertions on and off)

call, Geo3d contextexpectedmainfix
rect(-50, -40, -30, -20).reset(10, 20, 30, 40), then getMinX()..getMaxY()10, 20, 30, 40-50, -40, -30, -20 (but it equals rect(10, 20, 30, 40))10, 20, 30, 40
shape values of P = (10, 20, 30, 40) and Q = (15, 25, 35, 45)P, Q(0, 0, 0, 0), (0, 0, 0, 0)P, Q
overlap scores of P, Q for the query (10, 20, 30, 40), proportion 0.51.0, 0.250.0, 0.01.0, 0.25
self-overlap of the box (-10, 10, -10, 10), which contains the point (0, 0)1.00.51.0 (with the getCrossesDateLine fix too; see Suggested fix)
distance from the point (15, 35) to P and Q0.0, 6.3799610.0, 6.3799610.0, 6.379961

The default spatial4j context (SpatialContext.GEO) gives the "expected" column on main.

Not affected (when the rectangles are built fresh)

  • Matching: BBoxStrategy.makeQuery reads the query rectangle's getters but never calls reset, so a freshly built query box matches correctly (code reading). In a 300-document probe with the class's separate getCrossesDateLine fix applied, the 125 queries return exactly the arithmetic truth with and without this fix. Without that fix, stock main already mismatches 62 of the 125 queries because of it, with identical aggregate counts with and without this fix (hit sets not compared query by query).
  • Distances: makeDistanceValueSource measures to getCenter(), which comes from the new GeoBBox (the table's last row). The Geo3d context measures with Geo3dDistanceCalculator, the default context with spatial4j's haversine calculator; both give degrees on the default sphere.
  • The index: documents are indexed from freshly built rectangles, so the stored numbers are right and need no reindex after a fix. Only a rectangle that the application has reset itself would be indexed with stale bounds and a stale dateline flag, and those documents would need a reindex; used as a query box, such a rectangle would also match by its stale bounds (code reading).
  • RecursivePrefixTreeStrategy, the default spatial4j contexts and Lucene's core geo types do not use this method.
Exposure and likelihood381 words
  • Who is hit. Applications that pair the Geo3d spatial context (Geo3dSpatialContextFactory, @lucene.experimental) with BBoxStrategy (@lucene.experimental) and read makeShapeValueSource() or rank by makeOverlapRatioValueSource(); and any application code that calls reset on a Geo3d rectangle. Downstream, by reading Solr's code only (not run): Solr maps spatialContextFactory="Geo3D" to this context, and its BBoxField builds the overlapRatio score on these shape values; one public downstream configuration uses such a field.
  • Default vs opt-in. Opt-in: two experimental pieces together, plus the value-source path. Within that pairing it is deterministic, assertions on or off: every document reads back as (0, 0, 0, 0), so every stored box other than (0, 0, 0, 0) itself is wrong (269 of 269 generated boxes in the probe).
  • What goes wrong. Every shape value is (0, 0, 0, 0). Overlap relevance is computed against the point (0, 0): 0.0 for every document when the query box does not contain that point (all ties, so ranking by overlap does nothing), and wrong in other ways when it does (the box -10..10 x -10..10 scores itself 0.5). Scores are wrong in general; a few can be right by coincidence.
  • At scale (follow-up probe: 300 boxes in 3 segments, 31 deleted, two tenants, 25 query boxes scored by overlap ratio): all 269 live documents report (0, 0, 0, 0). With the class's separate getCrossesDateLine bug fixed to isolate this one, 141 of 141 returned scores and the top-10 order of 16 of 25 queries differ from the default spatial4j context; with both fixes 0 of 141 and 0 of 25.
  • Silent, but contained. No exception or hang; explain says "No intersection". This bug does not change matching with freshly built query boxes, filter clauses or deletions (0 other-tenant and 0 deleted documents in the probe, all scores in [0, 1]), and an index built from fresh rectangles holds the right numbers. On stock main the same probe's matching was already wrong for 62 of 125 queries, and 77 of 218 scored documents did not satisfy the query, because of the class's separate getCrossesDateLine bug; with that fix both are 0.
  • How likely. Rare: the pairing appears little used (no upstream test combines the Geo3d context with BBoxStrategy), and nobody reported it in the nine years since 7.1.0.

Minimal repro

The test shows that in the Geo3d spatial context, Rectangle.reset(minX, maxX, minY, maxY) does not update the bounds that getMinX()..getMaxY() return.

TestGeo3dRectangleResetBounds.java (JUnit test)32 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.spatial4j;

import org.apache.lucene.tests.util.LuceneTestCase;
import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.shape.Rectangle;

public class TestGeo3dRectangleResetBounds extends LuceneTestCase {

  public void testResetReplacesMinX() {
    SpatialContext ctx = new Geo3dSpatialContextFactory().newSpatialContext();
    Rectangle r = ctx.getShapeFactory().rect(-50, -40, -30, -20);
    r.reset(10, 20, 30, 40);
    // expected: getMinX() is the reset value 10; actual: the old value -50
    assertEquals(10, r.getMinX(), 0);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-675/, 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.TestGeo3dRectangleResetBounds
# the module(s) spatial-extras also need their third-party runtime jars
Expected vs actual
Expected rect(-50, -40, -30, -20).reset(10, 20, 30, 40).getMinX() == 10; actual -50 (expected:<10.0> but was:<-50.0>), with -ea and -da.

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

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:<10.0> but was:<-50.0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:555)
       org.junit.Assert.assertEquals(Assert.java:685)
       org.apache.lucene.spatial.spatial4j.TestGeo3dRectangleResetBounds.testResetReplacesMinX(TestGeo3dRectangleResetBounds.java:30)

Why it looks unintended

  • spatial4j's Rectangle.reset: "Resets the state of this shape given the arguments"; spatial4j's own rectangle assigns all four bounds.
  • After reset the Geo3d rectangle contradicts itself: equals sees the new box, getMinX()..getMaxY() the old one.
  • In the introducing PR, reset cleared a bounds cache; a refactor turned the cache into four fields and dropped that line.
The full argument274 words
  • The contract. spatial4j 0.8 Rectangle.reset: "Expert: Resets the state of this shape given the arguments. This is a performance feature to avoid excessive Shape object allocation as well as some argument error checking. Mutable shapes is error-prone so use with care." getMinX(): "The left edge of the X coordinate." spatial4j's RectangleImpl.reset assigns minX, maxX, minY and maxY (four putfields in its bytecode). BBoxValueSource uses reset for exactly the documented purpose, and ShapeValues.value() "Returns a Shape for the current document".
  • The class contradicts itself. After reset, equals and the rectangle's own relate see the new box and the getters the old one.
  • The fields are the coordinates, not a constructor cache. In LUCENE-7951 (#9000) the author explained that the 6-argument constructor exists because wide Geo3d rectangles (more than 180 degrees of longitude) cannot compute their longitude bounds from the GeoBBox, and the reviewer accepted it.
  • History. In the PR's first version of the class (lucene-solr#246, ea31e5ce0f, 2017-09-07) the bounds were computed lazily from a cached LatLonBounds, and reset cleared the cache (bounds = null;). PR commit cfbac2fb00 (2017-09-12) replaced the cache with the four stored fields and removed bounds = null; from reset without assigning them. No review comment on #246 and none of the 43 comments on #9000 mention reset.
  • The sibling resets are complete. Geo3dPointShape.reset and Geo3dCircleShape.reset rebuild the wrapped shape, and their getters read it; only the rectangle caches coordinates, and only it forgets them.
  • Tests. No test under lucene/spatial-extras/src/test calls reset(, and TestBBoxStrategy uses only spatial4j's own contexts. No test, CHANGES or MIGRATE entry accepts the current behaviour.
The devil's-advocate check169 words

The strongest case for "intended" (an intended-behaviour checker): the public Lucene classes involved (Geo3dSpatialContextFactory, Geo3dRectangleShape, BBoxStrategy) are @lucene.experimental, BBoxValueSource is @lucene.internal and reuses the rectangle on purpose, spatial4j calls mutable shapes "expert" and "error-prone", the Geo3d context with BBoxStrategy is untested and partly unsupported (Geo3dShape.getArea throws UnsupportedOperationException), and nobody has reported it since 2017.

Why it fails: the "expert" warning is about callers sharing a mutable object, not a licence for reset to update half of it; experimental status warns about API changes, not wrong values; the wrong values are read right away, for the current document, with ordinary boxes; nothing documents the pairing as unsupported, and where something really is unsupported (getArea) it throws instead of returning wrong numbers; and the direct trigger, reset on a rectangle from the Geo3d factory, needs no BBoxStrategy at all. Verdict: not intended, a defect (the adjudication and independent checks; the cross-model reviewer rated the intended-behaviour argument weak under both of its lenses).

Suggested fix

Recommended A: assign the four fields in reset

Assign the four fields in Geo3dRectangleShape.reset (4 lines), as a freshly built rectangle has them. The new test, every earlier repro and the spatial-extras module check pass. Land it together with, or after, the fix for the class's separate getCrossesDateLine bug: alone, it exposes that bug and a self-overlap score becomes 19.0. Indexes built from fresh rectangles need no reindex.

@@ -106,6 +106,10 @@ public class Geo3dRectangleShape extends Geo3dShape<GeoBBox> implements Rectangl
             minY * DistanceUtils.DEGREES_TO_RADIANS,
             minX * DistanceUtils.DEGREES_TO_RADIANS,
             maxX * DistanceUtils.DEGREES_TO_RADIANS);
+    this.minX = minX;
+    this.maxX = maxX;
+    this.minY = minY;
+    this.maxY = maxY;
     center = null;
     boundingBox = null;
   }
All options and trade-offs4 options ยท 785 words

One option was built and validated in a scratch worktree at a9779ef3c193 with gradle (JDK 25), alone and together with the getCrossesDateLine fix, and with the standalone runner against every repro (patched class shadowed). The other three were weighed and not built. Any fix needs a regression test (this doc's test is one).

A: assign the four fields in reset (validated by the checks) (recommended)

Add four assignments after the new GeoBBox is built in Geo3dRectangleShape.reset (after line 108). A reset rectangle then matches the one Geo3dShapeFactory.rect builds from the same arguments (the factory passes the raw arguments to the 6-argument constructor). Because the assignments come after makeGeoBBox, an invalid argument that makes it throw leaves the object unchanged. It restores what bounds = null; did before the bounds became fields.

@@ -106,6 +106,10 @@ public class Geo3dRectangleShape extends Geo3dShape<GeoBBox> implements Rectangl
             minY * DistanceUtils.DEGREES_TO_RADIANS,
             minX * DistanceUtils.DEGREES_TO_RADIANS,
             maxX * DistanceUtils.DEGREES_TO_RADIANS);
+    this.minX = minX;
+    this.maxX = maxX;
+    this.minY = minY;
+    this.maxY = maxY;
     center = null;
     boundingBox = null;
   }

Trade-offs

  • Smallest change, at the defect; the same hunk as the finder's fix. It also makes getWidth() and getHeight() right after a reset, since they read the fields, and gives getCrossesDateLine() the new bounds as input; that getter's formula stays wrong for boxes that span longitude 0 until the class's separate getCrossesDateLine fix lands (with this fix alone: crossesDateLine=true and 19.0 for -10..10 x -10..10; with both: false and 1.0).
  • Fix ordering. On main the stale (0, 0, 0, 0) happens to hide the class's separate getCrossesDateLine bug (it returns maxX > 0 && minX < 0) from the overlap scorer. With only this fix, boxes that span longitude 0 reach the scorer with the wrong flag: the self-overlap of -10..10 x -10..10 is 19.0 with assertions off and an AssertionError: 19.0 from the queryRatio assertion with assertions on; in the 300-document probe 28 of 218 scores fell outside [0, 1] with assertions off and 14 of 25 queries threw with assertions on. With both fixes: 1.0, and 0 and 0. So land it together with that fix (one PR with both lines and both tests is simplest) or after it.
  • No reindex for indexes built from fresh rectangles. Lucene's indexing does not call reset, so existing BBoxStrategy indexes built from factory rectangles hold the right numbers and read back correctly after the fix. An application that indexed rectangles it had reset itself stored stale bounds and needs to reindex those documents (code reading).

What the checks validated

  • Gradle, scratch worktree at a9779ef3c193, seed DEADBEEF, this doc's test added: it fails 3 of 3 on stock main (assertions on and off) and passes with the fix.
  • ./gradlew :lucene:spatial-extras:check with the fix passes: 27 suites, 120 tests (the module's 117 plus the 3 new), 5 skipped, 0 failures, and the format, ecjLint and forbiddenApis checks. The suites include TestBBoxStrategy, TestGeo3d, TestGeo3dRpt, both Geo3d RectRelation tests, TestDistanceStrategy and TestPointVectorStrategy.
  • TestBBoxStrategy, TestDistanceStrategy and TestGeo3d* 10 times with a random root seed and assertions off: 480 tests, 50 skipped, 0 failures.
  • With the getCrossesDateLine fix added: ./gradlew :lucene:spatial-extras:check passes (120 tests, 5 skipped, 0 failures); the fix-order probe gives 1.0 for both boxes, assertions on and off.
  • Standalone, fix shadowed: this doc's test 3/3 (assertions on and off, alone and with both fixes); earlier, by the hunt: the finder's 3/3, the independent reproducer's 7/7, the cross-model reviewer's and the devil's advocate's own tests, the history test on 7.1.0 and main, the bridge model clean (0 of 10,000 resets, with the hunt's first five fixes applied together, not this fix alone), and the 300-document probe with both fixes (0 of 141 scores differ from the default context).

B: derive the fields from the new GeoBBox (not built)

Call the existing setBoundsFromshape() at the end of reset.

Trade-offs

A reset rectangle would then differ from a freshly built one: bounds derived from a GeoBBox do not round-trip exactly (-50 comes back as -49.999999999999986) and cannot be computed for boxes wider than 180 degrees of longitude, which is why the 6-argument constructor exists. Not recommended.

C: stop reusing the rectangle in BBoxValueSource (not built)

Build a fresh ctx.getShapeFactory().rect(...) per document instead of rect.reset(...).

Trade-offs

Fixes only the in-tree path: Rectangle.reset stays broken for application code, and the class keeps contradicting itself. By code reading it costs one extra object per document (reset already builds a new GeoBBox per document). Not sufficient on its own.

D: bring back the PR's first design (not built)

Compute the bounds lazily from a cached LatLonBounds and clear the cache in reset.

Trade-offs

Reintroduces the wide-rectangle problem that the stored fields solved. Not recommended.

Downstream note

Solr maps spatialContextFactory="Geo3D" to this context and builds its BBoxField overlap-ratio score on these shape values (read in Solr's code, not run), so such a field ranks every document against (0, 0, 0, 0).

How it was found

Model-based differential testing of the spatial4j-to-spatial3d bridge (not formal verification). A harness checked that a shape changed with reset reports the same coordinates as one freshly built: 10,000 of 10,000 random resets kept the old ones while equals said the shapes matched; 0 with the hunt's combined fixes applied. A public-API JUnit then showed the BBoxStrategy effects.

The method in full185 words

Method: model-based differential testing (non-formal; the finder tagged it semi-formal), in a hunt unit on the spatial4j bridge to spatial3d.

  • While modelling the shapes' mutators, the finder wrote the rule G6 of the bridge model: a shape changed with reset must equal, and report the same values as, a freshly built shape with the same arguments.
  • The harness Geo3dBridgeDiff checked G6a on 10,000 random rectangle resets (5,000 on the SPHERE and 5,000 on the WGS84 planet model): every reset rectangle kept its old getMinX()..getMaxY() while equals said it matched the fresh one; with the hunt unit's first five fixes applied together (this one among them), 0 of 10,000.
  • The finder then looked for in-tree callers of Rectangle.reset, found BBoxValueSource, and wrote a public-API JUnit on the stock main jar: a direct reset, BBoxStrategy shape values, and the overlap-ratio score compared with the default spatial4j context. It fails 3 of 3.
  • The same rule's circle family (G6d) also flagged Geo3dCircleShape.reset on WGS84 (a reset circle never equals a fresh one); it has no in-tree caller and is recorded as a lead only.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): two runs of the prior-report tool (45 and 51 queries), 45 manual GitHub searches, the changed files of the 409 open PRs (only #16911, a branch_10x decommission, removes the two files), 8 JIRA queries, the Lucene and Solr lists and the web. Closest: #9000 (LUCENE-7951), the introducing change, whose review never mentions reset.

Searches4 searches

None of the hits is this bug. Limits: keyword search only; private security reports and unindexed chat cannot be searched; the changed-file list of #16911 (7,630 files) could not be enumerated in full.

What was searched, when, and what turned up
WhereWhenHowFound
the prior-report tool: GitHub issues, PRs and commits (apache/lucene), downstream repositories, the lists, JIRA and the web2026-10-27two runs: 45 queries (the finder's; its 6 web queries left pending), then 51 queries in all, 45 with expanded class, method and symptom terms (Geo3dRectangleShape, BBoxValueSource, BBoxOverlapRatioValueSource, makeOverlapRatioValueSource, Geo3dSpatialContextFactory, reset, Geo3D BBoxField, overlapRatio) and 6 web queries; 201 distinct hits, 0 high, 6 medium, all readno report; related: #9000, lucene-solr#246, #9134, #6776, SOLR-11382
GitHub, manual search2026-10-2745 searches across apache/lucene, apache/lucene-solr, apache/solr, locationtech/spatial4j, Elasticsearch, OpenSearch and all of GitHubnothing matching; one downstream configuration uses a solr.BBoxField with the Geo3D context (not a report)
Open PRs2026-10-27the changed files of the 409 open apache/lucene PRs (the list of #16911, 7,630 files, could not be enumerated in full)none changes Geo3dRectangleShape.java or BBoxValueSource.java, except #16911 "Decommission branch 10x", which removes every file from branch_10x (a branch decommission, not a fix); #12295 changes only a cast in BBoxStrategy.makeQuery
JIRA and mailing lists2026-10-278 JQL queries (LUCENE, SOLR); dev@, java-user@ and issues@ lucene, users@ and dev@ solr, the old solr-user@; 7 web queriesSOLR-11382 (added the Geo3D alias, no reset discussion); a 2013 fix of the same bug class in another class (LUCENE-4914, Cell.reset)

History

Introduced in 7.1.0 by LUCENE-7951 (035523ba7a86 on main, 85491401d54c on branch_7x, 2017-09-20). In its PR, reset first cleared a bounds cache; a refactor replaced the cache with four fields and dropped the line. 7.0.x has no Geo3d context; 7.1.0 to 10.5.2 and main fail. Never fixed.

Versions tested5 rows

Introduced by the LUCENE-7951 commit "Spatial4j implementations using Geo3d" (issue #9000, "New wrapper classes for Geo3d"): code from lucene-solr#246 by @iverase (closed without a GitHub merge), committed by @dsmiley on 2017-09-20 as 035523ba7a86 on main (first released in 8.0.0) and the same-day cherry-pick 85491401d54c on branch_7x, which shipped in 7.1.0 (it is in the 7.1.0 tag, not in 7.0.1). Geo3dRectangleShape.java is new in it, and reset has never assigned the four fields; the only later commit on the method is the LUCENE-9570 reformat 2cbf261032d (2021). Inside the PR, the first version ea31e5ce0f cleared a lazy bounds cache in reset (bounds = null;); cfbac2fb00 (2017-09-12) replaced the cache with four stored fields and dropped that line. A source scan of the 63 local release tags from 7.1.0 to 10.5.2 finds the same reset body; 8.8.2 to 8.11.3 are not tagged locally; in their release jars javap shows reset writing only shape, center and boundingBox (8.9.0 and 8.11.4 were also run). Before 7.1.0 the class and the Geo3d context did not exist, and the BBoxStrategy usage that existed then (Geo3d shapes under the default context) is still correct, so this is not an upgrade regression. Upstream main and branch_10x still have the unchanged reset.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test fails 3 of 3, assertions on and off (gradle and standalone); the finder's 3 of 3
10.5.2, 9.12.3 affectedthis doc's test fails 3 of 3; the finder's 3 of 3; the history test 1 of 3 (controls pass)
10.0.0, 9.0.0, 8.11.4, 8.9.0, 8.0.0, 7.7.3, 7.1.0 affectedthe finder's test fails 3 of 3; the history test 1 of 3 (not run on 8.9.0)
7.0.1, 7.0.0 not affectedthe history test passes (the bounding box is a spatial4j RectangleImpl); the Geo3d spatial context does not exist yet
fix on 7.1.0 and main not affectedevery repro passes; on main the spatial-extras module check passes, alone and with the getCrossesDateLine fix

Independent checks

Reproduced by the finder, an independent reproducer, two executed reviews by another model family (OpenAI GPT-5.6), a devil's advocate, the adjudication, the follow-up and a credibility audit, each with its own runs. All rated it real and not intended; the audit lowered the impact because the pairing is rare.

Checks and verdicts296 words

Each check ran its own tests on main a9779ef3c193 (the cross-model reviews on ea57360912ed, whose spatial-extras and spatial3d code is the same).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateBridge model G6a (10,000 of 10,000 resets stale, 0 with the hunt's combined fixes); a 3-test JUnit fails 3 of 3 with assertions on and off and passes with the fix.
Independent reproducer realOwn test from a non-zero rectangle with spatial4j controls: 3 of 7 fail on main, 10.5.2 and 9.12.3, the controls pass; 7 of 7 with the fix.
refute review (OpenAI GPT-5.6, executed) real, highOwn tests: a direct reset kept -120.0 where 20.0 was due, a shape value reported minX 0 instead of 65, a self-overlap scored 0 instead of 1; all pass with the fix, and so do focused existing tests.
intended review (OpenAI GPT-5.6, executed) real, highIntended-behaviour argument weak; own tests: 5 run, 3 failures, 2 controls pass; 5 of 5 with the fix.
Devil's advocate real, highBuilt the strongest intended case and showed why it fails; showed from the LUCENE-7951 review that the fields are the coordinates; found that getArea throws, so area scores are not claimed.
adjudication confirmed, highRe-ran the independent reproduction's test (3 of 7 fail with assertions on and off, 7 of 7 with the fix) and the finder's (3 of 3 fail).
Follow-up since 7.1.0Release boundary with a version-portable test; the 300-document probe (matching unaffected, 269 of 269 documents stale, the fix-ordering effect); prior-report search.
credibility audit real; impact downgradedRe-ran the finder's test with a fresh seed: 3 of 3 fail with assertions on and off. Kept the bug, lowered the impact (rare pairing, scoring only).

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

Finding record

LFV-675, snapshot of 2026-11-06

Registry title
Geo3dRectangleShape.reset(minX, maxX, minY, maxY) replaces the wrapped spatial3d box but keeps the old minX/maxX/minY/maxY, so with the Geo3d spatial context BBoxStrategy shape values report a stale box for every document and BBoxStrategy overlap-ratio relevance scores are wrong
Area
spatial-extras / spatial4j bridge to spatial3d (Geo3dRectangleShape.reset), read by BBoxStrategy's value sources (BBoxValueSource: makeShapeValueSource, makeOverlapRatioValueSource)
Priority
Low, rank 435: It only affects scores and shape values, never matching, when two rarely combined experimental pieces are used together, the same scoring-only BBoxStrategy class as the Low row LFV-369, so it moves to Low just ahead of LFV-369 because every document is affected.
Impact class
silent wrong results
Impact note
Applications on the opt-in, @lucene.experimental Geo3d spatial context (Geo3dSpatialContextFactory; Solr exposes it as spatialContextFactory="Geo3D" and its BBoxField can score by overlap ratio, read, not run) that read BBoxStrategy value sources get wrong values with no error for every document: makeShapeValueSource reports the per-segment initial box (0, 0, 0, 0) instead of each document's indexed box (executed: 269 of 269 live documents in 3 segments), and makeOverlapRatioValueSource ranks by overlap with that constant (a document box equal to the query scores 0.0 instead of 1.0; in an executed 300-document probe, with the separate getCrossesDateLine sign bug fixed to isolate this one, 141 of 141 returned scores and the top-10 order of 16 of 25 queries differed from the default spatial4j context); application code that calls Rectangle.reset on a Geo3d rectangle itself also reads the old bounds; it is deterministic with -ea and -da, needs no scale and is in every release since 7.1.0; BBoxStrategy matching (makeQuery, executed), distance value sources (code reading), RecursivePrefixTreeStrategy, the default spatial4j contexts and Lucene's core geo types are not affected, and the index is not corrupted.
Summary
You use the Geo3d spatial context (Geo3dSpatialContextFactory) with BBoxStrategy and read each document's box with makeShapeValueSource() or rank by makeOverlapRatioValueSource(queryBox, ...), or you call reset(10, 20, 30, 40) on a Geo3d rectangle yourself. You expect reset to replace the whole rectangle (the spatial4j Rectangle.reset javadoc: 'Resets the state of this shape given the arguments'; spatial4j's own RectangleImpl.reset assigns all four bounds), so getMinX()..getMaxY() return 10, 20, 30, 40, the shape values return each document's indexed box, and a document box equal to the query scores 1.0, as it does with the default SpatialContext.GEO. Instead Geo3dRectangleShape.reset rebuilds only the wrapped GeoBBox and clears the center and bounding-box caches, never assigning the four fields that getMinX()..getMaxY(), getWidth(), getHeight() and getCrossesDateLine() read: the rectangle equals rect(10, 20, 30, 40) but reports its previous bounds, BBoxValueSource (which creates one rect(0, 0, 0, 0) per segment and resets it for each document) reports (0, 0, 0, 0) for every document, and the self-overlap score is 0.0.
Introduced
035523ba7a86773500c053129f65c9a6dc4fd384 (master) and its branch_7x cherry-pick 85491401d54c59e1593a5c9f13dca7bfcdda31a2 (the commit that shipped in 7.1.0): LUCENE-7951 'Spatial4j implementations using Geo3d' (migrated as apache/lucene#9000), from PR apache/lucene-solr#246 by GitHub iverase, committed directly by GitHub dsmiley (the PR was closed without a GitHub merge). Geo3dRectangleShape is a new file in it and its reset has never assigned the four bound fields [read: git log -L, git blame -w -M -C and git log -S over the full history agree; the only later commit on the method is the LUCENE-9570 reformat 2cbf261032d]. Inside the PR, the first version ea31e5ce0f derived the bounds lazily from a cached LatLonBounds and reset cleared it ('bounds = null'); commit cfbac2fb00 (iverase, 2017-09-12) replaced the cache with four stored fields and the 6-argument constructor and removed the cache clear from reset without assigning the new fields [read: GitHub API GETs, snapshots in the follow-up]. No review comment mentions reset. (2017-09-20 (commit date 23:09:46 -0400, i.e. 2017-09-21T03:09:46Z); the omission entered the PR in cfbac2fb00 on 2017-09-12)
First affected version
7.1.0 [exec: the finder's JUnit fails 3 of 3 and the version-portable bisection test fails 1 of 3 with both controls passing]; 7.0.0 and 7.0.1 are not affected [exec: the bisection test passes 3 of 3, because Geo3dShape.getBoundingBox() returns a spatial4j RectangleImpl there; Geo3dRectangleShape and Geo3dSpatialContextFactory do not exist before 7.1.0, so the finder's JUnit does not compile on 7.0.1]
Affected versions
Every release from 7.1.0 through 10.5.2 (the last release before 10.6.0) and main a9779ef3c193; not fixed upstream. [exec, JDK 25, SHA-1-checked release jars] the finder's JUnit (the 7.x and 8.x runs use a source variant that differs only in the LuceneTestCase import) fails 3 of 3 on 7.1.0, 7.7.3, 8.0.0, 8.9.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the bisection test fails 1 of 3 (both controls pass) on 7.1.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da) and passes on 7.0.0 and 7.0.1; with the 4-line fix both tests pass on 7.1.0 and main (follow-up); the independent reproduction's JUnit fails 3 of 7 (controls pass) on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check ran the finder's JUnit on main (-ea and -da) and 10.5.2 (3 of 3 fail). [read] a source scan of the 63 local release tags 7.1.0-8.8.1 and 9.0.0-10.5.2 finds that reset never assigns the fields; the 8.8.2-8.11.x tags are not in the local clones (8.9.0 and 8.11.4 executed, the rest inferred from branch lineage); upstream main (a95c754ee003, 2026-10-26) and branch_10x still have the unchanged reset (read 2026-10-27).
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; then 51 queries with an expanded spec plus 6 web queries, 201 distinct hits, 0 high, 6 medium, all triaged), 45 manual GitHub searches (apache/lucene, apache/lucene-solr, apache/solr, locationtech/spatial4j, Elasticsearch, OpenSearch and all of GitHub), a scan of the changed files of all 409 open apache/lucene PRs, a full read of PR apache/lucene-solr#246 and issue #9000, 8 JIRA queries over LUCENE and SOLR, list searches over the Lucene dev, java-user and issues lists and the Solr user and dev lists, and 7 web queries; the intended-behaviour check's own scripted prior-report search (40 queries) agrees. Nothing reports or discusses Geo3dRectangleShape.reset keeping stale bounds, or wrong BBoxStrategy shape values or overlap-ratio scores with the Geo3d context. Not fixed upstream: on upstream main (a95c754ee003, 2026-10-26) and branch_10x the last commit on Geo3dRectangleShape.java is the 2021 reformat 2cbf261032d and reset still assigns only shape, center and boundingBox; no open PR touches Geo3dRectangleShape.java or BBoxValueSource.java (the nearest, #12295, changes only a cast in BBoxStrategy.makeQuery). Related items, not reports of this bug: #9000 / LUCENE-7951 and apache/lucene-solr#246 (the introducing issue and PR; nobody discusses reset), #9134 / LUCENE-8086 (a later bridge change), #6776 / LUCENE-5714 (origin of BBoxValueSource and its rectangle reuse, before Geo3d existed), SOLR-11382 (added Solr's Geo3D context alias) and commit a22e571189c (LUCENE-4914, 2013: the same bug class, a reset that forgets state, in SpatialPrefixTree Cell).
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27 and 2026-10-28) with gradle in a scratch worktree and with the standalone JUnit runner, assertions on and off; the 10.5.2 (on and off) and 9.12.3 (on) release jars. The follow-up ran the finder's test on 7.1.0, 7.7.3, 8.0.0, 8.9.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main, and its version-portable test also on 7.0.0 and 7.0.1 (both clean). The cross-model reviews ran on ea57360912ed, whose spatial-extras and spatial3d sources are the same. The fix was validated with gradle (the new test, the whole spatial-extras check alone and together with the getCrossesDateLine fix, the closest suites 10 times) and with the standalone runner against every repro.

Lucene and upstream sources

Finding record
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dRectangleShape.java:35 (at a9779ef3c193) (@lucene.experimental), :39-42 (the four bound fields), :44-56 (6-argument constructor stores the caller's bounds), :100-111 (reset assigns shape, center and boundingBox only), :118-130 (getWidth, getHeight), :132-150 (getMinX..getMaxY return the fields), :152-155 (getCrossesDateLine reads the fields)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dShapeFactory.java:164-173 (at a9779ef3c193) (rect builds the GeoBBox and passes the raw minX..maxY to the 6-argument constructor) and lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dSpatialContextFactory.java:28 (@lucene.experimental)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxValueSource.java:32 (at a9779ef3c193) (@lucene.internal), :55-56 ('// reused' rect(0, 0, 0, 0) per getValues call, i.e. per segment), :74 (rect.reset per document); lucene/spatial-extras/src/java/org/apache/lucene/spatial/ShapeValues.java:35 ('Returns a Shape for the current document')
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxStrategy.java:71 (at a9779ef3c193) (@lucene.experimental), :218-219 (makeShapeValueSource returns BBoxValueSource), :223-225 (makeDistanceValueSource via getCenter), :232-235 (makeOverlapRatioValueSource over makeShapeValueSource); lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxOverlapRatioValueSource.java:67 (@lucene.experimental), :148-149, :219-232 (score from target.getMinX()..getMaxY(), getWidth(), getHeight(); asserts at 231-232)
The finding in 30 seconds
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dRectangleShape.java:35, (at a9779ef3c193) :39-42, :44-56, :85-98, :100-111, :113-116, :118-155; Geo3dDistanceCalculator.java:43-71
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxValueSource.java:32, (at a9779ef3c193) :55-56, :74; lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxOverlapRatioValueSource.java:146-156, :228-229; lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxStrategy.java:179-182, :206, :218-236, :254-276; BBoxSimilarityValueSource.java:113-124 (explain makes its own getValues call)
Who is affected
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dSpatialContextFactory.java:28; (at a9779ef3c193) lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxStrategy.java:71
Why it looks unintended
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/ShapeValues.java:34-35; (at a9779ef3c193) lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dPointShape.java:40-59; lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dCircleShape.java:39-70; lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dShape.java:126-128; lucene/spatial-extras/src/test/org/apache/lucene/spatial/bbox/TestBBoxStrategy.java:85-90, :178, :214
Suggested fix
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dRectangleShape.java:63-83 (at a9779ef3c193) (setBoundsFromshape), :152-155; lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxOverlapRatioValueSource.java:231 (the queryRatio assertion); lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxStrategy.java:179-182, :206
History
  • a Lucene clone: git merge-base --is-ancestor 85491401d54c releases/lucene-solr/7.1.0 (yes) and releases/lucene-solr/7.0.1 (no)

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

Repro files

About the diagrams

A step-by-step picture made in parallel with this doc; the PNG is the final result frame. Every value on screen is parsed from its own executed probe (main a9779ef3c193 with assertions on and off, and with the fix shadowed) and checked before it is drawn, with the same boxes as this doc.

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.