DoubleRangeFactory puts every value below min into one bucket and returns it as a DoubleRange, documented as [min, max). For that bucket it writes Double.MIN_VALUE, 4.9E-324, as the lower end.
DoubleRangeGroupSelectorgroups search results by a double value. ADoubleRangeFactory(min, width, max)defines the buckets:width-wide buckets frommintomax, one bucket for every value belowmin, one formaxand above.- Each bucket comes back as the group's value,
GroupDocs.groupValue(), aDoubleRangewith an inclusiveminand an exclusivemax. - In Java,
Double.MIN_VALUEis 4.9E-324, the smallest positive double. The most negative double is-Double.MAX_VALUE, and below that comesDouble.NEGATIVE_INFINITY.
- You group documents by a double field with
DoubleRangeGroupSelectorandnew DoubleRangeFactory(-10, 5, 10): buckets 5 wide from -10 to 10, one bucket for everything below -10, one for 10 and up. The values are -20, -15, -11, -3, 0, 4 and 12. - You expect the bucket of -20, -15 and -11 to carry a
[min, max)label that contains them, such as[-Infinity, -10). The long twin does that:LongRangeFactory(-10, 5, 10)gives[Long.MIN_VALUE, -10). - Instead the label is
DoubleRange(4.9E-324, -10.0). The code usesDouble.MIN_VALUEas the lower end, and in Java that is the smallest positive double, not the most negative one. A range from just above 0 up to -10 contains no number at all. - The group itself is right: the same 3 documents, count and order. But a query built from its bounds, the way Lucene's own selector test rebuilds a group (
DoublePoint.newRangeQuery(min, Math.nextDown(max))), finds 0 of the 3. - A positive
mindoes not save it: with Lucene's test configDoubleRangeFactory(100, 100, 900)and the values 0.0, -1.0 and 50.0 below 100, the labelDoubleRange(4.9E-324, 100.0)leaves out 0.0 and -1.0, and its query finds 1 of the group's 3 documents.
Applications that group with DoubleRangeGroupSelector (opt-in) and read the below-min bucket's lower bound, for a heading or a drill-down query, when that bucket holds 0.0 or a negative value: always when min <= 0. Every such search, any index size, default settings otherwise. Membership, counts and order stay right.
Mechanism, terms and numbers482 words
DoubleRangeFactory(min, width, max) buckets double values for DoubleRangeGroupSelector: "all doubles below" min go "into a single range", and every bucket is returned as a DoubleRange, "a contiguous range of double values, with an inclusive minimum and exclusive maximum". For a value below min, getRange returns [Double.MIN_VALUE, min) (DoubleRangeFactory.java:52-56 on main 645d4fe14a20). Double.MIN_VALUE is 4.9E-324, the smallest positive double, not the most negative one. So with new DoubleRangeFactory(-10, 5, 10) the group of -20, -15 and -11 is labelled DoubleRange(4.9E-324, -10.0), an inverted range that contains no number, and with a positive min the label leaves out 0.0 and every negative member. Membership, counts and order are right, because the selector uses the range only as an equality key; what is wrong is GroupDocs.groupValue() of that one group, and anything built from it: a query rebuilt from the bounds, as Lucene's own selector test does, finds 0 of the group's 3 documents. The twin LongRangeFactory uses Long.MIN_VALUE. Every release since 8.6.0 has it, 10.6.0 included. The fix is Double.NEGATIVE_INFINITY, plus updating the one upstream assertion that pins the old label.
- The factory (
DoubleRangeFactory.java:48-66):
if (reuse == null) {
reuse = new DoubleRange(Double.MIN_VALUE, Double.MAX_VALUE); // :50, always overwritten
}
if (value < min) {
reuse.max = min;
reuse.min = Double.MIN_VALUE; // :54, the bug
return reuse;
}Double.MIN_VALUE is "the smallest positive nonzero value of type double", 2^-1074 = 4.9E-324 (JDK javadoc).
- What the label says.
DoubleRange.minis "The inclusive minimum value of this range" andmaxthe exclusive maximum (DoubleRange.java:27-31). Withmin <= 0,[4.9E-324, min)is inverted and empty. Withmin > 0it holds the positive members and leaves out 0.0, -0.0 and every negative member. - Why membership is still right.
DoubleRangeGroupSelector.advanceTocallsgetRangeonce per document (DoubleRangeGroupSelector.java:71) and keeps a document in the second pass if aHashSet<DoubleRange>of the first pass's group values contains its range (:75,:89-97);DoubleRange.equalscomparesminandmax(DoubleRange.java:44-50). Every value belowmingets the same key, so groups, counts, order and shard merging (SearchGroup.java:195-207) are consistent; only the numbers in the key are wrong. - The twin.
LongRangeFactory.getRangeis the same code withLong.MIN_VALUE(LongRangeFactory.java:50,:54).
| factory | value(s) below min | label | contains its values? |
|---|---|---|---|
DoubleRangeFactory(-10, 5, 10) | -20, -15, -11 | DoubleRange(4.9E-324, -10.0) | none (query from the bounds: 0 of 3 docs) |
DoubleRangeFactory(0, 1, 10) | -0.5 | DoubleRange(4.9E-324, 0.0) | no |
DoubleRangeFactory(10, 10, 50) | 0.0, -5.0 | DoubleRange(4.9E-324, 10.0) | no |
DoubleRangeFactory(10, 10, 50) | 4 (the upstream test's case) | DoubleRange(4.9E-324, 10.0) | yes |
DoubleRangeFactory(100, 100, 900) (Lucene's selector-test config) | 0.0, -1.0, 50.0 | DoubleRange(4.9E-324, 100.0) | 1 of 3 (query: 1 of 3 docs) |
LongRangeFactory(-10, 5, 10) (control) | -20 | LongRange(Long.MIN_VALUE, -10) | yes |
With the fix the below-min labels become DoubleRange(-Infinity, min) and every case contains its values.
Exposure and likelihood235 words
- Who is hit. Users of
DoubleRangeGroupSelector, throughGroupingSearch(Supplier<GroupSelector>)or the grouping collectors, who show the below-min bucket's bounds or build a filter, link or drill-down query from them, and whose values belowmininclude 0.0 or a negative number: always whenmin <= 0(temperatures, deltas, balances, scores around zero); withmin > 0, for example a free item (0.0) under a price floor. - Not hit. Applications that name the open-ended bucket only by its upper end ("below 10"), use the group value only as an opaque key, or whose values below
minare all positive;LongRangeFactory. - Opt-in, otherwise default. Range grouping must be chosen explicitly; nothing else is needed: default
IndexWriterConfig, codec and searcher, any index size, every search whose below-min group holds a value <= 0, deterministic, the same with assertions on and off.GroupingSearch,TopGroups,GroupDocsandSearchGroup(the types whosegroupValuecarries the label) are@lucene.experimental; the three range classes carry no such tag. - Effect. A wrong, too-narrow label: displays show an impossible range, and a query rebuilt from it matches fewer documents than the group holds (0 of 3, 1 of 3), never extra ones. No exception, no hang.
- Use in the wild. Not established: code searches of three large open-source servers that embed Lucene found no use, but a positive control hit a rate limit, so those zeros are not relied on.
One JUnit test method in lucene/grouping, public API, one assertion: new DoubleRangeFactory(-10, 5, 10).getRange(-20, null) must contain -20. No index, no randomness; the same result 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.search.grouping;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestDoubleRangeFactoryBelowMin extends LuceneTestCase {
public void testBelowMinRangeContainsValue() {
DoubleRange range = new DoubleRangeFactory(-10, 5, 10).getRange(-20, null);
// expected a [min, -10.0) range that contains -20; main returns DoubleRange(4.9E-324, -10.0)
assertTrue(range + " should contain -20.0", range.min <= -20 && -20 < range.max);
}
}
cp $REPRO/TestDoubleRangeFactoryBelowMin.java $LUCENE/lucene/grouping/src/test/org/apache/lucene/search/grouping/
cd $LUCENE && ./gradlew :lucene:grouping:test --tests org.apache.lucene.search.grouping.TestDoubleRangeFactoryBelowMin -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
- Expected
- A
[min, -10.0)range that contains -20, such asDoubleRange(-Infinity, -10.0). - Observed
DoubleRange(4.9E-324, -10.0):AssertionError: DoubleRange(4.9E-324, -10.0) should contain -20.0, with assertions on and off.
More files, environment, full output176 words
The assertion uses only DoubleRange's documented [min, max) contract, so it passes with either fix (NEGATIVE_INFINITY or -Double.MAX_VALUE). The package is Lucene's grouping test package only to sit next to its own tests; DoubleRange.min and max are public fields. The seed only picks the test framework's locale and settings, which the test does not use.
Also run: the 10.6.0 and 10.5.2 release jars fail the same way (assertions off).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 and 10.5.2 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 arithmetic) |
cp $REPRO/TestDoubleRangeFactoryBelowMin.java $LUCENE/lucene/grouping/src/test/org/apache/lucene/search/grouping/
cd $LUCENE && ./gradlew :lucene:grouping:test --tests org.apache.lucene.search.grouping.TestDoubleRangeFactoryBelowMin -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
TestDoubleRangeFactoryBelowMin > testBelowMinRangeContainsValue FAILED
java.lang.AssertionError: DoubleRange(4.9E-324, -10.0) should contain -20.0
at __randomizedtesting.SeedInfo.seed([DEADBEEF:661E24B25ADF817]:0)
at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
at junit@4.13.2/org.junit.Assert.assertTrue(Assert.java:42)
at org.apache.lucene.search.grouping.TestDoubleRangeFactoryBelowMin.testBelowMinRangeContainsValue(TestDoubleRangeFactoryBelowMin.java:26)
:lucene:grouping:test (FAILURE): 1 test, 1 failure
# the same with -Ptests.asserts=false, on the standalone runner (-ea, -da) and on the 10.6.0 and 10.5.2 jars (-da)
# with the recommended fix: 1 test, 0 failuresDoubleRangeis "a contiguous range of double values, with an inclusive minimum and exclusive maximum";DoubleRange(4.9E-324, -10.0)contains no value at all.- The twin
LongRangeFactoryusesLong.MIN_VALUE, the true minimum, in the same place. - Lucene's own selector test rebuilds every group from its bounds with
DoublePoint.newRangeQuery(min, Math.nextDown(max)); it passes only because all its values are positive.
The full argument264 words
- Intent. No record states why
Double.MIN_VALUEwas chosen (see History), so the original intent is not known; the points below are why the evidence favours a defect. - Contract.
DoubleRange.java:23("Represents a contiguous range of double values, with an inclusive minimum and exclusive maximum"),:27("The inclusive minimum value of this range"),:33;DoubleRangeFactory.java:30("all doubles below this value are grouped into a single range"),:31-33("Ranges are inclusive at the lower end, and exclusive at the upper end."),:43("Finds the DoubleRange that a value should be grouped into"). No sign or domain restriction anywhere in the class or the package javadoc. - The twin.
LongRangeFactory.java:54usesLong.MIN_VALUE. The two factory tests are identical apart from the type. The Double code and its assertion are already in the 2017 Double-only LUCENE-7889 patch;LongRangeFactoryfirst appears in the 2020 commit. - Lucene's own test.
BaseGroupSelectorTestCase.java:66-71: "Each group should have a result set equal to that returned by the top-level query, filtered by the group value"; the double filter isDoublePoint.newRangeQuery("double", groupValue.min, Math.nextDown(groupValue.max))(TestDoubleRangeGroupSelector.java:57), already in the feature's commit. Its values arenextDouble() * 1000(:37-38), so the below-min group (min 100) holds only positive values. - Lucene's open-end convention is infinity. Core
document/DoubleRange.java:33-34("open ended ranges can be defined using Double.NEGATIVE_INFINITY and Double.POSITIVE_INFINITY");DoublePoint.java:207-208(half-open range queries vialowerValue = Double.NEGATIVE_INFINITY). - History. No discussion chose the value: PR apache/lucene-solr#1484 has no comments, and the 6 comments on #8940 do not mention it. Reading
Double.MIN_VALUEas "most negative" is a known Java pitfall (the same mistake was caught in review in elastic/elasticsearch#42035).
The devil's-advocate check157 words
Strongest case (the cross-model reviews rated it moderate): TestDoubleRangeFactory.java:29 pins new DoubleRange(Double.MIN_VALUE, 10); the selector uses the range only as an equality key, so nothing inside Lucene misbehaves; the above-max label [max, Double.MAX_VALUE) is also imprecise, so both open ends could be read as sentinels; and no javadoc spells out the below-min label.
Why it fails: the pinned test uses min = 10 and the value 4, the one shape where the label happens to contain the value, so it pins the implementation, not a stated contract; the javadoc defines min as an inclusive bound; the feature's own selector test treats bounds as membership; a sentinel reading is served just as well by NEGATIVE_INFINITY, which also contains every member; and the above-max gap is limited to Double.MAX_VALUE and +Infinity, while this one drops every value <= 0. The independent reproduction, the intended-behaviour check and the adjudication reject it. A fix must update that assertion.
Recommended Double.NEGATIVE_INFINITY as the lower end
Use Double.NEGATIVE_INFINITY as the below-min bucket's lower end (DoubleRangeFactory.java:54, and the never-returned initializer on :50), and update the one assertion that pins the old label (TestDoubleRangeFactory.java:29). The repro and the grouping module's tests pass.
if (reuse == null) {
- reuse = new DoubleRange(Double.MIN_VALUE, Double.MAX_VALUE);
+ reuse = new DoubleRange(Double.NEGATIVE_INFINITY, Double.MAX_VALUE);
}
if (value < min) {
reuse.max = min;
- reuse.min = Double.MIN_VALUE;
+ reuse.min = Double.NEGATIVE_INFINITY;
return reuse;
}
# TestDoubleRangeFactory.java:29
- assertEquals(new DoubleRange(Double.MIN_VALUE, 10), factory.getRange(4, scratch));
+ assertEquals(new DoubleRange(Double.NEGATIVE_INFINITY, 10), factory.getRange(4, scratch));All options and trade-offs3 options · 488 words
Option A was built and run in a scratch worktree of 645d4fe14a20; option B only with the repro; option C is not recommended.
The label becomes [-Infinity, min), which contains every value below min, -Infinity included. It matches core DoubleRange's open-end convention and DoublePoint's half-open range queries, so newRangeQuery(-Infinity, Math.nextDown(min)) rebuilds the group: exactly, for the finite configurations run here (a GroupingSearch drill-down test passes 3 of 3); NaN and infinite values, and NaN, infinite or signed-zero factory bounds, were not run. The :50 change is cosmetic and optional. The regression test is the repro test.
if (reuse == null) {
- reuse = new DoubleRange(Double.MIN_VALUE, Double.MAX_VALUE);
+ reuse = new DoubleRange(Double.NEGATIVE_INFINITY, Double.MAX_VALUE);
}
if (value < min) {
reuse.max = min;
- reuse.min = Double.MIN_VALUE;
+ reuse.min = Double.NEGATIVE_INFINITY;
return reuse;
}
# TestDoubleRangeFactory.java:29
- assertEquals(new DoubleRange(Double.MIN_VALUE, 10), factory.getRange(4, scratch));
+ assertEquals(new DoubleRange(Double.NEGATIVE_INFINITY, 10), factory.getRange(4, scratch));Trade-offs
- It changes a returned value: code that stored, displayed or compared the old label sees
-Infinityinstead of4.9E-324; say so in CHANGES. - The label is also the group's identity (
SearchGroup.java:195-207,DoubleRangeGroupSelector.java:75,:89-97). By reading, shards on different Lucene versions during an upgrade would treat the old and the new below-min label as two groups (not executed). - No algorithmic or allocation change is expected (two constants are replaced); no benchmark was run.
What the checks validated
- Stock + test: fails with assertions on and off (gradle, scratch worktree of
645d4fe14a20). - + fix + the updated
TestDoubleRangeFactory.java:29+ test::lucene:grouping:testpasses, 57 tests, with assertions on and off; the module'scheckGoogleJavaFormat,ecjLintandforbiddenApispass. - Standalone runner with the fix shadowed: the repro passes (
-ea,-da); aGroupingSearchdrill-down test passes 3 of 3 (stock: 2 of 3 fail). - The production change alone leaves exactly one module failure, the pinned assertion ("56 tests completed, 1 failed"): run on
ea57360912ed, and re-run on645d4fe14a20with assertions on by the independent fact check. - Not run: the full Lucene test suite, nightly tests, benchmarks; no saved test covers NaN or infinite values, NaN, infinite or signed-zero factory bounds, zero or negative widths, or
min > max.
Keeps the label finite and contains every finite value below min; the repro passes with it.
Trade-offs
A document whose value is -Infinity still lands in the group but outside its label. TestDoubleRangeFactory.java:29 would change to -Double.MAX_VALUE. Run with the repro only, not with the module's tests.
What the checks validated
- The repro passes with this fix shadowed (assertions off).
Javadoc saying the below-min label's lower end is not meaningful.
Trade-offs
Contradicts DoubleRange's own contract, keeps an inverted range in the API, and breaks the bounds-equal-members rule Lucene's own selector test relies on.
Applications built on Lucene that show range-group bounds, or build drill-down links from them, get a wrong lower bound for the below-min bucket (an inverted, empty range when min <= 0) and drill-downs that return fewer of its documents.
Code reading during a sweep that compared each range group's reported [min, max) with the documents it holds; a public-API probe put -20 into DoubleRange(4.9E-324, -10.0), and a JUnit followed. A later SMT query plus a bounded-exhaustive check of the real class found the same bug independently.
The method in full121 words
Method: code reading (non-formal), a hunt unit (range bounds across code paths), 2026-10-26 on main ea57360912ed. The sweep compared each range group's reported bounds with the documents it holds; a scratch probe through the public API confirmed that DoubleRangeFactory(-10, 5, 10) puts -20 into DoubleRange(4.9E-324, -10.0), and the AI tool reduced it to a public-API JUnit. An earlier grouping unit had noted the same label from code reading and a scratch probe and skipped it as label-only; a later unit found it independently with an SMT query plus a bounded-exhaustive check of the real class, a formal signal, but after both code-reading finds. The one-assertion repro shown here is the finder's test, minimized and re-verified on 645d4fe14a20.
No prior report found: 147 queries on 2026-10-26 (GitHub issues, PRs and commits, four mailing lists, the JIRA archive, downstream projects, the web), refreshed on 2026-10-30. Every exact class name gives 0 issues or PRs; no open PR touches the files.
Searches5 searches
Read, not reports of this bug: #8940 / LUCENE-7889 and apache/lucene-solr#1484 (the feature; nobody discussed the bound); the 2017 LUCENE-7889 JIRA patch (origin of the line and of the test assertion); #10039 / LUCENE-8996 (a Float.MIN_VALUE maxScore sentinel in TopGroups.merge, different code); elastic/elasticsearch#42035 (the same Java mistake in that project's own code).
Judgment call: an outside Go port copies the value, and its code comment calls it "the smallest positive nonzero double ... the open lower bound" without calling it a defect; it is read as a faithful copy, not a report. Counted as partial overlap, the row would become known.
Related hunt findings (not outside reports): LFV-155, the same Double.MIN_VALUE mistake in lucene/misc DocValuesStats; LFV-407 (the last bucket below max runs past max) and LFV-412 (decimal widths: the bucket and its bounds are rounded separately), both in the same getRange, with their own fixes.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs, commits; apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet, all of GitHub) | 2026-10-26 | two scripted prior-report search runs, 40 GraphQL issue/PR searches, code search; every exact class name (DoubleRangeFactory, DoubleRangeGroupSelector, LongRangeFactory, LongRangeGroupSelector, RangeGroupSelector, TestDoubleRangeFactory); all 86 module:grouping items | 0 issues or PRs on the classes (positive control DoubleRangeFacetCounts: 8); only the feature's own records |
| Open apache/lucene PRs | 2026-10-26, 2026-10-30 | changed-file lists of all 411 (then 410) open PRs, full lists for the PRs with 100 or more files | none touches DoubleRangeFactory, DoubleRange, DoubleRangeGroupSelector or their tests |
| Mailing lists and JIRA archive | 2026-10-26 | dev@, java-user@, issues@, commits@ (tool and manual searches, no date limit); 4 JQL searches | only the feature's commit notices; nothing about the bound |
| Web | 2026-10-26 | 7 searches (plus one throttled attempt, retried) | generic pages; an outside Go port that copies the value |
| Refresh (GitHub) | 2026-10-30 | DoubleRangeFactory, DoubleRangeGroupSelector and "Double.MIN_VALUE grouping" in issues and PRs; grouping issues updated since 2026-10-26; the independent fact check re-searched all six class names | 0 hits (control: 4); all six class names: 0 issues and 0 PRs each |
Introduced with the feature by LUCENE-7889 (7c350d22c707, 2020-05-11, PR apache/lucene-solr#1484), first released in 8.6.0; the line has not changed since. Every release from 8.6.0 to 10.6.0 has it (all executed), and so does main.
Versions tested7 rows
7c350d22c707 "LUCENE-7889: Allow grouping on Double/LongValuesSource (#1484)" (romseygeek, 2020-05-11; branch_8x backport 5e62c42c5f30) added the factories, the selectors and their tests, including the TestDoubleRangeFactory assertion new DoubleRange(Double.MIN_VALUE, 10). The line and the assertion are already in the 2017 Double-only JIRA patch. The only later change is the LUCENE-9570 reformat 2cbf261032dc (2021-01-05), whitespace and braces only. CHANGES.txt lists LUCENE-7889 under 8.6.0. Not fixed upstream: the file is the same blob on main and branch_10_6 on 2026-10-29; branch_10x was decommissioned that day (#16911).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the repro fails (gradle and standalone, assertions on and off) |
| 10.6.0 (released 2026-10-29) | affected | the repro fails on the release jars (assertions off) |
| 10.5.2 | affected | the repro fails on the release jars (assertions off) |
| 8.6.0 to 10.5.2, all 49 releases | sweep | a public-API main() probe: 3 bug cases fail, 2 controls pass, on every release |
| 8.5.2 | API absent | the probe does not compile: no DoubleRange classes |
main 716d6f216f5, branch_10_6 7e5aec4370e (2026-10-29) | read | the same file (by reading, not run) |
with the fix (main 645d4fe14a20) | not affected | the repro and the grouping module's tests pass |
The finder, the independent reproduction, the intended-behaviour check and the adjudication (confirmed, high) rate it real, as does the intended-lens cross-model review (reading only); the refute-lens review, also reading only, returned a procedural "not real" but agreed on the root cause. Every executed check reproduced it.
Checks and verdicts336 words
The reviewer from another model family (OpenAI GPT-5.6) could not execute code in either lens; its execution evidence is a separate re-run of the finder's repro.
| Check | Verdict | What it found |
|---|---|---|
| finder (a hunt unit) | candidate | JUnit fails with -ea and -da on main and with -ea on 10.5.2; main() form fails on 9.12.3 and 8.6.0; the fix passes; with it the module has 1 failure, the pinned assertion. |
| cross-model review, refute lens | not real, low, procedural | Could not run code; agreed on the root cause and that the inputs are in contract; rated the intended case moderate. A separate run re-ran the repro: fails. |
| Independent reproducer | real | Own 7-test JUnit: 5 of 7 fail (2 controls pass) on main, 10.5.2, 9.12.3 and 8.6.0; the 3-document query from the bounds finds 0; end-to-end GroupingSearch test fails; all pass with the fix. Re-run on 645d4fe14a20: 5 of 7 fail. |
| Cross-model review, intended lens | real, medium (reading only) | No domain restriction in the javadoc; the pinned assertion and the equality-key use rated a moderate counter-argument. Supplementary end-to-end runs fail 2 of 3 on main and pass with the fix. |
| Devil's advocate | real, low impact | Built the strongest intended case and showed why it fails; its GroupingSearch drill-down test fails 2 of 3, including Lucene's own selector config, and passes with the fix. |
| adjudication | confirmed, high; class minor | Re-ran the independent reproduction (5 of 7 fail, -ea and -da; 7 of 7 pass with the fix), the independent reproduction's end-to-end test and the finder's repro with a fresh seed. Duplicate of none. |
| Follow-up, audit, main refresh, minimization | keep | 49-release sweep, prior-report search, security screen, re-runs on ea57360912ed, a9779ef3c193 and 645d4fe14a20; the finder's test reduced to one assertion. |
| this doc's runs | reproduced; fix validated | The repro on main 645d4fe14a20 (gradle and standalone, -ea, -da), 10.6.0 and 10.5.2; the recommended fix with the module's tests and lint in a scratch worktree. |
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 429 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,554 words
- Registry title
- Grouping DoubleRangeFactory gives the below-min group the lower bound Double.MIN_VALUE (4.9E-324, the smallest positive double) instead of the most negative value, so that group's DoubleRange label does not contain its own members
- Area
- grouping / range group selectors (DoubleRangeFactory, used by DoubleRangeGroupSelector)
- Priority
- Low, rank 568: DoubleRangeFactory gives the below-min group the lower bound Double.MIN_VALUE, so that group's label does not contain its members, which only matters when min is zero or below with values under it, so it closes the range-label rows.
- Impact class
- minor
- Impact note
- Applications that group by double ranges with DoubleRangeGroupSelector (through GroupingSearch or the grouping collectors) get, on every search in 8.6.0 through 10.6.0 and on main (-ea and -da, any index size, default IndexWriterConfig, codec and searcher), a group value for the 'below min' bucket whose lower end is 4.9E-324: with a factory min <= 0 the label is an empty, inverted range that contains none of the group's documents (DoubleRange(4.9E-324, -10.0) for min = -10), and with min > 0 it leaves out 0.0 (both signs) and every negative member; which documents land in which group, the counts and the group order are correct, so only the label and anything built from it are wrong, such as a bucket heading or a drill-down range query that finds 0 of the group's 3 documents.
- Summary
- You group documents by a double field with new DoubleRangeFactory(-10, 5, 10), meaning buckets 5 wide from -10 to 10 and every value below -10 in one bucket, and expect the bucket returned for -20 to be a [min, max) range that contains -20, the way LongRangeFactory returns [Long.MIN_VALUE, -10). Instead DoubleRangeFactory returns DoubleRange(4.9E-324, -10.0): it uses Double.MIN_VALUE as the lower end, and Double.MIN_VALUE is the smallest positive double, not the most negative one, so the range is inverted and holds none of its members (with min > 0 it leaves out 0.0 and every negative member). Each group still holds the right documents, but its reported bounds are wrong, and a range query built from them, the way Lucene's own selector tests rebuild a group, finds none or only some of the group's documents.
- Introduced
- 7c350d22c7070acf362fb4f477e6797f2711c910 (LUCENE-7889 'Allow grouping on Double/LongValuesSource', PR apache/lucene-solr#1484 by romseygeek, migrated issue apache/lucene#8940; branch_8x backport 5e62c42c5f304eee7b5695f97f02a2aea7d00366) [read: git log --follow on DoubleRangeFactory.java in the full-history clone shows only this commit and 2cbf261032dc (LUCENE-9570 reformat, 2021-01-05, whitespace and braces only per git diff -w); git show 7c350d22c707 adds reuse.min = Double.MIN_VALUE and the TestDoubleRangeFactory assertion new DoubleRange(Double.MIN_VALUE, 10)]. The line and the assertion already exist in the 2017 Double-only LUCENE-7889 JIRA patch; the LongRangeFactory twin, first added in the 2020 commit, uses Long.MIN_VALUE. Born with the feature, not a regression (2020-05-11 (committed to main and to branch_8x; the code first appears in the 2017 LUCENE-7889 JIRA patch))
- First affected version
- 8.6.0 [exec] (the first release with DoubleRangeFactory, through the branch_8x backport; 8.5.2 has no such API [exec: the probe does not compile, the 8.5.2 grouping jar has no DoubleRange* classes]); first on the main line: 9.0.0
- Affected versions
- every release that has the class: 8.6.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.6.0 and main ea57360912ed [exec: the follow-up's public-API main() probe DoubleRangeFactoryBelowMinHistory (3 bug cases with min = -10, 0 and 10, plus 2 controls) fails 3 of 3 with both controls passing on all 49 releases from 8.6.0 to 10.5.2 and on main (-ea and -da), and passes 5 of 5 on main with the NEGATIVE_INFINITY fix shadowed; the finder's JUnit fails on 9.12.3, 10.0.0, 10.5.2 (-ea, -da) and main (-ea, -da); the independent reproduction's JUnit fails 5 of 7 (2 controls pass) on main (-ea, -da), 10.5.2, 9.12.3 and 8.6.0; follow-up re-runs 2026-10-26: the finder's JUnit on main -ea and -da, the independent reproduction's JUnit on main -da and 10.5.2 -da, the independent reproduction's end-to-end test on main -da]. 8.8.2-8.11.4 rest on execution against Maven Central release jars only (those tags are not in the local history clones). 10.6.0 (released 2026-10-29) has it: the bug doc's repro fails on the 10.6.0 release jars (assertions off) [exec], and the file is the same blob on branch_10_6 [read, 2026-10-29]. Not fixed upstream: DoubleRangeFactory.java is unchanged since the 2021 reformat on main, branch_10x, branch_10_5 and branch_9x, and none of the 411 open apache/lucene PRs touches it or its test [read, follow-up prior-report record, 2026-10-26]. All runs on one aarch64 Linux host with JDK 25.0.3
- 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 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-10-26, read-only; the prior-report record, verdict new): 147 logged queries (scripted prior-report search run 1: 51 queries over GitHub issues and PRs, commits, downstream projects, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; run 2: 15 GitHub queries; 40 GraphQL issue/PR searches; 33 other manual queries and reads; 7 web searches). Every exact class name (DoubleRangeFactory, DoubleRangeGroupSelector, LongRangeFactory, LongRangeGroupSelector, RangeGroupSelector, TestDoubleRangeFactory) returns 0 issues or PRs in apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet and all of GitHub (positive control DoubleRangeFacetCounts: 8), 0 JIRA issues and 0 list mails other than the feature's commit notices; all 86 module:grouping items were triaged; the changed files of all 411 open apache/lucene PRs were scanned (8 touch lucene/grouping, none the DoubleRange* classes or TestDoubleRangeFactory). Read, not reports: #8940 (LUCENE-7889, the feature; body and 6 comments, nothing on the bound), apache/lucene-solr#1484 (no comments or reviews), the 2017 LUCENE-7889 JIRA patch (origin of the line and of the test assertion), #10039 / LUCENE-8996 (a Float.MIN_VALUE maxScore sentinel in TopGroups.merge, different code), elastic/elasticsearch#42035 (the same Java mistake fixed in that project's own aggregator), and an outside Go port that copies the value and describes it in a comment as the open lower bound without calling it a defect. Not fixed upstream: DoubleRangeFactory.java unchanged since 2cbf261032dc on main, branch_10x, branch_10_5 and branch_9x. The finder's, the independent reproduction's and the intended-behaviour check's earlier searches also found nothing. Gaps: GitHub search sees a closed, unmerged PR only through its text; code search covers only default branches; apache/lucenenet code search was rate-limited (that port tracks 4.8, before the class)
- Lucene commit tested
- main
645d4fe14a20(commit of 2026-10-28): the repro through gradle in a scratch worktree (assertions on and off) and through the standalone runner (-ea,-da), stock and with the fix; the grouping module's tests and lint with the fix; the 10.6.0 and 10.5.2 release jars. The hunt, the verifiers, the adjudication and the follow-up's main runs used mainea57360912ed, where the grouping module is identical; the follow-up's 49-release sweep (8.6.0 to 10.5.2) ran on the release jars.
- Finding record
lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRangeFactory.java:30 (at ea57360912ed) ('@param min a minimum value; all doubles below this value are grouped into a single range'), :42-47 (getRange javadoc 'Finds the DoubleRange that a value should be grouped into'), :50 (reuse initialised to new DoubleRange(Double.MIN_VALUE, Double.MAX_VALUE)), :52-55 (value < min: reuse.max = min; reuse.min = Double.MIN_VALUE), :57-60 (value >= max: [max, Double.MAX_VALUE))lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRange.java:23 (at ea57360912ed) ('Represents a contiguous range of double values, with an inclusive minimum and exclusive maximum'), :27 ('The inclusive minimum value of this range'), :30, :33lucene/grouping/src/java/org/apache/lucene/search/grouping/LongRangeFactory.java:50 (at ea57360912ed) and :54 (the long twin uses Long.MIN_VALUE)lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRangeGroupSelector.java:71 (at ea57360912ed) (one getRange call per document), :75 and :90 (second pass: HashSet of DoubleRange, equality only, so membership is right)lucene/grouping/src/test/org/apache/lucene/search/grouping/TestDoubleRangeFactory.java:26 (at ea57360912ed) (factory (10, 10, 50)) and :29 (assertEquals(new DoubleRange(Double.MIN_VALUE, 10), factory.getRange(4, scratch)), the assertion a fix must change); TestDoubleRangeGroupSelector.java:38 (values nextDouble() * 1000, non-negative), :46 (factory (100, 100, 900)), :57 (filterQuery: DoublePoint.newRangeQuery(min, Math.nextDown(max))); BaseGroupSelectorTestCase.java:50, :66-71 ('Each group should have a result set equal to that returned by the top-level query, filtered by the group value')lucene/core/src/java/org/apache/lucene/document/DoubleRange.java:33-34 (at ea57360912ed) (open-ended ranges 'can be defined using Double.NEGATIVE_INFINITY and Double.POSITIVE_INFINITY'); lucene/CHANGES.txt:5694 (LUCENE-7889 under 8.6.0)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRangeFactory.java:20-66a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRange.java:22-55a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRangeGroupSelector.java:66-98a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/SearchGroup.java:195-207a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/LongRangeFactory.java:48-66JDK java.lang.Double#MIN_VALUE javadoc ('the smallest positive nonzero value of type double, 2^-1074')
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:44; TopGroups.java:38; GroupDocs.java:33; SearchGroup.java:38 (@lucene.experimental)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRange.java:22-33; DoubleRangeFactory.java:27-47; LongRangeFactory.java:48-66; package-info.javaa Lucene main checkout (645d4fe14a20) lucene/grouping/src/test/org/apache/lucene/search/grouping/TestDoubleRangeFactory.java:26-29; TestLongRangeFactory.java:29; TestDoubleRangeGroupSelector.java:37-57; BaseGroupSelectorTestCase.java:52-81a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/DoubleRange.java:33-34; lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:207-211
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/SearchGroup.java:195-207; DoubleRangeGroupSelector.java:75, :89-97a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:207-208
- History
a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:5727 (8.6.0 section from :5689)
- TestDoubleRangeFactoryBelowMin.java (lucene/grouping, one test):
repro/LFV-411/src/org/apache/lucene/search/grouping/TestDoubleRangeFactoryBelowMin.java(28 lines)
Made in parallel with this doc from the scene plan in the diagram's script; every value drawn comes from the code at 645d4fe14a20 or the diagram's executed probe (main with assertions on and off, and with the NEGATIVE_INFINITY fix).
assets/LFV-411/bug.png(429 KB)
