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.
GroupingSearchgroups hits by one value per document.DoubleRangeGroupSelectorcomputes that value withDoubleRangeFactory.getRange(value).new DoubleRangeFactory(min, width, max)makes one bucket belowmin, buckets ofwidthfromminup tomax, and one bucket for everything at or abovemax. Each bucket is aDoubleRangefrom an inclusive minimum to an exclusive maximum.- Doubles are binary: 0.1 is really 0.1000000000000000055..., and every
+,*or/rounds to the nearest double. The double next to 0.6 is0.6000000000000001, one ulp (unit in the last place) away.
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.- Group documents by a double field with
DoubleRangeGroupSelectorandnew 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. - You expect each group's label, a
DoubleRangedocumented as[min, max), to contain the values grouped under it, and each label to end where the next one begins. - 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 at0.6000000000000001, so no label contains 0.6. A range query over the label's bounds finds 1 of the group's 2 documents. - Why:
getRangepicks the bucketfloor((0.6 - 0) / 0.1)=floor(5.999999999999999)= 5 and ends it at0.5 + 0.1, which rounds to 0.6, while the next bucket starts at6 * 0.1=0.6000000000000001. For 1.7 the division rounds up to exactly 17, so its labelDoubleRange(1.7000000000000002, 1.8000000000000003)starts above the value. - 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.
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.
DoubleRangeFactory.getRange(DoubleRangeFactory.java:62-64):
double bucket = Math.floor((value - min) / width);
reuse.min = min + (bucket * width);
reuse.max = reuse.min + width;- 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, andreuse.max=0.5 + 0.1, which rounds to 0.6. The next bucket starts at6 * 0.1=0.6000000000000001. So 0.6 lies in no range. - 1.7: the bucket is one too high.
1.7 / 0.1rounds up to exactly17.0, although the double 1.7 lies below17 * 0.1=1.7000000000000002; the range starts above the value. - The ranges do not tile.
reuse.maxis the roundedreuse.minpluswidth, rounded again, while the next range starts atmin + (bucket + 1) * width, rounded once. - Membership stays consistent.
DoubleRangeGroupSelectorcallsgetRangeonce 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.equalscompares both bounds exactly (DoubleRange.java:44-50). Only the reported bounds are off.
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
priceform the groupsDoubleRange(0.5, 0.6)(0.55 and 0.6) andDoubleRange(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
minis enough. With widths 1, 10, 100, 0.5 and 0.25, no checked value (bucket boundaries and two-decimal values) is outside its range whenminis an integer, but 8,519 of 499,985 are whenminhas two decimals, e.g.DoubleRangeFactory(248.8, 1.0, 928.8).getRange(520.8)=DoubleRange(519.8, 520.8). A negativeminis enough too, even an integer one, becausevalue - minthen rounds:new DoubleRangeFactory(-10, 1, 10).getRange(0.9999999999999999, null)=DoubleRange(1.0, 2.0). With a non-negative integerminand an integerwidth, 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 ofDoubleRangeFactoryin Elasticsearch, OpenSearch or Solr (default branches).GroupingSearchandGroupDocsare 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
minand an integerwidth, 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).
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).
/*
* 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);
}
}
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)withmin <= 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).
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).
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0, 10.5.2 and 9.12.3 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; Java floating point is strict IEEE-754) |
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
# 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)DoubleRangeis documented as[min, max)andgetRangeas 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";:27and: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 onwidth(javadoc,package-info.java, the 8.6.0 CHANGES entry). - Lucene's tests read the range as a range.
BaseGroupSelectorTestCasedeclaresfilterQuery(groupValue)(:50) and checks each group's documents against it (:71,:108,:160,:193,:215,:261), whichTestDoubleRangeGroupSelector.java:57builds asDoublePoint.newRangeQuery("double", groupValue.min, Math.nextDown(groupValue.max)). Its factory is(100, 100, 900)(:46), andTestDoubleRangeFactoryuses(10, 10, 50)(:26): a positive integerminandwidth, 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 inBaseGroupSelectorTestCasethat runs the filter came a week earlier with LUCENE-9348 (0c58687a978e, 2020-05-04, #10388). The PR of7c350d22c707(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.
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.
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 theirmaxby 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.1rounds down onto the value, soDoubleRange(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
widthis below the spacing of doubles near the values: forDoubleRangeFactory(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 emptyDoubleRange(1000000.0000000001, 1000000.0000000001). - Cost. Up to four
min + k * widthevaluations 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/groupingmodule, 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
DoubleRangeFactorytest,TestDoubleRangeFactoryandTestDoubleRangeGroupSelectorpass (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.
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.
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.
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.
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
getRangein 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.
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".
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, apache/lucene-solr) | 2026-10-26 | two 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 item | 0 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 history | 2026-10-26 and 2026-10-30 | the changed files of all 411 open apache/lucene PRs; the commit history of DoubleRangeFactory.java on main, branch_10x and branch_10_6 | no 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 code | 2026-10-26 | 53 list, 21 JIRA, 15 web and 2 downstream queries | notification mirrors of the feature's issue and PR; Elasticsearch #23664 and #4847 (another engine's own histogram code) |
| GitHub refresh | 2026-10-30 | issue and PR search in apache/lucene for six phrasings (positive control: 4 hits) | 0 hits |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails, gradle and standalone, assertions on and off |
| 10.6.0 | affected | the minimal test fails (release jars, -ea, -da) |
| 10.5.2, 9.12.3 | affected | the minimal test fails (release jars) |
| 8.6.0 to 10.5.2 (29 releases) | affected | the 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 API | the class does not exist (the repro does not compile) |
fix (scratch worktree of 645d4fe14a20) | not affected | the test and the grouping module pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | JUnit 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, medium | Reading only (no code run); real, medium confidence, no impact class given; intended case rated moderate; root cause agrees. |
| Independent reproducer | real | Own 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, procedural | Could 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, minor | Intended 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 minor | Re-ran the independent reproduction's tests (5 of 5 fail, -ea and -da; pass with the fix) and the finder's. |
| this doc | as above | Minimal 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. |
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 480 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,451 words
- 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 mainea57360912ed, the follow-up's version sweep on 29 releases from 8.6.0 to 10.5.2;lucene/groupingis identical atea57360912edand645d4fe14a20.
- 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:85a 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-31a 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:26a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:5727-5728
- History
a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:5689, :5727-5728
- TestDoubleRangeFactoryContainsValue.java (lucene/grouping, 1 test):
repro/LFV-412/src/org/apache/lucene/search/grouping/TestDoubleRangeFactoryContainsValue.java(28 lines)
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).
assets/LFV-412/bug.png(480 KB)
