All findings · Lucene finding · LFV-310 · facet-doublerange-signed-zero-count-vs-accept

DoubleRangeFacetCounts does not count -0.0 in a range that starts at an inclusive 0.0, although the range's own drill-down matches it

A facet range such as [0, 1] accepts -0.0 and its drill-down returns the -0.0 document, but DoubleRangeFacetCounts leaves that document out of the count: three documents -0.0, 0.5 and 2.0 drill down to 2 hits and count 1. Every release from 4.7.0 (2014) to 10.6.0 is affected.

Summary

Priority
Low, rank 576DoubleRangeFacetCounts silently undercounts -0.0 values at an inclusive zero bound, the same signed-zero boundary class as LFV-140, which it follows.
Impact
silent wrong results
Since
4.7.0 (LUCENE-5371, 2014)introduced 2013-12-18 · over 12 years
Affected
every release from 4.7.0 to 10.6.0, and main
Status
documented
Found by
a Z3 model of the range-count conversion, then a JUnit

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

Facet range counting turns doubles into sortable longs, an order in which -0.0 sits just below 0.0. The range's own accept() and drill-down compare plain doubles, where the two zeros are equal. So at a zero bound the count and the drill-down disagree.

Background: range facets and negative zero

  1. DoubleRangeFacetCounts counts how many hits fall into each DoubleRange bucket; range.getQuery(...) is the drill-down that returns those hits. The two are meant to agree.
  2. Java doubles have two zeros, 0.0 and -0.0. Plain comparisons treat them as equal (-0.0 == 0.0 is true); Double.compare puts -0.0 first.
  3. NumericUtils.doubleToSortableLong turns a double into a long that sorts like Double.compare: -0.0 becomes -1 and 0.0 becomes 0.
