All findings · Lucene finding · LFV-140 · range-validation-accepts-positive-zero-to-negative-zero

DoubleRange and FloatRange accept the bounds [0.0, -0.0] and store them inverted, so INTERSECTS queries silently miss the document

new DoubleRange(field, {0.0}, {-0.0}) is accepted (in Java 0.0 > -0.0 is false) but stored in Lucene's sort order, where -0.0 comes first, so the range is saved backwards. The document is then inside [0, 0] yet not overlapping it: INTERSECTS queries silently miss it, in every release since 6.2.0.

Summary

Priority
Low, rank 575Core 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
silent wrong results
Since
6.2.0 (2016, LUCENE-7381); in core since 6.5.0introduced 2016-07-15 · over 10 years
Affected
all 89 releases 6.2.0-10.5.2 (executed), 10.6.0 (by reading) and main
Status
documented
Found by
a bounded-exhaustive check of range-field relations, then a Z3 proof

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 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.

Background: two zeros, two orders

  1. Java doubles have two zeros, 0.0 and -0.0. The operators ==, < and > treat them as equal: 0.0 > -0.0 is false.
  2. Double.compare and Double#compareTo do order them: -0.0 comes before 0.0. Lucene stores float and double values as bytes in this order, and documents that its point range comparisons follow it.
  3. An indexed DoubleRange keeps 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.
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.
The repro's example step by step: the bounds the application passes, how the check and the stored byte order treat 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.

The finding, step by step

  1. 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].
  2. Count DoubleRange.newIntersectsQuery("r", {0.0}, {0.0}). You expect 1 hit, or else an IllegalArgumentException when the field is built.
  3. You get 0 hits and no error. Yet newWithinQuery [0, 0] finds the document (1 hit) and newIntersectsQuery [-1, 1] finds it (1 hit): it lies inside [0, 0] and overlaps [-1, 1], but does not overlap [0, 0].
  4. Why: the bounds check uses Java's >, but the stored bytes follow Double#compareTo, where -0.0 sorts below 0.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.
  5. 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.

Who is affected

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 mechanism (main 645d4fe14a20)

  1. The check. new DoubleRange(name, min, max) (DoubleRange.java:59-61) calls setRangeValues (:84), which calls verifyAndEncode (:105). After the NaN checks it rejects min[d] > max[d] (:144) with "min value (..) is greater than max value (..)". With Java's >, 0.0 > -0.0 is false, so [0.0, -0.0] passes.
  2. The encoding. It then stores each bound with NumericUtils.longToSortableBytes(NumericUtils.doubleToSortableLong(val), ...) (:148-149, :153-156). The sort order of doubleToSortableLong "is defined by Double#compareTo" (NumericUtils.java:37-43; floatToSortableInt by Float#compareTo, :60-66), where -0.0 < 0.0. So the stored min (+0.0) is above the stored max (-0.0). FloatRange is the same code (FloatRange.java:144, :155).
  3. 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.
  4. Other paths. DoubleRangeDocValuesField's constructor encodes through DoubleRange.encode (DoubleRangeDocValuesField.java:33), its slow queries call DoubleRange.verifyAndEncode (DoubleRangeSlowRangeQuery.java:89), and DoubleRangeOnRangeFacetCounts calls it too (:19, :90). Their own argument checks use the same primitive >: DoubleRangeDocValuesField.java:84, FloatRangeDocValuesField.java:84, and the range-on-range facet DoubleRange.java:58 and :117.
  5. Only this pair. For non-NaN values, a > b and Double.compare(a, b) > 0 differ only for a = +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 a Double.compare check no accepted pair is stored inverted.

What one DoubleRange [0.0, -0.0] document does (main, assertions off)

queryWITHINCONTAINSINTERSECTS
own bounds [0.0, -0.0]110
[0.0, 0.0]10
[-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.0 then -0.0. By code reading (not executed), Elasticsearch passes double_range / float_range document bounds straight to new DoubleRange / new FloatRange with no ordering check of its own.
  • How it arises. Math.ceil(x) is -0.0 for -1 < x < 0 (documented by the JDK), and -x is -0.0 for x = 0.0, so new 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 with Math.min / Math.max and 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.

Minimal repro

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.

TestDoubleRangeSignedZeroBounds.java (lucene/core, one test)53 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.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)));
      }
    }
  }
}
Command
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 an IllegalArgumentException from 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.

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

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 _)).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.5.2 and 9.12.3 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
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

