All findings · Lucene finding · LFV-412 · doublerangefactory-label-boundaries-rounding-gap

Range grouping can label a value with a range that leaves it out: DoubleRangeFactory(0, 0.1, 10).getRange(0.6) is DoubleRange(0.5, 0.6)

With new DoubleRangeFactory(0, 0.1, 10), range grouping puts the value 0.6 in a group labelled DoubleRange(0.5, 0.6), a [min, max) range that leaves 0.6 out. Groups hold the right documents, but a range query built from such a label misses or adds boundary documents.

Summary

Priority
Low, rank 567DoubleRangeFactory with a decimal width such as 0.1 labels many values with ranges that exclude them while membership stays correct, the same rare-API wrong-label profile as LFV-407, so it follows that row.
Impact
minor
Since
8.6.0 (LUCENE-7889, 2020)introduced 2020-05-11 · about 6 years
Affected
8.6.0 to 10.6.0 and main
Status
documented
Found by
a solver model of the double arithmetic, then an exhaustive check of the real factory

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Range grouping asks DoubleRangeFactory for each document's bucket and returns that bucket, a [min, max) range, as the group's value. The factory computes the bucket number and the bucket's two bounds with separately rounded formulas, and near a boundary they can disagree.

Background: grouping by double ranges

  1. GroupingSearch groups hits by one value per document. DoubleRangeGroupSelector computes that value with DoubleRangeFactory.getRange(value).
  2. new DoubleRangeFactory(min, width, max) makes one bucket below min, buckets of width from min up to max, and one bucket for everything at or above max. Each bucket is a DoubleRange from an inclusive minimum to an exclusive maximum.
  3. Doubles are binary: 0.1 is really 0.1000000000000000055..., and every +, * or / rounds to the nearest double. The double next to 0.6 is 0.6000000000000001, one ulp (unit in the last place) away.