Three documents with values -0.0, 0.5 and 2.0 and the facet range [0, 1]: accept() and the drill-down match -0.0 and 0.5 (2 hits), but DoubleRangeFacetCounts counts 1, because as sortable longs -0.0 is -1 and the range starts at 0.
The three-document example: -0.0, 0.5 and 2.0 in the bucket [0, 1], as doubles and as sortable longs; the drill-down returns 2 hits, the facet count is 1. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index three documents whose DoubleDocValuesField holds -0.0, 0.5 and 2.0. (-0.0 is Java's negative zero; ordinary arithmetic such as Math.ceil(-0.25) produces it.)
  2. Count them with DoubleRangeFacetCounts and the bucket new DoubleRange("[0, 1]", 0.0, true, 1.0, true). You expect 2: range.accept(-0.0) is true, because -0.0 >= 0.0 in Java, and the bucket's drill-down range.getQuery(...) returns 2 hits.
  3. Instead the count is 1, with no error: the -0.0 document is in the drill-down but not in the count.
  4. Why: the counter compares sortable longs (NumericUtils.doubleToSortableLong), where -0.0 is -1 and 0.0 is 0, so -0.0 falls just below the bucket. accept() and getQuery() compare plain doubles, where -0.0 == 0.0.
  5. Same cause: the buckets "< 0" and ">= 0" count a -0.0 document in neither, and the range [+0.0, -0.0], which the constructor accepts, makes counting throw "matches nothing".

Who is affected

Applications that range-facet a double field with DoubleRangeFacetCounts (any constructor, single- or multi-valued) and a bucket starting at an inclusive 0.0, such as [0, x), when values can be exactly -0.0. Default codec and settings; one document suffices. Counts are only ever too low; queries and drill-downs are unaffected. Rare.

Mechanism, terms and numbers671 words

DoubleRange defines membership with plain double comparisons: accept(value) is value >= min && value <= max (range/DoubleRange.java:84-87 on main 645d4fe14a20), so -0.0 is inside [0, 1], and both getQuery(...) drill-downs match through accept(). DoubleRangeFacetCounts instead converts each range's bounds and every value with NumericUtils.doubleToSortableLong (DoubleRangeFacetCounts.java:255-270, :182, :225, :231, :273-275) and counts in that long order, where -0.0 (-1) sorts below +0.0 (0). A document whose value is -0.0 is therefore matched by an inclusive-0.0 bucket's own drill-down but not counted (and +0.0 at an inclusive -0.0 upper bound likewise); the split "< 0" + ">= 0" counts it in neither bucket; and the range [+0.0, -0.0], which the constructor's plain minIn > maxIn check accepts, makes counting throw IllegalArgumentException ... matches nothing. Counts are only ever too low and no query result changes. The divergence came with the LUCENE-5371 speed-up (8e1879225a35, first release 4.7.0); 4.6.1 counted with accept(). Every executed release through 10.6.0 and main fails. A 2-file fix that widens an inclusive zero bound in the long conversion passes the repro and the facet module's checks.

The mechanism (main 645d4fe14a20)

  1. Membership is a plain comparison. range/DoubleRange.java:84-87:
   /** True if this range accepts the provided value. */
   public boolean accept(double value) {
     return value >= min && value <= max;
   }

Both drill-down queries built by getQuery(...) match through it: values.advanceExact(...) && range.accept(values.doubleValue()) (:195-196) and, multi-valued, if (range.accept(values.nextValue())) return true; (:298-300).

  1. Counting is in sortable-long order. DoubleRangeFacetCounts.getLongRanges() builds new LongRange(label, doubleToSortableLong(range.min), true, doubleToSortableLong(range.max), true) (DoubleRangeFacetCounts.java:255-270), and every counting path maps each value the same way (:182, :225, :231; the field path through mapDocValue, :273-275, of the raw bits that DoubleDocValuesField stores, -0.0 included). doubleToSortableLong follows Double#compareTo (NumericUtils.java:41-42): -0.0 is -1, 0.0 is 0.
  2. Where they split. For [0, 1] the counter starts at 0, so -0.0 (-1) is outside; accept(-0.0) is true. Mirror: +0.0 (0) is outside a counter range that ends at -0.0 (-1). A Z3 model proved that without a signed zero the count and accept() never disagree on the two single-valued paths, and that on the value-source path these two are exactly the mismatches.
  3. The split-at-zero hole. An exclusive max of 0.0 is stored as Math.nextAfter(0.0, -Inf) = -Double.MIN_VALUE (DoubleRange.java:71-74), which is sortable -2. So "< 0" ends at -2, ">= 0" starts at 0, and -0.0 (-1) is in neither.
  4. The throw. The constructor checks minIn > maxIn on doubles (:76-78, and 0.0 > -0.0 is false), so it builds [+0.0, -0.0]; the converted LongRange(0, -1) fails its own check (LongRange.java:74-76) with range "..." matches nothing (Range.java:38-40) when counting starts (RangeFacetCounts.java:65-68). The counter is built lazily, only when there are hits to count, so with no hits nothing is thrown.

Executed example (main 645d4fe14a20, public API, default IndexWriterConfig)

Three documents with a DoubleDocValuesField value of -0.0, 0.5 and 2.0; the three counting paths are the field, DoubleValuesSource and MultiDoubleValuesSource constructors. Each document also holds a DoublePoint of the same value, used only for the points comparison; the counts read the doc values.

rangeaccept / drill-downcount (all three paths)
[0, 1]-0.0 and 0.5 / 2 hits1 (expected 2)
control: values 0.0, 0.5, 2.0, [0, 1]2 hits2
mirror: values 0.0, -0.5, -2.0, [-1, -0.0]2 hits1 (expected 2)
"< 0" = (-Inf, true, 0.0, false) and ">= 0" = (0.0, true, +Inf, true)0 and 3 hits0 and 2: -0.0 in neither
[+0.0, -0.0]1 hit (the -0.0 document)throws IllegalArgumentException: range "[+0.0, -0.0]" matches nothing

A plain main() probe with the default codec and no test framework gives the same: -0.0 (from Math.ceil(-0.25)) in [0,1]: accept 2, drill-down 2, facet count 1, on main and 10.6.0. With one multi-valued document {-0.0, 5.0}, getQuery(MultiDoubleValuesSource) matches it, and the field and MultiDoubleValuesSource counts are 0.

Exposure and likelihood242 words
  • Who can hit it. Applications that count range facets over a double with DoubleRangeFacetCounts, through any of its constructors (field, DoubleValuesSource or MultiDoubleValuesSource; single- or multi-valued), with a bucket that starts at an inclusive 0.0. That bucket shape is common: the facet demo's distance buckets are new DoubleRange("< 1 km", 0.0, true, 1.0, false) and so on. The values must include an exact -0.0, which ordinary code produces (Math.ceil(-0.25), -1 * 0.0, Double.parseDouble("-0.0")). The mirror case is +0.0 at an inclusive -0.0 upper bound.
  • What it takes. Public API; default codec, IndexWriterConfig and searcher; no opt-in setting; one document.
  • Effect. Silent wrong results: the bucket's count is one too low for each such document (for a multi-valued document, when none of its other values is in the bucket), while drilling into the bucket shows the document. FacetResult.value is too low as well when no other requested bucket counts the document. Counts are never too high, and deleted or non-hit documents are never counted. The only loud symptom is the [+0.0, -0.0] range, which throws.
  • How likely. Rare: most double fields never hold a negative zero, the randomized range-facet tests never generate one (values from random().nextDouble()), and no public report was found since 4.7.0 (2014). A code search of the default branches found no use of DoubleRangeFacetCounts in Solr, Elasticsearch or OpenSearch (not a full inventory), so the known reach is custom applications built on lucene-facet.

Minimal repro

One JUnit test method in lucene/facet, public API: index one document with DoubleDocValuesField("f", -0.0), check that [0, 1] accepts -0.0, then count it with DoubleRangeFacetCounts. Deterministic, one thread; the same with assertions on and off.

TestDoubleRangeSignedZero.java (lucene/facet, one test)55 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.facet.range;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.DoubleDocValuesField;
import org.apache.lucene.facet.FacetResult;
import org.apache.lucene.facet.FacetsCollector;
import org.apache.lucene.facet.FacetsCollectorManager;
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.search.MatchAllDocsQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDoubleRangeSignedZero extends LuceneTestCase {

  public void testNegativeZeroCountedInRangeStartingAtZero() throws Exception {
    ByteBuffersDirectory dir = new ByteBuffersDirectory();
    IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
    Document doc = new Document();
    doc.add(new DoubleDocValuesField("f", -0.0));
    w.addDocument(doc);
    w.close();
    DirectoryReader r = DirectoryReader.open(dir);
    FacetsCollector fc =
        new IndexSearcher(r).search(MatchAllDocsQuery.INSTANCE, new FacetsCollectorManager());
    DoubleRange range = new DoubleRange("[0, 1]", 0.0, true, 1.0, true);
    assertTrue(range.accept(-0.0));
    FacetResult result = new DoubleRangeFacetCounts("f", fc, range).getAllChildren("f");
    r.close();
    dir.close();
    // expected: the -0.0 document that the range accepts is counted once; actual: count 0
    assertEquals(
        "facet count of [0, 1] for one document with value -0.0",
        1,
        result.labelValues[0].value.intValue());
  }
}
Command
cp $REPRO/TestDoubleRangeSignedZero.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/range/
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestDoubleRangeSignedZero -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
Expected
The [0, 1] bucket counts the -0.0 document once (count 1), since range.accept(-0.0) is true.
Observed
Count 0: AssertionError: facet count of [0, 1] for one document with value -0.0 expected:<1> but was:<0>.

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

More files, environment, full output258 words

The test asserts the inconsistency inside the class: range.accept(-0.0) is true, so the count of the one document must be 1. The test body has one document and no threads or random calls (ByteBuffersDirectory). The seed still drives the test framework's per-run randomization (codec, similarity, locale, time zone, file encoding); the result was the same in every recorded run, and a plain main() probe with the default codec and no test framework fails the same way. The file carries the Apache license header; with the fix applied, :lucene:facet:check passes with it included unchanged (license, format, lint, forbidden APIs and the test), so it can go into a PR as is.

Left out on purpose: the drill-down count (it is accept() per document), the other two counting paths, the mirror case, the split at zero and the [+0.0, -0.0] throw; they are in the explanation above.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 and 10.5.2 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/TestDoubleRangeSignedZero.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/range/
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestDoubleRangeSignedZero -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false

Observed output

TestDoubleRangeSignedZero > testNegativeZeroCountedInRangeStartingAtZero FAILED
    java.lang.AssertionError: facet count of [0, 1] for one document with value -0.0 expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:A2F9029A4FF0C2A7]:0)
        ...
        at org.apache.lucene.facet.range.TestDoubleRangeSignedZero.testNegativeZeroCountedInRangeStartingAtZero(TestDoubleRangeSignedZero.java:50)