Observed output

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

Why it looks unintended

  • 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].
  • newIntersectsQuery promises ranges that "overlap, within, or contains"; this document is WITHIN [0, 0] but does not INTERSECT it.
The full argument287 words
  • Deliberate validation. verifyAndEncode throws "min value (..) is greater than max value (..)" (DoubleRange.java:144-147); the upstream test TestDoubleRangeField.testMinGreaterThanMax ("min cannot be greater than max", on core DoubleRange, 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 in Double#compare order, 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 with Double#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 DoublePoint and FloatPoint already documented that range comparisons follow Double#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.

Suggested fix

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 an IllegalArgumentException, so it likely needs a CHANGES entry. Documents already indexed that way stay inverted until reindexed.
  • For non-NaN bounds Double.compare is 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; facet TestRangeOnRangeFacetCounts, TestRangeFacetCounts; sandbox TestDoubleRangeField, TestRangeFacet (97 tests with the new one, 2 skipped, 0 failures).
  • + fix: :lucene:facet:check without tests passes (formatting, ecj lint, forbidden APIs); :lucene:core:check passes once the test's unused catch parameter is written as _ (ecj rejects the name rejected).
  • 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.

Accept it as the point zero: store [+0.0, -0.0] as [-0.0, +0.0] (validated by the checks)

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.

Canonicalise every -0.0 bound to +0.0 (not recommended)

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 note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr)2026-10-26two 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", RangeFieldQueryno report; #12008 and #9409 (unrelated verifyAndEncode hits); related #8645, #6001, #13301
Open apache/lucene PRs2026-10-26changed-file lists of all 413 open PRsnone touches DoubleRange.java, FloatRange.java or the facet DoubleRange.java
Mailing lists, JIRA archive, web2026-10-26dev@, java-user@, issues@, commits@ (27 queries), JIRA (12), web (8); a second tool run by the devil's-advocate verifier (23 queries)no report
Refresh2026-10-30upstream main vs 645d4fe14a20 (changed files), last commits touching the fix sites, issue and PR searches all time and updated since 2026-10-26main 716d6f216f52 touches none of the cited files; fix sites last changed 2020-2022; no new item

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe 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 affectedthe minimal test fails (assertions off)
7.0.0-10.5.2 (all releases) affectedversion-portable probe fails; the results shown in this doc
6.5.0-6.6.6 other symptomWITHIN [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 symptomWITHIN [0,0] and INTERSECTS [0,0] but not CONTAINS [0,0]
6.0.0, 6.0.1, 6.1.0 API absentthe probe does not compile
branch_11x, branch_10_6, branch_10_5, branch_9_12; tag 10.6.0 readthe 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 affectedthe test and 9 range suites pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidateExhaustive 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, highOpenAI 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 realOwn 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, highOpenAI 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, highAdjacent inverted pairs rejected; witnesses against both readings of signed zero; advised leading with the validation gap.
adjudication confirmed, highThe independent reproduction's test with a fresh seed: 5 of 9 fail with -ea and -da, 0 of 9 with the fix.
Follow-up keepVersion matrix (89 releases fail), doc-values probe from 8.2.0, prior-report search, security probe (no security impact).
this doc reproduced, fix validatedThe 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 fixedAn 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.

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

Finding record

LFV-140, snapshot of 2026-11-06

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 plain main() probe with the production default codec on main and 10.5.2. Earlier hunt runs used main ea57360912ed and a9779ef3c193, where every cited source file is identical; the version matrix ran on all releases from 6.0.0 to 10.5.2.

Lucene and upstream sources

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, :198
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/FloatRange.java:144, :155
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:37-43, :60-66
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/RangeFieldQuery.java:104-115, :147-158, :190-201
  • a 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:89
  • a 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:198
  • a Lucene main checkout (645d4fe14a20) lucene/sandbox/src/test/org/apache/lucene/sandbox/document/TestDoubleRangeField.java:71-80
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:213, :235; FloatPoint.java:213, :235
  • GitHub 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)

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

Repro files

About the diagrams

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.

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.