All findings · Lucene finding · LFV-611 · geo3d-bbox-expand-clamps-longitude-at-dateline

GeoBBox.expand stops a widened longitude edge at ±180° instead of crossing the dateline, so buffered Geo3D boxes miss the strip just past it

GeoBBox.expand grows a geo3d box by an angle, but an edge pushed past ±180° is clamped to 180° instead of crossing the dateline. In the opt-in Geo3D spatial context, Intersects(BUFFER(ENVELOPE(172, 178, 4, -4), 4)) misses a point at (-179.5, 1): 0 hits instead of 1 (since 7.1.0; expand since 5.2.0).

Summary

Priority
Low, rank 413Expanded or buffered geo3d boxes near the antimeridian silently miss points beyond the dateline, but only through the experimental GeoBBox.expand or WKT BUFFER(ENVELOPE) in the opt-in Geo3D context.
Impact
silent wrong results
Since
5.2.0 (LUCENE-6196, 2015)introduced 2015-04-13 · over 11 years
Affected
5.2.0 to 10.5.2, 10.6.0 (by reading) and main (the WKT BUFFER path since 7.1.0)
Status
documented
Found by
bounded-exhaustive checking of the real jar against exact geometry

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

The finding in 30 seconds

A geo3d box (GeoBBox) has a latitude range and a longitude range, and the longitude range may cross the dateline (left > right). expand(angle) grows the box by moving each edge out by angle radians. In spatial-extras, the Geo3D context's Rectangle.getBuffered and WKT BUFFER(ENVELOPE(...), d) are built on it.

A strip of longitudes around the 180° meridian. The envelope from 172°E to 178°E, buffered by 4°, should reach from 168°E across the dateline to 178°W and cover the point at 179.5°W, 1°N; the Geo3D context's buffered box stops at exactly 180°, so the point is outside and the query returns 0 hits instead of 1.
The envelope 172°E to 178°E buffered by 4°: the expected box crosses the dateline to 178°W and covers the point at 179.5°W; Geo3D's box stops at 180°. Select the still to open it at full size. Generated with AI, checked against the code.
  1. In a Geo3D spatial context, index one point at longitude -179.5, latitude 1, just across the dateline, and search Intersects(BUFFER(ENVELOPE(172, 178, 4, -4), 4)): the box from 172° to 178° east, grown by 4°.
  2. You expect the grown box to run from 168° east across the dateline to 178° west (182°), so the point, 0.5° past the dateline (1.5° inside the buffered edge at 178° west), matches: 1 hit, as in the plain spatial4j geo context.
  3. BUFFER calls GeoBBox.expand, which computes the new east edge as 178° + 4° = 182° (3.18 rad) and passes it unwrapped to GeoBBoxFactory.makeGeoBBox. The factory clamps every longitude above 180° to 180°.
  4. You get a box from 168° to 180° and 0 hits, with no error. All 13 geo3d box classes with a longitude range do this, at both edges: [2.5, 3.0] rad expanded by 0.3 ends at π instead of -2.983.
  5. It is not even a consistent rule: a box whose east edge already crosses the dateline is widened across it, so two boxes 0.002 rad apart grow by 0.3 rad and by 0.001 rad.

Who is affected

