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.
- Java has two zeros.
-0.0 == 0.0is true, butDouble.compare(-0.0, 0.0)is -1: -0.0 sorts just below +0.0. It comes from ordinary arithmetic, for example-1 * 0.0orMath.ceil(-0.25). - 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. - A
DimRangeholds inclusive bounds only, sofromDoublesturns an exclusive bound into the neighbouring value inside the range.
[-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.- Index one document whose facet set holds -0.0:
FacetSetsField.create("f", new DoubleFacetSet(-0.0)). Count it withMatchingFacetSetsCountsand twoRangeFacetSetMatchers:[-1, 0)=DimRange.fromDoubles(-1, true, 0, false)and[0, 1]=DimRange.fromDoubles(0, true, 1, true). - 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. - 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. - 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,
fromDoublesstoresMath.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. - The same step is in
fromFloatsand the range-on-rangeDoubleRange, and the mirror case (+0.0 against[-1, -0.0]and(-0.0, 1]) fails the same way. Fix: step withDoublePoint/FloatPoint.nextUp/nextDown, which Lucene added in 2016 for exactly this mismatch.
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.
- Indexing.
DoubleFacetSet.getComparableValues()stores each value asNumericUtils.doubleToSortableLong(value)(facetset/DoubleFacetSet.java:40-41). FacetSets.adoc: "the matching of the values occurs in thelongspace", and the built-in sets pack sortable values (lucene/facet/docs/FacetSets.adoc:134-138). That order "is defined byDouble#compareTo" (NumericUtils.java:41-42). - 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-80Math.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.
- 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. - The integer sibling is exact.
fromLongsmoves 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). - Copies.
fromFloats(DimRange.java:84-104; steps at:90,:97). Range-on-rangeDoubleRange(rangeonrange/DoubleRange.java:39-64):Math.nextUpat: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.
| range | stored as (sortable) | -0.0 (sortable -1) counted? |
|---|---|---|
[-1, 0) = fromDoubles(-1, true, 0, false) | max = -2 | no |
[0, 1] = fromDoubles(0, true, 1, true) | min = 0 | no |
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-rangeDoubleRangeextends the experimentalRange(rangeonrange/Range.java:24);DoubleRangeOnRangeFacetCountsitself carries no such tag (DoubleRangeOnRangeFacetCounts.java:37), and its constructors takeRangeFieldQuery.QueryType, whose class is@lucene.internal(coreRangeFieldQuery.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).
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.
/*
* 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());
}
}
}
}
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>.
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.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); also the 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/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
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)DimRangekeeps inclusive bounds and takesminInclusive/maxInclusiveflags: excluding 0 should drop 0 only, asfromLongsdoes.- 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.
DimRangedocuments "@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-138puts matching in the sortablelongspace;NumericUtils.java:41-42defines that order byDouble#compareTo. - Both readings. Under
Double.compareorder (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(coreDoublePoint.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 inlucene/facet. - History and tests. The #841 review discussed only
Math.nextAfter(d, -Inf)versusMath.nextDown, never zeros; #11901 copied the range-on-range lines fromfacet.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.
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 primitive0.0 > -0.0is false, so it would slip through as a silently empty range;Double.comparekeeps 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)(andDoubleRange(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)andfromDoubles(-0.0, false, +0.0, true), which stock code rejects (Math.nextDown(+0.0)andMath.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;fromFloatsand range-on-rangeDoubleRangechange the same way. The suggested test covers none of these. Worth a CHANGES line. - A semantics choice: the fix adopts the
Double.comparereading, so -0.0 lands in[-1, 0). A maintainer may want to settle it together with the olderfacet.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
facetsetandrangeonrangesuites pass (35 tests, 1 skipped because it needs assertions); the wholelucene/facetmodule passes with assertions on (307 tests, 0 failures, 7 skipped);:lucene:facet:checkwithout 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.
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.
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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs, commits, incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr, Lucene.NET) | 2026-11-02 | scripted and manual queries (DimRange, fromDoubles, RangeFacetSetMatcher, rangeonrange DoubleRange, negative/signed zero, nextUp/nextDown), open-PR-only and recently-updated queries | no report; #8645, #841, #11901, #8148 are related background |
| Open apache/lucene PRs | 2026-11-02 | changed-file lists of all 408 open PRs; the 19 updated since 2026-10-31 | none touches DimRange.java, rangeonrange/DoubleRange.java, DoublePoint, FloatPoint or NumericUtils |
| Mailing lists (dev@, java-user@, issues@, commits@) and JIRA | 2026-11-02 | list and JQL queries; LUCENE-10274 and LUCENE-7092 read in full | only notification mirrors of the PRs above |
| Threads read in full | 2026-11-02 | #841, #11310, #11901, #11869, #13328, #8645, #8148, #6403, #13568 | zeros never discussed for facet sets or range-on-range |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | repro fails with assertions on and off (gradle and a standalone runner); range-on-range test fails |
| 10.6.0 | affected | repro fails with assertions on and off; range-on-range test fails |
| 10.5.2 | affected | a 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 | affected | repro fails (assertions off) |
| 9.12.3 | affected | facet-set test and range-on-range test fail |
| 9.5.0 | affected | range-on-range test fails (first release with rangeonrange) |
| 9.4.2 | range-on-range absent | no rangeonrange package (compile error); facet-set lines identical to 9.3.0 (read, not run) |
| 9.3.0 | affected | facet-set test fails with assertions on and off (first release with facetset) |
| 9.2.0 | API absent | no facetset package (compile error) |
with the fix (main 5adff78bb168) | not affected | the test passes; the whole facet module passes (307 tests) |
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.
| Check | Verdict | What it found |
|---|---|---|
| reproducer | real | Its 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, high | Its 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, high | Its 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 | real | Built 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, Low | Re-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 | confirmed | One class, one method, ordinary inputs, a silently wrong count against a documented order. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 491 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,619 words
- 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.
- 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-233Lucene 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)
- TestDimRangeExclusiveZeroBound.java (lucene/facet, one test):
repro/LFV-1131/TestDimRangeExclusiveZeroBound.java(57 lines)
Every value shown comes from the code at 5adff78bb168 or an executed probe.
assets/LFV-1131/bug.png(491 KB)
