All findings · Lucene finding · LFV-411 · grouping-doublerangefactory-below-min-sentinel

Grouping DoubleRangeFactory gives the below-min group the lower bound 4.9E-324 (Double.MIN_VALUE), so its label leaves out the group's zero and negative values

The bucket for values below min is labelled [Double.MIN_VALUE, min), and Double.MIN_VALUE is 4.9E-324, the smallest positive double. So new DoubleRangeFactory(-10, 5, 10) groups -20, -15 and -11 correctly but labels the group DoubleRange(4.9E-324, -10.0), a range that holds none of them.

Summary

Priority
Low, rank 568DoubleRangeFactory 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
minor
Since
8.6.0 (LUCENE-7889, 2020)introduced 2020-05-11 · about 6 years
Affected
every release from 8.6.0 to 10.6.0, and main
Status
documented
Found by
code reading of range-group bounds, then a one-assertion 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

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.

Background: grouping by double ranges

  1. DoubleRangeGroupSelector groups search results by a double value. A DoubleRangeFactory(min, width, max) defines the buckets: width-wide buckets from min to max, one bucket for every value below min, one for max and above.
  2. Each bucket comes back as the group's value, GroupDocs.groupValue(), a DoubleRange with an inclusive min and an exclusive max.
  3. In Java, Double.MIN_VALUE is 4.9E-324, the smallest positive double. The most negative double is -Double.MAX_VALUE, and below that comes Double.NEGATIVE_INFINITY.
Summary card. A number line of temp from -25 to 15 with the buckets of DoubleRangeFactory(-10, 5, 10) and seven documents (-20, -15, -11, -3, 0, 4, 12): -20, -15 and -11 form one group, which is right. The expected label [-infinity, -10) holds them; the actual label DoubleRange(4.9E-324, -10.0) runs backwards (min > max) and holds none. Table: documents inside the label 3 of 3 expected, 0 of 3 actual; a drill-down by the label finds 3 versus 0 documents; the group has 3 members either way. Why: DoubleRangeFactory.java:54 uses Double.MIN_VALUE, the smallest positive double; with min > 0 the label leaves out 0.0, -0.0 and all negatives; the fix Double.NEGATIVE_INFINITY gives (-Infinity, -10.0).
Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You group documents by a double field with DoubleRangeGroupSelector and new 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.
  2. 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).
  3. Instead the label is DoubleRange(4.9E-324, -10.0). The code uses Double.MIN_VALUE as 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.
  4. 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.
  5. A positive min does not save it: with Lucene's test config DoubleRangeFactory(100, 100, 900) and the values 0.0, -1.0 and 50.0 below 100, the label DoubleRange(4.9E-324, 100.0) leaves out 0.0 and -1.0, and its query finds 1 of the group's 3 documents.

Who is affected

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

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

  1. What the label says. DoubleRange.min is "The inclusive minimum value of this range" and max the exclusive maximum (DoubleRange.java:27-31). With min <= 0, [4.9E-324, min) is inverted and empty. With min > 0 it holds the positive members and leaves out 0.0, -0.0 and every negative member.
  2. Why membership is still right. DoubleRangeGroupSelector.advanceTo calls getRange once per document (DoubleRangeGroupSelector.java:71) and keeps a document in the second pass if a HashSet<DoubleRange> of the first pass's group values contains its range (:75, :89-97); DoubleRange.equals compares min and max (DoubleRange.java:44-50). Every value below min gets the same key, so groups, counts, order and shard merging (SearchGroup.java:195-207) are consistent; only the numbers in the key are wrong.
  3. The twin. LongRangeFactory.getRange is the same code with Long.MIN_VALUE (LongRangeFactory.java:50, :54).

Executed cases (main 645d4fe14a20)