Summary card for DoubleRangeFactory(0, 0.1, 10): a number line from 0.4 to 0.8 with documents at 0.55, 0.6 and 0.65 and the returned labels DoubleRange(0.5, 0.6) and DoubleRange(0.6000000000000001, ...); a zoom to neighbouring doubles showing that 0.6 lies in the one-double gap between the two labels; a table of expected versus actual results (27 of 100 values outside their label, 27 of 99 neighbouring labels with a gap or overlap, a drill-down that finds 1 of the group's 2 documents); and the rounding steps for 0.6 and 1.7 with the executed fix.
How DoubleRangeFactory(0, 0.1, 10) labels 0.6 and 1.7: the range for 0.6 ends at 0.6 and the next starts at 0.6000000000000001, so 0.6 falls into a one-double gap; 1.7 gets a range that starts above it; with the fix both are inside their ranges. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Group documents by a double field with DoubleRangeGroupSelector and new DoubleRangeFactory(0, 0.1, 10): buckets 0.1 wide from 0 to 10. Three documents have the values 0.55, 0.6 and 0.65.
  2. You expect each group's label, a DoubleRange documented as [min, max), to contain the values grouped under it, and each label to end where the next one begins.
  3. You get the group DoubleRange(0.5, 0.6) holding 0.55 and 0.6: its label leaves out its own value 0.6, and the next label starts at 0.6000000000000001, so no label contains 0.6. A range query over the label's bounds finds 1 of the group's 2 documents.
  4. Why: getRange picks the bucket floor((0.6 - 0) / 0.1) = floor(5.999999999999999) = 5 and ends it at 0.5 + 0.1, which rounds to 0.6, while the next bucket starts at 6 * 0.1 = 0.6000000000000001. For 1.7 the division rounds up to exactly 17, so its label DoubleRange(1.7000000000000002, 1.8000000000000003) starts above the value.
  5. Scale: 27 of the 100 values 0.0, 0.1, ..., 9.9 lie outside their own label, and 27 of 99 neighbouring labels leave a one-ulp gap or overlap. Every release since 8.6.0. Lucene's own tests use a positive integer min and width, where no wrong label was found; a decimal min or width, or a negative min, can trigger it.

Who is affected

Applications that group by double ranges with DoubleRangeGroupSelector (opt-in, rarely used) and a min or width that binary cannot represent, such as 0.1, or a negative min. Every search, any index size. Groups, counts and order stay right; the bounds of boundary values' labels, and range queries built from them, are off by about one ulp.

Mechanism, terms and numbers673 words

DoubleRangeFactory(min, width, max) turns a document's double value into the range that DoubleRangeGroupSelector groups it by, and that range comes back as the group's value. DoubleRange is documented as an inclusive minimum and an exclusive maximum, and the factory's ranges as "inclusive at the lower end, and exclusive at the upper end". getRange picks the bucket number with Math.floor((value - min) / width) and computes the bounds as min + bucket * width and then + width: three separately rounded results that can disagree near bucket boundaries, most visibly when width or min is not exactly representable in binary. With new DoubleRangeFactory(0, 0.1, 10), getRange(0.6) is DoubleRange(0.5, 0.6) and getRange(1.7) is DoubleRange(1.7000000000000002, 1.8000000000000003); 27 of the 100 values 0.0 to 9.9 lie outside their own range, and neighbouring ranges leave one-ulp gaps or overlap. Group membership, counts and order stay consistent; only the returned ranges are wrong, and anything built from them, such as a drill-down range query, which then misses or adds boundary documents. Present since the class was added in 8.6.0, in every release through 10.6.0 and on main; a small fix takes both bounds from one grid and moves the bucket by one when needed.

The mechanism (main 645d4fe14a20)

  1. DoubleRangeFactory.getRange (DoubleRangeFactory.java:62-64):
   double bucket = Math.floor((value - min) / width);
   reuse.min = min + (bucket * width);
   reuse.max = reuse.min + width;
  1. 0.6: the range ends too early. (0.6 - 0) / 0.1 = 5.999999999999999, so bucket 5, which is right for the binary values (the double 0.6 lies below 6 times the double 0.1). reuse.min = 0.5, and reuse.max = 0.5 + 0.1, which rounds to 0.6. The next bucket starts at 6 * 0.1 = 0.6000000000000001. So 0.6 lies in no range.
  2. 1.7: the bucket is one too high. 1.7 / 0.1 rounds up to exactly 17.0, although the double 1.7 lies below 17 * 0.1 = 1.7000000000000002; the range starts above the value.
  3. The ranges do not tile. reuse.max is the rounded reuse.min plus width, rounded again, while the next range starts at min + (bucket + 1) * width, rounded once.
  4. Membership stays consistent. DoubleRangeGroupSelector calls getRange once per document on its own value (DoubleRangeGroupSelector.java:71) and keeps a document in the second pass if its range is one of the top groups (:75); DoubleRange.equals compares both bounds exactly (DoubleRange.java:44-50). Only the reported bounds are off.

The numbers

  • DoubleRangeFactory(0, 0.1, 10): getRange(0.6) = DoubleRange(0.5, 0.6), getRange(1.7) = DoubleRange(1.7000000000000002, 1.8000000000000003), getRange(4.1) = DoubleRange(4.0, 4.1).
  • Of the 100 values 0.0, 0.1, ..., 9.9, 27 lie outside their own range (0.6, 1.7, 3.4, 3.9, 4.1, ...). Of the 99 pairs of neighbouring bucket ranges, 27 do not meet: 21 gaps and 6 overlaps such as [1.2000000000000002, 1.3000000000000003) and [1.3, 1.4000000000000001), each one ulp.
  • End to end: documents with the values 0.55, 0.6 and 0.65 in a field price form the groups DoubleRange(0.5, 0.6) (0.55 and 0.6) and DoubleRange(0.6000000000000001, 0.7000000000000001) (0.65); DoublePoint.newRangeQuery("price", 0.5, Math.nextDown(0.6)) over the first label counts 1 document.
  • A boundary effect: over six factories every gap or overlap is at most one ulp of the factory's largest bound, and no bucket midpoint is outside its range. Within 200 doubles of every boundary, a value outside its range is one double outside in five of the factories and up to 31 doubles outside in (-5, 0.7, 5) near -0.1, where doubles are denser.
  • Which factories: an inexact min is enough. With widths 1, 10, 100, 0.5 and 0.25, no checked value (bucket boundaries and two-decimal values) is outside its range when min is an integer, but 8,519 of 499,985 are when min has two decimals, e.g. DoubleRangeFactory(248.8, 1.0, 928.8).getRange(520.8) = DoubleRange(519.8, 520.8). A negative min is enough too, even an integer one, because value - min then rounds: new DoubleRangeFactory(-10, 1, 10).getRange(0.9999999999999999, null) = DoubleRange(1.0, 2.0). With a non-negative integer min and an integer width, like Lucene's tests, 0 of 600,000 values next to a boundary were outside.
Exposure and likelihood206 words
  • Who. Range grouping through GroupingSearch(Supplier) (GroupingSearch.java:85) or the grouping collectors, with a decimal width (the natural choice for decimal data) or a decimal (or negative) min. A read-only code search on 2026-10-26 found no use of DoubleRangeFactory in Elasticsearch, OpenSearch or Solr (default branches). GroupingSearch and GroupDocs are marked @lucene.experimental (GroupingSearch.java:44, GroupDocs.java:33).
  • What goes wrong. On every search, with assertions on or off: some values on a bucket boundary get a range that excludes them (27 of 100 for width 0.1), and neighbouring ranges leave one-ulp gaps or overlaps. A drill-down built from a group's range misses that group's boundary documents (a gap) or also matches the neighbour's (an overlap).
  • What stays right. Which documents are in which group, the counts, the group sort and the documents within each group. Factories with a non-negative integer min and an integer width, the only ones in Lucene's tests, showed no value outside its range.
  • How likely. Plausible within a rarely used API: decimal data puts many values exactly on boundaries (27 of the 100 one-decimal values for width 0.1), but it shows only once an application reads the bounds or filters by them, and nobody has reported it since 8.6.0 (2020).

Minimal repro

One JUnit class in lucene/grouping, public API, one assertion, no index: getRange(0.6) of a (0, 0.1, 10) factory must contain 0.6. Deterministic; it fails the same way on main and 10.6.0 (assertions on and off) and on 10.5.2 and 9.12.3 (assertions on).

TestDoubleRangeFactoryContainsValue.java (lucene/grouping, 1 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 TestDoubleRangeFactoryContainsValue extends LuceneTestCase {

  public void testRangeContainsValue() {
    DoubleRange range = new DoubleRangeFactory(0, 0.1, 10).getRange(0.6, null);
    // expected: a range [min, max) that contains 0.6; actual: DoubleRange(0.5, 0.6)
    assertTrue(range + " should contain 0.6", range.min <= 0.6 && 0.6 < range.max);
  }
}
Command
cp $REPRO/org/apache/lucene/search/grouping/TestDoubleRangeFactoryContainsValue.java \
   $LUCENE/lucene/grouping/src/test/org/apache/lucene/search/grouping/
cd $LUCENE && ./gradlew :lucene:grouping:test --tests org.apache.lucene.search.grouping.TestDoubleRangeFactoryContainsValue \
  -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false (same failure); the test uses no randomness
Expected
A range [min, max) with min <= 0.6 < max: the factory's range for 0.6 contains 0.6.
Observed
DoubleRange(0.5, 0.6), which leaves 0.6 out: AssertionError: DoubleRange(0.5, 0.6) should contain 0.6, assertions on and off (main, 10.6.0).

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

More files, environment, full output204 words

Same package as Lucene's grouping tests (the file adds the Apache license header). The seed only picks the test framework's locale and settings, which the test does not use. The same test is the suggested regression test.

End to end through GroupingSearch (not part of the repro): documents 0.55, 0.6 and 0.65 give the group DoubleRange(0.5, 0.6) with 2 documents, whose range query DoublePoint.newRangeQuery("double", 0.5, Math.nextDown(0.6)) (the test's field is double) counts 1 (group DoubleRange(0.5, 0.6) expected:<2> but was:<1>, the hunt's test).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0, 10.5.2 and 9.12.3 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; Java floating point is strict IEEE-754)
Command (environment)
cp $REPRO/org/apache/lucene/search/grouping/TestDoubleRangeFactoryContainsValue.java \
   $LUCENE/lucene/grouping/src/test/org/apache/lucene/search/grouping/
cd $LUCENE && ./gradlew :lucene:grouping:test --tests org.apache.lucene.search.grouping.TestDoubleRangeFactoryContainsValue \
  -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false (same failure); the test uses no randomness

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF, -Ptests.asserts=true (identical with =false)
TestDoubleRangeFactoryContainsValue > testRangeContainsValue FAILED
    java.lang.AssertionError: DoubleRange(0.5, 0.6) should contain 0.6
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:476F598A28E9D11B]:0)
        at org.apache.lucene.search.grouping.TestDoubleRangeFactoryContainsValue.testRangeContainsValue(TestDoubleRangeFactoryContainsValue.java:26)
