Both strategies turn the query rectangle into DoublePoint range queries. An inclusive bound is passed unchanged, and DoublePoint sorts -0.0 just below +0.0, so an edge at one zero leaves out a coordinate at the other zero, although spatial4j treats the two as the same coordinate.
- A Java
doublehas two zeros, +0.0 and -0.0. The primitive comparison-0.0 == 0.0is true, butDouble.compare(-0.0, 0.0)is negative: in that order -0.0 comes first. - spatial4j's
relateof a rectangle and a point, whichSpatialOperation.IntersectsandIsWithinevaluate through for this pair, compares coordinates with primitive operators, so a point at x = -0.0 lies on a rectangle edge at x = 0.0. (spatial4j'sequalsusesDouble.compare; see "Not part of this finding" below.) - BBoxStrategy and PointVectorStrategy (module
lucene/spatial-extras) index coordinates asDoublePointfields and answer queries withDoublePoint.newRangeQuery, whose ranges are inclusive and "consistent with Double#compareTo".
- Index one point at longitude -0.0, latitude 5 with BBoxStrategy and PointVectorStrategy, for example parsed from WKT
POINT(-0 5). Java has two zeros, and-0.0 == 0.0is true. - Query the rectangle minX = 0, maxX = 10, minY = 0, maxY = 10. The point is on its west edge, and
SpatialOperation.IntersectsandIsWithinboth evaluate to true. You expect 1 hit from each of the four queries. - You get [1, 0, 0, 0]: only BBoxStrategy Intersects finds the point, with no error. Stored at +0.0, the same point is found by all four.
- Why: both strategies pass the edge 0.0 unchanged as an inclusive
DoublePointrange bound, andDoublePointsorts likeDouble.compareTo, where -0.0 comes just before +0.0, so -0.0 falls outside [0.0, 10]. BBoxStrategy Intersects uses only exclusive bounds, moved withMath.nextUp/nextDown, which treat both zeros alike. - The same happens on the equator, in non-geo contexts, the other way round (+0.0 stored, -0.0 edge), for BBoxStrategy Contains and for PointVectorStrategy circles. Hits are only lost, never added.
Applications that use BBoxStrategy (IsWithin, Contains) or PointVectorStrategy (rectangle or circle queries), both @lucene.experimental, with default config. It needs a shape exactly on a query edge at x = 0 or y = 0 (prime meridian, equator, tile boundary) whose coordinate is -0.0 from caller data, such as WKT POINT(-0 5). Missed hits only; rare.
Mechanism, terms and numbers847 words
BBoxStrategy and PointVectorStrategy (module lucene/spatial-extras, both @lucene.experimental) answer a spatial query with DoublePoint range queries on the stored coordinates. An inclusive bound taken from the query rectangle is passed to DoublePoint.newRangeQuery unchanged, and DoublePoint ranges follow Double#compareTo, where -0.0 sorts just below +0.0. spatial4j, whose relate decides SpatialOperation.evaluate for a point and a rectangle, compares coordinates there as primitives, where -0.0 == 0.0. So a shape exactly on a query edge at x = 0 or y = 0 whose coordinate is the other zero silently drops out: a point stored at (-0.0, 5) against Rect(0.0, 10, 0, 10) gives hits [BBox Intersects 1, BBox IsWithin 0, PointVector Intersects 0, PointVector IsWithin 0] instead of [1, 1, 1, 1]. BBoxStrategy's Intersects uses only exclusive bounds, moved with Math.nextUp/Math.nextDown, which give the same double for both zeros, so it still finds the point: BBoxStrategy contradicts itself. The same miss affects BBoxStrategy Contains, PointVectorStrategy circle queries, the Y axis, non-geo contexts and the reverse direction. Only missed hits, never extra ones. Every release that ships the strategies has it (PointVectorStrategy since 4.0.0, BBoxStrategy since 4.10.0) through 10.6.0 and main. Widening a zero bound to [-0.0, +0.0] before the range query is built fixes it.
- The contract.
SpatialStrategy.makeQuerybuilds "a Query based principally on SpatialOperation and Shape" (SpatialStrategy.java:116).SpatialOperation's predicates are explained by "the source implementation of evaluate", and "Lucene-spatial makes no distinction of shape boundaries" (query/SpatialOperation.java:30-35).Intersectsisrelate(...).intersects()(:89-95);IsWithinis "CoveredBy ... (boundary-neutral)",relate == WITHIN || equals(:124-136). - BBoxStrategy, inclusive bounds.
makeWithin(bbox/BBoxStrategy.java:523) states "docMinX >= queryExtent.getMinX() ... docMaxX <= queryExtent.getMaxX()" (:526-527) and builds inclusive ranges (:531-532,:546-547);makeContains(:283) is the mirror (:286-287,:291-292,:304-305). - BBoxStrategy, the range helper
makeNumericRangeQuery(:651-672): a null bound becomes ±infinity (:654-660); an exclusive lower bound becomesMath.nextUp(min)(:663), an exclusive upper boundMath.nextDown(max)(:667), thenDoublePoint.newRangeQuery(fieldname, min, max)(:670). An inclusive bound is passed unchanged. Fix site. Every caller passes its null side as exclusive, so that side ends at -/+Double.MAX_VALUE. - BBoxStrategy Intersects is "has an envelope AND NOT disjoint" (
makeIntersects,:473,:491-494), andmakeDisjoint(:366) uses only exclusive bounds, e.g. docMaxX < query minX (:393). For the edge 0.0 that range is[-Double.MAX_VALUE, -4.9E-324](printedbbox__maxX:[-1.7976931348623157E308 TO -4.9E-324]), which holds neither zero, so the point is not disjoint and is found.Math.nextUp/nextDownreturn the same double for -0.0 and +0.0. - PointVectorStrategy.
makeQuery(vector/PointVectorStrategy.java:188-205) answers both Intersects and IsWithin of a rectangle withmakeWithin(bbox)(:194), and a circle withmakeWithin(circle.getBoundingBox())plus a distance filter (:196-198).makeWithin(:209-222) adds MUST ranges on x and y viarangeQuery(:228-238), which passes the bounds unchanged toDoublePoint.newRangeQuery(:238). Fix site. - The order.
DoublePoint.newRangeQuery: "Ranges are inclusive ... Range comparisons are consistent with Double#compareTo(Double)" (lucene/core/.../document/DoublePoint.java:210-213). So[0.0, 10]excludes a stored -0.0, and[-10, -0.0]excludes a stored +0.0.
Point (-0.0, 5), query Rect(minX=0.0, maxX=10, minY=0, maxY=10):
- BBox IsWithin needs docMinX in
[0.0, Double.MAX_VALUE](printedbbox__minX:[0.0 TO 1.7976931348623157E308]): -0.0 sorts below 0.0, so 0 hits. - PointVector, both operations, needs x in
[0.0, 10.0]: 0 hits. - BBox Intersects needs the point to be outside every disjoint range, e.g. docMaxX in
[-Double.MAX_VALUE, -4.9E-324]or docMinX in[10.000000000000002, Double.MAX_VALUE]: -0.0 is in none of them, so 1 hit.
- Same point, other zero (control, default
IndexWriterConfig, main and 10.6.0):makePoint(0.0, 5)gives[1, 1, 1, 1];makePoint(-0.0, 5)and WKTPOINT(-0 5)give[1, 0, 0, 0]. - Parsed query strings: a point at longitude -0.0, latitude 51.5 is missed by
IsWithin(ENVELOPE(0, 180, 90, -90))(the eastern hemisphere) in both strategies and byIntersects(ENVELOPE(0, 20, 60, 0))in PointVectorStrategy; RecursivePrefixTreeStrategy finds it. The western neighbour cell still finds the point, so it drops out on one side only. - Circles:
Circle(Pt(5, 0), 5 deg)has a spatial4j-computed bounding box with west edge +0.0; PointVectorStrategy misses a point at (-0.0, 0) that lies on the circle. - Bounded-exhaustive sweep (exhaustive over a small domain: coordinates {-10, -0.0, +0.0, 10}, 121 query rectangles, 137 shapes, 5 operations, 4 strategies, geo and non-geo; plus 7 selected circles; 439,082 checks): 6,120 zero-sign defects, all missed matches (per context BBox IsWithin 1,184, BBox Contains 1,313, PointVector Intersects 276 and IsWithin 276, circles 5-6 per operation); 0 for BBox Intersects / IsDisjointTo / IsEqualTo, RecursivePrefixTreeStrategy and SerializedDVStrategy; 0 with the fix.
Not part of this finding: IsEqualTo (an exact match, which agrees with spatial4j's own Double.compare-based equals); IsWithin of two rectangles identical up to a zero's sign (spatial4j's equals again); BBoxStrategy Contains / IsEqualTo extra hits for non-rectangles, which occur without any zero because other shapes are indexed by their bounding box (BBoxStrategy.java:54-55).
Exposure and likelihood394 words
- Who is hit. Applications that index points or rectangles with BBoxStrategy and query with IsWithin or Contains, or with PointVectorStrategy and query a rectangle (Intersects or IsWithin) or a circle. Both strategies are
@lucene.experimental(BBoxStrategy.java:71,PointVectorStrategy.java:80); otherwise default codec,IndexWriterConfigand searcher, any index size, geo and non-geo contexts. - When. A shape exactly on a query edge at x = 0 or y = 0 (the prime meridian, the equator, a hemisphere or tile boundary, or a 0 bound in a non-geo context), with -0.0 on one side and +0.0 on the other. The query edge is usually an ordinary +0.0; -0.0 comes from caller data, for example WKT or text such as
-0or-0.0, a negated 0.0 (a 0-degree "W" longitude stored as-lon), orMath.ceilof a small negative number. The WKT reader keeps the sign, inPOINT(-0 5)and inENVELOPE(-10, -0, 10, 0). In the hunt's producer search none of 4,114 spatial4j circle bounding boxes had a -0.0 edge, but spatial4j'spointOnBearingreturned x = -0.0 in 88 of 82,280 calls, all from 8 start points on longitude 180 (2 of them the poles), e.g. from (180, 10) with bearing 0 and distance 90: another way -0.0 can reach caller data. - Size of the error. Only missed matches, never extra ones: a raw inclusive bound at one zero only leaves out a coordinate at the other zero, and every inclusive range sits in MUST or SHOULD clauses (BBoxStrategy's only MUST_NOT clauses,
:390,:398,:494, hold term queries on ±180 and the exclusive-only disjoint query; PointVectorStrategy has none). In the sweep all 6,120 zero-sign defects were misses. The shape drops out on one side of the edge only; the neighbouring cell still finds it. - Silent. No exception and no assertion, with assertions on and off.
- Other strategies are fine. In the point-on-edge cases tested, RecursivePrefixTreeStrategy and SerializedDVStrategy in the same module return the hit, and so do the core geo queries: the
XYShapeandLatLonShapebox queries and theXYPointFieldpolygon query (48 checks, 0 mismatches), andLatLonPoint.newBoxQuery,LatLonPoint.newPolygonQueryandXYPointField.newBoxQuery(32 checks, 0 mismatches, run by this doc's fact check). - Likelihood: rare. It needs an exact -0.0 on exactly a zero edge, in two experimental strategies; the searches recorded on 2026-11-02 found no report of it since 4.0.0 (2012).
One JUnit test for lucene/spatial-extras, public API only: index one point at (-0.0, 5) with both strategies, check that IsWithin.evaluate is true for the rectangle 0..10 by 0..10, then count the four queries. Deterministic, single thread; fails with assertions on and off.
/*
* 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;
import java.util.Arrays;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.spatial.bbox.BBoxStrategy;
import org.apache.lucene.spatial.query.SpatialArgs;
import org.apache.lucene.spatial.query.SpatialOperation;
import org.apache.lucene.spatial.vector.PointVectorStrategy;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.locationtech.spatial4j.context.SpatialContext;
import org.locationtech.spatial4j.shape.Point;
import org.locationtech.spatial4j.shape.Rectangle;
public class TestNegativeZeroOnZeroEdge extends LuceneTestCase {
public void testPointAtNegativeZeroOnZeroEdge() throws Exception {
SpatialContext ctx = SpatialContext.GEO;
Point point = ctx.makePoint(-0.0, 5);
Rectangle rect = ctx.makeRectangle(0.0, 10, 0, 10);
assertTrue(SpatialOperation.IsWithin.evaluate(point, rect));
SpatialStrategy bbox = BBoxStrategy.newInstance(ctx, "bbox");
SpatialStrategy pv = PointVectorStrategy.newInstance(ctx, "pv");
try (Directory dir = newDirectory()) {
try (IndexWriter w = new IndexWriter(dir, newIndexWriterConfig())) {
Document doc = new Document();
for (Field f : bbox.createIndexableFields(point)) doc.add(f);
for (Field f : pv.createIndexableFields(point)) doc.add(f);
w.addDocument(doc);
}
try (DirectoryReader r = DirectoryReader.open(dir)) {
IndexSearcher s = new IndexSearcher(r);
int[] hits = {
s.count(bbox.makeQuery(new SpatialArgs(SpatialOperation.Intersects, rect))),
s.count(bbox.makeQuery(new SpatialArgs(SpatialOperation.IsWithin, rect))),
s.count(pv.makeQuery(new SpatialArgs(SpatialOperation.Intersects, rect))),
s.count(pv.makeQuery(new SpatialArgs(SpatialOperation.IsWithin, rect)))
};
// hits of BBox Intersects, BBox IsWithin, PointVector Intersects, PointVector IsWithin
assertEquals("[1, 1, 1, 1]", Arrays.toString(hits));
}
}
}
}
cp $REPRO/TestNegativeZeroOnZeroEdge.java $LUCENE/lucene/spatial-extras/src/test/org/apache/lucene/spatial/
cd $LUCENE && ./gradlew :lucene:spatial-extras:test --tests TestNegativeZeroOnZeroEdge -Ptests.seed=5EED1113 -Ptests.asserts=true
# also with -Ptests.asserts=false; 5 of 5 random-seed iterations (-Ptests.iters=5) failed the same way
- Expected
[1, 1, 1, 1]: the point is on the rectangle's west edge, so all four queries find it.- Observed
[1, 0, 0, 0], no error:expected:<[1, [1, 1, 1]]> but was:<[1, [0, 0, 0]]>, with assertions on and off.
More files, environment, full output236 words
The test is the hunt's minimal repro with the license header added and its one comment reworded so that it passes :lucene:spatial-extras:check -x test (google-java-format); the code is otherwise identical. It uses newIndexWriterConfig(), the usual randomized test config; a plain IndexWriterConfig fails the same way (the independent reproducer's test and this doc's control). Executed: seed 5EED1113 (codec Asserting(Lucene104)), 5 random-seed iterations and the plain config; not every codec was run, and no codec dependence is expected because the order comes from DoublePoint's sortable encoding of the double (DoublePoint.java:176).
Executed: gradle on main e1faeba08bde with assertions on and off (seed 5EED1113) and 5 iterations with a random master seed: fails every time; with the fix it passes. The hunt's runs fail the same way.
Use the same test as the regression test.
| lucene | main e1faeba08bde (12.0.0-SNAPSHOT) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (only this platform was run) |
cp $REPRO/TestNegativeZeroOnZeroEdge.java $LUCENE/lucene/spatial-extras/src/test/org/apache/lucene/spatial/
cd $LUCENE && ./gradlew :lucene:spatial-extras:test --tests TestNegativeZeroOnZeroEdge -Ptests.seed=5EED1113 -Ptests.asserts=true
# also with -Ptests.asserts=false; 5 of 5 random-seed iterations (-Ptests.iters=5) failed the same way
TestNegativeZeroOnZeroEdge > testPointAtNegativeZeroOnZeroEdge FAILED
org.junit.ComparisonFailure: expected:<[1, [1, 1, 1]]> but was:<[1, [0, 0, 0]]>
at __randomizedtesting.SeedInfo.seed([5EED1113:6E82DED7DFEFBC3D]:0)
at app/junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:117)
at app/junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
at app//org.apache.lucene.spatial.TestNegativeZeroOnZeroEdge.testPointAtNegativeZeroOnZeroEdge(TestNegativeZeroOnZeroEdge.java:60)
:lucene:spatial-extras:test (FAILURE): 1 test, 1 failure
# JUnit's diff brackets: expected "[1, 1, 1, 1]", actual "[1, 0, 0, 0]"
# with the fix: :lucene:spatial-extras:test (SUCCESS): 1 test- For a point, Intersects and IsWithin a rectangle are the same question, yet BBoxStrategy answers 1 and 0 for the same document.
- The strategies promise
SpatialOperationpredicates, defined byevaluate(), which is true here and is the oracle of the module's own tests. - RecursivePrefixTreeStrategy, SerializedDVStrategy and the core geo point and shape queries all find such a point.
The full argument214 words
- Contract. BBoxStrategy "supports all SpatialOperations" (
BBoxStrategy.java:47), qualified at:57as "Supports most SpatialOperations but not Overlaps", and itsmakeQuerymaps Intersects, IsWithin and Contains to their own queries (:265-271); PointVectorStrategy: "Intersects and IsWithin is supported" (PointVectorStrategy.java:63-64). The query is built "principally on SpatialOperation" (SpatialStrategy.java:116), whose predicates are defined byevaluate()and are boundary-neutral (SpatialOperation.java:30-35,:124). BBoxStrategy's own comments state the Within and Contains conditions as plain comparisons,docMinX >= queryExtent.getMinX()(:526-527,:286-287), which hold for -0.0 against 0.0. Nothing says -0.0 is a different position from 0.0. - Tests.
StrategyTestCase.testOperationassertsoperation.evaluate(indexedShape, queryShape) == matchbefore it searches (StrategyTestCase.java:233), andRandomSpatialOpStrategyTestCaseusesoperation.evaluateas truth (prefix/RandomSpatialOpStrategyTestCase.java:103).TestBBoxStrategy.testAlongDatelineOppositeSignandtestWithinBugDatelineEdgerequire IsWithin to match when one edge is written as +180 and as -180 (bbox/TestBBoxStrategy.java:154,:126): the same kind of equivalence. No test in the module uses -0.0. - History. The strategies built inclusive ranges from raw bounds from the start (2012). LUCENE-7094 (#8150, 2016-03-30) moved them to
DoublePointranges and toMath.nextUp/nextDownfor exclusive bounds, which created the Intersects-vs-Within split; its discussion never mentions zeros. LUCENE-7594 (#8645, 2016-12-21), nine months later, set theDouble.compareguidance for core point ranges and never mentions spatial-extras.
The devil's-advocate check195 words
The strongest "intended" case (built by the intended-behaviour verifier; an intended-lens cross-model review, OpenAI GPT-5.6, rated it moderate): DoublePoint documents Double#compareTo order on purpose, and LUCENE-7594's reporter wrote that Float/Double points "are supposed to be consistent with Double/Float.compare, so +0 is supposed to compare greater than -0"; both strategies say they are built on DoublePoint ranges; spatial4j's RectangleImpl.equals is sign-sensitive too; the strategies are experimental, and callers could normalize -0.0.
Why it fails: DoublePoint order is the encoding the strategies use, not the contract they promise. Users call SpatialOperation, whose predicates are evaluate(), and the module's tests use evaluate as truth; RPT, SerializedDV and the core geo queries find a point on the zero edge. The sign-sensitive equals is why IsEqualTo is not part of this finding; a point against a rectangle goes through relate, which compares primitives. "Experimental" allows API changes, not missed hits, and nothing documents a normalization rule while the WKT reader keeps -0.0. Decisive: BBoxStrategy answers Intersects and IsWithin of a point differently for the same document. Switching its exclusive bounds to DoublePoint.nextUp would remove the contradiction only by making Intersects miss too. Verdict: not intended, a defect.
Recommended Widen a zero bound to [-0.0, +0.0] (fix A)
Make a zero range bound cover both zeros, right before DoublePoint.newRangeQuery in BBoxStrategy.makeNumericRangeQuery and PointVectorStrategy.rangeQuery: if (min == 0) min = -0d; if (max == 0) max = 0d;. The repro then passes, and so do the whole lucene/spatial-extras test suite (118 tests) and :lucene:spatial-extras:check -x test.
// spatial4j compares coordinates as primitives (-0.0 == 0.0) but DoublePoint orders -0.0
// below 0.0, so a zero bound must cover both zeros
if (min == 0) {
min = -0d;
}
if (max == 0) {
max = 0d;
}
return DoublePoint.newRangeQuery(fieldname, min, max); // fieldName in PointVectorStrategyAll options and trade-offs4 options · 648 words
One fix was built and validated in a scratch worktree of e1faeba08bde; the alternatives are listed with their trade-offs.
The same lines in both methods, just before the return DoublePoint.newRangeQuery(...). min and max are Double, so min == 0 unboxes and is true for both zeros; a lower bound of either zero becomes -0.0 and an upper bound +0.0, so a zero edge includes both. Exclusive bounds were already moved off zero by nextUp/nextDown, so they are unaffected. The exact-match path used by IsEqualTo and the dateline clauses is left alone.
// spatial4j compares coordinates as primitives (-0.0 == 0.0) but DoublePoint orders -0.0
// below 0.0, so a zero bound must cover both zeros
if (min == 0) {
min = -0d;
}
if (max == 0) {
max = 0d;
}
return DoublePoint.newRangeQuery(fieldname, min, max); // fieldName in PointVectorStrategyTrade-offs
Results change only on a zero edge: on the inclusive range paths the patch changes, a -0.0 input now gets the answer of its +0.0 twin (the exact-term path used by IsEqualTo is unchanged and still tells the zeros apart, as spatial4j's equals does), so BBoxStrategy's documented bounding-box extra hits for non-rectangles under Contains also appear for those twins (no new kind of mismatch). No index-format change; documents already stored with -0.0 are found without reindexing. One comparison per bound (two per range query) at query-build time.
What the checks validated
- Gradle, JDK 25, scratch worktree of
e1faeba08bde(removed afterwards): the test fails on stock with assertions on and off (seed5EED1113) and in 5 of 5 random-seed iterations, and passes with the fix, assertions on and off. - With the fix: the test plus
TestBBoxStrategyandTestPointVectorStrategy, 17 tests, 0 failures, 1 skipped; the whole:lucene:spatial-extras:test, 118 tests including the new one, 0 failures, 5 skipped (on stock: the same 118 tests with exactly 1 failure, the new test);:lucene:spatial-extras:check -x testpasses (google-java-format, ecj lint and forbidden APIs ran; eclint was not configured and Error Prone was skipped). - Which copy of the test: the 5 iterations, the 17-test and 118-test runs and the fixed run with assertions off used an earlier copy whose only difference was one over-long comment line; the check, the fixed run with assertions on and the stock runs on and off used the final copy. The fact check re-ran the final copy through gradle: stock fails on and off, fixed passes on and off, and the whole fixed module passes (118 tests, 5 skipped).
- The hunt: the minimal repro and the mirror-direction test pass with the fix shadowed, the cross-model reviewers' own tests pass with it, and the bounded-exhaustive sweep has 0 zero-sign defects.
Add 0.0 to every indexed coordinate in createIndexableFields and to every query bound (x + 0.0 turns -0.0 into +0.0).
Trade-offs
Fixes new documents only: those already indexed with -0.0 stay invisible on a 0 edge until reindexed, and it touches more code paths (stored values, doc values, distance sources). Not validated.
Use DoublePoint.nextUp/nextDown instead of Math.nextUp/nextDown in BBoxStrategy.makeNumericRangeQuery, as LUCENE-7594 recommends for core ranges.
Trade-offs
Not recommended: it removes the contradiction only by making Intersects miss the point too, so both strategies would still disagree with SpatialOperation.evaluate, RPT and SerializedDV. Not validated. A side note: DoublePoint's 1-D newRangeQuery javadoc names DoublePoint.nextUp/nextDown for exclusive ranges (DoublePoint.java:210-211), while the n-dimensional overload's javadoc still says Math#nextUp / Math.nextDown (:232-233).
State the restriction in the class javadocs.
Trade-offs
Not recommended: it legitimises silent missed hits that a small change removes, and the WKT reader produces -0.0 from ordinary input. Not validated.
A Z3 query asked which doubles are equal under == but sort apart under Double.compare; the only answers are the two zeros. A grep for code that hands spatial4j bounds to DoublePoint ranges found these two strategies, and executed probes on main and 10.6.0 showed the missed hits. The hunt unit that judged it then widened and confirmed it.
The method in full170 words
Method: semi-formal (a Z3 witness for a generic fact about doubles, which became a Lucene lead through a caller grep and an executed probe).
- Round 22 of the hunt ran a numeric-casts lens with Z3. One of its queries asked for non-NaN doubles x <= q under primitive comparison (the semantics spatial4j's
relateuses) whoseDoublePointorder is x > q; the only witnesses are x = +0.0, q = -0.0. A grep for callers that pass spatial4j bounds straight toDoublePointranges found only BBoxStrategy and PointVectorStrategy outside the lucene/sandbox module and the query parsers. Public-API probes on main and 10.6.0 (2026-11-02) showed the missed hits, and a core-geo control showed none. - The round's skeptic re-ran it. The hunt unit that judged it wrote a public-API JUnit, a bounded-exhaustive differential sweep with a sign-erasure oracle (439,082 checks), a producer search for -0.0 and a history port. An independent reproducer, cross-model reviews (OpenAI GPT-5.6, executed), a devil's-advocate verifier, an adjudication, an audit and a last minimal-repro check then re-ran it.
No public report found in the searches recorded on 2026-11-02: GitHub issues and PRs incl. the migrated JIRA archive, JIRA (LUCENE, SOLR), the Lucene and Solr mailing lists, Solr, Elasticsearch, OpenSearch, lucenenet, spatial4j, the web and the open PRs. Nearest: #8645 (DoublePoint follows Double.compare), #8150 (the 2016 points cutover), #6833 (boundary-neutral predicates).
Searches4 searches
None of the hits is the same bug. Other nearest items: #8240 (LUCENE-7185, open: a comment suggests generating -0.0 in core random geo tests) and elastic/elasticsearch#35707 (2018: a downstream geo layer normalizes coordinates so they never return -0.0 before building Lucene geometry). Not fixed upstream: the newest commits on both strategy files, bc7deedc092 (#15499) and dfd536186fe (#15889), are cosmetic and in e1faeba08bde; none of the 16 open apache/lucene PRs updated since 2026-10-31, checked file by file on 2026-11-02, touches them or DoublePoint.java (a snapshot). Search limits: keyword search only; GitHub search returns at most 100 results per query; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; apache/solr, lucenenet, Elasticsearch, OpenSearch, locationtech/spatial4j) | 2026-11-02 | a scripted prior-report search (71 queries: edge, boundary, Contains, circle, prime meridian, equator, -0.0, signed and negative zero, method and class names) on top of the finder's 51 queries; every apache/lucene item naming BBoxStrategy (20) or PointVectorStrategy (9) read or grepped; 4 more GitHub search queries in a same-day re-check | no report |
| Open PRs and upstream commits | 2026-11-02 | the changed files of the 16 open apache/lucene PRs updated since 2026-10-31 and all PRs created since; git log on both strategy files | only #16847 touches spatial-extras, in other files; newest commits on the strategy files are cosmetic |
| JIRA, mailing lists and the web | 2026-11-02 | the LUCENE entries naming BBoxStrategy (21) or PointVectorStrategy (10) and the SOLR entries naming BBoxField (19); dev@, java-user@, issues@, commits@ and the Solr lists; web queries | no report |
| Nearest items (read) | 2026-11-02 | #8645 (LUCENE-7594), #8150 (LUCENE-7094), #6833 (LUCENE-5771), #8240 (LUCENE-7185), elastic/elasticsearch#35707 | the DoublePoint ordering decision, the points cutover, boundary-neutral semantics, a test-generation suggestion and a downstream normalization; none reports the miss |
As old as the strategies: PointVectorStrategy since 4.0.0 (2012), BBoxStrategy since 4.10.0. Every executed release fails: 4.x and 5.x miss all four queries (legacy trie order); LUCENE-7094 (#8150, 6.0.0) moved to DoublePoint and made BBox Intersects find the point, creating the split. Not a regression; not fixed upstream.
Versions tested7 rows
Origin: PointVectorStrategy (then TwoDoublesStrategy) built an inclusive range from the raw rectangle bounds from its first commit, 66a852234d4 (2012-02-20, LUCENE-3795 / #4868); BBoxStrategy came with 26d3f1f813e (LUCENE-4175 / #5247, 2012-06-28) and reached a release through the branch_4x port c608265684c (2014-07-09). Today's lines: e1b45568b41 on master and f9da8164483 on branch_6_0 (LUCENE-7094 / #8150, 2016-03-30), which moved both strategies to DoublePoint.newRangeQuery with raw inclusive bounds and Math.nextUp/nextDown for exclusive ones; git blame on main attributes the range lines to it, later commits only reformat. Releases between the executed ones were not run individually; the range code is unchanged between them apart from formatting.
| Version or commit | Verdict | Result |
|---|---|---|
main e1faeba08bde | affected | this doc's test fails through gradle with assertions on and off; passes with the fix |
| 10.6.0 | affected | [1, 0, 0, 0] (the reproducer's JUnit and this doc's control; +0.0 gives [1, 1, 1, 1]) |
| 10.5.2, 9.12.3 | affected | [1, 0, 0, 0] (the reproducer's JUnit, release jars) |
| 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 10.0.0 | affected | [1, 0, 0, 0] (main() port) |
| 6.0.0, legacy trie encoding | control | [0, 0, 0, 0]: same spatial4j jar, so the 6.0 change in Intersects comes from the switch to DoublePoint |
| 4.10.0, 4.10.4, 5.0.0, 5.5.5 | affected | [0, 0, 0, 0] (legacy trie order splits the zeros on every path) |
| 4.0.0, 4.9.1 | first release | PointVectorStrategy [0, 0]; BBoxStrategy not shipped yet |
An independent reproducer, two executed cross-model reviews (both OpenAI GPT-5.6, with a refute and an intended lens), a devil's-advocate verifier, an adjudication, an audit and a last minimal-repro check reproduced it with their own tests and agree on the root cause. This doc's gradle runs reproduced it on main and validated the fix.
Checks and verdicts414 words
Every executed check saw the miss come from the inclusive DoublePoint bounds, and every one that ran the fix saw it pass. The cross-model reviews ran on an earlier main checkout, ea57360912ed, whose involved source files are identical to e1faeba08bde's.
| Check | Verdict | What it found |
|---|---|---|
| first signal (round 22) | missed hits | Z3 witness (only the two zeros), a caller grep, and public-API probes on main and 10.6.0 with misses in both strategies; a core-geo control (XYShape and LatLonShape box queries, XYPointField polygon query) had 0 mismatches. |
| finder | reproduced | Mirror-direction JUnit (point at +0.0, edge at -0.0) fails on main (on, off), 10.6.0 and 9.12.3, passes with the fix; sweep 6,120 missed matches, 0 with the fix; history port back to 4.0.0. |
| cross-model review, refute lens | real (high) | OpenAI GPT-5.6, executed: its own equator test (latitude +0.0, query maximum -0.0) gives BBox Intersects 1, BBox IsWithin 0, PointVector IsWithin 0; the same-sign control passes; the fix passes it. |
| independent reproducer | real | Own test with a plain IndexWriterConfig fails [1, 0, 0, 0] on main (on, off), 10.6.0, 10.5.2 and 9.12.3, passes with its own fix; probes for the equator, non-geo, a circle and Contains, each with a same-sign control. |
| cross-model review, intended lens | real (high) | OpenAI GPT-5.6, executed: its own test misses in BBox IsWithin and both PointVector queries while SerializedDV finds the point; the same-sign control finds it everywhere; the intended argument is moderate and outweighed. |
| devil's advocate | real | Tested the DoublePoint order and LUCENE-7594, spatial4j's equals and the experimental status; none survives. Parsed-query probe: IsWithin(ENVELOPE(0, 180, 90, -90)) misses a point at longitude -0.0 in both strategies; RPT finds it. |
| adjudication | confirmed (high), Low | Re-ran the independent reproduction's test on main (on, off) and 10.6.0: fails; with the fix: passes; the finder's test fails too. Silent wrong results, security none. |
| audit and last minimal-repro check | confirmed | The audit re-ran it on main (off) and confirmed it; the last check reduced the repro to one test that fails on main with assertions on and off and passes with the fix. |
| this doc | reproduced; fix validated | Gradle on main e1faeba08bde: the test fails with assertions on and off and in 5 random iterations; with the fix it passes, the whole lucene/spatial-extras suite passes (118 tests) and :lucene:spatial-extras:check -x test passes. A control on main and 10.6.0 finds the point at +0.0 and misses it at -0.0. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 518 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,536 words
- Registry title
- BBoxStrategy and PointVectorStrategy miss a shape on a query edge at 0 when the shape's coordinate and the edge are zeros of opposite sign (-0.0 vs +0.0), so BBoxStrategy finds a point Intersecting a rectangle but not Within it
- Area
- spatial-extras / BBoxStrategy and PointVectorStrategy query construction (inclusive DoublePoint range bounds taken unchanged from spatial4j rectangles: BBoxStrategy.makeNumericRangeQuery, PointVectorStrategy.rangeQuery)
- Priority
- Low, rank 424: In every release that ships them, the experimental BBoxStrategy and PointVectorStrategy silently miss a shape that lies exactly on a query edge at 0 when the coordinate and the edge are zeros of opposite sign. It needs a -0.0 coordinate exactly on such an edge, a rare boundary value, so it is Low.
- Impact class
- silent wrong results
- Impact note
- Applications that index points or rectangles with the @lucene.experimental BBoxStrategy (IsWithin, Contains) or PointVectorStrategy (rectangle Intersects and IsWithin, and circle queries through their bounding box) silently lose a document that lies exactly on a query edge at x = 0 or y = 0 (the prime meridian, the equator, or a 0 bound in a non-geo context) when the document's coordinate and the edge are zeros of opposite sign, one -0.0 (from caller data such as WKT 'POINT(-0 5)' or a negated 0.0) and the other +0.0, at any index size with the default IndexWriterConfig, codec and searcher, in geo and non-geo contexts, in every release that ships them (PointVectorStrategy since 4.0.0, BBoxStrategy since 4.10.0); only missed matches, never extra ones.
- Summary
- You index a point stored at longitude -0.0 (for example parsed from 'POINT(-0 5)') with BBoxStrategy and PointVectorStrategy and query the rectangle Rect(minX=0.0, maxX=10, minY=0, maxY=10); you expect the point to match Intersects and IsWithin in both strategies, because SpatialOperation.evaluate says it lies on the rectangle's west edge (spatial4j compares coordinates as primitives, so -0.0 == 0.0). Instead BBoxStrategy finds it for Intersects but not for IsWithin, and PointVectorStrategy finds it for neither, with no error. Both strategies pass the rectangle's 0.0 edge unchanged as an inclusive DoublePoint range bound, and DoublePoint orders -0.0 below +0.0; BBoxStrategy's Intersects uses only exclusive bounds, which it moves with Math.nextUp/Math.nextDown, and those give the same result for both zeros.
- Introduced
- As old as the strategies, not a regression. PointVectorStrategy: 66a852234d4 (2012-02-20, David Wayne Smiley; initial copy of the spatial-lucene module, LUCENE-3795 / #4868; the class was then TwoDoublesStrategy, renamed by e9cffebf96f, LUCENE-4374), whose makeWithin built an inclusive NumericRangeQuery.newDoubleRange from the raw rectangle bounds. BBoxStrategy: 26d3f1f813e (LUCENE-4175 / #5247, 2012-06-28, Ryan McKinley), inclusive raw bounds in makeWithin / makeContains, released through the branch_4x port c608265684c (LUCENE-5714 / #6776 and LUCENE-5779, 2014-07-09). Today's lines and BBoxStrategy's Intersects-vs-IsWithin contradiction: e1b45568b41 on master and f9da8164483 on the 6.0 branch (LUCENE-7094 / #8150, 2016-03-30, nknize), which moved both strategies to DoublePoint.newRangeQuery with raw inclusive bounds and Math.nextUp/Math.nextDown for exclusive ones. [read: git blame on main attributes the range lines to e1b45568b41, later commits only reformat; git log -S 'Math.nextUp' and 'DoublePoint.newRangeQuery' on both files find only e1b45568b41; exec: every release that ships the strategies fails, and a 6.0.0 legacy-trie control (same spatial4j jar) misses on Intersects too, so the Intersects change at 6.0.0 comes from the switch to DoublePoint] (2012-02-20 (66a852234d4, PointVectorStrategy); BBoxStrategy 2012-06-28 on trunk (26d3f1f813e) and 2014-07-09 on branch_4x (c608265684c); current form 2016-03-30 (e1b45568b41); author dates equal commit dates for all cited commits)
- First affected version
- 4.0.0 [exec: the release-portable port misses with PointVectorStrategy on 4.0.0, -da; BBoxStrategy first shipped in 4.10.0, which misses too]; not a regression: no release ever matched these shapes (3.6.2 ships only the old contrib spatial code, with no SpatialStrategy API)
- Affected versions
- Every release that ships the strategies: PointVectorStrategy 4.0.0 through 10.6.0 (released 2026-10-29, 4ae946e5a9b), BBoxStrategy 4.10.0 through 10.6.0, and main e1faeba08bde (12.0.0-SNAPSHOT). Executed with a point stored at x = -0.0 against Rect(0.0, 10, 0, 10), hits of [BBox Intersects, BBox IsWithin, PointVector Intersects, PointVector IsWithin], expected [1, 1, 1, 1], -1 = strategy not in the release (main() ports, -da): 4.0.0 and 4.9.1 [-1, -1, 0, 0]; 4.10.0, 4.10.4, 5.0.0 and 5.5.5 [0, 0, 0, 0] (the legacy trie encoding orders -0.0 below +0.0 on the exclusive path too, so BBox Intersects misses as well); 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.6.0 and main [1, 0, 0, 0] [exec]. The independent reproducer's JUnit fails [1, 0, 0, 0] on main -ea and -da, and on 10.6.0, 10.5.2 and 9.12.3 with -da [exec]; the finder's mirror-direction JUnit fails on main -ea and -da, main ea57360912ed, 10.6.0 and 9.12.3 [exec]. Releases between the executed ones were not run individually; the range code is unchanged between them apart from formatting [read]. Upstream: the newest commits on both files (bc7deedc092 #15499, author 2025-12-11; dfd536186fe #15889, author 2026-04-01) are in e1faeba08bde and cosmetic; no open PR touches either file (2026-11-02) [read]. Not new in 10.6.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal; also reached by Z3
- Upstream and prior reports
- none found (2026-11-02: 125 queries, 71 in a scripted prior-report search and 54 manual, on top of the finder's 51-query search and the independent reproduction's, the intended-behaviour check's and the adjudication's GitHub searches; rechecked the same day): apache/lucene issues and PRs (all 20 items naming BBoxStrategy and all 9 naming PointVectorStrategy read or grepped), commits, the LUCENE and SOLR JIRA archive (21 LUCENE entries naming BBoxStrategy, 10 naming PointVectorStrategy and 19 SOLR entries naming BBoxField scanned), downstream repositories (apache/solr, Elasticsearch, OpenSearch, lucenenet, locationtech/spatial4j), the dev@, java-user@, issues@, commits@ and solr-user@ lists, the web, the git history, and the changed-file lists of the 16 open apache/lucene PRs updated since 2026-10-31 (only #16847 touches spatial-extras, in other files): no report of the missed zero-edge matches or of BBoxStrategy's Intersects-vs-IsWithin contradiction. A same-day recheck found no apache/lucene issue or PR updated since 2026-10-31 for BBoxStrategy, PointVectorStrategy, "negative zero spatial" or "signed zero"; none of the 16 open PRs updated since 2026-10-31 touches BBoxStrategy.java, PointVectorStrategy.java or DoublePoint.java; the newest upstream commits on the two strategy files are still bc7deedc092 (#15499) and dfd536186fe (#15889), both in e1faeba08bde. Related, not reports: #8645 (LUCENE-7594, 2016: core Float/DoublePoint follow Double.compare, +0 above -0, and the Math.nextUp guidance was changed; spatial-extras is never mentioned), #8150 (LUCENE-7094, the 2016 points cutover that created today's inclusive/exclusive split; no discussion of zeros), #6833 (LUCENE-5771, boundary-neutral predicate semantics), #8240 (LUCENE-7185, open: a comment suggests generating -0.0 in core random geo tests) and elastic/elasticsearch#35707 (2018: a downstream geo layer normalizes coordinates so they never return -0.0 before building Lucene geometry).
- Lucene commit tested
- main
e1faeba08bde(2026-11-02) through gradle in a scratch worktree, with assertions on and off and 5 random-seed iterations, without and with the fix (the repro,TestBBoxStrategy,TestPointVectorStrategy, the wholelucene/spatial-extrastest suite and:lucene:spatial-extras:check -x test); a plain-IndexWriterConfigcontrol on main and the 10.6.0 release jars. The hunt ran the repro or a main() port on 4.0.0, 4.9.1, 4.10.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0 (also with the legacy trie encoding), 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0, and a bounded-exhaustive sweep of 439,082 checks on main. Only Linux aarch64 with OpenJDK 25.0.3 was run.
- Finding record
lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxStrategy.java:47 (at e1faeba08bde) ('It supports all SpatialOperations'), :54-55 (other shapes are indexed by their bounding box), :71 (@lucene.experimental), :283-305 (makeContains: comment 'docMinX <= queryExtent.getMinX() ... docMaxX >= queryExtent.getMaxX()', inclusive bounds at :291-292 and :304-305), :366-450 and :473 (makeDisjoint and makeIntersects: exclusive bounds only, e.g. :374-375, :386, :393), :523-547 (makeWithin: comment 'docMinX >= queryExtent.getMinX() ... docMaxX <= queryExtent.getMaxX()', inclusive bounds at :531-532 and :546-547), :651-672 (makeNumericRangeQuery: exclusive bounds through Math.nextUp/Math.nextDown, inclusive bounds passed unchanged to DoublePoint.newRangeQuery; fix site)lucene/spatial-extras/src/java/org/apache/lucene/spatial/vector/PointVectorStrategy.java:63-64 (at e1faeba08bde) ('Intersects and IsWithin is supported'), :73-74 (a circle query runs the same bbox query plus a distance filter), :80 (@lucene.experimental), :188-205 (makeQuery: rectangle -> makeWithin; circle -> makeWithin(circle.getBoundingBox()) plus DistanceRangeQuery), :209-222 (makeWithin: MUST ranges on x and y), :228-238 (rangeQuery: bounds passed unchanged to DoublePoint.newRangeQuery; fix site)lucene/spatial-extras/src/java/org/apache/lucene/spatial/query/SpatialOperation.java:30-35 (at e1faeba08bde) (predicates explained by the source of evaluate(); 'Lucene-spatial makes no distinction of shape boundaries'), :89-95 (Intersects: indexedShape.relate(queryShape).intersects()), :124-136 (IsWithin, 'Meets the "CoveredBy" OGC definition (boundary-neutral)': relate == WITHIN || equals)lucene/spatial-extras/src/java/org/apache/lucene/spatial/SpatialStrategy.java:116 (at e1faeba08bde) ('Make a Query based principally on SpatialOperation and Shape')lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:213 (at e1faeba08bde) and :235 ('Range comparisons are consistent with Double#compareTo(Double)'), :232-233 ('Ranges are inclusive')
- The finding in 30 seconds
lucene/spatial-extras/src/java/org/apache/lucene/spatial/SpatialStrategy.java:116; (at e1faeba08bde) query/SpatialOperation.java:30-35, :89-95, :124-136lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxStrategy.java:283-305, (at e1faeba08bde) :366-393, :473-500, :523-547, :651-672 (fix site), :54-55lucene/spatial-extras/src/java/org/apache/lucene/spatial/vector/PointVectorStrategy.java:188-205, (at e1faeba08bde) :209-222, :228-238 (fix site)lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:210-213, (at e1faeba08bde) :232-235
- Who is affected
lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxStrategy.java:47, (at e1faeba08bde) :71; vector/PointVectorStrategy.java:63-64, :80
- Why it looks unintended
lucene/spatial-extras/src/java/org/apache/lucene/spatial/bbox/BBoxStrategy.java:47, (at e1faeba08bde) :57, :265-271, :286-287, :526-527; vector/PointVectorStrategy.java:63-64; SpatialStrategy.java:116; query/SpatialOperation.java:30-35, :124lucene/spatial-extras/src/test/org/apache/lucene/spatial/StrategyTestCase.java:233; (at e1faeba08bde) prefix/RandomSpatialOpStrategyTestCase.java:103; bbox/TestBBoxStrategy.java:126, :154
- TestNegativeZeroOnZeroEdge.java (lucene/spatial-extras, public API):
repro/LFV-1113/TestNegativeZeroOnZeroEdge.java(64 lines)
A summary still (PNG).
assets/LFV-1113/bug.png(518 KB)
![Diagram of the bug: a point stored at longitude -0.0 lies on the west edge x = 0.0 of a query rectangle; spatial4j treats -0.0 and +0.0 as equal, but the DoublePoint range [0.0, 10] used by BBoxStrategy IsWithin and PointVectorStrategy sorts -0.0 just below +0.0 and excludes it, while BBoxStrategy Intersects, built from exclusive bounds, still finds it; widening the bound to [-0.0, 10] fixes it.](../assets/LFV-1113/bug.png)