:lucene:facet:test (FAILURE): 1 test, 1  failure

# the same with -Ptests.asserts=false; the same on the 10.6.0 and 10.5.2 release jars (assertions on and off)
# with the fix: :lucene:facet:test (SUCCESS): 1 test (assertions on and off)

Why it looks unintended

  • The count disagrees with the range's own accept() and its drill-down getQuery(), which both match the -0.0 document.
  • Up to 4.6.1 the count called accept(); the 4.7.0 speed-up (LUCENE-5371) changed it silently, with a CHANGES entry about speed only.
  • It follows no single order: the buckets "< 0" and ">= 0" count a -0.0 document in neither.
The full argument225 words
  • Contract. min and max are "Minimum (inclusive)" and "Maximum (inclusive" (DoubleRange.java:45, :48); accept() is "True if this range accepts the provided value" (:84); each getQuery() will "Create a Query that matches documents in this range" (:324, :340). For -0.0 in [0, 1] the count of a range and its own drill-down disagree.
  • The upstream oracle. testRandomDoublesSingleValued computes expected counts with plain >= / <= ("slow but hopefully correct", TestRangeFacetCounts.java:1161-1176) and asserts that the facet count (:1222) and the drill-down count (:1232-1242) both equal it. Its values come from random().nextDouble() (:1090), so -0.0 never occurs.
  • History. Up to 4.6.1 the count loop was if (rangeFR.ranges[j].accept(v)) { counts[j]++; } (4.6.1 RangeAccumulator.java:85-89). LUCENE-5371 (8e1879225a35, 2013-12-18) replaced that loop (if (ranges[j].accept(v)) on its branch) with the sortable-long counter; its CHANGES entry says only "Speed up Lucene range faceting from O(N) per hit to O(log(N)) per hit using segment trees" (lucene/CHANGES.txt:12748-12751), and the issue (#6435, body and 5 comments) never mentions zero or sign. Executed: 4.6.1 counts -0.0 in [0, 1], 4.7.0 does not; the commit's parent is consistent and the commit is not (both built from source).
  • Not documented. No javadoc, package doc, CHANGES or MIGRATE text says that range facet counts treat the two zeros differently.
The devil's-advocate check220 words

The strongest case for "intended": NumericUtils and DoublePoint order doubles by Double.compareTo. NumericUtils defines its encoding by it, DoublePoint range queries are "consistent with Double#compareTo" (DoublePoint.java:213; LUCENE-7594 / #8645 made the points' nextUp / nextDown follow it), DoubleRange.equals uses Double.compare, and the upstream test's own DoublePoint.newRangeQuery(range.min, range.max) drill-down agrees with the counter; both getQuery() javadocs suggest PointRangeQuery as possibly faster for an indexed field; and the three classes are @lucene.experimental, which leaves maintainers room to choose. So the counter is right and accept() is the odd one out.

Why it fails as a defence of today's behaviour: the facet module's own membership API (accept(), both getQuery()s) is the plain comparison, and the facet demo drills into a counted bucket with range.getQuery; the divergence came from a performance rewrite whose release counted -0.0 one version earlier; the counts follow neither order (in the split at zero, plain order puts -0.0 in ">= 0", Double.compare order with DoublePoint.nextDown(0.0) puts it in "< 0", the counter in neither); and equals (Double.compare) and hashCode (Objects.hash) both tell the zeros apart, which is about object identity and says nothing about membership. What the defence does show is that maintainers may prefer to fix the other side. Verdict: not intended, a defect.

Suggested fix

Recommended Widen an inclusive zero bound in the long conversion

Widen an inclusive zero bound when converting the range to sortable longs, so both zeros are inside as they are for accept(): DoubleRange.toLongRange() maps a 0.0 min to -0.0 and a 0.0 max to +0.0, and getLongRanges() calls it (2 files, +7/-9). The repro passes, and :lucene:facet:check passes (307 tests).

  LongRange toLongRange() {
+    // accept() uses primitive comparisons (-0.0 == +0.0) but sortable longs order -0.0 < +0.0:
+    // widen a zero bound so that both zeros are inside, as they are for accept()
+    double lo = min == 0.0 ? -0.0 : min;
+    double hi = max == 0.0 ? 0.0 : max;
     return new LongRange(
         label,
-        NumericUtils.doubleToSortableLong(min),
+        NumericUtils.doubleToSortableLong(lo),
         true,
-        NumericUtils.doubleToSortableLong(max),
+        NumericUtils.doubleToSortableLong(hi),
         true);
   }
All options and trade-offs3 options · 494 words

One option was built and run in a scratch worktree of 645d4fe14a20; the other two are not built. Which side to change is the maintainers' call; option A changes no query result.

Widen an inclusive zero bound in the long conversion (validated by the checks) (recommended)

The finder's fix. The package-private DoubleRange.toLongRange() already does the conversion but is not called on main; it gains two lines, and DoubleRangeFacetCounts.getLongRanges() replaces its own 7-line new LongRange(...) with longRanges[i] = range.toLongRange();.

  LongRange toLongRange() {
+    // accept() uses primitive comparisons (-0.0 == +0.0) but sortable longs order -0.0 < +0.0:
+    // widen a zero bound so that both zeros are inside, as they are for accept()
+    double lo = min == 0.0 ? -0.0 : min;
+    double hi = max == 0.0 ? 0.0 : max;
     return new LongRange(
         label,
-        NumericUtils.doubleToSortableLong(min),
+        NumericUtils.doubleToSortableLong(lo),
         true,
-        NumericUtils.doubleToSortableLong(max),
+        NumericUtils.doubleToSortableLong(hi),
         true);
   }

Trade-offs

  • Restores the pre-4.7.0 counts; no query, filter or drill-down result changes; no per-document cost (the added work runs once per range, when counting starts; no benchmark was run).
  • Visible count change for buckets with a zero bound (up by the number of zeros of the other sign), so a CHANGES entry is appropriate. [+0.0, -0.0] counts both zeros instead of throwing.
  • Counts still differ from a DoublePoint range query on signed zero, by design of points.
  • The sandbox DoubleRangeFacetCutter repeats the conversion (DoubleRangeFacetCutter.java:74-86, read only) and is not covered; toLongRange() is package-private, so it would need the same two lines or a public helper.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle, JDK 25: stock + test fails with assertions on and off; + fix: the test passes with assertions on and off.
  • + fix: TestRangeFacetCounts 22 tests pass. + fix and the test: :lucene:facet:check BUILD SUCCESSFUL (format, ecj lint, forbidden APIs, licenses; :lucene:facet:test 307 tests, 7 skipped, 0 failures); :lucene:facet:test with assertions off: 307 tests, 0 failures.
  • Earlier, shadowed onto the main jars: the finder's, the independent reproduction's, the intended-behaviour check's and the tie-break's tests pass, and TestRangeFacetCounts + TestDynamicRangeUtil pass 36 of 36 and 108 of 108 (3 iterations). With the fix the split at zero counts the three-document example as 0 + 3.
  • Not run: the full Lucene test suite, nightly tests, the sandbox module.

Move membership to Double.compare order instead

Make accept() (and so both getQuery()s) compare with Double.compare, reject [+0.0, -0.0] in the constructor, and step exclusive bounds with signed-zero-aware helpers (like DoublePoint.nextUp / nextDown) so the split at zero stays complete. Not built.

Trade-offs

Counts stay as they are and match points; but it changes which documents existing drill-downs match (-0.0 leaves [0, x] buckets) and touches the constructor, accept() and both query classes.

Document it

A javadoc note that range facet counts order -0.0 below +0.0 while accept() and getQuery() do not.

Trade-offs

Leaves the miscount, the split-at-zero hole and the [+0.0, -0.0] throw in place.

Downstream note

A code search of the default branches found no use of DoubleRangeFacetCounts in Solr, Elasticsearch or OpenSearch (not a full inventory); an application that enforced a quota or allow/deny rule from a range facet count would undercount documents holding -0.0, while its filters still match them.

How it was found

A Z3 bit-precise model of how DoubleRangeFacetCounts converts ranges and values to sortable longs. A counterexample search, posed while modelling signed zeros, returned -0.0 against an inclusive +0.0 minimum; further queries proved that a zero of the other sign at a zero bound is the only mismatch. A bounded-exhaustive run and a JUnit confirmed it.

The method in full189 words

Method: SMT (Z3) bit-precise IEEE-754 modelling of the DoubleRange-to-counter pipeline, a hunt unit (facet taxonomy and ranges); tier semi-formal. Obligation O2c, "counter membership in sortable-long space equals DoubleRange.accept", was posed as an expected-SAT counterexample search and returned SAT with value -0.0 and an inclusive min of +0.0; the first logged run already contained the signed-zero refinements, so the hypothesis was formed while modelling and witnessed by the solver. O2h (UNSAT) proves that on the value-source path the mismatch happens exactly for a zero value at an inclusive bound that is the opposite-signed zero, O2e and O2f (UNSAT) that no other mismatch exists on the value-source and raw-bit field paths, and O2d (SAT) gives the [+0.0, -0.0] throw. A bounded-exhaustive public-API harness (11 special values, 34,040 range sequences, 411,854 checks over three counting paths) found only the predicted classes: these two, the throw, and two exclusive-infinity classes that belong to LFV-313. The finder then wrote a public-API JUnit on the stock main jars; the independent reproduction added the multi-valued paths and the intended-behaviour check the split at zero. This doc's test is a one-method form of the finder's.

Prior reports, history and checks

Prior reports

No prior report found in over 400 searches on 2026-10-26 and 2026-10-30 (GitHub issues, PRs and commits, downstream projects, four mailing lists, the JIRA archive, the web) and scans of all open PRs. Related: #6435 (LUCENE-5371) introduced the counter; #8645 set the signed-zero order for points; #13568 copied the conversion into the sandbox.

Searches4 searches

Related, not reports of this bug: #6435 / LUCENE-5371 (introduced the sortable-long counter; the discussion is about speed only); #8645 / LUCENE-7594 (float/double points follow Double.compare, +0.0 above -0.0; never touched lucene/facet); #13568 (sandbox facets copied getLongRanges into DoubleRangeFacetCutter; a reviewer asked about inclusive/exclusive handling of the conversion, not signed zero); #6001 / LUCENE-4937 and #13301 (signed-zero fixes in sort comparators and geo equals). Open PR #16122 touches the sandbox copy (makes it static).

Coverage gaps: GitHub and the list archive tokenise "-0.0" as "0.0", so the search relies on identifier and phrase queries; private security@ reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; lucene-solr, the .NET port, Solr, Elasticsearch, OpenSearch)2026-10-26scripted prior-report search A (73 queries incl. lists, JIRA and web; 475 distinct hits, 0 high, 12 medium, all checked) and run B (24 list and JIRA queries; 2,382 distinct hits, almost all from the broad term "accept"; 0 high, 1 medium), the finder's 34 queries, 25+ manual searches, 123 GraphQL searches; 39 threads read in fullno report; #6435, #8645, #13568, #6001, #13301 are related
Open apache/lucene PRs2026-10-26, 2026-10-30changed-file scans of all open PRs (413, then 410)none changes accept(), the constructor check or getLongRanges; #16122 touches only the sandbox copy
Mailing lists and JIRA archive2026-10-26, 2026-10-30dev@, java-user@, issues@, commits@ and the JIRA archive through a scripted prior-report searchno report
Refresh (all of the above, plus web)2026-10-30a scripted prior-report search (85 queries, 491 hits, 0 high, 11 medium), 51 GraphQL, 5 REST and 10 web searches, recent commits and CHANGES.txtnothing new; upstream main 716d6f216f52 has the affected files unchanged

History

Introduced in 4.7.0 by LUCENE-5371 (8e1879225a35, 2013-12-18), a speed-up that replaced counting through accept() with a sortable-long counter. 4.6.1 and the commit's parent are consistent; the commit, every executed release from 4.7.0 to 10.6.0, and main fail.

Versions tested6 rows

LUCENE-5371 "faster range faceting using segment trees" (Mike McCandless; 8e1879225a35 on the lucene5339 branch, svn r1552086; trunk 9d0b60388da and branch_4x 7477fde1cb0 on 2014-01-04, squashed with LUCENE-5339; migrated as GitHub issue #6435; SVN era, no PR). It removed the ranges[j].accept(v) loop and added the sortable-long conversion, the minIn > maxIn check and the LongRange "matches nothing" check. Later counting paths reuse the same conversion: DoubleValuesSource (6.4.0), multi-valued fields (8.10.0, LUCENE-9946), MultiDoubleValuesSource (9.1.0). Releases between the executed ones were not run one by one; all runs used JDK 25, not each release's own JDK.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe test fails through gradle, assertions on and off
10.6.0, 10.5.2 (release jars) affectedthe test fails, assertions on and off
4.7.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.12.3 affectedversion-adapted probe: -0.0 in [0, 1] facet count 1 vs accept 2; +0.0 in [-1, -0.0] 1 vs 2; [+0.0, -0.0] throws
8e1879225a35 (LUCENE-5371) vs parent 8da43e722e38 bisectbuilt from source: the commit counts 0 for -0.0 in [0, 1] and throws for [+0.0, -0.0]; the parent counts 1 and 1
4.6.1 not affectedcounts through DoubleRange.accept: consistent
with the fix (main 645d4fe14a20) not affectedthe test passes; :lucene:facet:check passes (307 tests)

Independent checks

The independent reproduction, the intended-behaviour check, an adjudication (confirmed, high) and a tie-break rate it real, as does the intended-lens cross-model review (reading only); the finder's record carries its stage label, candidate. The refute-lens review ran twice (hunt and current main), reading only both times, and returned a procedural "not real" both times while agreeing on the root cause.

Checks and verdicts306 words

The reviewer from another model family (OpenAI GPT-5.6) could not execute code in any lens; the executed evidence comes from the other checks.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidateZ3 model (O2c sat; O2e, O2f, O2h unsat) and a bounded-exhaustive run (411,854 checks); JUnit fails 3 of 3 on main -ea and -da, 10.5.2 and 9.12.3; passes with the fix, and 22 of 22 TestRangeFacetCounts pass.
cross-model review, refute lens (hunt and current main) not real, low, procedural (both runs)Neither run could execute code; both agreed on the root cause and that the inputs are in contract; their one substantive point is the Double.compare precedent; pointed out the sandbox copy.
Independent reproducer realOwn JUnit: 4 of 5 fail on main (-ea, -da), 10.5.2 and 9.12.3; found the multi-valued path; 4.6.1 consistent, 4.7.0 not.
Cross-model review, intended lens real, medium (reading only)Rated the intended case moderate and rejected it as deciding only which side to fix; a separate cross-path JUnit fails on main.
Devil's advocate realThe split-at-zero test counts one -0.0 document in neither bucket (2 of 5 fail on main and 10.5.2; 5 of 5 pass with the fix); nothing documents the divergence.
adjudication confirmed, highRe-ran the independent reproduction's (4 of 5 fail) and the finder's (3 of 3) tests.
current-main re-check and tie-break real, highMinimal JUnit fails on 645d4fe14a20 (-ea, -da), 10.6.0 and 10.5.2; own 8-path JUnit fails 4 of 6 and passes with the fix; upstream range-facet tests 36/36 and 108/108 with the fix.
this doc reproducesThe test through gradle on 645d4fe14a20 (asserts on and off); with the fix in a scratch worktree it passes, and TestRangeFacetCounts and :lucene:facet:check (307 tests) pass.

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

Finding record

LFV-310, snapshot of 2026-11-06

Registry title
DoubleRangeFacetCounts orders -0.0 below +0.0 while DoubleRange.accept and getQuery treat the two zeros as equal: a document holding a zero of the opposite sign at an inclusive zero bound (for example -0.0 in [0, 1]) is matched by the range's own drill-down but not counted, and the accepted range [+0.0, -0.0] makes counting throw
Area
facet / range facet counts (DoubleRange bounds and values converted to sortable longs in DoubleRangeFacetCounts, against DoubleRange.accept and getQuery)
Priority
Low, rank 576: DoubleRangeFacetCounts silently undercounts -0.0 values at an inclusive zero bound, the same signed-zero boundary class as LFV-140, which it follows.
Impact class
silent wrong results
Impact note
Applications that range-facet a double field (DoubleRangeFacetCounts over a DoubleDocValuesField, a DoubleValuesSource, a multi-valued field or a MultiDoubleValuesSource) with an inclusive bound at 0.0, such as the common [0, x] or [0, x) buckets, get a count that is silently one too low for every hit whose value is exactly -0.0 (or +0.0 at an inclusive -0.0 upper bound; a multi-valued hit only when none of its other values is in the bucket), and a FacetResult.value that is too low when no other requested range counts the hit, while the range's own accept() and getQuery() drill-down still match that document; default codec, IndexWriterConfig and searcher, no scale needed (one document suffices), every release from 4.7.0 to 10.6.0; counts are only ever too low, queries and filters are unaffected, and the louder secondary symptom (IllegalArgumentException for the range [+0.0, -0.0]) needs both bounds to be opposite-signed zeros.
Summary
You count range facets over a double field with DoubleRangeFacetCounts and a range that starts at an inclusive 0.0, such as new DoubleRange("[0, 1]", 0.0, true, 1.0, true), and some documents hold -0.0 (Java's negative zero, which ordinary arithmetic such as Math.ceil(-0.25) or -1 * 0.0 produces). You expect the count to include every document the range accepts, because DoubleRange.accept and the range's own drill-down query getQuery() compare with primitive >= and <=, treat -0.0 as equal to 0.0, and match those documents. Instead the counter converts bounds and values with NumericUtils.doubleToSortableLong, where -0.0 sorts below +0.0, so those documents are silently left out of the count (likewise +0.0 at an inclusive -0.0 upper bound), and the range [+0.0, -0.0], which the DoubleRange constructor accepts, makes DoubleRangeFacetCounts throw IllegalArgumentException 'matches nothing'.
Introduced
8e1879225a35 (LUCENE-5371 'faster range faceting using segment trees', migrated as apache/lucene#6435, committed by mikemccand on the lucene5339 feature branch, svn r1552086; landed on trunk as 9d0b60388da (r1555338) and on branch_4x as 7477fde1cb0 (r1555342), together with LUCENE-5339; SVN era, no pull request). It replaced the per-hit loop 'if (ranges[j].accept(v)) counts[j]++' with LongRangeCounter over NumericUtils.doubleToSortableLong of the bounds and of each value, and added the primitive 'minIn > maxIn' constructor check and the LongRange 'matches nothing' check that rejects the converted [+0.0, -0.0]. [exec at release level: 4.6.1 consistent, 4.7.0 inconsistent; read: git blame on main, later commits only reformat (LUCENE-9570), rename (LUCENE-7061), move the helper (LUCENE-6917, LUCENE-7063) or relocate the code (LUCENE-9946); the commit itself was not built]. the hunt's record's 'LUCENE-5339 (2013)' is imprecise: LUCENE-5339 created DoubleRangeFacetCounts but still counted through accept() (2013-12-18 (8e1879225a3 on the lucene5339 branch); trunk and branch_4x 2014-01-04; first release 4.7.0, tagged 2014-02-25)
First affected version
4.7.0 [exec] (version-adapted probe: -0.0 in [0,1] facet count 1 vs accept()/getFilter 2; +0.0 in [-1,-0.0] 1 vs 2; [+0.0,-0.0] throws 'matches nothing'); 4.6.1, the last release before, is consistent [exec] (its RangeAccumulator counted with DoubleRange.accept); range faceting first appeared in 4.4.0 (LUCENE-4965) [read]
Affected versions
every release from 4.7.0 through 10.6.0 and main (ea57360912ed, 645d4fe14a20); not fixed upstream. [exec] the follow-up's version-adapted main() probes (control plus the three cases) fail on 4.7.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main and pass on 4.6.1 (the control is consistent on every version); the finder's JUnit fails 3 of 3 on 9.12.3 (one-line compat copy), 10.5.2 and main (-ea and -da); the independent reproduction's JUnit, which adds the multi-valued field and MultiDoubleValuesSource paths, fails 4 of 5 on 9.12.3, 10.5.2 and main (-ea and -da), and the independent reproduction's own probes fail on 4.7.0 and pass on 4.6.1; the minimal JUnit fails on main 645d4fe14a20 (gradle, assertions on and off) and on the 10.6.0 and 10.5.2 release jars (-ea and -da). Counting paths and the release that added each [read]: single-valued field 4.7.0, DoubleValuesSource 6.4.0 (LUCENE-7610), multi-valued field 8.10.0 (LUCENE-9946), MultiDoubleValuesSource 9.1.0 (LUCENE-10245). [read] 4.4.0-4.6.0 use the same accept() loop as 4.6.1; releases between the executed ones were not run one by one, and the conversion code is the same at every checked tag; upstream main 716d6f216f52, branch_11x 603af8703753 and branch_10_6 7e5aec4370e0 carry the same accept(), constructor check and conversion (branch_10x was emptied upstream on 2026-10-29), so the next releases are affected unless fixed first. All runs on JDK 25.0.3, aarch64 Linux, not each release's own JDK.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: Z3; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, read-only: scripted prior-report search A (73 queries: 30 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA, 14 web; 475 distinct hits, 0 high and 12 medium overlap, all checked) and run B (24 list and JIRA queries; 2,382 hits, 0 high, 1 medium); the finder's run (34 queries) and 25+ manual GitHub searches; 123 GitHub GraphQL searches over apache/lucene, apache/lucene-solr, apache/lucenenet, apache/solr, Elasticsearch and OpenSearch; 39 issue and PR threads read in full; a changed-file scan of all 413 open apache/lucene PRs (8 touch nearby files, none changes DoubleRange.accept, its constructor check or getLongRanges; #16122 touches only the sandbox copy of the conversion); main, branch_10x and branch_10_6 unchanged and CHANGES.txt silent); related prior items, same family but different code, all fixed: #8645 (LUCENE-7594: float/double points follow Double.compare, +0 above -0; it never touched lucene/facet), #6001 (LUCENE-4937, sort comparators), #13301 (geo equals); the introducing issue #6435 (LUCENE-5371) and the later range-facet work (#6029, #6409, #127/#10985, #543, #841, #11901, #13568) never mention signed zero; in #13568 a reviewer checked the inclusive/exclusive handling of the same conversion, not signed zero
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): the repro test through gradle in a scratch worktree, assertions on and off, stock and with the fix, plus TestRangeFacetCounts and :lucene:facet:check with the fix; the 10.6.0 and 10.5.2 release jars through the standalone runner, assertions on and off. Upstream main 716d6f216f52 (2026-10-30) has no change under facet/range/ or NumericUtils.java. The hunt's earlier runs (finder, verifiers, adjudication, follow-up, the release history back to 4.6.1) used main ea57360912ed, where the facet range sources and tests are identical.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:44-87 (at ea57360912ed) (javadoc 'Minimum (inclusive)', 'Maximum (inclusive'; the TODO about long-space comparisons at :56-59; Math.nextUp/Math.nextAfter exclusive-bound stepping at :65 and :73; primitive 'minIn > maxIn' check at :76; accept() 'value >= min && value <= max' at :84-87), :89-96 (toLongRange, unused on main), :109-110 (equals uses Double.compare), :196 and :299 (both getQuery TwoPhaseIterators call range.accept), :322-358 (getQuery javadoc 'matches documents in this range', PointRangeQuery as a faster alternative)
  • lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java:48-58 (at ea57360912ed) (field constructor assumes DoubleDocValuesField), :182, :225, :231 (values mapped with NumericUtils.doubleToSortableLong), :253-270 (getLongRanges converts min/max with doubleToSortableLong), :273-275 (mapDocValue = sortableDoubleBits)
  • lucene/facet/src/java/org/apache/lucene/facet/range/RangeFacetCounts.java:65-68 (at ea57360912ed) (setupCounter calls getLongRanges), :121; Range.java:38-40 (failNoMatch 'matches nothing'); LongRange.java:74-76 (min > max -> failNoMatch)
  • lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:42 (at ea57360912ed) (sortable order defined by Double#compareTo); lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:213,235 ('Range comparisons are consistent with Double#compareTo'); lucene/core/src/java/org/apache/lucene/document/DoubleDocValuesField.java:39 (stores Double.doubleToRawLongBits, keeps -0.0)
  • lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:1090, (at ea57360912ed) 1161, 1235 (testRandomDoubles*: random().nextDouble() values, 'slow but hopefully correct' primitive oracle, DoublePoint.newRangeQuery(range.min, range.max) drill-down); lucene/demo/src/java/org/apache/lucene/demo/facet/DistanceFacetsExample.java:59-62 (DoubleRange(..., 0.0, true, ...))
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:44-87, :89-96, :190-197, :290-302, :323-358
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java:176-188, :217-241, :253-275
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/RangeFacetCounts.java:59-69, :143, :167, :173; LongRange.java:54-80; Range.java:37-40
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:37-49; lucene/core/src/java/org/apache/lucene/document/DoubleDocValuesField.java:38-40
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/demo/src/java/org/apache/lucene/demo/facet/DistanceFacetsExample.java:59-62
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:1090
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java:60, :73, :88, :107, :134 (the five public constructors); RangeFacetCounts.java:191, :213 (FacetResult.value = totCount)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:45, :48, :84-87, :103-116, :323-351
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:1090, :1161-1176, :1222, :1232-1242
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:210-213; lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:41-42
  • a Lucene main checkout (645d4fe14a20) lucene/demo/src/java/org/apache/lucene/demo/facet/DistanceFacetsExample.java:235-243; lucene/CHANGES.txt:12748-12751
  • a Lucene main checkout (645d4fe14a20) DoubleRange.java:42, :329-330, :345-346; DoubleRangeFacetCounts.java:44; RangeFacetCounts.java:37 (@lucene.experimental; PointRangeQuery hint)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/cutters/ranges/DoubleRangeFacetCutter.java:74-86
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:89-96 (toLongRange, unused on main)

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

Repro files

About the diagrams

Made in parallel with this doc from the diagram's executed public-API probe on main 645d4fe14a20 (the three-document example, the mirror, the split at zero and [+0.0, -0.0], stock and with the fix).

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.