1 test completed, 1 failed

# standalone runner: the same failure on main (-ea, -da), 10.6.0 (-ea, -da), 10.5.2 (-ea) and 9.12.3 (-ea)
# with the fix: 1 test, 0 failures (assertions on and off)

Why it looks unintended

  • DoubleRange is documented as [min, max) and getRange as finding "the DoubleRange that a value should be grouped into"; 0.6's range excludes 0.6.
  • Lucene's own selector tests filter each group by its bounds; their factories use a positive integer min and width.
  • Even for 0.1's binary value the ranges exclude values, leave gaps and overlap; one consistent formula fixes that.
The full argument250 words
  • The contract. DoubleRange.java:23: "Represents a contiguous range of double values, with an inclusive minimum and exclusive maximum"; :27 and :30: "The inclusive minimum value of this range", "The exclusive maximum value of this range". DoubleRangeFactory.java: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". Nothing documents a rounding tolerance or a restriction on width (javadoc, package-info.java, the 8.6.0 CHANGES entry).
  • Lucene's tests read the range as a range. BaseGroupSelectorTestCase declares filterQuery(groupValue) (:50) and checks each group's documents against it (:71, :108, :160, :193, :215, :261), which TestDoubleRangeGroupSelector.java:57 builds as DoublePoint.newRangeQuery("double", groupValue.min, Math.nextDown(groupValue.max)). Its factory is (100, 100, 900) (:46), and TestDoubleRangeFactory uses (10, 10, 50) (:26): a positive integer min and width, where no wrong range was found.
  • The binary reading fails too. Reading 0.1 as its exact binary value, the stock ranges still exclude their own values, leave gaps and overlap; taking both bounds from one grid removes all three.
  • History. The formula, the javadoc and the selector test's label filter come from the introducing commit 7c350d22c707 (LUCENE-7889); the generic oracle in BaseGroupSelectorTestCase that runs the filter came a week earlier with LUCENE-9348 (0c58687a978e, 2020-05-04, #10388). The PR of 7c350d22c707 (apache/lucene-solr#1484) has no review comments, and issue #8940 discusses javadocs and tests, not rounding. No CHANGES, MIGRATE, test or comment endorses gaps.