factoryvalue(s) below minlabelcontains its values?
DoubleRangeFactory(-10, 5, 10)-20, -15, -11DoubleRange(4.9E-324, -10.0)none (query from the bounds: 0 of 3 docs)
DoubleRangeFactory(0, 1, 10)-0.5DoubleRange(4.9E-324, 0.0)no
DoubleRangeFactory(10, 10, 50)0.0, -5.0DoubleRange(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.0DoubleRange(4.9E-324, 100.0)1 of 3 (query: 1 of 3 docs)
LongRangeFactory(-10, 5, 10) (control)-20LongRange(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, through GroupingSearch(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 below min include 0.0 or a negative number: always when min <= 0 (temperatures, deltas, balances, scores around zero); with min > 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 min are 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, GroupDocs and SearchGroup (the types whose groupValue carries 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.

Minimal repro

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.

TestDoubleRangeFactoryBelowMin.java (lucene/grouping, one test)28 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.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);
  }
}
Command
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 as DoubleRange(-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.

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

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

Environment

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

Observed output

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 failures

Why it looks unintended

  • DoubleRange is "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 LongRangeFactory uses Long.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_VALUE was 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:54 uses Long.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; LongRangeFactory first 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 is DoublePoint.newRangeQuery("double", groupValue.min, Math.nextDown(groupValue.max)) (TestDoubleRangeGroupSelector.java:57), already in the feature's commit. Its values are nextDouble() * 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 via lowerValue = 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_VALUE as "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.

Suggested fix

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.

Double.NEGATIVE_INFINITY as the lower end (validated by the checks) (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 -Infinity instead of 4.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:test passes, 57 tests, with assertions on and off; the module's checkGoogleJavaFormat, ecjLint and forbiddenApis pass.
  • Standalone runner with the fix shadowed: the repro passes (-ea, -da); a GroupingSearch drill-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 on 645d4fe14a20 with 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.

-Double.MAX_VALUE as the lower end (validated by the checks)

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

Document the label instead (not recommended)

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.

Downstream note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, apache/lucenenet, all of GitHub)2026-10-26two 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 items0 issues or PRs on the classes (positive control DoubleRangeFacetCounts: 8); only the feature's own records
Open apache/lucene PRs2026-10-26, 2026-10-30changed-file lists of all 411 (then 410) open PRs, full lists for the PRs with 100 or more filesnone touches DoubleRangeFactory, DoubleRange, DoubleRangeGroupSelector or their tests
Mailing lists and JIRA archive2026-10-26dev@, java-user@, issues@, commits@ (tool and manual searches, no date limit); 4 JQL searchesonly the feature's commit notices; nothing about the bound
Web2026-10-267 searches (plus one throttled attempt, retried)generic pages; an outside Go port that copies the value
Refresh (GitHub)2026-10-30DoubleRangeFactory, 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 names0 hits (control: 4); all six class names: 0 issues and 0 PRs each

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe repro fails (gradle and standalone, assertions on and off)
10.6.0 (released 2026-10-29) affectedthe repro fails on the release jars (assertions off)
10.5.2 affectedthe repro fails on the release jars (assertions off)
8.6.0 to 10.5.2, all 49 releases sweepa public-API main() probe: 3 bug cases fail, 2 controls pass, on every release
8.5.2 API absentthe probe does not compile: no DoubleRange classes
main 716d6f216f5, branch_10_6 7e5aec4370e (2026-10-29) readthe same file (by reading, not run)
with the fix (main 645d4fe14a20) not affectedthe repro and the grouping module's tests pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidateJUnit 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, proceduralCould 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 realOwn 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 impactBuilt 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 minorRe-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 keep49-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 validatedThe 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.

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

Finding record

LFV-411, snapshot of 2026-11-06

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 main ea57360912ed, 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.

Lucene and upstream sources

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, :33
  • lucene/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-66
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRange.java:22-55
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRangeGroupSelector.java:66-98
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/SearchGroup.java:195-207
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/LongRangeFactory.java:48-66
  • JDK 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.java
  • a 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-81
  • a 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-97
  • a 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)

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

Repro files

About the diagrams

Made in parallel with this doc from the 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).

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.