All findings · Lucene finding · LFV-1131 · facetset-dimrange-exclusive-bound-skips-signed-zero

Facet-set DimRange.fromDoubles with an exclusive bound at 0 skips -0.0: [-1, 0) and [0, 1] together count 0 of 1 document

Facet-set ranges [-1, 0) and [0, 1] should together count every value in [-1, 1], but a value of -0.0 is counted by neither: excluding the bound 0 steps to the next double below 0, which jumps over -0.0. Every release since 9.3.0 is affected.

Summary

Priority
Low, rank 578A facet set holding the zero of the other sign silently drops out of both adjacent ranges that meet at zero with one exclusive bound, so counts come out too low, in every release since 9.3.0. It needs a signed zero exactly at that bound in the experimental facet-set or range-on-range APIs, so it is a rare boundary case.
Impact
silent wrong results
Since
9.3.0 (LUCENE-10274, #841, 2022-06-25)introduced 2022-06-25 · about 4 years
Affected
every release from 9.3.0 to 10.6.0, and main (6 releases and main run)
Status
documented
Found by
code reading for a known signed-zero pattern, then a JUnit

AI-assisted page: this finding was first signalled by code reading or testing 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 sets store values as sortable longs, where -0.0 sits directly below +0.0. Making the bound 0 exclusive with Math.nextDown skips that slot.

Background: two zeros and inclusive bounds

  1. Java has two zeros. -0.0 == 0.0 is true, but Double.compare(-0.0, 0.0) is -1: -0.0 sorts just below +0.0. It comes from ordinary arithmetic, for example -1 * 0.0 or Math.ceil(-0.25).
  2. A facet set stores each double as a sortable long ordered like Double.compare: -Double.MIN_VALUE (-4.9E-324) is -2, -0.0 is -1, +0.0 is 0.
  3. A DimRange holds inclusive bounds only, so fromDoubles turns an exclusive bound into the neighbouring value inside the range.
Diagram of the example: one document whose facet set holds -0.0, counted with DimRange.fromDoubles(-1, true, 0, false) and fromDoubles(0, true, 1, true). In sortable order -Double.MIN_VALUE is -2, -0.0 is -1 and +0.0 is 0; Math.nextDown(0.0) makes the first range end at -2 and the second starts at 0, so -0.0 is counted by neither: expected 1, got 0 + 0.
The one-document example: the sortable values around zero, where [-1, 0) ends and [0, 1] starts, and the slot of -0.0 between them. 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 whose facet set holds -0.0: FacetSetsField.create("f", new DoubleFacetSet(-0.0)). Count it with MatchingFacetSetsCounts and two RangeFacetSetMatchers: [-1, 0) = DimRange.fromDoubles(-1, true, 0, false) and [0, 1] = DimRange.fromDoubles(0, true, 1, true).
  2. You expect the two counts to add up to 1: the ranges meet at 0 with no gap, so -0.0 belongs to [-1, 0) if it sorts below 0, or to [0, 1] if the two zeros are equal.
  3. Instead both counts are 0, with no error. A covering [-1, 1] range still counts the document, and a value of +0.0 is counted by [0, 1] as it should be.
  4. Why: facet sets compare values as sortable longs, where -Double.MIN_VALUE is -2, -0.0 is -1 and +0.0 is 0. To exclude 0, fromDoubles stores Math.nextDown(0.0) = -Double.MIN_VALUE, so [-1, 0) ends at -2 and [0, 1] starts at 0: -0.0, at -1, falls in the gap.
  5. The same step is in fromFloats and the range-on-range DoubleRange, and the mirror case (+0.0 against [-1, -0.0] and (-0.0, 1]) fails the same way. Fix: step with DoublePoint/FloatPoint.nextUp/nextDown, which Lucene added in 2016 for exactly this mismatch.

Who is affected

Applications that count DoubleFacetSet/FloatFacetSet values with RangeFacetSetMatcher ranges from DimRange.fromDoubles/fromFloats, or range-on-range DoubleRange buckets, that meet at zero with one exclusive bound. Opt-in, mostly @lucene.experimental APIs; default codec and settings; any index size. Each such value drops out of both buckets; nothing throws. Rare.

Mechanism, terms and numbers494 words

DimRange stores inclusive bounds as sortable longs, and facet sets match values in that long space, whose order is Double.compare order (facetset/DimRange.java:24-25, :79-80; FacetSets.adoc:134-138; NumericUtils.java:41-42). To honour an exclusive bound, DimRange.fromDoubles first moves it to the neighbouring double with Math.nextUp(min) / Math.nextDown(max) (DimRange.java:65-67, :72-74, main 5adff78bb168). In sortable order -Double.MIN_VALUE is -2, -0.0 is -1 and +0.0 is 0, so Math.nextDown(+0.0) = -Double.MIN_VALUE jumps over -0.0: fromDoubles(-1, true, 0, false) ends at -2 while fromDoubles(0, true, 1, true) starts at 0, and a facet set holding -0.0 is counted by neither. fromFloats, the +0.0 mirror (Math.nextUp(-0.0) skips +0.0) and range-on-range DoubleRange behave the same way. Lucene fixed this exact Math.nextUp/nextDown versus Double.compare mismatch for point queries in LUCENE-7594 (#8645, 2016) by adding DoublePoint/FloatPoint.nextUp/nextDown; using them here (plus a Double.compare emptiness check) fixes it, and the repro and the whole facet module pass. Present since facet sets were added in 9.3.0; every executed release through 10.6.0 and main fails.

The mechanism (main 5adff78bb168)

  1. Indexing. DoubleFacetSet.getComparableValues() stores each value as NumericUtils.doubleToSortableLong(value) (facetset/DoubleFacetSet.java:40-41). FacetSets.adoc: "the matching of the values occurs in the long space", and the built-in sets pack sortable values (lucene/facet/docs/FacetSets.adoc:134-138). That order "is defined by Double#compareTo" (NumericUtils.java:41-42).
  2. The range (facetset/DimRange.java:60-81):
   if (!minInclusive) {
     min = Math.nextUp(min);        // :65-67
   }
   ...
   if (!maxInclusive) {
     max = Math.nextDown(max);      // :72-74
   }
   if (min > max) { throw ... }     // :76-78, primitive comparison
   return new DimRange(
       NumericUtils.doubleToSortableLong(min), NumericUtils.doubleToSortableLong(max));   // :79-80

Math.nextDown(+0.0) is -4.9E-324 (-Double.MIN_VALUE), sortable -2; Math.nextUp(-0.0) is +4.9E-324, sortable 1. Each step jumps over one encoded value: the other zero.

  1. Matching (facetset/RangeFacetSetMatcher.java:50-58) rejects a value below the stored min or above the stored max. -1 > -2, so [-1, 0) rejects -0.0; -1 < 0, so [0, 1] rejects it too.
  2. The integer sibling is exact. fromLongs moves an exclusive bound by exactly one encoded value (max--, DimRange.java:43-49): applied to the encoded +0.0 it gives -1 and keeps -0.0, where the double path gives -2 (executed probe).
  3. Copies. fromFloats (DimRange.java:84-104; steps at :90, :97). Range-on-range DoubleRange (rangeonrange/DoubleRange.java:39-64): Math.nextUp at :47, and under the comment // Why no Math.nextDown?, Math.nextAfter(maxIn, Double.NEGATIVE_INFINITY) at :55, which also steps from +0.0 to -Double.MIN_VALUE.

Executed example (main, assertions on and off)

rangestored as (sortable)-0.0 (sortable -1) counted?
[-1, 0) = fromDoubles(-1, true, 0, false)max = -2no
[0, 1] = fromDoubles(0, true, 1, true)min = 0no
control [-1, 1]yes
control: value +0.0[0, 1] counts it

Also executed, outside the one-test repro: fromFloats with -0.0f counts 0 + 0; the mirror (+0.0 against [-1, -0.0] and (-0.0, 1]) counts 0 + 0; range-on-range DoubleRangeOnRangeFacetCounts under INTERSECTS with an indexed range [-0.0, -0.0] counts [-1, 0) 0, [0, 1] 0, [-1, 1] 1.

Exposure and likelihood230 words
  • Who can hit it. Bucketing that splits at zero with one exclusive bound, such as "below zero" [-1, 0) and "zero and above" [0, 1], over facet-set values that are exactly the zero of the other sign (-0.0 for that pair; +0.0 for [-1, -0.0] and (-0.0, 1]).
  • Default vs opt-in. The default IndexWriterConfig, codec and searcher suffice, but facet sets and range-on-range faceting are opt-in. The facet-set classes are @lucene.experimental (DimRange.java:26, RangeFacetSetMatcher.java:25) and range-on-range DoubleRange extends the experimental Range (rangeonrange/Range.java:24); DoubleRangeOnRangeFacetCounts itself carries no such tag (DoubleRangeOnRangeFacetCounts.java:37), and its constructors take RangeFieldQuery.QueryType, whose class is @lucene.internal (core RangeFieldQuery.java:46).
  • Scale. Per value, at any index size; assertions on and off behave the same.
  • Effect. Counts are only ever too low, by the number of such values, so bucket totals no longer add up; a covering range still counts the value. No exception.
  • Likelihood: rare. -0.0 comes from ordinary arithmetic or parsing (-1 * 0.0, Math.ceil(-0.25), Double.parseDouble("-0")), but most double data never holds one, and no public report was found in the documented searches.
  • Workaround on current releases. Write the lower bucket with an inclusive -0.0 upper bound, DimRange.fromDoubles(-1, true, -0.0, true): next to [0, 1] it counts -0.0 once, and +0.0 still goes to [0, 1] (executed on stock main, assertions on and off).

Minimal repro

One JUnit test method in lucene/facet, public API: index one document holding -0.0 and count it with [-1, 0) and [0, 1]; the test asserts that the two counts add up to 1. Deterministic, one thread, about 1.5 s; the same with assertions on and off.

TestDimRangeExclusiveZeroBound.java (lucene/facet, one test)57 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.facetset;

import org.apache.lucene.document.Document;
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.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestDimRangeExclusiveZeroBound extends LuceneTestCase {

  // Expected: -0.0 is counted by exactly one of [-1, 0) and [0, 1]; actual: by neither (sum 0).
  public void testNegativeZeroIsInOneOfTwoAdjacentRanges() throws Exception {
    try (Directory dir = newDirectory();
        IndexWriter w = new IndexWriter(dir, newIndexWriterConfig())) {
      Document doc = new Document();
      doc.add(FacetSetsField.create("f", new DoubleFacetSet(-0.0)));
      w.addDocument(doc);
      try (DirectoryReader r = DirectoryReader.open(w)) {
        FacetsCollector fc =
            new IndexSearcher(r).search(MatchAllDocsQuery.INSTANCE, new FacetsCollectorManager());
        FacetResult res =
            new MatchingFacetSetsCounts(
                    "f",
                    fc,
                    FacetSetDecoder::decodeLongs,
                    new RangeFacetSetMatcher("[-1, 0)", DimRange.fromDoubles(-1, true, 0, false)),
                    new RangeFacetSetMatcher("[0, 1]", DimRange.fromDoubles(0, true, 1, true)))
                .getAllChildren("f");
        assertEquals(
            "[-1, 0) + [0, 1]",
            1,
            res.labelValues[0].value.intValue() + res.labelValues[1].value.intValue());
      }
    }
  }
}
Command
cp $REPRO/TestDimRangeExclusiveZeroBound.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/facetset/
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestDimRangeExclusiveZeroBound -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and the same with -Ptests.asserts=false
Expected
The two counts add up to 1: -0.0 is counted by exactly one of [-1, 0) and [0, 1].
Observed
Both counts are 0: AssertionError: [-1, 0) + [0, 1] expected:<1> but was:<0>.

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

More files, environment, full output159 words

The suggested regression test is this same test. Left out on purpose: fromFloats, the +0.0 mirror and range-on-range, which are the same step at parallel fix sites and are covered by the same fix; they were run only in diagnostics and reviewers' tests, not in this test.

For 9.3.0 to 10.3.x, replace MatchAllDocsQuery.INSTANCE with new MatchAllDocsQuery() (INSTANCE exists from 10.4.0, #15499); nothing else changes. The file passes :lucene:facet:check's google-java-format, ecj lint and forbidden-API checks.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); also the 10.6.0 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/TestDimRangeExclusiveZeroBound.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/facetset/
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestDimRangeExclusiveZeroBound -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and the same with -Ptests.asserts=false

Observed output

TestDimRangeExclusiveZeroBound > testNegativeZeroIsInOneOfTwoAdjacentRanges FAILED
    java.lang.AssertionError: [-1, 0) + [0, 1] expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:4682CD8B2290E5FB]:0)
        ...
        at org.apache.lucene.facet.facetset.TestDimRangeExclusiveZeroBound.testNegativeZeroIsInOneOfTwoAdjacentRanges(TestDimRangeExclusiveZeroBound.java:50)
:lucene:facet:test (FAILURE): 1 test, 1  failure
(main 5adff78bb168, -Ptests.asserts=true; the same with -Ptests.asserts=false)

Why it looks unintended

  • DimRange keeps inclusive bounds and takes minInclusive/maxInclusive flags: excluding 0 should drop 0 only, as fromLongs does.
  • Facet sets match on sortable longs ordered like Double.compare; there the value just below +0.0 is -0.0.
  • Under either natural reading of the zeros, -0.0 belongs in one range, not neither; LUCENE-7594 (#8645) fixed this mismatch for point queries.
The full argument181 words
  • Contract. DimRange documents "@param min Inclusive min" and "@param max Inclusive max" (DimRange.java:24-25); fromDoubles(min, minInclusive, max, maxInclusive) takes the exclusive meaning as input (:60-61).
  • Documented order. FacetSets.adoc:134-138 puts matching in the sortable long space; NumericUtils.java:41-42 defines that order by Double#compareTo.
  • Both readings. Under Double.compare order (what the encoding does) -0.0 belongs to [-1, 0); if the zeros are treated as equal (-0.0 == 0.0) it belongs to [0, 1]. Under either of these two natural readings the value belongs in one half; the code puts it in neither.
  • Precedent. LUCENE-7594 (#8645, Dec 2016) says "Math.nextUp/nextDown is not consistent with Double/Float.compare" and added DoublePoint/FloatPoint.nextUp/nextDown (core DoublePoint.java:48-67; lucene/CHANGES.txt:8745-8747). Dawid Weiss, reviewing it, called the behaviour "trappy". Facet sets (2022) came later and never picked the helpers up: no commit has used them in lucene/facet.
  • History and tests. The #841 review discussed only Math.nextAfter(d, -Inf) versus Math.nextDown, never zeros; #11901 copied the range-on-range lines from facet.range.DoubleRange. No upstream test covering this signed-zero boundary behaviour was found.
The devil's-advocate check138 words

Strongest case for intended: the order of the zeros is not spelled out for facet sets, the API is experimental, Math.nextUp/nextDown is the standard idiom, and Lucene's own n-dimensional DoublePoint.newRangeQuery(String, double[], double[]) javadoc still says to pass Math#nextUp(lowerValue[i]) or Math.nextDown(upperValue[i]) (core DoublePoint.java:232-233; the same in FloatPoint.java:232-233).

Why it fails: the module does document its order (sortable longs, Double#compareTo). The n-dimensional paragraph was not updated by LUCENE-7594 (its commit 18d53a43f7b changed only the one-dimensional text) and is inconsistent with the one-dimensional overload just above, which recommends DoublePoint.nextUp/nextDown (:210-211); the n-dimensional javadoc itself says "Range comparisons are consistent with Double#compareTo" (:235). And under either natural reading of the zeros, -0.0 belongs in one of the two ranges. Updating that javadoc is optional and cosmetic, not part of this finding.

Suggested fix

Recommended Step with DoublePoint/FloatPoint.nextUp/nextDown and compare with Double.compare

Step an exclusive bound with DoublePoint/FloatPoint.nextUp/nextDown (consistent with Double.compare) instead of Math.nextUp/nextDown, and check emptiness with Double.compare/Float.compare, in DimRange.fromDoubles/fromFloats and range-on-range DoubleRange (+12/-10, 2 files). The repro and all 307 facet tests pass.

 if (!minInclusive) {
-  min = Math.nextUp(min);
+  min = DoublePoint.nextUp(min);
 }
 ...
 if (!maxInclusive) {
-  max = Math.nextDown(max);
+  max = DoublePoint.nextDown(max);
 }
-if (min > max) {
+if (Double.compare(min, max) > 0) {
   throw new IllegalArgumentException("Minimum cannot be greater than maximum");
 }
All options and trade-offs3 options ยท 557 words

One option was built and run in a scratch worktree of 5adff78bb168; the two alternatives were not built. The same lines carry a separate defect, LFV-313 (an exclusive bound at an infinity is not excluded); its guard sits just before the step and composes with this fix, but the two diffs need a manual merge if made separately.

Step with DoublePoint/FloatPoint.nextUp/nextDown and compare with Double.compare (validated by the checks) (recommended)

In DimRange.fromDoubles/fromFloats and rangeonrange.DoubleRange, replace Math.nextUp/Math.nextDown/Math.nextAfter(x, -Inf) by DoublePoint/FloatPoint.nextUp/nextDown, and the check min > max by Double.compare(min, max) > 0 (Float.compare for floats). Shown for fromDoubles:

 if (!minInclusive) {
-  min = Math.nextUp(min);
+  min = DoublePoint.nextUp(min);
 }
 ...
 if (!maxInclusive) {
-  max = Math.nextDown(max);
+  max = DoublePoint.nextDown(max);
 }
-if (min > max) {
+if (Double.compare(min, max) > 0) {
   throw new IllegalArgumentException("Minimum cannot be greater than maximum");
 }

Trade-offs

  • Reuses the helpers Lucene added for this mismatch (LUCENE-7594); no per-document cost, the change runs once per range construction.
  • The compare change is needed: with the new stepping [0, 0) gives max = -0.0, and the primitive 0.0 > -0.0 is false, so it would slip through as a silently empty range; Double.compare keeps it rejected.
  • Behaviour changes for three signed-zero endpoint pairs (executed, stock and fixed, assertions on and off): the inverted pair fromDoubles(+0.0, true, -0.0, true) (and DoubleRange(label, 0.0, true, -0.0, true)) now throws like any min > max range instead of building a silently empty one; fromDoubles(-0.0, true, +0.0, false) and fromDoubles(-0.0, false, +0.0, true), which stock code rejects (Math.nextDown(+0.0) and Math.nextUp(-0.0) step past the other zero, so the primitive min > max check throws), now build the one-value ranges {-0.0} and {+0.0}, each counting only its own zero; fromFloats and range-on-range DoubleRange change the same way. The suggested test covers none of these. Worth a CHANGES line.
  • A semantics choice: the fix adopts the Double.compare reading, so -0.0 lands in [-1, 0). A maintainer may want to settle it together with the older facet.range.DoubleRange, which has the same stepping lines (range/DoubleRange.java:65, :72-73) and whose own signed-zero mismatch is the separate LFV-310.
  • Does not touch LFV-313: DoublePoint.nextUp(+Inf) is still +Inf.

What the checks validated

  • Scratch worktree of 5adff78bb168, gradle, JDK 25.0.3: stock + test fails with assertions on and off; with the fix the test passes.
  • With the fix: all facetset and rangeonrange suites pass (35 tests, 1 skipped because it needs assertions); the whole lucene/facet module passes with assertions on (307 tests, 0 failures, 7 skipped); :lucene:facet:check without tests passes (google-java-format, ecj lint, forbidden APIs).
  • Not run: the full Lucene test suite and nightly tests; any build on a 10.x branch.

Step in the encoded space (not built)

Encode both bounds first, then move an exclusive bound by one sortable value (+1/-1), as fromLongs does.

Trade-offs

Same result at the zeros; a bigger change than option 1, and its behaviour at the infinities and NaN would need its own review.

Make the two zeros equal (not recommended)

Normalize -0.0 to +0.0 when a facet set is encoded.

Trade-offs

Changes the stored values and how ExactFacetSetMatcher treats the zeros (it compares exact longs, so today -0.0 and +0.0 differ); far larger than the bug.

How it was found

Code reading that carried a known bug pattern into the facet module: an exclusive float/double bound stepped with Math.nextUp/nextDown, which skips the other signed zero. An executed public-API probe witnessed it; a Z3 bit-vector model and a bounded-exhaustive replay on the real jar showed it is the only gap at a zero split.

The method in full171 words

Method: non-formal first signal, then a small formal check. A code-reading pass carried the signed-zero bound pattern of two earlier bugs (LFV-059, flexible query parser; LFV-1113, spatial-extras) over to the facet module and ran public-API probes on the stock main jars: -0.0 against [-1, 0) and [0, 1] counted 0 and 0 while the covering [-1, 1] counted 1, and the float, mirror and range-on-range forms did the same.

It was then reduced to the one JUnit above. A Z3 bit-vector model of the stepping, the sortable encoding and the matcher (56 queries, 64- and 32-bit) showed that "every non-NaN value lies in exactly one of two adjacent halves" fails only for the opposite-signed zero at a zero split (this gap) and for the separate exclusive-infinity defect (LFV-313, a double count); with DoublePoint/FloatPoint stepping only the infinity class remains. A bounded-exhaustive replay on the real jar (784 checks: 14 special values as split point and value, 2 split forms, double and float) agreed with the model on every row.

Prior reports, history and checks

Prior reports

No prior report found (about 100 queries over GitHub issues, PRs and commits incl. the JIRA archive, the Lucene mailing lists, the web and downstream projects, 2026-11-02). Nearest: LUCENE-7594 (#8645), the same mismatch fixed for point queries in 2016.

Searches4 searches

No public issue, PR, review comment, JIRA entry, mailing-list thread or web page reports that facet-set DimRange.fromDoubles/fromFloats or range-on-range DoubleRange lose a signed zero at an exclusive zero bound. Background only: #8645 (LUCENE-7594, 2016) fixed the same Math.nextUp/nextDown versus Double.compare mismatch for point queries before facet sets existed and names neither class; the introducing PRs #841 and #11901 never discussed zeros; #8148 (LUCENE-7092, 2016) advised Math.nextUp/nextDown, later corrected by LUCENE-7594.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits, incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr, Lucene.NET)2026-11-02scripted and manual queries (DimRange, fromDoubles, RangeFacetSetMatcher, rangeonrange DoubleRange, negative/signed zero, nextUp/nextDown), open-PR-only and recently-updated queriesno report; #8645, #841, #11901, #8148 are related background
Open apache/lucene PRs2026-11-02changed-file lists of all 408 open PRs; the 19 updated since 2026-10-31none touches DimRange.java, rangeonrange/DoubleRange.java, DoublePoint, FloatPoint or NumericUtils
Mailing lists (dev@, java-user@, issues@, commits@) and JIRA2026-11-02list and JQL queries; LUCENE-10274 and LUCENE-7092 read in fullonly notification mirrors of the PRs above
Threads read in full2026-11-02#841, #11310, #11901, #11869, #13328, #8645, #8148, #6403, #13568zeros never discussed for facet sets or range-on-range

History

Born with facet sets in 9.3.0 (f6bb9d218c0e, #841, 2022-06-25); range-on-range copied the same step in 9.5.0 (#11901). Every release through 10.6.0 and main; not a regression.

Versions tested10 rows

git blame on main attributes the four stepping lines (DimRange.java:66, :73, :90, :97), the sortable encoding (:80, :103) and the matcher comparisons (RangeFacetSetMatcher.java:51, :55) to f6bb9d218c0e ("LUCENE-10274: Add FacetSets faceting capabilities (#841)", Marc D'mello, 2022-06-25; branch_9x backport #981, 0e1502c12ba). The only later change to DimRange.java, 942065cc3e7 (#13328, 2024-09-10), turned it into a record and left the stepping alone. Range-on-range: cbfed77fd33 (#11901, 2022-12-30). Upstream main 1c2072f02843 (2026-11-02) has both files unchanged. Releases between the executed ones have identical lines (read).

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedrepro fails with assertions on and off (gradle and a standalone runner); range-on-range test fails
10.6.0 affectedrepro fails with assertions on and off; range-on-range test fails
10.5.2 affecteda reviewer's own public-API facet-set test fails with assertions on and off (the one-test repro was not run here)
10.0.0 affectedrepro fails (assertions off)
9.12.3 affectedfacet-set test and range-on-range test fail
9.5.0 affectedrange-on-range test fails (first release with rangeonrange)
9.4.2 range-on-range absentno rangeonrange package (compile error); facet-set lines identical to 9.3.0 (read, not run)
9.3.0 affectedfacet-set test fails with assertions on and off (first release with facetset)
9.2.0 API absentno facetset package (compile error)
with the fix (main 5adff78bb168) not affectedthe test passes; the whole facet module passes (307 tests)

Independent checks

Independent checks, each running tests on main: a reproducer, reviewers from another model family (OpenAI GPT-5.6), a devil's advocate and an adjudication all rate it real; a final repro check confirmed it. The fix was validated here with gradle.

Checks and verdicts251 words

Each check ran its own tests or re-ran the repro on main. The reproducer, the devil's advocate and the adjudication ran on 5adff78bb168. The cross-model reviews ran on the earlier main ea57360912ed, whose facet-set, range-on-range, NumericUtils and DoublePoint/FloatPoint sources are identical to 5adff78bb168; the intended-lens review's tests were re-run on 5adff78bb168.

Each independent check and its verdict
CheckVerdictWhat it found
reproducer realIts own public-API test fails on main (assertions on and off), 10.6.0, 10.5.2, 9.12.3 and 9.3.0; 9.2.0 has no facetset package. Its diagnostic confirms the sortable values and the float, mirror and range-on-range forms.
cross-model review, refute lens (OpenAI GPT-5.6) real, highIts own tests fail on main for fromDoubles, fromFloats, the +0.0 mirror and range-on-range; its controls pass; agrees on the root cause and the fix.
cross-model review, intended lens (OpenAI GPT-5.6) real, highIts tests fail on main and pass with the fix; the works-as-designed case fails on the documented sortable-long order and LUCENE-7594.
devil's advocate realBuilt the strongest intended case (unspecified zero order, the Math.nextUp idiom, the n-dimensional javadoc that still names Math.nextUp) and refuted it; fromLongs steps by one encoded value where fromDoubles skips -0.0.
adjudication confirmed, high confidence, LowRe-ran the reproducer's test with another seed on main: fails with assertions on and off. It is separate from LFV-313 and LFV-310.
final repro check confirmedOne class, one method, ordinary inputs, a silently wrong count against a documented order.

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

Finding record

LFV-1131, snapshot of 2026-11-06

Registry title
Facet-set DimRange.fromDoubles/fromFloats (and range-on-range DoubleRange) make an exclusive bound inclusive with Math.nextUp/Math.nextDown, which skip the other signed zero, so a facet set holding -0.0 is counted by neither of the adjacent ranges [-1, 0) and [0, 1]
Area
facet / facetset and rangeonrange (exclusive-bound stepping of double and float ranges: facetset.DimRange.fromDoubles/fromFloats with RangeFacetSetMatcher, rangeonrange.DoubleRange with DoubleRangeOnRangeFacetCounts)
Priority
Low, rank 578: A facet set holding the zero of the other sign silently drops out of both adjacent ranges that meet at zero with one exclusive bound, so counts come out too low, in every release since 9.3.0. It needs a signed zero exactly at that bound in the experimental facet-set or range-on-range APIs, so it is a rare boundary case.
Impact class
silent wrong results
Impact note
Applications that count DoubleFacetSet or FloatFacetSet values with RangeFacetSetMatcher ranges built by DimRange.fromDoubles/fromFloats (or range-on-range DoubleRange buckets) that meet at zero with one exclusive bound, such as 'below zero' [-1, 0) and 'zero and above' [0, 1], silently undercount every facet set whose value is the zero of the other sign (-0.0 for that pair; +0.0 for [-1, -0.0] and (-0.0, 1]), with the default IndexWriterConfig, codec and searcher, at any index size, -ea and -da alike, in every release since 9.3.0 (range-on-range since 9.5.0); both APIs are @lucene.experimental, counts are only ever too low, a covering range still counts the value, and nothing throws.
Summary
You index one document with a DoubleFacetSet holding -0.0 and count it with two RangeFacetSetMatchers, DimRange.fromDoubles(-1, true, 0, false) and DimRange.fromDoubles(0, true, 1, true), which split [-1, 1] at zero; you expect the two counts to add up to 1, because -0.0 belongs to [-1, 0) if the zeros are ordered as Double.compare orders them (the sortable order the matcher compares in) and to [0, 1] if they compare equal. Instead both counts are 0, with no error, while a covering [-1, 1] range counts the document: fromDoubles turns the exclusive max 0 into Math.nextDown(+0.0) = -Double.MIN_VALUE, which in sortable order lies below -0.0, so -0.0 falls between the two ranges. fromFloats, the mirror case (+0.0 against [-1, -0.0] and (-0.0, 1], where Math.nextUp(-0.0) skips +0.0) and range-on-range DoubleRange behave the same way.
Introduced
f6bb9d218c0e (LUCENE-10274 / #841, 'Add FacetSets faceting capabilities', by mdmarshmallow; branch_9x backport #981, merge commit 0e1502c12ba, byte-identical for DimRange.java) [read: git blame on main attributes the four stepping lines (DimRange.java:66, :73, :90, :97), the sortable encoding of the bounds (:80, :103) and the matcher comparisons (RangeFacetSetMatcher.java:51, :55) to f6bb9d218c0; born with the feature, not a regression; the only later change, 942065cc3e7 (#13328, record conversion), left the stepping unchanged]. Range-on-range reach: cbfed77fd33 (GitHub#11869 / #11901; branch_9x d9fc418914a), which copied the same stepping (with the comment 'Why no Math.nextDown?') from facet.range.DoubleRange. (2022-06-25 (f6bb9d218c0, author date 2022-06-25T07:27:11-07:00 on main; branch_9x backport merged the same day); range-on-range 2022-12-30 (cbfed77fd33))
First affected version
9.3.0 [exec: the one-test repro (adapted for 9.x with new MatchAllDocsQuery()) fails on 9.3.0 with -ea and -da; 9.2.0 does not compile it, because the facetset package does not exist there]; range-on-range reach first in 9.5.0 [exec: fails on 9.5.0; 9.4.2 has no rangeonrange package, compile error]; not a regression
Affected versions
Every release with facet sets: 9.3.0 through 10.6.0 (released 2026-10-29, 4ae946e5a9b), and main 5adff78bb168 (12.0.0-SNAPSHOT). Executed: the one-test repro fails '[-1, 0) + [0, 1] expected:<1> but was:<0>' on main -ea and -da, 10.6.0 -ea and -da, 9.3.0 -ea and -da, 9.12.3 -da and 10.0.0 -da (on 9.x to 10.3.x the test uses new MatchAllDocsQuery() in place of MatchAllDocsQuery.INSTANCE, nothing else); the independent reproduction's own test fails on main -ea and -da, 10.6.0 -da, 10.5.2 -ea and -da, 9.12.3 -ea and -da and 9.3.0 -da; 9.2.0 does not compile (no facetset package) [exec]. Range-on-range reach test (indexed [-0.0, -0.0], query ranges [-1, 0) and [0, 1] under INTERSECTS): fails on 9.5.0, 9.12.3 and 10.6.0 (-da) and on main (-ea and -da); 9.4.2 has no rangeonrange package [exec]. Releases between the executed ones by reading: the stepping lines and the matcher comparisons are unchanged in every releases/lucene tag from 9.3.0 to 10.6.0 [read]. Upstream main 1c2072f02843 (2026-11-02): both files unchanged since 2024-09-10 and 2022-12-30 (author dates) [read]. Not new in 10.6.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading; also reached by Z3, exhaustive check
Upstream and prior reports
none found (2026-11-02: about 103 queries (a scripted prior-report search with 51: 16 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive, 6 web; plus 32 manual GitHub queries incl. open-PR-only and updated>=2026-10-31 queries and Elasticsearch, OpenSearch, Solr and Lucene.NET, 7 JIRA JQL, 12 mailing-list and 2 web queries), the file lists of all 408 open apache/lucene PRs, per-file commit history with author dates, and full comment reads of #841, #11310, #11901, #11869, #13328, #8645, #8148, #6403 and #13568; on top of the finder's 51-query search and the independent reproduction's and the intended-behaviour check's searches; rechecked the same day): no report of facet-set DimRange.fromDoubles/fromFloats or range-on-range DoubleRange losing a signed zero at an exclusive zero bound. Same-day recheck: GitHub search on apache/lucene (issues and PRs, all states) for 'DimRange', 'fromDoubles', 'RangeFacetSetMatcher', 'rangeonrange DoubleRange', facet 'negative zero', facet 'signed zero' and 'DoublePoint.nextDown': only #841 and #11664; none of the 19 open PRs updated since 2026-10-31 touches facetset/, rangeonrange/, DoublePoint.java, FloatPoint.java or NumericUtils.java; the newest upstream commits on the two files are still 942065cc3e7 (#13328, 2024-09-10) and cbfed77fd33 (#11901, 2022-12-30). Related, not reports: #8645 (LUCENE-7594, 2016, fixed: 'Math.nextUp/nextDown is not consistent with Double/Float.compare'; added DoublePoint/FloatPoint.nextUp/nextDown for point queries, before facet sets and range-on-range existed, and names neither), #841 (the introducing PR; its review discussed only Math.nextAfter(d, -Inf) vs Math.nextDown, never zeros), #11901 (introduced rangeonrange.DoubleRange; no discussion of zeros), #8148 (LUCENE-7092, 2016: Math.nextUp/nextDown advice for exclusive point bounds, later corrected by LUCENE-7594).
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT), gradle in a scratch worktree, JDK 25.0.3, Linux aarch64 (2026-11-03): the repro fails with assertions on and off and passes with the fix; a standalone runner on main and on the 10.6.0 release jars (2026-11-02); releases 9.3.0, 9.5.0 (range-on-range test), 9.12.3, 10.0.0, 10.5.2 (a reviewer's own test) and 10.6.0 run.

Lucene and upstream sources

Finding record
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/DimRange.java:24-26 (at 5adff78bb168) ('@param min Inclusive min', '@param max Inclusive max', @lucene.experimental), :34-57 (fromLongs: exact one-step min++ / max--), :59-81 (fromDoubles: Math.nextUp at :66, Math.nextDown at :73, primitive min > max check at :76, NumericUtils.doubleToSortableLong at :79-80; fix site), :83-104 (fromFloats: :90, :97, :100, :103; fix site)
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/RangeFacetSetMatcher.java:25 (at 5adff78bb168) (@lucene.experimental), :42-61 (matches: signed long comparison of the sortable values against the inclusive bounds, :51 and :55)
  • lucene/facet/src/java/org/apache/lucene/facet/facetset/DoubleFacetSet.java:25 (at 5adff78bb168) and :40-41 (getComparableValues: NumericUtils.doubleToSortableLong); facetset/FloatFacetSet.java:26; facetset/MatchingFacetSetsCounts.java:38 (@lucene.experimental)
  • lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/DoubleRange.java:39-64 (at 5adff78bb168) (constructor: Math.nextUp at :47, '// Why no Math.nextDown?' and Math.nextAfter(maxIn, Double.NEGATIVE_INFINITY) at :54-55, primitive check at :58; fix site); rangeonrange/DoubleRangeOnRangeFacetCounts.java:19, :42-46 and :90 (RangeFieldQuery.QueryType parameter; bounds encoded through core DoubleRange.verifyAndEncode); rangeonrange/Range.java:24 (@lucene.experimental)
  • lucene/facet/docs/FacetSets.adoc:130-139 (at 5adff78bb168) ('FacetSets Under the Hood': values decoded into a long[] and 'the matching of the values occurs in the long space'; out-of-the-box sets pack values as sortable long / int)
  • lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:48-67 (at 5adff78bb168) (nextUp/nextDown 'consistently with Double#compare'; the only difference with Math#nextUp/nextDown is the signed-zero step) and :232-233 (the n-dimensional newRangeQuery javadoc still recommends Math#nextUp/nextDown, the stale text the intended-behaviour check flagged); lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:37-42 (sortable order defined by Double#compareTo)
The finding in 30 seconds
  • Lucene main 5adff78bb168 lucene/facet/src/java/org/apache/lucene/facet/facetset/DimRange.java:24-26 (inclusive min/max, @lucene.experimental), :43-49 (fromLongs steps by one), :60-81 (fromDoubles: Math.nextUp :65-67, Math.nextDown :72-74, primitive check :76-78, sortable encoding :79-80), :84-104 (fromFloats)
  • Lucene main 5adff78bb168 lucene/facet/src/java/org/apache/lucene/facet/facetset/RangeFacetSetMatcher.java:25, :50-58 (signed long comparison against inclusive bounds)
  • Lucene main 5adff78bb168 lucene/facet/src/java/org/apache/lucene/facet/facetset/DoubleFacetSet.java:40-41 (values stored as doubleToSortableLong)
  • Lucene main 5adff78bb168 lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/DoubleRange.java:39-64 (Math.nextUp :47, Math.nextAfter(maxIn, -Inf) :53-56, primitive check :58-60)
  • Lucene main 5adff78bb168 lucene/facet/docs/FacetSets.adoc:130-139 (matching in the long space; sortable packing)
  • Lucene main 5adff78bb168 lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:37-43 (sort order defined by Double#compareTo)
  • Lucene main 5adff78bb168 lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:48-67 (nextUp/nextDown consistent with Double#compare), :210-211 and :232-235 (1-D and n-D newRangeQuery javadoc); FloatPoint.java:232-233
Who is affected
  • Lucene main 5adff78bb168 lucene/facet/src/java/org/apache/lucene/facet/facetset/DimRange.java:26, facetset/RangeFacetSetMatcher.java:25, rangeonrange/Range.java:24 (@lucene.experimental)
  • Lucene main 5adff78bb168 lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/DoubleRangeOnRangeFacetCounts.java:37, :42-46 (no experimental tag; QueryType parameter) and lucene/core/src/java/org/apache/lucene/document/RangeFieldQuery.java:46 (@lucene.internal)
Why it looks unintended
  • Lucene main 5adff78bb168 lucene/facet/src/java/org/apache/lucene/facet/facetset/DimRange.java:24-26 (inclusive min/max, @lucene.experimental), :43-49 (fromLongs steps by one), :60-81 (fromDoubles: Math.nextUp :65-67, Math.nextDown :72-74, primitive check :76-78, sortable encoding :79-80), :84-104 (fromFloats)
  • Lucene main 5adff78bb168 lucene/facet/src/java/org/apache/lucene/facet/facetset/RangeFacetSetMatcher.java:25, :50-58 (signed long comparison against inclusive bounds)
  • Lucene main 5adff78bb168 lucene/facet/src/java/org/apache/lucene/facet/facetset/DoubleFacetSet.java:40-41 (values stored as doubleToSortableLong)
  • Lucene main 5adff78bb168 lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/DoubleRange.java:39-64 (Math.nextUp :47, Math.nextAfter(maxIn, -Inf) :53-56, primitive check :58-60)
  • Lucene main 5adff78bb168 lucene/facet/docs/FacetSets.adoc:130-139 (matching in the long space; sortable packing)
  • Lucene main 5adff78bb168 lucene/core/src/java/org/apache/lucene/util/NumericUtils.java:37-43 (sort order defined by Double#compareTo)
  • Lucene main 5adff78bb168 lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:48-67 (nextUp/nextDown consistent with Double#compare), :210-211 and :232-235 (1-D and n-D newRangeQuery javadoc); FloatPoint.java:232-233
  • Lucene main 5adff78bb168 lucene/CHANGES.txt:8745-8747 (LUCENE-7594 entry)
  • GitHub #8645 (LUCENE-7594) issue text and review comment; #841 and #11901 review threads (read 2026-11-02)
  • lucene-history: git show 18d53a43f7b (LUCENE-7594) -- DoublePoint.java (only the one-dimensional newRangeQuery javadoc changed)
Suggested fix
  • Lucene main 5adff78bb168 lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:48-67 and FloatPoint.java nextUp/nextDown
History
  • lucene-history: git log of f6bb9d218c0e and cbfed77fd33; release tags releases/lucene/9.3.0 and 10.6.0 (DimRange.java stepping lines read)
Independent checks
  • lucene-history: git diff ea57360912ed 5adff78bb168 of facetset/, rangeonrange/, NumericUtils.java, DoublePoint.java, FloatPoint.java (empty)

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

Repro files

About the diagrams

Every value shown comes from the code at 5adff78bb168 or an executed probe.

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.