The devil's-advocate check157 words

The strongest case (the cross-model reviews and the intended-behaviour checker rated it moderate; the reproducer and the adjudication make the same case): the API works on binary doubles and 0.1 is not exact, so ranges can only approximate decimal boundaries; another engine's histogram uses the same floor((value - offset) / interval) formula, documents it and treats this symptom as normal; inside Lucene both grouping passes compute the same key, so membership is right, and no Lucene code outside its tests turns a label into a query; and no binary fix gives decimal buckets (with the fix, 1.7 moves to [1.6, 1.7000000000000002)).

Why it fails: Lucene, unlike that histogram, returns an explicit range object and documents what it contains; the stock ranges are not a consistent binary reading either; the introducing commit's own test uses the range as an exact half-open filter; and the trigger is a public constructor with an ordinary width. Verdict: a defect, minor.

Suggested fix

Recommended One grid for both bounds, step the bucket by one (validated)

Take both bounds from one grid, min + bucket * width and min + (bucket + 1) * width, and step the bucket by one when the value falls outside. In every tested factory of ordinary scale the ranges then tile and contain their value; the grouping module passes. Some boundary values change group (1.7 down, 4.3 up).

     double bucket = Math.floor((value - min) / width);
+    if (bucket > 0 && value < min + bucket * width) {
+      bucket--;
+    } else if (value >= min + (bucket + 1) * width) {
+      bucket++;
+    }
     reuse.min = min + (bucket * width);
-    reuse.max = reuse.min + width;
+    reuse.max = min + ((bucket + 1) * width);
All options and trade-offs3 options · 787 words

