A range field stores each range as two sortable byte strings, its minimum and its maximum, and every range query compares those bytes. The constructor checks min <= max with Java's >, but the bytes are ordered by Double#compareTo. The two orders disagree on exactly one pair: +0.0 followed by -0.0.
- Java doubles have two zeros,
0.0and-0.0. The operators==,<and>treat them as equal:0.0 > -0.0is false. Double.compareandDouble#compareTodo order them:-0.0comes before0.0. Lucene stores float and double values as bytes in this order, and documents that its point range comparisons follow it.- An indexed
DoubleRangekeeps its min and max in that byte order. INTERSECTS matches when the query's max is at least the document's min and the query's min is at most the document's max, a test that is only right when min <= max.
0.0 and -0.0, the stored range with its minimum above its maximum, which queries still match it, and the fix. Select the still to open it at full size. Generated with AI, checked against the code.- Index one document with
new DoubleRange("r", {0.0}, {-0.0}). In Java-0.0 == 0.0, so this reads as the point zero. The constructor accepts it, as it accepts[0.0, 0.0]; it rejects every other pair with min > max, such as[1, -1]. - Count
DoubleRange.newIntersectsQuery("r", {0.0}, {0.0}). You expect 1 hit, or else anIllegalArgumentExceptionwhen the field is built. - You get 0 hits and no error. Yet
newWithinQuery [0, 0]finds the document (1 hit) andnewIntersectsQuery [-1, 1]finds it (1 hit): it lies inside[0, 0]and overlaps[-1, 1], but does not overlap[0, 0]. - Why: the bounds check uses Java's
>, but the stored bytes followDouble#compareTo, where-0.0sorts below0.0. So the stored minimum (+0.0) is above the stored maximum (-0.0), and the INTERSECTS test "query max >= document min and query min <= document max" fails on it. - The same happens with
FloatRange, the doc-values range fields, range-on-range facets, and with[0.0, -0.0]as a query's bounds. It is the only pair stored this way.
Users of DoubleRange, FloatRange, their doc-values fields or range-on-range facets, with the default codec, whose range runs exactly from +0.0 to -0.0 in some dimension. That document is silently missed by INTERSECTS, the usual relation, and by some CONTAINS queries. Rare, but plain arithmetic produces it: Math.ceil(-0.3) is -0.0.
Mechanism, terms and numbers606 words
DoubleRange.verifyAndEncode rejects min > max with Java's primitive > (DoubleRange.java:144 on main 645d4fe14a20; the same in FloatRange.java:144), for which 0.0 > -0.0 is false, and then stores the bounds with NumericUtils.doubleToSortableLong / floatToSortableInt, whose order is Double#compareTo / Float#compareTo, where -0.0 sorts below +0.0. So [+0.0, -0.0] is accepted and stored with min above max; a Z3 model proves it is the only such pair among non-NaN bounds (NaN is rejected separately). RangeFieldQuery's INTERSECTS test (query.max >= doc.min && query.min <= doc.max) is an overlap test only for ordered ranges, so such a document is WITHIN [0, 0] and INTERSECTS [-1, 1] but does not INTERSECT [0, 0], which fits no reading of signed zero. The doc-values fields and their slow queries, range-on-range facets and query bounds go through the same check. Every release since 6.2.0 is affected (all 89 through 10.5.2 executed, 10.6.0 by reading). Validating with Double.compare / Float.compare (6 lines in 5 files) rejects the pair like every other inverted range; the minimal test and 9 more range suites pass with it.
- The check.
new DoubleRange(name, min, max)(DoubleRange.java:59-61) callssetRangeValues(:84), which callsverifyAndEncode(:105). After the NaN checks it rejectsmin[d] > max[d](:144) with "min value (..) is greater than max value (..)". With Java's>,0.0 > -0.0is false, so[0.0, -0.0]passes. - The encoding. It then stores each bound with
NumericUtils.longToSortableBytes(NumericUtils.doubleToSortableLong(val), ...)(:148-149,:153-156). The sort order ofdoubleToSortableLong"is defined byDouble#compareTo" (NumericUtils.java:37-43;floatToSortableIntbyFloat#compareTo,:60-66), where-0.0 < 0.0. So the stored min (+0.0) is above the stored max (-0.0).FloatRangeis the same code (FloatRange.java:144,:155). - The relations. INTERSECTS matches when
query.max >= doc.min && query.min <= doc.max(RangeFieldQuery.java:104-115). For the query[0.0, 0.0]the second test is+0.0 <= -0.0, false, so no match; for[-1, 1]both hold. WITHIN (:147-158) and CONTAINS (:190-201) compare min with min and max with max, and still match. - Other paths.
DoubleRangeDocValuesField's constructor encodes throughDoubleRange.encode(DoubleRangeDocValuesField.java:33), its slow queries callDoubleRange.verifyAndEncode(DoubleRangeSlowRangeQuery.java:89), andDoubleRangeOnRangeFacetCountscalls it too (:19,:90). Their own argument checks use the same primitive>:DoubleRangeDocValuesField.java:84,FloatRangeDocValuesField.java:84, and the range-on-range facetDoubleRange.java:58and:117. - Only this pair. For non-NaN values,
a > bandDouble.compare(a, b) > 0differ only fora = +0.0,b = -0.0. A Z3 floating-point and bit-vector model of the check and the encoding, for non-NaN bounds (NaN is rejected before this check,DoubleRange.java:136-143), proves[+0.0, -0.0]is the only accepted pair stored inverted, and that with aDouble.comparecheck no accepted pair is stored inverted.
| query | WITHIN | CONTAINS | INTERSECTS |
|---|---|---|---|
own bounds [0.0, -0.0] | 1 | 1 | 0 |
[0.0, 0.0] | 1 | 0 | |
[-0.0, -0.0] | 0 | ||
[-0.0, 0.0] | 1 | ||
[-1, 1] | 1 |
FloatRange gives the same counts for the queries run (own bounds, [0, 0], [-1, 1]). Doc values: DoubleRangeDocValuesField.newSlowIntersectsQuery [0, 0] = 0, [-1, 1] = 1, own bounds = 0; FloatRangeDocValuesField misses its own bounds too. Range-on-range facets: WITHIN={zero=1, wide=1} but INTERSECTS={zero=0, wide=1}. As query bounds: a document [0.0, 0.0] is matched by newContainsQuery(r, {0.0}, {-0.0}) but not by newIntersectsQuery with the same bounds. A plain main() probe outside the test framework, with the production default codec, prints the same DoubleRange counts on main and 10.5.2.
Exposure and likelihood193 words
- Who. Applications that index or query
DoubleRange/FloatRange(points or doc values) or use range-on-range facets, with bounds that can be exactly+0.0then-0.0. By code reading (not executed), Elasticsearch passesdouble_range/float_rangedocument bounds straight tonew DoubleRange/new FloatRangewith no ordering check of its own. - How it arises.
Math.ceil(x)is-0.0for-1 < x < 0(documented by the JDK), and-xis-0.0forx = 0.0, sonew DoubleRange(f, {0.0}, {Math.ceil(-0.3)})looks like[0, 0]to its author. Upstream tests never build the pair: their generator orders bounds withMath.min/Math.maxand never yields-0.0. - Effect. Silent wrong results, mostly missed matches for documents stored with these bounds (and, with
[0.0, -0.0]as a query's bounds, for valid documents such as[0.0, 0.0]), and relations that contradict each other. No exception, no index corruption: CheckIndex is clean on indexes of about 21,000-24,500 documents with a quarter of them inverted. - Not affected.
[-0.0, +0.0],[0.0, 0.0],[-0.0, -0.0]and every pair without both signed zeros. - Releases. Every release since 6.2.0; default codec and configuration; any index size.
One JUnit test method in lucene/core, public API: index one DoubleRange [0.0, -0.0] with the default codec and count newIntersectsQuery [0.0, 0.0]. It passes if the constructor rejects the range, so it accepts either fix. Fixed data, one thread; the same result with assertions on and off, for every seed tried and outside the test framework.
/*
* 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.document;
import java.io.IOException;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestDoubleRangeSignedZeroBounds extends LuceneTestCase {
public void testAcceptedZeroToNegativeZeroIntersectsZero() throws IOException {
DoubleRange field;
try {
field = new DoubleRange("r", new double[] {0.0}, new double[] {-0.0});
} catch (IllegalArgumentException rejected) {
return;
}
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(field);
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
double[] zero = {0.0};
// expected 1 (or rejection above); actual 0: [0.0, -0.0] is accepted but stored inverted
assertEquals(
"INTERSECTS [0.0, 0.0] hits for an indexed DoubleRange [0.0, -0.0]",
1,
new IndexSearcher(reader).count(DoubleRange.newIntersectsQuery("r", zero, zero)));
}
}
}
}
cp $REPRO/TestDoubleRangeSignedZeroBounds.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestDoubleRangeSignedZeroBounds -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
- Expected
- 1 hit for INTERSECTS
[0.0, 0.0](or anIllegalArgumentExceptionfrom the constructor, which the test also accepts). - Observed
- 0 hits:
AssertionError: INTERSECTS [0.0, 0.0] hits for an indexed DoubleRange [0.0, -0.0] expected:<1> but was:<0>, with assertions on and off.
More files, environment, full output284 words
The data are fixed and the test chooses no codec (new IndexWriterConfig(), ByteBuffersDirectory, new IndexSearcher(reader)). As a LuceneTestCase it still runs under the test framework's randomized setup: the gradle runs used codec=Asserting(Lucene104), a random maxPointsInLeafNode, RandomSimilarity, locale and time zone. The result does not depend on that: seeds DEADBEEF and 5EED1234 give the same failure, and a plain main() probe outside the test framework, with the production default codec, also counts 0 on main and 10.5.2. It fails the same way on the 10.5.2 and 9.12.3 release jars.
The other faces of the bug (FloatRange, the doc-values slow query, range-on-range facets, query bounds) share the root cause and the fix and are left out of the minimal test; they are in the table under "The finding in 30 seconds".
As a regression test it is the same test, except that Lucene's ecj lint rejects the unused catch parameter name rejected and accepts it written as _ (catch (IllegalArgumentException _)).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.5.2 and 9.12.3 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/TestDoubleRangeSignedZeroBounds.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestDoubleRangeSignedZeroBounds -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
TestDoubleRangeSignedZeroBounds > testAcceptedZeroToNegativeZeroIntersectsZero FAILED
java.lang.AssertionError: INTERSECTS [0.0, 0.0] hits for an indexed DoubleRange [0.0, -0.0] expected:<1> but was:<0>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:5F92A52E027EB78A]:0)
...
at org.apache.lucene.document.TestDoubleRangeSignedZeroBounds.testAcceptedZeroToNegativeZeroIntersectsZero(TestDoubleRangeSignedZeroBounds.java:46)
:lucene:core:test (FAILURE): 1 test, 1 failure
# the same with -Ptests.asserts=false, on the standalone runner (-ea, -da) and on the 10.5.2 and 9.12.3 jars (-da)
# with the recommended fix: the test passes (1 test, 0 failures), assertions on and off- The constructor rejects min > max on purpose, and rejects every other inverted pair, even the adjacent
[Double.MIN_VALUE, 0.0]. - No reading of signed zero fits: the point zero must overlap
[0, 0]; an empty range could not overlap[-1, 1]. newIntersectsQuerypromises ranges that "overlap, within, or contains"; this document is WITHIN[0, 0]but does not INTERSECT it.
The full argument287 words
- Deliberate validation.
verifyAndEncodethrows "min value (..) is greater than max value (..)" (DoubleRange.java:144-147); the upstream testTestDoubleRangeField.testMinGreaterThanMax("min cannot be greater than max", on coreDoubleRange, in the sandbox test tree,:71-80) asserts it. Executed:[MIN_VALUE, 0.0],[0.0, -MIN_VALUE]and[-0.0, -MIN_VALUE], the pairs adjacent inDouble#compareorder, are all rejected;[+0.0, -0.0]is the only one let through (also proved with Z3). - Witnesses against both readings (executed). Numeric reading (
-0.0 == 0.0, the point zero): it must INTERSECT[0, 0], and does not; a query[0.0, -0.0]misses a document[0.0, 0.0]. Lucene's order (-0.0 < 0.0, an empty range): it could not INTERSECT[-1, 1](it does), and would be WITHIN[1, 2](it is not); a query[0.0, -0.0]intersects a document[-1, 1]. - Contract.
newIntersectsQuery: "@return query for matching intersecting ranges (overlap, within, or contains)" (DoubleRange.java:198,FloatRange.java:198). Lucene's documented order for floating-point values: "Range comparisons are consistent withDouble#compareTo" (DoublePoint.java:213,:235; documented since LUCENE-7061, 6.0.0), and #8645 (LUCENE-7594): float/double points "are supposed to be consistent with Double/Float.compare, so +0 is supposed to compare greater than -0". The check uses a different order from the encoding. - History. The check and the encoding were written together in LUCENE-7381 (2016-07). By then
DoublePointandFloatPointalready documented that range comparisons followDouble#compareTo(LUCENE-7061, 2016-03, in 6.0.0). LUCENE-7594 (2016-12) later made the exclusive-bound helpers follow that order, and its issue states the intent (+0 above -0). The range-field check was never revisited. No discussion of signed zero in the introducing issues or in later range-field discussions. - Precedent. LUCENE-7738, "Fix min/max verification bug in InetAddressRange" (2017).
The devil's-advocate check130 words
Strongest case (the works-as-designed review and the devil's-advocate verifier): under Lucene's documented Double#compare order, [+0.0, -0.0] is an inverted, empty range, so the caller passed bad bounds. Read that way, "WITHIN and CONTAINS itself but not INTERSECTS itself" is exactly what an empty set does, and Lucene's point range queries accept inverted bounds too: DoublePoint.newRangeQuery(f, 0.0, -0.0) matches nothing without an error (executed). No javadoc on the range fields defines signed-zero semantics, and the upstream test oracle compares bounds with primitive operators, so neither reading is pinned down by tests.
Why it fails: the range fields, unlike point queries, reject inverted ranges on purpose, and this is the only inverted pair they accept; the results fit neither reading; nothing documents a precondition that bounds must be ordered in Double#compare order.
Recommended Reject [+0.0, -0.0]: validate with Double.compare / Float.compare
Check the bounds in the order they are stored: Double.compare(min[d], max[d]) > 0 (and Float.compare) instead of min[d] > max[d] in DoubleRange/FloatRange.verifyAndEncode, and the same in the doc-values and range-on-range facet argument checks (5 files, 6 lines). [0.0, -0.0] is then rejected like any inverted pair; the test and 9 more range suites pass.
- if (min[d] > max[d]) {
+ if (Double.compare(min[d], max[d]) > 0) {
throw new IllegalArgumentException(
"min value (" + min[d] + ") is greater than max value (" + max[d] + ")");All options and trade-offs3 options · 585 words
Option A was built and run in a scratch worktree of 645d4fe14a20 with gradle; option B was run on the minimal test only; option C was not built. The choice is upstream's: A changes what indexes today, B rewrites stored bounds.
Reject [+0.0, -0.0]: validate with Double.compare / Float.compare (validated by the checks) (recommended)
Replace min > max by Double.compare(min, max) > 0 (Float.compare for floats) in DoubleRange.verifyAndEncode and FloatRange.verifyAndEncode (:144 each), the doc-values fields' checkArgs (:84 each) and the range-on-range facet DoubleRange (:58, :117). The two verifyAndEncode lines alone cover every path (the doc-values constructors and slow queries and the facet counting all call them). Of the other four, the doc-values checkArgs lines make the slow-query factories reject with their own message ("min should be less than max"), and the facet lines make the facet range reject when it is built instead of when counting. The doc-values constructors reject through verifyAndEncode either way: super(field, DoubleRange.encode(min, max), ...) at :33 runs before checkArgs at :34 (executed with the fix shadowed).
- if (min[d] > max[d]) {
+ if (Double.compare(min[d], max[d]) > 0) {
throw new IllegalArgumentException(
"min value (" + min[d] + ") is greater than max value (" + max[d] + ")");Trade-offs
- A behaviour change: code that indexes
[0.0, -0.0]today starts to get anIllegalArgumentException, so it likely needs a CHANGES entry. Documents already indexed that way stay inverted until reindexed. - For non-NaN bounds
Double.compareis the order the encoding uses, so it rejects exactly the pairs that would be stored inverted: no ordered pair is newly rejected. The query-side case becomes a clean exception too. - It matches how at least one downstream server validates its own range-query bounds (
from.compareTo(to) > 0, read, not executed).
What the checks validated
- Stock + test: fails with assertions on and off (gradle).
- + fix: the test and 9 suites pass with assertions on and off: core
TestDoubleRange,TestFloatRange,TestDoubleRangeFieldQueries,TestFloatRangeFieldQueries,TestRangeFieldsDocValuesQuery; facetTestRangeOnRangeFacetCounts,TestRangeFacetCounts; sandboxTestDoubleRangeField,TestRangeFacet(97 tests with the new one, 2 skipped, 0 failures). - + fix:
:lucene:facet:checkwithout tests passes (formatting, ecj lint, forbidden APIs);:lucene:core:checkpasses once the test's unused catch parameter is written as_(ecj rejects the namerejected). - With the 5 patched files shadowed on the standalone runner: the independent reproduction's 9 tests and the finder's 6 tests (double, float, both doc-values fields, query bounds, facets) pass.
- Not run: the full Lucene test suite, nightly tests.
Keep the primitive check; when Double.compare(min, max) > 0 (which then means [+0.0, -0.0]), store [-0.0, +0.0], the numeric point zero (+6 lines in DoubleRange.verifyAndEncode).
Trade-offs
Indexing keeps working, but the stored bounds differ from the input (getMin/getMax return -0.0, +0.0), the same change is needed in FloatRange and the facet code, and it adopts the numeric reading of signed zero for this one pair while Lucene's documented order is Double#compare.
What the checks validated
The minimal test passes with this DoubleRange shadowed (assertions on). Not run against the suites.
Remove signed zero from range fields entirely. Not built.
Trade-offs
Changes results for valid ranges that start or end at -0.0 today (for example a document [-1, -0.0] would start to match INTERSECTS [0.0, 1]) and mixes with documents already indexed.
Downstream servers that pass client-supplied range bounds straight to new DoubleRange or new FloatRange store [0.0, -0.0] inverted, and a MUST_NOT INTERSECTS filter whose lower bound is 0.0, for example [0.0, 10], then does not exclude that document, just as it does not exclude a valid [-0.0, -0.0] one.
A bounded-exhaustive check ran every 1-D and 2-D range over boundary values (both zeros, the smallest and largest values, infinities, NaN) through the stock jar and compared each relation with exact range semantics. Every mismatch involved [0.0, -0.0]; a Z3 model then proved it is the only accepted pair stored inverted.
The method in full203 words
Method: bounded-exhaustive translation validation (semi-formal), a hunt unit. The harness built every 1-D and 2-D range over boundary value sets including ±0.0, ±MIN_VALUE, ±MAX_VALUE, ±Inf and NaN, and checked each relation (INTERSECTS, WITHIN, CONTAINS, CROSSES) across points, doc values, both, multi-valued points, conjunctions, count() and range-on-range facets, in 8 index variants, against exact range semantics. Code reading while writing the harness suggested that the primitive check and the sortable encoding disagree on signed zero; the harness then reported the range [0.0, -0.0] as accepted but invalid for double and float: 2,376 of 10,304 double and 1,992 of 8,512 float 1-D checks mismatched, every one involving that range, and 0 of 1,742,048 2-D checks once it is excluded.
A Z3 floating-point and bit-vector model of the check and the encoding, for non-NaN bounds (NaN is rejected separately), then proved that [+0.0, -0.0] is the only accepted pair stored inverted (Q2 unsat), that today's check never rejects a pair whose stored order is ordered (Q3 unsat), and that a Double.compare check leaves no accepted pair inverted (F1 unsat); a mutant of the encoding is caught (M1 sat). The first signal came from the exhaustive check, so this is not a formal-tool-first finding.
No prior report found (searched 2026-10-26, refreshed 2026-10-30): GitHub issues, PRs and commits, the JIRA archive, four mailing lists, Elasticsearch, OpenSearch and Solr, the web, and all open PRs. Closest, all fixed and different code: #8645 (LUCENE-7594, signed zero in point bounds), #6001 (sort comparators), #13301 (geo equals).
Searches4 searches
Related, not reports of this bug: #8645 (LUCENE-7594) states the intended order ("+0 is supposed to compare greater than -0") and fixed exclusive point bounds. #6001 (LUCENE-4937, signed zero in sort comparators) and #13301 (geo equals switched to Double.compare) are the same family in other code. LUCENE-7738 (InetAddressRange min/max verification) is the precedent for this kind of fix. The introducing issues #8434 and #8441 and the later range-field discussions (LUCENE-7449, LUCENE-7740, LUCENE-8362, #11901, #12008) never mention signed zero.
Downstream issues elastic/elasticsearch#108121, #128200 and #28147 are test-generator inversions (min > max in plain order), not signed zero.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs, commits; elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr) | 2026-10-26 | two scripted prior-report searches (45 and 75 planned queries; quota-refused ones repeated as 34 GraphQL searches), 36 manual apache/lucene and 54 downstream searches: verifyAndEncode, DoubleRange/FloatRange with negative or signed zero and -0.0, "is greater than max value", RangeFieldQuery | no report; #12008 and #9409 (unrelated verifyAndEncode hits); related #8645, #6001, #13301 |
| Open apache/lucene PRs | 2026-10-26 | changed-file lists of all 413 open PRs | none touches DoubleRange.java, FloatRange.java or the facet DoubleRange.java |
| Mailing lists, JIRA archive, web | 2026-10-26 | dev@, java-user@, issues@, commits@ (27 queries), JIRA (12), web (8); a second tool run by the devil's-advocate verifier (23 queries) | no report |
| Refresh | 2026-10-30 | upstream main vs 645d4fe14a20 (changed files), last commits touching the fix sites, issue and PR searches all time and updated since 2026-10-26 | main 716d6f216f52 touches none of the cited files; fix sites last changed 2020-2022; no new item |
Introduced with the API by LUCENE-7381 (f5964ec6e45a, 2016), which wrote the check and the encoding together; FloatRange copied it (LUCENE-7388). All 89 releases 6.2.0-10.5.2 fail; the exact results shown here are those of 7.0.0 and later.
Versions tested8 rows
LUCENE-7381 "add point based DoubleRangeField and RangeFieldQuery" (#8434, nknize; a JIRA-era patch, no GitHub PR) is f5964ec6e45a (author date 2016-07-15), backport 7dfcfcc71851; FloatRange came from LUCENE-7388 (#8441, 249780cf6082, backport 59807c530c9c). Both check lines are unchanged on main (later edits formatting only). The fields moved to core in 6.5.0 (LUCENE-7740); the doc-values fields arrived in 8.2.0 (LUCENE-8362) and range-on-range facets in 9.5.0 (#11901), each affected from its first release.
The symptom changed, the bug did not: three RangeFieldQuery rewrites changed which contradiction appears. 6.2.0-6.4.2: WITHIN [0,0] and INTERSECTS [0,0] but not CONTAINS [0,0]. 6.5.x: WITHIN [0,0] and INTERSECTS [-1,1] but not INTERSECTS [0,0]. 6.6.x: as 6.5.x, and results also depend on the segment's other documents. 7.0.0 onwards (LUCENE-7828, 528899d845cc): the results shown in this doc. Suggested wording: "the check has accepted this pair since 6.2.0, and range queries have returned inconsistent results for it in every release since". The 6.x-8.x jars were run on JDK 25; controls behaved as expected.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails, assertions on and off (gradle and standalone); the independent reproduction's tests 5 of 9 fail |
| 10.5.2, 9.12.3 | affected | the minimal test fails (assertions off) |
| 7.0.0-10.5.2 (all releases) | affected | version-portable probe fails; the results shown in this doc |
| 6.5.0-6.6.6 | other symptom | WITHIN [0,0] and INTERSECTS [-1,1] but not INTERSECTS [0,0]; on 6.6.x also segment-dependent |
| 6.2.0-6.4.2 (sandbox) | other symptom | WITHIN [0,0] and INTERSECTS [0,0] but not CONTAINS [0,0] |
| 6.0.0, 6.0.1, 6.1.0 | API absent | the probe does not compile |
| branch_11x, branch_10_6, branch_10_5, branch_9_12; tag 10.6.0 | read | the same line (read 2026-10-30, not run); branch_10x was decommissioned on 2026-10-29 (#16911) and no longer holds the code |
with the fix (main 645d4fe14a20) | not affected | the test and 9 range suites pass |
The finder, a cross-model review before the candidate, an independent reproducer, a works-as-designed review by another model family, a devil's-advocate verifier and an adjudication all rate it real (adjudication: confirmed, high). Each ran its own or the finder's test on the stock jars. Nobody dissented.
Checks and verdicts361 words
Every check executed a repro on the stock jars; the reviews from another model family (OpenAI GPT-5.6) wrote and ran their own tests.
| Check | Verdict | What it found |
|---|---|---|
| finder (a hunt unit) | candidate | Exhaustive check: every mismatch involves [0.0, -0.0]; Z3: the only such pair. JUnit 6 of 6 fail on main (-ea, -da), 10.5.2 and 9.12.3; pass with the fix. |
| cross-model review, refute lens | real, high | OpenAI GPT-5.6: own test fails on main with -ea, -da and 3 more seeds; ordered [-0.0, +0.0] control passes; with the fix its test and 40 existing core and facet range tests pass. |
| Independent reproducer | real | Own public-API JUnit written from the claim only: 5 of 9 fail (4 controls pass) on main, 10.5.2 and 9.12.3; its main() fails on 8.11.4 and 7.7.3; 0 of 9 with the fix. |
| Cross-model review, works-as-designed lens | real, high | OpenAI GPT-5.6: "unstated precondition, garbage in" judged weak; own test fails 2 of 3 on main and 10.5.2, passes with the fix; 38 existing tests pass with the fix. |
| Devil's advocate | real, high | Adjacent inverted pairs rejected; witnesses against both readings of signed zero; advised leading with the validation gap. |
| adjudication | confirmed, high | The independent reproduction's test with a fresh seed: 5 of 9 fail with -ea and -da, 0 of 9 with the fix. |
| Follow-up | keep | Version matrix (89 releases fail), doc-values probe from 8.2.0, prior-report search, security probe (no security impact). |
| this doc | reproduced, fix validated | The minimal test on 645d4fe14a20 (gradle, assertions on and off), 10.5.2 and 9.12.3; the independent reproduction's tests on 645d4fe14a20; the recommended fix with 9 suites and static checks; the alternative fix on the minimal test. |
| fact checks of this doc | correct on the defect; wording fixed | An independent fact check re-ran the gradle repro, the standalone runner, the fix with the 9 suites, the lint and the Z3 model, plus two probes; a second one, by another model family (OpenAI GPT-5.6), re-read the code and the saved runs. Both found the bug as described; their precision corrections are applied. |
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 576 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,429 words
- Registry title
- DoubleRange and FloatRange accept the bounds [0.0, -0.0] and store them inverted, so range queries silently miss such documents and contradict each other (WITHIN [0,0] but not INTERSECTS [0,0])
- Area
- document / range fields (DoubleRange, FloatRange, their doc-values fields; facet range-on-range)
- Priority
- Low, rank 575: Core DoubleRange/FloatRange accept [+0.0, -0.0] and store it inverted, so INTERSECTS queries silently miss that document with default config since 6.2, but it needs that exact signed-zero pair and affects only that one document.
- Impact class
- silent wrong results
- Impact note
- Users of the core DoubleRange/FloatRange fields, the DoubleRangeDocValuesField/FloatRangeDocValuesField slow queries or range-on-range facets, with the default codec and configuration, whose document or query range has exactly +0.0 as the lower and -0.0 as the upper bound in some dimension, get silently missed matches (mainly on INTERSECTS, the usual relation, and on CONTAINS) and relation results that contradict each other; no exception, no index corruption, no scale needed, deterministic with -ea and -da, in every release since 6.2.0; the trigger is narrow (both bounds must be exact signed zeros in that order).
- Summary
- You index a DoubleRange or FloatRange with min 0.0 and max -0.0 (Java's negative zero, numerically equal to 0.0); the constructor accepts it, as it accepts [0.0, 0.0], so you expect queries to treat it as the point zero, or else an IllegalArgumentException like the one thrown for every other min > max pair. Instead Lucene stores the bounds in its sort order, where -0.0 is below 0.0, so the stored range is inverted: the document matches newWithinQuery [0,0] and newIntersectsQuery [-1,1] but not newIntersectsQuery [0,0] or newIntersectsQuery of its own bounds. The same happens with the doc-values slow intersects query and range-on-range facet counts, and a query with bounds [0.0, -0.0] misses a document [0.0, 0.0] that newContainsQuery with the same bounds matches.
- Introduced
- f5964ec6e45a (LUCENE-7381 'add point based DoubleRangeField and RangeFieldQuery', migrated issue #8434, author nknize, a JIRA-era patch with no GitHub PR; branch_6x backport 7dfcfcc71851) wrote both the primitive check 'if (min[d] > max[d])' and the sortable encoding (NumericUtils.doubleToSortableLong) in the same method; FloatRange got the same code from 249780cf6082 (LUCENE-7388, #8441; branch_6x 59807c530c9c); both lines are unchanged on main (git blame; later edits are formatting only, LUCENE-9570) [read: git blame -w -M, git log -S 'if (min[d] > max[d]) {'; not a regression, the gap was born with the API]; the doc-values fields reach it from LUCENE-8362 (8.2.0) and range-on-range facets from #11901 (GITHUB#11869, 9.5.0); later RangeFieldQuery rewrites (LUCENE-7449 in 6.5.0, LUCENE-7847 in 6.6.0, LUCENE-7828 528899d845cc in 7.0.0) changed which contradiction appears, not whether one appears [exec at commit level with shadowed RangeFieldQuery] (2016-07-15 (DoubleRangeField, author date; committed 2016-07-20); FloatRangeField 2016-07-21 (committed 2016-08-17))
- First affected version
- 6.2.0 [exec] (sandbox DoubleRangeField/FloatRangeField); first affected core release 6.5.0 [exec] (LUCENE-7740 moved the fields to core as DoubleRange/FloatRange); the exact symptoms the repros assert (own pair WITHIN and CONTAINS but not INTERSECTS; WITHIN [0,0] but not INTERSECTS [0,0]) from 7.0.0 [exec]; doc-values fields from 8.2.0 [exec]; range-on-range facets from 9.5.0 [exec]
- Affected versions
- all 89 releases from 6.2.0 through 10.5.2 and main ea57360912ed, each executed failing with a version-portable main() probe (two index shapes, reading-independent consistency oracles, passing controls) [exec], and 10.6.0 (by reading); the finder's JUnit fails 6 of 6 on 9.5.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da) and its core part 5 of 5 on 9.4.2 [exec]; the independent reproduction's JUnit fails 5 of 9 on 9.12.3, 10.5.2 and main (-ea and -da) and the independent reproduction's core-only main() fails on 7.7.3 and 8.11.4 [exec]; doc-values probe fails on 8.2.0, 8.11.4, 9.12.3, 10.5.2 and main [exec]; the symptom differs by era: 6.2.0-6.4.2 WITHIN [0,0] and INTERSECTS [0,0] but not CONTAINS [0,0], 6.5.x and 6.6.x WITHIN [0,0] and INTERSECTS [-1,1] but not INTERSECTS [0,0] (6.6.x also depends on the segment's other documents), 7.0.0 onwards the symptoms in the summary [exec]; unaffected: 6.1.0 and earlier (the API does not exist; 6.0.0, 6.0.1 and 6.1.0 executed, compile error); branch_10x, branch_10_6 and branch_9_12 carry the same line [read, upstream contents, 2026-10-26], so 10.6.0 (released 2026-10-29) is affected and later releases will be unless fixed first [not executed]; not fixed upstream [read]; caveat: the 6.x-8.x jars were run on JDK 25
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check, code reading
- Upstream and prior reports
- none found (2026-10-26: scripted prior-report searches by the finder (45 queries: 16 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list, 4 JIRA) and by the follow-up step (75 planned: 30 GitHub, 5 commit, 4 downstream, 24 mailing-list, 6 JIRA, 6 web; 11 GitHub, commit and downstream queries went unanswered because of GitHub's search rate limit and were repeated as 34 GraphQL searches with the same strings); 36 manual apache/lucene issue and PR searches, 54 downstream searches (Elasticsearch, OpenSearch, Solr), 12 JIRA and 27 mailing-list (dev@, java-user@, issues@) queries, 8 web searches; a file-level scan of all 413 open apache/lucene PRs (none touches DoubleRange.java, FloatRange.java or the facet DoubleRange.java); the intended-behaviour check's scripted prior-report search (23 queries) and GitHub searches; main and branch_10x unchanged and CHANGES.txt silent); related prior items, same family but different code, symptom and fix, all fixed: #8645 (LUCENE-7594: float/double points follow Double/Float.compare, +0 above -0; fixed exclusive point bounds through nextUp/nextDown), #6001 (LUCENE-4937, signed zero in sort comparators), #13301 (geo equals switched to Double.compare); precedent: LUCENE-7738 (fixed a min/max verification bug in InetAddressRange); the introducing issues #8434 and #8441 and the later range-field discussions (LUCENE-7449, LUCENE-7740, LUCENE-8362, #11901, #12008) never mention signed zero
- Lucene commit tested
- main
645d4fe14a20(commit of 2026-10-28): the minimal test through gradle in a scratch worktree (assertions on and off), stock and with the recommended fix, plus 9 range suites in core, facet and sandbox with the fix; the standalone runner on main (the independent reproduction's 9 tests stock and with the fix shadowed, the finder's 6 tests with the fix shadowed, the alternative fix on the minimal test) and on the 10.5.2 and 9.12.3 release jars; a plainmain()probe with the production default codec on main and 10.5.2. Earlier hunt runs used mainea57360912edanda9779ef3c193, where every cited source file is identical; the version matrix ran on all releases from 6.0.0 to 10.5.2.
- Finding record
a Lucene main checkout @ ea57360912ed: lucene/core/src/java/org/apache/lucene/document/DoubleRange.java:134-156 (verifyAndEncode: NaN checks, primitive 'min[d] > max[d]' at :144, encode via NumericUtils.doubleToSortableLong at :154-156), :192-202 (newIntersectsQuery '@return query for matching intersecting ranges (overlap, within, or contains)'); FloatRange.java:144; RangeFieldQuery.java:104-115 (INTERSECTS.matches); DoubleRangeDocValuesField.java:33 and FloatRangeDocValuesField.java:33 (constructor encodes through DoubleRange.encode / FloatRange.encode), :84 (query checkArgs, primitive '>'); DoubleRangeSlowRangeQuery.java:86-89 and FloatRangeSlowRangeQuery.java:89 (verifyAndEncode); facet rangeonrange/DoubleRange.java:117 (checkArgs, primitive '>'); DoubleRangeOnRangeFacetCounts.java:19,90 (verifyAndEncode); NumericUtils.java:37-43 (sort order defined by Double#compareTo); DoublePoint.java:213,235 ('Range comparisons are consistent with Double#compareTo')a Lucene main checkout @ ea57360912ed: lucene/sandbox/src/test/org/apache/lucene/sandbox/document/TestDoubleRangeField.java:71-80 (testMinGreaterThanMax on core DoubleRange: 'min cannot be greater than max', message 'is greater than max value')
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/DoubleRange.java:59-61, :84-106, :134-156, :198a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/FloatRange.java:144, :155a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:37-43, :60-66a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/RangeFieldQuery.java:104-115, :147-158, :190-201a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/DoubleRangeDocValuesField.java:33, :84; FloatRangeDocValuesField.java:33, :84; DoubleRangeSlowRangeQuery.java:89; FloatRangeSlowRangeQuery.java:89a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/DoubleRange.java:58, :117; DoubleRangeOnRangeFacetCounts.java:19, :90
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/TestDoubleRangeFieldQueries.java:31-58 (generator)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/DoubleRange.java:144-147, :198; FloatRange.java:198a Lucene main checkout (645d4fe14a20) lucene/sandbox/src/test/org/apache/lucene/sandbox/document/TestDoubleRangeField.java:71-80a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:213, :235; FloatPoint.java:213, :235GitHub apache/lucene#8645 (LUCENE-7594 body, quoted in the follow-up and the intended-behaviour check)
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:41-43 (sort order defined by Double#compareTo)a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/DoubleRangeDocValuesField.java:33-34, :58-61, :84; FloatRangeDocValuesField.java:33-34 (constructor encodes before checkArgs)
- History
Branch read 2026-10-30: a Lucene clone refs origin/branch_11x 603af870375, branch_10_6 7e5aec4370e, branch_10_5 e85bb7b2c86, branch_10x 48cf3f515d6 (#16911, files removed); GitHub contents API for branch_9_12 and tag releases/lucene/10.6.0 (commit 4ae946e5a9b, tagged 2026-10-29)
- TestDoubleRangeSignedZeroBounds.java (lucene/core, one test):
repro/LFV-140/src/org/apache/lucene/document/TestDoubleRangeSignedZeroBounds.java(53 lines)
Every on-screen value comes from the code at 645d4fe14a20 or from the diagram probe's executed runs (main and 10.5.2, assertions on and off); an independent diagram check found no errors.
assets/LFV-140/bug.png(576 KB)
![Diagram of the bug: new DoubleRange("r", {0.0}, {-0.0}) passes the check because 0.0 > -0.0 is false, but is stored in Double#compareTo order with min +0.0 above max -0.0; WITHIN [0,0] = 1 and INTERSECTS [-1,1] = 1, but INTERSECTS [0,0] = 0; fix: Double.compare(min, max) > 0.](../assets/LFV-140/bug.png)