Users of the experimental spatial3d GeoBBox.expand, and of spatial-extras with the opt-in Geo3D spatial context (for example Solr's spatialContextFactory="Geo3D") who buffer rectangles near ±180° longitude. Deterministic, any data size, every planet model tested. Geo3DPoint, LatLonPoint and LatLonShape queries never call expand.

Mechanism, terms and numbers728 words

GeoBBox.expand(angle) grows a geo3d box by moving each edge out by angle. Geo3d boxes may cross the dateline (left longitude > right longitude), and expand widens such a box across it correctly. But when an edge that does not yet cross the dateline is moved past ±π, expand passes the unwrapped value (for example 3.0 + 0.3 = 3.3) to GeoBBoxFactory.makeGeoBBox, which clamps any right longitude above π to π and any left longitude below -π to -π. The grown box stops at exactly 180° and silently leaves out the strip beyond it. All 13 GeoBBox classes with a longitude range share the code, on every planet model. In spatial-extras, Geo3dRectangleShape.getBuffered is expand, and spatial4j's WKT reader calls getBuffered for BUFFER(...), so in the opt-in Geo3D spatial context Intersects(BUFFER(ENVELOPE(172, 178, 4, -4), 4)) covers 168° to 180° instead of 168° to -178° and misses a document at (-179.5, 1): 0 hits instead of 1. For a positive angle the grown box still contains the original box. Present since geo3d's first check-in, so in every release from 5.2.0 to 10.6.0 (10.6.0 by reading) and main; the Geo3D BUFFER path since 7.1.0. Fix: in each expand, wrap the moved longitudes into [-π, π] after the existing whole-world check.

Terms

  • geo3d box (GeoBBox, spatial3d): a shape bounded by two latitudes and two longitudes, in radians (π = 180°). GeoBBoxFactory.makeGeoBBox(planetModel, topLat, bottomLat, leftLon, rightLon) picks one of 18 classes (GeoRectangle, GeoWideRectangle, slices, degenerate lines and points, latitude zones, GeoWorld). When leftLon > rightLon the box crosses the dateline (the ±180° meridian).
  • expand(angle): "Expand box by specified angle" (GeoBBox.java:27-33): move each edge out by angle.
  • Geo3D spatial context (spatial-extras): Geo3dSpatialContextFactory, an opt-in spatial4j context whose shapes are geo3d shapes (default planet model: sphere). A spatial4j rectangle in it is a Geo3dRectangleShape, and its getBuffered(d) is expand(d in radians). spatial4j's WKT reader turns BUFFER(shape, d) into shape.getBuffered(d), and SpatialArgsParser reads query strings with that reader.

What happens, step by step (main a9779ef3c193)

  1. GeoRectangle.expand (GeoRectangle.java:211-227) computes newLeftLon = leftLon - angle and newRightLon = rightLon + angle. Only a span that reaches the whole world is special-cased (:221-224). The values go to the factory unwrapped.
  2. GeoBBoxFactory.makeGeoBBox (GeoBBoxFactory.java:37-57) clamps leftLon < -π to -π and rightLon > π to π (:52-57), as it clamps latitudes at the poles (:46-51). So 3.3 becomes π: the part past the dateline is cut off.
  3. Given wrapped bounds (leftLon > rightLon) the same factory builds a crossing box (extent += 2π, :79-82). That is why a box that already crosses the dateline is widened across it: its moved edge, for example -π + 0.301, is in range.
  4. The same pattern is in all 13 classes with a longitude range: GeoRectangle, GeoNorthRectangle, GeoSouthRectangle, GeoWideRectangle, GeoWideNorthRectangle, GeoWideSouthRectangle, GeoLongitudeSlice, GeoWideLongitudeSlice, GeoDegenerateHorizontalLine, GeoWideDegenerateHorizontalLine, GeoDegenerateVerticalLine, GeoDegenerateLongitudeSlice and GeoDegeneratePoint (which also lacks the whole-world check). GeoWorld and the four latitude zones have no longitude bounds.
  5. Geo3dRectangleShape.getBuffered is shape.expand(distance * DEGREES_TO_RADIANS) (Geo3dRectangleShape.java:169-173), the only caller of expand in Lucene's main sources. Other Geo3D shapes do not reach it: Geo3dShape.getBuffered throws UnsupportedOperationException (Geo3dShape.java:141-143) and points buffer to circles (Geo3dPointShape.java:72). So the affected WKT form is BUFFER(ENVELOPE(...), d), in a query string or in an indexed document shape.

Examples (executed on main)

inputexpectedactual
[2.5, 3.0].expand(0.3), sphere[2.2, -2.983], crossing the dateline[2.2, π]: stops at 180°
GeoWideRectangle [-3.0, 1.0].expand(0.3), WGS84[2.983, 1.3][-π, 1.3]
[2.5, -π + 0.001].expand(0.3) (crosses already)reaches -π + 0.301as expected
[2.5, π - 0.001].expand(0.3) (0.002 rad narrower)reaches -π + 0.299[2.2, π]: grew 0.001 rad east
Geo3D BUFFER(ENVELOPE(172, 178, 4, -4), 4)longitudes 168 to -178, latitudes -8 to 8longitudes 168 to 180
the same as an Intersects query, one point at (-179.5, 1)1 hit (the plain geo context: 1)0 hits

Nothing is corrupted. For a positive angle the grown box contains the original box (0 failures in 38.5 million containment checks), and a document indexed as BUFFER(ENVELOPE(...)) is stored as the well-formed, clamped box. The defect is that the box is too small on the dateline side.

Exposure and likelihood298 words
  • Who is exposed. Applications that call the @lucene.experimental GeoBBox.expand directly, and applications on spatial-extras with the opt-in, @lucene.experimental Geo3D context that buffer a rectangle: spatial4j Rectangle.getBuffered, or WKT BUFFER(ENVELOPE(...), d) in a query string or an indexed document. Solr exposes the context as spatialContextFactory="Geo3D" and documents WKT BUFFER (read from Solr's source and reference guide, not run). A code search found no use of the Geo3D context in Elasticsearch or OpenSearch.
  • What it takes. A box whose edge lies within the buffer angle of ±180° and that does not already cross the dateline. Then it is deterministic: no scale, no randomness, the same with assertions on and off, on the sphere (the Geo3D default) and WGS84. Data near the antimeridian (Fiji, the Aleutians, Chukotka) is ordinary.
  • What goes wrong. The buffered shape is too small on the dateline side. Intersects(BUFFER(...)) misses documents there (0 hits instead of 1), and predicates that match more when the shape is smaller over-match: MUST_NOT Intersects(BUFFER(...)), IsDisjointTo(BUFFER(...)), Contains(BUFFER(...)), and IsWithin for a document indexed as a buffered envelope. A probe of 100 comparisons over four strategies (serialized doc values, a geohash prefix tree, and composite strategies with geohash and S2 grids) and both planet models gave 44 wrong answers on main and 10.5.2, and 0 with the fix.
  • No error, nothing corrupted. No exception anywhere; the grown box still contains the original, and indexed buffered shapes are stored as well-formed clamped boxes.
  • Not affected. Geo3DPoint queries, core geo (LatLonPoint, LatLonShape), the plain spatial4j geo context, and buffers away from ±180°.
  • How likely. Rare: the experimental API or the opt-in Geo3D context, plus a buffered rectangle near the antimeridian. Buffered envelopes are an uncommon WKT form, and no report was found in the eleven years since 5.2.0.

Minimal repro

The test shows that GeoBBox.expand clamps a longitude bound moved past the dateline at PI instead of wrapping it: a SPHERE box with lon [2.5, 3.0] expanded by 0.3 stops at 180 degrees.

TestGeoBBoxExpandAcrossDateline.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.spatial3d.geom;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestGeoBBoxExpandAcrossDateline extends LuceneTestCase {

  public void testExpandRightBoundAcrossDateline() {
    GeoBBox box = GeoBBoxFactory.makeGeoBBox(PlanetModel.SPHERE, 0, -Math.PI / 4, 2.5, 3.0);
    GeoBBox expanded = box.expand(0.3);
    GeoPoint east = new GeoPoint(PlanetModel.SPHERE, -0.1, 3.2 - 2 * Math.PI);
    // expected right lon 3.0 + 0.3 to wrap to 3.3 - 2 PI (east is inside), actual: clamped at PI
    assertTrue(
        "expected the box to reach lon 3.3 - 2 PI and contain " + east + ", but was " + expanded,
        expanded.isWithin(east));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-611/, 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/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.spatial3d.geom.TestGeoBBoxExpandAcrossDateline
Expected vs actual
Expected the expanded box to reach lon 3.3 - 2 PI and contain the point at lon 3.2 - 2 PI; actual isWithin false, box leftlon=2.2, rightlon=3.141592653589793(180.0), with -ea and -da.

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

More files, environment, full output53 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 the box to reach lon 3.3 - 2 PI and contain [lat=-0.1, lon=-3.083185307179586([X=-0.9933074600910722, Y=-0.05808251585497932, Z=-0.09983341664682815])], but was GeoRectangle: {planetmodel=PlanetModel.SPHERE, toplat=0.3(17.188733853924695), bottomlat=-1.0853981633974483(-62.188733853924695), leftlon=2.2(126.05071492878113), rightlon=3.141592653589793(180.0)}
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.spatial3d.geom.TestGeoBBoxExpandAcrossDateline.testExpandRightBoundAcrossDateline(TestGeoBBoxExpandAcrossDateline.java:28)

Why it looks unintended

  • expand is documented as "Expand box by specified angle"; upstream testBBoxExpand checks each edge moves out (by 0.05 to 0.15 for 0.1).
  • Geo3d boxes may cross the dateline, and expand widens a box that already crosses it; a box 0.002 rad narrower is cut off.
  • spatial4j's geo buffer and Lucene's WithinPrefixTreeQuery wrap across the dateline; nothing documents a clamp.
The full argument310 words
  • The contract. GeoBBox.expand: "Expand box by specified angle. @param angle is the angle amount to expand the GeoBBox by." (GeoBBox.java:27-33).
  • The upstream test. TestGeoBBox.testBBoxExpand (TestGeoBBox.java:123-145) checks every edge after expand(0.1): a point 0.05 past it is inside, 0.15 past it is outside. Its only case is commented "Standard normal Rect box, not crossing dateline" (:126); no test covers expand near the dateline.
  • Longitude is cyclic everywhere else in geo3d. The factory builds dateline-crossing boxes (GeoBBoxFactory.java:79-82), expand widens a crossing box straight across the dateline, and every expand except GeoDegeneratePoint's special-cases a span that reaches the whole world (GeoRectangle.java:221-224), which only makes sense if longitude wraps.
  • The continuity pair. [2.5, -π + 0.001] and [2.5, π - 0.001], two boxes 0.002 rad apart, grow east by 0.3 and 0.001 rad under expand(0.3). A deliberate saturation rule would not depend on which side of the seam an edge sits.
  • spatial4j's own geo buffer wraps. Geo3dRectangleShape implements spatial4j's Rectangle.getBuffered. In a geo context, spatial4j 0.8's RectangleImpl.getBuffered normalizes both moved longitudes with DistanceUtils.normLonDEG, and it clamps to the world bounds only in a non-geo context. The Geo3D context is a geo context (spatial4j's factory defaults to geo = true, and the Geo3D factory does not change it).
  • Lucene's own geo buffer wraps too. WithinPrefixTreeQuery.bufferShape (spatial-extras, WithinPrefixTreeQuery.java:112-150) grows a shape's bounding box by a distance and, in a geo context, wraps both moved longitudes with DistanceUtils.normLonDEG (:126-140); it clamps only in a non-geo context (:141-147).
  • Nothing documents a clamp. No javadoc, CHANGES entry, MIGRATE note, test or code comment says that expanded boxes stop at ±180°.
  • History. The unwrapped bounds and the clamp both came with the first geo3d check-in (LUCENE-6196, 2015); its discussion, now #7258, never mentions expanding across the dateline.
The devil's-advocate check208 words

The strongest works-as-designed case (the hunt's intended-behaviour checker and a cross-model reviewer, rated moderate): the clamp looks purpose-built for expand. makeGeoBBox clamps exactly the four overflows that expand can produce, and no other in-tree caller was found to pass an out-of-range longitude (180 * DEGREES_TO_RADIANS is exactly Math.PI), while the constructors reject everything else out of range ("Accepts only values ... lon: -PI -> PI", GeoRectangle.java:91); expand and the clamp arrived together; the javadoc is one line and the API is @lucene.experimental; spatial4j's Shape.getBuffered contract is loose ("some shapes might buffer differently ... the returned shape should contain the current shape", which the clamped box satisfies); and the patch note of LUCENE-7951 (#9000), which added getBuffered, says "BUFFER: The Satial4j [sic] buffer extension is partially supported."

Why it fails: the continuity pair shows the result is not a consistent rule. Saturating at a limit makes sense for latitude at the poles, not for longitude, which has no edge. testBBoxExpand states the widening semantics, and spatial4j's own geo buffer and Lucene's WithinPrefixTreeQuery.bufferShape both wrap across the dateline. And "partially supported" most plausibly refers to the Geo3D shapes other than points and rectangles, whose getBuffered throws (Geo3dShape.java:141-143); nobody in #9000 mentions the dateline.

Suggested fix

Recommended Wrap the moved longitude bounds in each expand

In each of the 13 expand methods, wrap the moved longitudes into [-π, π] after the existing whole-world check, before calling GeoBBoxFactory.makeGeoBBox; GeoDegeneratePoint also gets the whole-world check the others have (+97/-2 lines). Both repros pass with assertions on and off, and all spatial3d and spatial-extras tests pass.

     if (currentLonSpan + 2.0 * angle >= Math.PI * 2.0) {
       newLeftLon = -Math.PI;
       newRightLon = Math.PI;
     }
+    // The moved longitude bounds wrap across the dateline (the factory would clamp them to +-PI)
+    if (newLeftLon < -Math.PI) {
+      newLeftLon += Math.PI * 2.0;
+    }
+    if (newRightLon > Math.PI) {
+      newRightLon -= Math.PI * 2.0;
+    }
     return GeoBBoxFactory.makeGeoBBox(
All options and trade-offs4 options · 708 words

Option A was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF), and checked against every hunt repro with the same classes shadowed onto the main jars; B, C and D were not run.

Wrap the moved longitude bounds in each expand (validated by the checks) (recommended)

In all 13 expand methods, after the whole-world check, add if (newLeftLon < -π) newLeftLon += 2π; and if (newRightLon > π) newRightLon -= 2π;. GeoDegeneratePoint.expand also gets the whole-world check (2 * angle >= 2π gives the full longitude range); without it, wrapping would turn a buffer of more than half the world into a small box. Only expand knows the box's span: after the whole-world check the new span is below 2π, so a bound past ±π wraps exactly once, and the factory then builds a dateline-crossing box, as it already does for boxes that cross. Latitude handling and the factory are unchanged.

     if (currentLonSpan + 2.0 * angle >= Math.PI * 2.0) {
       newLeftLon = -Math.PI;
       newRightLon = Math.PI;
     }
+    // The moved longitude bounds wrap across the dateline (the factory would clamp them to +-PI)
+    if (newLeftLon < -Math.PI) {
+      newLeftLon += Math.PI * 2.0;
+    }
+    if (newRightLon > Math.PI) {
+      newRightLon -= Math.PI * 2.0;
+    }
     return GeoBBoxFactory.makeGeoBBox(

Trade-offs

  • Behaviour change: boxes expanded near the dateline become larger (they now cross it), so buffered queries match more near ±180° and negated predicates match less. Results whose moved edges stay within ±180° are unchanged.
  • A second, separate change: the new GeoDegeneratePoint whole-world check makes a point expanded by an angle of at least π the full longitude range, where stock clamps one edge and (except at longitude 0) returns a range short of the world. No test in the fix covers it; it deserves its own test and a note.
  • Duplication: the same 7 lines in 13 classes (see option B).
  • Cost: two comparisons per expand (three in GeoDegeneratePoint); no benchmark was run. Validated for positive, finite angles only; negative, NaN and infinite angles were not tested. No format or API change.

What the checks validated

  • Stock main a9779ef3c193 (gradle, seed DEADBEEF): the repro fails 3 of 5 and 1 of 4 with assertions on and off; TestGeoBBox (11 tests) and the spatial4j Geo3D suites (TestGeo3d, TestGeo3dRpt, TestGeo3dShape*: 21 tests, 4 skipped) pass.
  • With the fix: the repro passes 9 of 9 with assertions on and off; all of :lucene:spatial3d:test (186 tests, 3 skipped) and :lucene:spatial-extras:test (121 tests, 5 skipped), repro classes included, pass with 0 failures, and per class the counts equal a stock run of both modules except the two repro classes; ecjLintMain, ecjLintTest and checkGoogleJavaFormat pass for spatial3d, and ecjLintTest and checkGoogleJavaFormat for spatial-extras (whose main sources the fix does not touch); the diff applies cleanly to a clean tree.
  • With the same 13 classes shadowed onto the main jars: this repro 9 of 9; the hunt's independent reproducer 12 of 12 (including the 23,040-check sweep), the continuity and WKT probes, the downstream probe (0 of 100 comparisons wrong, from 44) and the lattice check (0 longitude-widening violations, from 11,446).

The same logic in one shared helper

Move "whole-world check, then wrap" into one package-private helper (for example next to makeGeoBBox) that the 13 expand methods call with their unwrapped bounds and current span.

Trade-offs

Not run. Same behaviour as A with one place to maintain; a larger diff that touches every expand.

Wrap instead of clamp in GeoBBoxFactory.makeGeoBBox

Replace the two longitude clamps in the factory with a wrap.

Trade-offs

Not recommended; not run. The factory sees two numbers, not the span, so it cannot tell an edge moved past the dateline from a range slightly wider than the world, and it is a public entry point, so wrapping there would change the result for any external caller that passes out-of-range longitudes (no in-tree caller other than expand was found to do so). It changes behaviour for every caller, not only expand.

Document the clamp

Say in the expand and getBuffered javadoc that expanded boxes stop at ±180°.

Trade-offs

Not recommended. Keeps the wrong results and the jump between boxes on either side of the seam.

Downstream note

An application on the Geo3D context that writes an exclusion zone as MUST_NOT Intersects(BUFFER(ENVELOPE(...), d)) or IsDisjointTo(BUFFER(...)) near ±180° lets documents just across the dateline through.

How it was found

Bounded-exhaustive checking of the real spatial3d jar against exact geometry: a 10,117-box lattice on two planet models, each box expanded by three angles. All 38.5 million containment checks passed, but 11,446 longitude edges were not widened, all where the moved edge passes ±180° (the fix removes every one). Code reading found the cause; a JUnit confirmed it.

The method in full185 words

Method: semi-formal, bounded-exhaustive checking against exact semantics (hunt unit on spatial3d box and solid families, 2026-10-26, main a9779ef3c193).

  • A harness enumerated a lattice of 10,117 boxes (67 latitude pairs x 151 longitude pairs) through GeoBBoxFactory.makeGeoBBox on the sphere and WGS84, expanded each by 0.001, 0.1 and 0.5 rad, and checked two properties of the result against exact geometry: it contains the original box (38,549,892 checks, 0 failures), and a point half the angle past each latitude and longitude edge is inside it (705,366 latitude and 675,904 longitude checks).
  • 11,446 longitude checks failed; the finder reports that all are boxes whose moved edge passes ±π, across every class. 1,566 latitude checks also failed, all at pole points: a separate, untriaged observation that the fix does not change.
  • The finder found the root cause (unwrapped bounds into the clamping factory) by reading the code, wrote public-API JUnits for spatial3d and spatial-extras, and with the fix sketch shadowed the longitude failures went to 0.
  • An independent reproducer then found the end-to-end query path (SpatialArgsParser -> WKT BUFFER -> getBuffered -> expand) and its 0-instead-of-1 result.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26): about 200 queries over GitHub (apache/lucene, apache/solr, spatial4j, Elasticsearch, OpenSearch), 7 Lucene and Solr mailing lists, the JIRA archive, the web and the changed files of all 407 open Lucene PRs (one PR's file list capped). Closest: #9000's note that BUFFER is "partially supported", most plausibly about other shapes.

Searches6 searches

17 curated hits, none the same bug. The closest is #9000 (LUCENE-7951), which added getBuffered: its patch note says "BUFFER: The Satial4j [sic] buffer extension is partially supported."; the code shows that Geo3D shapes other than points and rectangles throw in getBuffered, the most plausible meaning, and nobody there mentions the dateline. locationtech/spatial4j#187 and #149 report the same kind of symptom in spatial4j's JTS adapter, a different library and fix site. Not fixed upstream: no later commit on upstream main touches the files, and no open PR does (one very large PR's file list is capped by the API).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, apache/solr, locationtech/spatial4j, Elasticsearch, OpenSearch)2026-10-26three runs of a scripted prior-report search (class, method, keyword and symptom queries: GeoBBox, GeoBBoxFactory, GeoRectangle, Geo3dRectangleShape, expand, getBuffered, dateline, antimeridian, 'clamped to 180', normWrapLongitude, getCrossesDateLine) plus 14 manual API searchesno report of this defect; #9000 (closest), #7258, #8256, #11544, #11708 have different topics or fix sites
Open apache/lucene PRs2026-10-26changed files of all 407 open PRs (the API caps the file list of one very large PR)none touches spatial3d geom or Geo3dRectangleShape (#12295 touches Geo3dShape only, an instanceof refactor)
Mailing lists2026-10-2649 searches over dev@, java-user@ and issues@ lucene, solr-user@, and users@, dev@ and issues@ solr; 6 dev@ mails on getBuffered read in fullonly LUCENE-7951 review mails and geo3d issue notifications
JIRA archive (LUCENE, SOLR)2026-10-267 queriesnothing on this; SOLR-8636 unrelated
Web2026-10-269 searcheslocationtech/spatial4j#187 and #149 (the same kind of symptom in spatial4j's JTS adapter)
Git history, CHANGES.txt, MIGRATE.md2026-10-26git log -S newRightLon, --grep expand/buffer/dateline, blameno change to expand since 2015; no entry on expand or buffering

History

Present since geo3d's first check-in (LUCENE-6196, bde056c7632, 2015-04-13), so in every release from 5.2.0 to 10.6.0 (10.6.0 by reading) and main; the getBuffered/WKT path since 7.1.0 (LUCENE-7951). Executed on 16 releases and main: affected from 5.2.0 on. Not a regression; not fixed upstream.

Versions tested6 rows

Introduced by bde056c7632 "LUCENE-6196: Geo3d initial checkin" (2015-04-13, on the lucene6196 feature branch; patch by @DaddyWri, committed by @dsmiley; JIRA era, no pull request; LUCENE-6196 is now #7258). It already has both halves: the unwrapped bounds in GeoRectangle, GeoWideRectangle, GeoLongitudeSlice and GeoWideLongitudeSlice, and the factory clamp. The other nine classes got the same pattern on the branch (400c39bac77, 1242bf1519c). Merged into trunk (f760a51f80d) and branch_5x (536ae683ff6) on 2015-05-06; first released in 5.2.0 (CHANGES: "New Spatial \"Geo3d\" API with partial Spatial4j integration"). LUCENE-7951 (#9000, 035523ba7a8, 2017-09-20, released in 7.1.0) added Geo3dRectangleShape.getBuffered on top of expand. Later edits to these lines are reformatting (55d46dc6d37, 2cbf261032d) and the planetModel argument of LUCENE-6487 (560f3a1a010, 2015-05-27); none changes the unwrapped bounds or the clamp, and every release tag from 5.2.0 to 10.5.2 still has both. branch_10x and branch_10_6 have the same expand, clamp and getBuffered code (read, 2026-10-28). The hunt first cited c3a4ee1c3b1; that commit only added final modifiers nearby.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis repro 4 of 9 fail with -ea and -da (gradle and classpath runner); the hunt's JUnit 3 of 4
10.5.2, 9.12.3 affectedthis repro 4 of 9 fail; the hunt's and the reproducer's JUnits fail the same way
7.1.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 10.0.0 affectedthe hunt's JUnit 3 of 4 fail, including getBuffered (8.x source variant where needed)
6.0.0, 6.6.6, 7.0.0 expand onlyboth dateline checks fail; no Geo3D spatial context and no getBuffered path before 7.1.0
5.2.0, 5.2.1, 5.3.0, 5.5.5 main() portsboth dateline checks fail, the control passes (5.2.x: unit sphere only)
5.1.0 no geo3dthe API does not exist; the port does not compile

Independent checks

Independent checks: a reproducer wrote its own tests (7 of 12 fail, including the end-to-end query and a 23,040-check sweep), an intended-behaviour devil's advocate (real; found the continuity pair), a root-cause check, read-only cross-model reviews (root cause agreed), an adjudication (confirmed, high confidence), and re-runs on main at the registration check and in a credibility audit.

Checks and verdicts285 words

Every executed check ran on main a9779ef3c193 (some also on 10.5.2 and 9.12.3), with assertions on and off where stated. The cross-model reviews could only read code, so their verdicts on reality are procedural.

Each independent check and its verdict
CheckVerdictWhat it found
Independent reproducer realOwn public-API JUnits from the claim alone: 7 of 12 fail on main (-ea, -da), 10.5.2 and 9.12.3, the 5 controls pass, 12 of 12 with the fix. Found the end-to-end path: the WKT BUFFER query returns 0 hits instead of 1.
Intended-behaviour devil's advocate real (high for the core claim)Built the saturation case and rejected it with the continuity pair; corrected the introducing commit; flagged that off-equator Geo3D buffering is angular, so the claim must not rest on matching the plain geo context.
Root-cause check root cause agreedThe finder's repro fails 3 of 4 on main -da and passes 4 of 4 with the fix; the cross-model root-cause lens found no class missed.
Cross-model reviews (OpenAI GPT-5.6, read-only) intended lens real (medium); refute lens not real (low, procedural); root-cause lens uncertain (procedural)The reviewer could not run code; every lens agreed on the root cause and that the inputs are in contract, and rated the intended-behaviour argument weak to moderate. Each separate re-run of the repro failed 3 of 4.
Adjudication confirmed, high confidence; silent wrong results, severity lowRe-ran the independent reproduction's JUnit: 7 of 12 fail with -ea and -da, 12 of 12 pass with the fix.
Registration check and audit keepRe-runs on main: the finder's JUnit 3 of 4 fail with -ea and -da; the end-to-end query 0 hits instead of 1 with -da.

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

Finding record

LFV-611, snapshot of 2026-11-06

Registry title
GeoBBox.expand clamps a widened longitude bound at +-180 degrees instead of wrapping it across the antimeridian, so expanded geo3d boxes (and Geo3dRectangleShape.getBuffered / WKT BUFFER(ENVELOPE(...)) in a Geo3D spatial context) miss the part beyond the dateline
Area
spatial3d / geom bounding boxes (GeoBBox.expand in the 13 GeoBBox classes with a longitude range, GeoBBoxFactory.makeGeoBBox); spatial-extras / spatial4j Geo3D context (Geo3dRectangleShape.getBuffered, reached by spatial4j's WKT BUFFER)
Priority
Low, rank 413: Expanded or buffered geo3d boxes near the antimeridian silently miss points beyond the dateline, but only through the experimental GeoBBox.expand or WKT BUFFER(ENVELOPE) in the opt-in Geo3D context.
Impact class
silent wrong results
Impact note
Applications that call the @lucene.experimental spatial3d GeoBBox.expand directly, or that use the opt-in Geo3D spatial context of spatial-extras (Geo3dSpatialContextFactory; Solr exposes it as spatialContextFactory="Geo3D", read, not executed) and buffer a rectangle through Shape.getBuffered or a WKT BUFFER(ENVELOPE(...), d) query or document shape, get a box that stops at +-180 longitude whenever a moved longitude bound passes the antimeridian, with no error: an Intersects(BUFFER(...)) filter misses documents just across the dateline (executed: 0 hits instead of 1), while predicates that match more when the shape is smaller (MUST_NOT Intersects, IsDisjointTo, Contains of the query, IsWithin of a buffered document) over-match; it is deterministic with -ea and -da, on the SPHERE and WGS84 models, needs no scale and affects every release since 5.2.0 (the spatial-extras path since 7.1.0); the expanded box always still contains the original box, Geo3DPoint queries and Lucene core geo (LatLonPoint, LatLonShape) never call expand, and a document indexed as BUFFER(ENVELOPE(...)) is stored as the well-formed clamped box (no corruption).
Summary
You take a geo3d box whose east edge is near the dateline, for example longitudes [2.5, 3.0] radians, and call expand(0.3), or in a Geo3D spatial context you search 'Intersects(BUFFER(ENVELOPE(172, 178, 4, -4), 4))'. You expect each bound to move out by the angle, so the box now crosses the dateline and reaches -2.983 radians (-178 degrees), as the same code already does for a box that crosses the dateline and as spatial4j's own geo rectangle buffer does. Instead expand passes the unwrapped bound 3.3 to GeoBBoxFactory.makeGeoBBox, which clamps it to PI, so the box stops at 180 degrees and a document at (-179.5, 1) is not found (0 hits instead of 1); the west edge and all 13 GeoBBox classes with a longitude range behave the same way.
Introduced
bde056c763231cd4f35acbc708cfc07dc181b1fc ('LUCENE-6196: Geo3d initial checkin', svn r1673165 on the lucene6196 feature branch; patch by GitHub DaddyWri, committed by GitHub dsmiley; no pull request (JIRA era; LUCENE-6196 is apache/lucene#7258)). It already has both halves: GeoRectangle, GeoWideRectangle, GeoLongitudeSlice and GeoWideLongitudeSlice compute newLeftLon = leftLon - angle and newRightLon = rightLon + angle, special-case only the full-world span and pass the unwrapped values on, and GeoBBoxFactory.makeGeoBBox clamps leftLon < -PI to -PI and rightLon > PI to PI. The other 9 classes got the same pattern on the same branch (400c39bac77, 2015-04-15; 1242bf1519c, 2015-04-22); the branch was merged into trunk as f760a51f80d and into branch_5x as 536ae683ff6 (2015-05-06). [read: git blame -M -C -C on main and git log -S newRightLon in the history clone; exec: 5.1.0 has no geo3d, 5.2.0 fails.] The spatial-extras path came with LUCENE-7951 (#9000; 035523ba7a8 on master, 85491401d54 on branch_7x, 2017-09-20), which added Geo3dRectangleShape.getBuffered = shape.expand(...) and did not change expand. Not a regression: the logic is unchanged since (later edits are reformatting only, 55d46dc6d37 and the LUCENE-9570 reformat 2cbf261032d). (2015-04-13 (lucene6196 feature branch); merged into trunk and branch_5x 2015-05-06; spatial-extras path 2017-09-20 (LUCENE-7951))
First affected version
5.2.0 [exec] (the first release with geo3d: a main() port in its package, unit sphere only, fails both dateline checks while its away-from-dateline control passes; 5.1.0 has no geo3d, the port does not compile). For the spatial-extras path (Geo3dRectangleShape.getBuffered, WKT BUFFER in a Geo3D context): 7.1.0 [exec: 3 of 4 fail]; 7.0.0 has no Geo3dSpatialContextFactory, and before 7.1.0 Geo3dShape.getBuffered returned the shape itself (5.2.x) or threw UnsupportedOperationException (5.3.0-7.0.0) [read]
Affected versions
Every release from 5.2.0 through 10.6.0 (10.6.0 by reading) and main a9779ef3c193; not fixed upstream (upstream main a95c754ee00 has no later commit touching spatial3d geom or Geo3dRectangleShape, and no open PR changes those files). [exec, SHA-1-checked release jars, JDK 25] the finder's checks fail on 5.2.0, 5.2.1, 5.3.0 and 5.5.5 (main() ports: 2 dateline checks fail, the control passes), 6.0.0, 6.6.6 and 7.0.0 (JUnit: 2 of 3 fail, no getBuffered test before 7.1.0), 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 with -ea and -da (3 of 4 fail; the away-from-dateline control passes); the independent reproduction's JUnit fails 7 of 12 (its 5 controls pass) on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's probes fail on main (-ea and -da) and 10.5.2; the security probe fails 7 of 10 on main (-ea and -da) and 10.5.2. [read] every release tag in the history clone from 5.2.0 to 8.8.1 and from 9.0.0 to 10.5.2 has the unwrapped bound and the clamp; the 8.9.0-8.11.x tags are not in the clone (8.11.4 was executed). branch_10x and branch_10_6 were not checked in this step; the doc's fact check (2026-10-28) read the same code on both, so 10.6.0 has it.
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
semi-formal: exhaustive check; also reached by code reading
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, verdict new): about 200 logged queries: three scripted prior-report searches (120 GitHub issue/PR, commit, downstream, mailing-list, JIRA and web queries), 14 manual GitHub API searches (apache/lucene, apache/solr, spatial4j, Elasticsearch, OpenSearch), 49 mailing-list searches over 7 Lucene and Solr lists, 7 JIRA REST queries over LUCENE and SOLR, 9 web searches, the changed files of all 407 open apache/lucene PRs, and the local git history, CHANGES.txt and MIGRATE.md; all 17 curated hits are not the same bug. Not fixed upstream: no commit after the build-only 54283150a81 touches the files on upstream main a95c754ee00, and no open PR touches spatial3d geom or Geo3dRectangleShape (the only open PR in spatial-extras/spatial4j, #12295, is an instanceof refactor of other files). Related items, not reports of this bug: #9000 / LUCENE-7951 (added getBuffered; its patch note says 'BUFFER: The Spatial4j buffer extension is partially supported', which most plausibly means that Geo3D shapes other than points and rectangles throw UnsupportedOperationException in getBuffered; nobody there mentions the dateline), #7258 / LUCENE-6196 (origin of expand and the clamp; no discussion of expanding across the dateline), #8256 / LUCENE-7201, LUCENE-7965, #11544 / LUCENE-10508 and #11708 / LUCENE-10673 (GeoBBoxFactory changes with different fix sites), locationtech/spatial4j#187 and #149 (the same 'buffer stops at the dateline' symptom in spatial4j's own JTS adapter, a different library and fix site).
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the two repro JUnits through gradle in a clean scratch worktree, stock and with the fix, assertions on and off, plus all of lucene/spatial3d (186 tests) and lucene/spatial-extras (121 tests) with the fix and on stock, and the module lint and format checks (2026-10-28); through the hunt's classpath runner, the same JUnits on main (-ea, -da, fix shadowed), 10.5.2 and 9.12.3. Hunt runs on main a9779ef3c193 and on 16 release versions from 5.1.0 (no geo3d) to 10.5.2. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBBox.java:23-33 (@lucene.experimental; 'Expand box by specified angle'); GeoBBoxFactory.java:46-57 (latitude and longitude clamps); GeoRectangle.java:211-227 (expand); the same pattern in GeoNorthRectangle.java:187-201, GeoSouthRectangle.java:177-189, GeoWideRectangle.java:214-228, GeoWideNorthRectangle.java:175-189, GeoWideSouthRectangle.java:168-182, GeoLongitudeSlice.java:128-140, GeoWideLongitudeSlice.java:125-137, GeoDegenerateHorizontalLine.java:154-168, GeoWideDegenerateHorizontalLine.java:160-174, GeoDegenerateVerticalLine.java:155-165, GeoDegenerateLongitudeSlice.java:84-93, GeoDegeneratePoint.java:67-72 (no full-span guard)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dRectangleShape.java:169-173 (getBuffered calls shape.expand); lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestGeoBBox.java:123-147 (testBBoxExpand: each bound moved by 0.1, 'not crossing dateline')
  • lucene/CHANGES.txt:10766-10770 (LUCENE-6196 'partial Spatial4j integration') and :7866-7868 (LUCENE-7951); git -C a Lucene main checkout diff ea57360912e a9779ef3c19 -- lucene/spatial3d lucene/spatial-extras is empty
The finding in 30 seconds
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBBox.java:23-33 (at a9779ef3c193) (@lucene.experimental; 'Expand box by specified angle')
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoRectangle.java:211-227 (at a9779ef3c193) (expand; whole-world check 221-224), :91 (constructor ranges)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBBoxFactory.java:37-57 (at a9779ef3c193) (clamps 46-57), :79-82 (crossing-box extent)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom: (at a9779ef3c193) expand in GeoNorthRectangle 187-203, GeoSouthRectangle 177-191, GeoWideRectangle 214-230, GeoWideNorthRectangle 175-191, GeoWideSouthRectangle 168-184, GeoLongitudeSlice 128-142, GeoWideLongitudeSlice 125-139, GeoDegenerateHorizontalLine 154-170, GeoWideDegenerateHorizontalLine 160-176, GeoDegenerateVerticalLine 155-167, GeoDegenerateLongitudeSlice 84-95, GeoDegeneratePoint 67-74; 18 implementations of expand in total (grep)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dRectangleShape.java:169-173; (at a9779ef3c193) Geo3dShape.java:141-143; Geo3dPointShape.java:72; Geo3dSpatialContextFactory.java:33 (default planet model SPHERE); query/SpatialArgsParser.java:120-122
  • spatial4j 0.8 (the build's dependency): WKTReader.parseBufferShape calls Shape.getBuffered (bytecode read with javap, 2026-10-28)
Who is affected
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBBox.java:23 (at a9779ef3c193) (@lucene.experimental); lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dSpatialContextFactory.java:25-33 (at a9779ef3c193) (@lucene.experimental; default SPHERE)
  • grep '\.expand(' over lucene/spatial3d/src/java and lucene/spatial-extras/src/java at a9779ef3c193: the only caller is Geo3dRectangleShape.java:171
Why it looks unintended
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/GeoBBox.java:27-33; (at a9779ef3c193) GeoRectangle.java:91, :221-224; GeoBBoxFactory.java:46-57, :79-82
  • lucene/spatial3d/src/test/org/apache/lucene/spatial3d/geom/TestGeoBBox.java:123-145 (at a9779ef3c193) (testBBoxExpand; comment at :126)
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dShape.java:141-143; (at a9779ef3c193) Geo3dShapeFactory.java:164-173; Geo3dShapeFactory.java:97-102 (normX wraps when normWrapLongitude is set); ../prefix/WithinPrefixTreeQuery.java:112-150 (bufferShape: normLonDEG in the geo branch 126-140, world-bounds clamp only in the non-geo branch 141-147)
  • spatial4j 0.8 RectangleImpl.getBuffered (normLonDEG in the geo branch, world-bounds clamp in the non-geo branch) and SpatialContextFactory (geo defaults to true): bytecode read with javap, 2026-10-28
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DUtil.java:179-183 (at a9779ef3c193) (fromBox checks its degrees before calling the factory); geom/LatLonBounds.java:347-354 (wraps its longitudes); 180 * DistanceUtils.DEGREES_TO_RADIANS == Math.PI exactly (IEEE doubles, checked 2026-10-28)
Suggested fix
  • lucene/spatial-extras/src/java/org/apache/lucene/spatial/spatial4j/Geo3dShapeFactory.java:164-173 (at a9779ef3c193) (rect passes degree-converted bounds to the factory; in-range degrees convert to at most exactly ±π)
History
  • lucene/CHANGES.txt:10766-10770 (at a9779ef3c193) (LUCENE-6196, under 5.2.0), :7866-7868 (LUCENE-7951, under 7.1.0)

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

Repro files

About the diagrams

Made in parallel with this doc; its values come from an executed probe of the same example (WKT BUFFER(ENVELOPE(172, 178, 4, -4), 4), a point at (-179.5, 1)) on main a9779ef3c193, with assertions on and off and with the fix.

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.