One fix was validated in a scratch worktree of 645d4fe14a20 with gradle; it is the hunt's fix, unchanged. A membership-preserving alternative (label each bucket with exactly the doubles it receives, found one double at a time) was tried and rejected: it hangs when a bucket boundary is at 0, e.g. new DoubleRangeFactory(-1000, 1, 1000).getRange(0.5, null) did not return within 30 s. Combined with LFV-407's clamp on the same line, the recommended fix becomes reuse.max = Math.min(min + ((bucket + 1) * width), max); (validated on both bugs' tests, assertions on). Fix A alone leaves LFV-407's problem (the last range is not cut at max) as it is.

One grid for both bounds, step the bucket by one (validated) (recommended)

After the floor, move the bucket down if the value is below min + bucket * width, or up if it is at or above min + (bucket + 1) * width, then use exactly those two grid points as the range. 1 file, +9/-1.

     double bucket = Math.floor((value - min) / width);
+    if (bucket > 0 && value < min + bucket * width) {
+      bucket--;
+    } else if (value >= min + (bucket + 1) * width) {
+      bucket++;
+    }
     reuse.min = min + (bucket * width);
-    reuse.max = reuse.min + width;
+    reuse.max = min + ((bucket + 1) * width);

Trade-offs

  • Behaviour change. For (0, 0.1, 10), 27 of the 100 bucket ranges change their max by one ulp ((1.7000000000000002, 1.8000000000000003) becomes (1.7000000000000002, 1.8)), and group membership changes: 10 of the 100 values 0.0 to 9.9 move to a neighbouring bucket, each into the range [min + k * width, min + (k + 1) * width) that contains it. 6 move down (1.7, 3.4, 3.9, 6.8, 7.3, 7.8: the division rounded up to exactly k), and 4 move up (4.3, 8.1, 8.6, 9.1: k * 0.1 rounds down onto the value, so DoubleRange(4.2, 4.3) becomes [4.3, 4.4)). Over 889,912 boundary values the grouping partition differs from stock.
  • Not decimal buckets. 0.6 gets [0.5, 0.6000000000000001) and 1.7 gets [1.6, 1.7000000000000002): correct ranges, but not what a decimal reading expects.
  • Limits. "One step is enough" held in every check of factories whose width is well above the spacing of doubles near the values (all finite, with a positive width); that is a test result, not a proof. It fails when width is below the spacing of doubles near the values: for DoubleRangeFactory(1e6, 1e-12, 1e6 + 1) only about one grid range in 116 is non-empty, getRange(1000000.0000000001) computes bucket 116 while the grid range that contains the value is bucket 174, and stock and the fix both return the empty DoubleRange(1000000.0000000001, 1000000.0000000001).
  • Cost. Up to four min + k * width evaluations per call (two distinct values, each computed twice) instead of one plus an addition, and two or three compares; no benchmark was run.

What the checks validated

  • Stock + test: fails, assertions on and off (gradle).
  • + fix: the test passes; the whole lucene/grouping module, 57 tests, 0 failures, assertions on and off; :lucene:grouping:check -x test (formatting, ecjLint, forbiddenApis) passes.
  • + fix and LFV-407's clamp: this test, LFV-407's DoubleRangeFactory test, TestDoubleRangeFactory and TestDoubleRangeGroupSelector pass (10 tests, assertions on).
  • Side probes (not tests to add): 0 of 100 grid values outside and 0 gaps or overlaps; 0 of 396,668 values outside in a random sweep of 20,000 factories (10,232 on stock); 0 of 499,985 with a two-decimal min (8,519 on stock).
  • Earlier: the hunt's 2 tests and an independent verifier's 5 tests pass with it.

Shared bounds only, no step (not recommended) (validated by the checks)

reuse.max = min + ((bucket + 1) * width) and keep the bucket as computed.

Trade-offs

The ranges tile, but 10 of the 100 grid values are still outside their range, because the bucket number still comes from the division alone: for 1.7, 3.4, 3.9, 6.8, 7.3 and 7.8 the division rounds up to exactly k; for 4.3, 8.1, 8.6 and 9.1 it rounds down and the shared upper bound (k + 1) * 0.1 rounds down onto the value.

What the checks validated

Side probe on main 645d4fe14a20 with the change shadowed: 10 of 100 outside, 0 gaps or overlaps.

Document the limitation (fallback)

Say in the javadoc that min and width should be exactly representable and min non-negative (for example integers), or that decimal data should be grouped with LongRangeGroupSelector over scaled values (e.g. cents).

Trade-offs

Keeps ranges that contradict DoubleRange's own [min, max) javadoc. Only a non-negative integer min with an integer width was measured clean, and no setting helps once width is below the spacing of doubles near the values. Within those limits it is also the workaround until a fix ships.

Downstream note

Applications that turn range-group labels into filters miss or add only documents whose value lies within about one ulp of a bucket boundary; no Lucene code outside its tests builds a query from a label.

How it was found

A solver model of the IEEE-754 double arithmetic in getRange found an input whose range does not contain the value. A bounded-exhaustive check of the real factory over decimal grids and a scan of the ordinary factory (0, 0.1, 10) then gave the natural example 0.6, reduced to a public-API JUnit.

The method in full126 words
  • Solver model. An SMT model of getRange in IEEE-754 double arithmetic (Z3; round-to-nearest subtraction, division, multiplication and addition, round-down floor) answered the query "min <= v < max, finite inputs, width > 0, yet the returned range does not contain v" with a satisfying input, DoubleRangeFactory(-31.051544275134813, 0.2985725411070654, 7.706943822235074E-301).getRange(-1.42110715556365E-14), which a replay on the real jar confirmed.
  • Bounded-exhaustive check of the real factory over decimal grids: 1,594 containment failures among 1,029,006 checks, 0 with the fix.
  • Grid scan of DoubleRangeFactory(0, 0.1, 10) over 0.0 to 9.9: 27 of 100 values outside their range, the first being 0.6.
  • Then a public-API JUnit, independent verifiers, cross-model reviews (reading only) and an adjudication. Every query of the model was a targeted counterexample search, so the finding counts as semi-formal.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26: 205 logged queries over GitHub, the Lucene lists, JIRA, downstream projects and the web; all 411 open PRs' files), refreshed 2026-10-30. Another engine's histogram shows a similar floating-point effect in its own code; read as context, not a report.

Searches4 searches

Judgment call: Elasticsearch #23664 (2017, closed as documented behaviour) and a 2019 Elasticsearch forum thread show the same kind of floating-point effect in that engine's own histogram aggregation, which is different code. A maintainer there states that buckets "do contain values greater than or equal to the key value and less than the key of the next bucket", the property Lucene breaks here. Read as related context; if counted as partial overlap, the bug becomes "known".

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, apache/lucene-solr)2026-10-26two runs of the scripted prior-report search and 82 read-only GraphQL phrasings: every bare class name, range-grouping wording, the values 0.6000000000000001 and 1.7000000000000002, every open grouping item0 hits for every class name; only the feature's own issue #8940 and PR apache/lucene-solr#1484, and unrelated items
Open PRs, commits and history2026-10-26 and 2026-10-30the changed files of all 411 open apache/lucene PRs; the commit history of DoubleRangeFactory.java on main, branch_10x and branch_10_6no open PR touches the file; only the introducing commit and a 2021 reformat; no fix (main 716d6f216f52 on 2026-10-30)
dev@, java-user@, issues@, commits@ lists; JIRA (LUCENE, SOLR); web; downstream code2026-10-2653 list, 21 JIRA, 15 web and 2 downstream queriesnotification mirrors of the feature's issue and PR; Elasticsearch #23664 and #4847 (another engine's own histogram code)
GitHub refresh2026-10-30issue and PR search in apache/lucene for six phrasings (positive control: 4 hits)0 hits

History

Introduced with the feature by 7c350d22c707 (LUCENE-7889, merged 2020-05-11), first released in 8.6.0; only reformatted since. Executed and affected from 8.6.0 to 10.6.0 and on main; not fixed upstream on 2026-10-30.

Versions tested6 rows

7c350d22c707, "LUCENE-7889: Allow grouping on Double/LongValuesSource (#1484)", Alan Woodward (romseygeek), 2020-05-11, PR apache/lucene-solr#1484, issue LUCENE-7889 (now #8940); branch_8x backport 5e62c42c5f30 the same day. The 2017 JIRA patch already had the formula. The only later change to the file is 2cbf261032dc (LUCENE-9570 reformat, 2021). CHANGES: Lucene 8.6.0, New Features. Not a regression; javap -c of getRange gives the same opcode sequence in the 8.6.0, 8.11.4, 9.0.0 and 10.5.2 jars (only constant-pool indices differ).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails, gradle and standalone, assertions on and off
10.6.0 affectedthe minimal test fails (release jars, -ea, -da)
10.5.2, 9.12.3 affectedthe minimal test fails (release jars)
8.6.0 to 10.5.2 (29 releases) affectedthe hunt's JUnit fails 2 of 2 on each (8.6.0, 9.12.3 and 10.5.2 also with -da)
8.5.2 no APIthe class does not exist (the repro does not compile)
fix (scratch worktree of 645d4fe14a20) not affectedthe test and the grouping module pass

Independent checks

Independent verifiers, cross-model reviews (reading only; one real, one procedural not-real) and an adjudication: confirmed, medium confidence, impact minor. This doc re-ran the minimal test on main and three releases and validated the fix with gradle.

Checks and verdicts281 words

The cross-model reviews used an independent reviewer from another model family (OpenAI GPT-5.6) that could only read code; every executed check comes from the hunt's own runs, mostly on main ea57360912ed, where lucene/grouping is identical to 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit fails 2 of 2 on main (-ea, -da) and on 10.5.2, 9.12.3 and 8.6.0 (-ea); solver model, exhaustive check and grid scan; the fix keeps the 8 upstream range-grouping tests green.
review, intended lens real, mediumReading only (no code run); real, medium confidence, no impact class given; intended case rated moderate; root cause agrees.
Independent reproducer realOwn 5 tests (0.6 not contained; 27 of 100; gaps and overlaps; no range contains 0.6; end to end) fail on main (-ea, -da), 10.5.2 and 9.12.3; pass with its own fix.
Cross-model review, refute lens not real, low, proceduralCould not run code and called its verdict "not a substantive refutation"; root cause agrees; a separate run re-ran the repro (fails) and the fix (passes).
Devil's advocate real, medium, minorIntended case rated moderate and rebutted; width 0.1 over 1,000 decimal values: 222 outside, 412 non-tiling, 0 with the fix.
adjudication confirmed, medium; impact minorRe-ran the independent reproduction's tests (5 of 5 fail, -ea and -da; pass with the fix) and the finder's.
this doc as aboveMinimal test on main (gradle and standalone, -ea, -da), 10.6.0, 10.5.2, 9.12.3; the fix with gradle; found that an inexact min also triggers it, and that an exact-label alternative fix hangs at 0.

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

Finding record

LFV-412, snapshot of 2026-11-06

Registry title
Grouping DoubleRangeFactory picks a value's bucket and computes that bucket's bounds with separately rounded arithmetic, so with a decimal width such as 0.1 the returned range can exclude the value and adjacent ranges leave gaps or overlap (DoubleRangeFactory(0, 0.1, 10).getRange(0.6) = DoubleRange(0.5, 0.6))
Area
grouping / range group selectors (DoubleRangeFactory, used by DoubleRangeGroupSelector)
Priority
Low, rank 567: DoubleRangeFactory with a decimal width such as 0.1 labels many values with ranges that exclude them while membership stays correct, the same rare-API wrong-label profile as LFV-407, so it follows that row.
Impact class
minor
Impact note
Applications that group by a double field with DoubleRangeGroupSelector (through GroupingSearch or the grouping collectors) and a DoubleRangeFactory whose width is not exactly representable in binary (0.1, 0.2, 0.3, 0.01, ...) get, in every release from 8.6.0 to 10.6.0 and on main (-ea and -da, any index size), group labels that exclude a value lying on a bucket boundary by one or a few representable doubles, and adjacent labels that leave gaps or overlap by about one ulp, so a drill-down range query built from a label (the way Lucene's own selector tests rebuild a group) misses the boundary documents or picks up a neighbour's; which group a document is in, the counts and the group order stay consistent, Lucene itself never turns a label into a query, and exactly representable widths (integers, 0.5, 0.25) are not affected.
Summary
You group documents by a double field with new DoubleRangeFactory(0, 0.1, 10), buckets 0.1 wide, and expect each group's DoubleRange label to contain the values grouped under it, because DoubleRange is documented as [min, max). Instead the value 0.6 is grouped under DoubleRange(0.5, 0.6), which excludes 0.6, and 1.7 under DoubleRange(1.7000000000000002, 1.8000000000000003); 27 of the 100 values 0.0, 0.1, ..., 9.9 lie outside their own label, adjacent labels leave gaps (no label contains 0.6) or overlap, and a range query built from a label misses or picks up documents (values 0.55, 0.6, 0.65 form a group DoubleRange(0.5, 0.6) of 2 documents whose label matches 1). The cause is that getRange picks the bucket with Math.floor((value - min) / width) but computes its bounds separately as min + bucket * width and then that + width, and the three roundings disagree near boundaries; group membership itself stays consistent.
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, the commit in 8.6.0) [read: git log --follow and git log -L on getRange in the full-history clone show only this commit and 2cbf261032d0 (LUCENE-9570 reformat, 2021-01-05, braces and javadoc wrapping only); git log --all -S on 'Math.floor((value - min) / width)' and on 'reuse.max = reuse.min + width' find only this commit, its backport and a branch-emptying commit of the history clone; the 2017 LUCENE-7889 JIRA patch already had the same formula]. Born with the feature, not a regression (2020-05-11)
First affected version
8.6.0 [exec] (the first release with DoubleRangeFactory; 8.5.2 has no such API [exec: the repro does not compile, 9 'cannot find symbol' errors])
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 bug doc's minimal test fails on the 10.6.0 release jars (-ea, -da); the finder's JUnit (variants that differ only in what each release line's API needs) fails 2 of 2 on 29 releases, 8.6.0, 8.6.1, 8.6.3, 8.7.0, 8.8.2, 8.9.0, 8.10.1, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.3.0, 9.4.2, 9.5.0, 9.6.0, 9.7.0, 9.8.0, 9.9.2, 9.10.0, 9.11.1, 9.12.0, 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0, 10.5.0, 10.5.2, and on main, with -ea everywhere and also -da at 8.6.0, 9.12.3, 10.5.2 and main; the independent reproduction's own tests fail 5 of 5 on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3 (-ea); the intended-behaviour check re-ran main (-ea, -da) and 10.5.2] [read: DoubleRangeFactory.java has two blobs across all release tags, differing only by the LUCENE-9570 reformat; the getRange bytecode is identical in the 8.6.0, 8.11.4, 9.0.0 and 10.5.2 jars; branch_10_6 and branch_10x carry the main blob, so 10.6.0 (released 2026-10-29) is affected]. Not fixed upstream: main, branch_10x and branch_10_6 last changed the file in LUCENE-9570 [read, GitHub commits API, 2026-10-26]. All runs on one aarch64 Linux host with JDK 25.0.3; Java floating point is strict IEEE-754 binary64 since Java 17, so other platforms are not expected to differ, but none was run
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
semi-formal: Z3; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record, verdict new): 205 logged queries (106 GitHub issue/PR, 6 commit, 53 list, 21 JIRA, 2 downstream, 15 web, 2 GitHub code), comprising the finder's scripted prior-report search (35 queries over apache/lucene issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 108 low-overlap hits), a second scripted prior-report search (51 queries, 0 high, 1 medium, 77 low), read-only GitHub GraphQL search (82 phrasings, including the values 0.6000000000000001 and 1.7000000000000002, the grouping label and every open grouping item), direct JIRA REST searches of LUCENE and SOLR and lists.apache.org searches; every bare class name (DoubleRangeFactory, DoubleRangeGroupSelector, LongRangeFactory, LongRangeGroupSelector) gives 0 GitHub issues or PRs, 0 JIRA entries and 0 user-list threads. Read, not reports: #8940 (LUCENE-7889, the feature; its comments ask for javadocs and tests only, its 2017 patch already has the formula), apache/lucene-solr#1484 (no comments or reviews), #10388 (LUCENE-9348, added BaseGroupSelectorTestCase and its label filter oracle), Elasticsearch #23664 and #4847 (the same kind of floating-point effect in another engine's own histogram code). Not fixed: DoubleRangeFactory.java was last changed by LUCENE-9570 (reformat, 2021-01-05) on main, branch_10x and branch_10_6 (GitHub commits API); none of the 411 open apache/lucene PRs touches DoubleRangeFactory, DoubleRange, DoubleRangeGroupSelector or the Long siblings (snapshot 2026-10-26). Gaps: keyword search only; GitHub's search rate limit was saturated, so most GitHub phrasings ran through GraphQL search, which queries the same index
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test through gradle in a clean scratch worktree (assertions on and off, stock and with the fix, plus the whole grouping module and its static checks with the fix) and through the standalone runner (-ea, -da); the 10.6.0 (-ea, -da), 10.5.2 and 9.12.3 release jars (Maven Central, SHA-1 checked). The hunt, the verifiers, the adjudication and the follow-up ran on main ea57360912ed, the follow-up's version sweep on 29 releases from 8.6.0 to 10.5.2; lucene/grouping is identical at ea57360912ed and 645d4fe14a20.

Lucene and upstream sources

Finding record
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRangeFactory.java:27-35 (at ea57360912ed) (constructor javadoc; @param width at :31-33: 'all ranges between {@code min} and {@code max} are this wide, with the exception of the final range which may be up to this width. Ranges are inclusive at the lower end, and exclusive at the upper end.'), :43 ('Finds the DoubleRange that a value should be grouped into'), :48-66 (getRange; :62 bucket = Math.floor((value - min) / width); :63 reuse.min = min + (bucket * width); :64 reuse.max = reuse.min + width)
  • 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-33 (field and constructor javadocs: inclusive minimum, exclusive maximum)
  • lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRangeGroupSelector.java:66-76 (at ea57360912ed) (advanceTo; :71 one getRange call per document on the document's own value)
  • lucene/grouping/src/test/org/apache/lucene/search/grouping/TestDoubleRangeGroupSelector.java:46 (at ea57360912ed) (factory (100, 100, 900)), :57 (filterQuery: DoublePoint.newRangeQuery(groupValue.min, Math.nextDown(groupValue.max))); TestDoubleRangeFactory.java:26 (factory (10, 10, 50)); BaseGroupSelectorTestCase.java:50, :71, :108, :160, :193, :215, :261 (each group's documents are checked against filterQuery(groupValue))
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRangeFactory.java:48-66 (getRange; :62-64)
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRange.java:23, :27, :30, :44-50; DoubleRangeGroupSelector.java:66-76
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:85
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/GroupingSearch.java:44, GroupDocs.java:33 (@lucene.experimental)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/java/org/apache/lucene/search/grouping/DoubleRange.java:23, :27, :30; DoubleRangeFactory.java:31-33, :43; package-info.java:29-31
  • a Lucene main checkout (645d4fe14a20) lucene/grouping/src/test/org/apache/lucene/search/grouping/BaseGroupSelectorTestCase.java:50, :71, :108, :160, :193, :215, :261; TestDoubleRangeGroupSelector.java:46, :57; TestDoubleRangeFactory.java:26
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:5727-5728
History
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:5689, :5727-5728

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 same example; every value drawn comes from the code at 645d4fe14a20 or the diagram's executed probe (stock, assertions on and off, and with the fix).

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