Facet range classes store inclusive bounds and turn an exclusive bound into the next double inward. At an infinity there is no next double, so the exclusive flag is silently dropped.
DoubleRange(label, min, minInclusive, max, maxInclusive)describes one bucket for facet counting (DoubleRangeFacetCounts) and drill-down (getQuery). "price > 7" is(7, false, +Inf, true).- It stores only inclusive bounds: an exclusive bound becomes the next double inward.
Math.nextUp(7)is 7.000000000000001, so "> 7" is stored as [7.000000000000001, +Inf]. - A range that can match nothing, such as
[7, 7), is rejected by the constructor:IllegalArgumentException: range "..." matches nothing.
- Index five documents whose double doc-values field holds -Infinity, -2.5, 1.5, 7 and +Infinity. Count them with
DoubleRangeFacetCountsand two ranges:new DoubleRange("> 7", 7, false, +Inf, true)andnew DoubleRange("> +Inf", +Inf, false, +Inf, true). - You expect "> 7" to count 1 (the +Infinity document) and "> +Inf" to count 0, because no number is greater than +Infinity, or to be rejected as "matches nothing", like
(+Inf, +Inf)andLongRange("> Long.MAX_VALUE"). - Instead "> +Inf" also counts 1, and its drill-down query returns 1 hit, the +Infinity document. No error.
- Why: the constructor turns an exclusive bound into the next double,
Math.nextUp(min), andMath.nextUp(+Infinity)is +Infinity. So "> +Inf" is stored as [+Inf, +Inf], and the only emptiness check, min > max, does not fire. - The same step is copied into the range-on-range
DoubleRange(a "> +Inf" range intersects an indexed [5, +Inf]) andDimRange.fromDoubles/fromFloats.
Code that builds a facet double range from a computed bound: "value > threshold" when the threshold is +Infinity (the minimum of no values), an overflow bucket after a +Infinity boundary, or range-on-range against indexed [x, +Inf] ranges. Default codec and settings, any index size. Rare: an always-empty range such as "> +Infinity" is unlikely to be written on purpose.
Mechanism, terms and numbers645 words
The facet range classes take explicit minInclusive / maxInclusive flags and store inclusive bounds: an exclusive min becomes Math.nextUp(min) and an exclusive max Math.nextAfter(max, -Inf) (range/DoubleRange.java:64-74 on main 645d4fe14a20). Math.nextUp(+Infinity) is +Infinity and Math.nextAfter(-Infinity, -Infinity) is -Infinity, so an exclusive bound that is the infinity on its own side silently becomes inclusive: "value > +Infinity", new DoubleRange(label, +Inf, false, +Inf, true), is stored as [+Inf, +Inf], and the only emptiness check, minIn > maxIn (:76-78), does not fire. The range then counts, and its drill-down query matches, every +Infinity document; the mirror [-Inf, -Inf) matches the -Infinity documents. Every other empty range in the class is rejected with "matches nothing", and LongRange rejects the same case for Long.MAX_VALUE since LUCENE-5371 (4.7.0). The same step is copied into rangeonrange.DoubleRange and DimRange.fromDoubles / fromFloats. Present since the API was added in 4.4.0; every executed release through 10.6.0 and main fails. A 2 x 3-line guard per constructor that rejects these inputs fixes it; the repro and the facet module's checks pass with it. Only this degenerate, always-empty shape is affected, so the priority is low.
- The constructor (
range/DoubleRange.java:61-78; the NaN checks are left out here):
if (!minInclusive) {
minIn = Math.nextUp(minIn); // :64-66
}
if (!maxInclusive) {
// Why no Math.nextDown?
maxIn = Math.nextAfter(maxIn, Double.NEGATIVE_INFINITY); // :71-74
}
if (minIn > maxIn) {
failNoMatch(); // :76-78
}Math.nextUp(+Inf) is +Inf and Math.nextAfter(-Inf, -Inf) is -Inf. For (+Inf, false, +Inf, true) both stored bounds are +Inf, so minIn > maxIn is false and the range is built.
- Matching is inclusive on the stored bounds.
accept(value)isvalue >= min && value <= max(:85-87).DoubleRangeFacetCounts.getLongRanges()counts with an inclusiveLongRangeoverdoubleToSortableLong(min/max)(DoubleRangeFacetCounts.java:255-270); the drill-downgetQuery(...)(:335) matchesvalues.advanceExact(approximation.docID()) && range.accept(values.doubleValue())(:194-196); for a multi-valued source (:351) the query calls the samerange.acceptper value (:299). - Only the half-open shapes reach it.
(+Inf, +Inf)is rejected, because the exclusive max steps down toDouble.MAX_VALUE;[x, +Inf)excludes +Inf for the same reason. A Z3 model of the scalarrange.DoubleRangeconstructor andacceptproved that, for that class, the two cases(+Inf, +Inf]with x = +Inf and[-Inf, -Inf)with x = -Inf are the only mismatches betweenacceptand the requested predicate, with no false negatives. The copies below are not in that model; they were tested separately. - Copies.
rangeonrange.DoubleRangerepeats the constructor's bound handling: the same NaN checks,nextUp/nextAftersteps, "Why no Math.nextDown?" comment andmin > maxcheck (rangeonrange/DoubleRange.java:43-60, writtenminInclusive == falseinstead of!minInclusive); a "> +Inf" query range then INTERSECTS every indexed range whose upper end is +Inf.DimRange.fromDoubles(facetset/DimRange.java:60-81) andfromFloats(:84-104) useMath.nextUp/Math.nextDownwith the samemin > maxcheck. - The long siblings guard it.
LongRangecallsfailNoMatch()for an exclusiveLong.MAX_VALUEmin orLong.MIN_VALUEmax (range/LongRange.java:58-72), as dorangeonrange.LongRangeandDimRange.fromLongs(DimRange.java:34-49,IllegalArgumentException("Invalid min input: ...")).
Five documents with a DoubleDocValuesField value of -Infinity, -2.5, 1.5, 7 and +Infinity:
| range | expected | main |
|---|---|---|
("> 7", 7, false, +Inf, true) | 1 | 1 (doc 4, +Inf) |
("> +Inf", +Inf, false, +Inf, true) | 0 or rejected | stored [+Inf, +Inf]; count 1, drill-down 1 hit (doc 4) |
("< -Inf", -Inf, true, -Inf, false) | 0 or rejected | stored [-Inf, -Inf]; count 1, drill-down 1 hit (doc 0) |
("(+Inf, +Inf)", +Inf, false, +Inf, false) | rejected | rejected: matches nothing |
("[1.5, +Inf)", 1.5, true, +Inf, false) | 2 | 2 (1.5 and 7) |
("> MAX_VALUE", MAX_VALUE, false, MAX_VALUE, true); LongRange("> Long.MAX_VALUE", ...) | rejected | rejected |
range-on-range "> +Inf", INTERSECTS, indexed [5, +Inf] | 0 | 1 |
An independent check reproduced the count and the drill-down hit with values {-Inf, -1.0, 0.5, 1.0, +Inf}.
Exposure and likelihood227 words
- Who can hit it. Code that computes a bound and builds "value > threshold" as
DoubleRange(label, threshold, false, +Inf, true), or the mirror "value < threshold", when the threshold is the infinity on that side. Natural sources: the minimum of no values is +Infinity (new DoubleSummaryStatistics().getMin()), and "value > min" then matched the +Infinity documents in an executed probe; a bucket list whose last boundary is +Infinity plus an overflow bucket "> last boundary" (an executed test with left-open buckets over {0, 10, +Inf} counted the +Infinity document in (10, +Inf] and again in the overflow bucket); for range-on-range, any indexed open-ended range [x, +Inf] is matched by a "> +Inf" query range. - What it takes. The standard facet range API with its own inclusive flags; default codec, configuration and searcher; no opt-in setting; one +Infinity document suffices. The classes are marked
@lucene.experimental. - Effect. Silent wrong results: a range that should be empty counts, and drill-down matches, exactly the documents at that infinity. No exception, no hang, no false negatives. The extra matches are only documents whose value is exactly that infinity, still within the hits the application's query collects and the drill-down's other clauses.
- How likely. Rare: it needs an exclusive bound that is itself an infinity and documents at that infinity. The recorded searches found no public report since 4.4.0 (2013).
One JUnit test method in lucene/facet, public API, no index: build "value > +Infinity" as new DoubleRange("x", +Inf, false, +Inf, true) and ask accept(+Inf). It passes if the constructor rejects the range or the range does not accept +Inf. Deterministic, one thread; the same with assertions on and off.
package org.apache.lucene.facet.range;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestDoubleRangeExclusiveInfinityBound extends LuceneTestCase {
public void testExclusiveMinAtPositiveInfinityMatchesNothing() {
// "value > +Infinity" matches no double: expected IllegalArgumentException or accept(+Inf) false
DoubleRange range;
try {
range = new DoubleRange("x", Double.POSITIVE_INFINITY, false, Double.POSITIVE_INFINITY, true);
} catch (IllegalArgumentException acceptable) {
return;
}
assertFalse(
"(+Inf, +Inf] should match nothing, but accept(+Inf) returned true",
range.accept(Double.POSITIVE_INFINITY));
}
}
cp $REPRO/TestDoubleRangeExclusiveInfinityBound.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/range/
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestDoubleRangeExclusiveInfinityBound -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
- Expected
- The constructor throws
IllegalArgumentException("matches nothing"), oraccept(+Inf)returns false. - Observed
- The range is built as [+Inf, +Inf] and
accept(+Inf)returns true:AssertionError: (+Inf, +Inf] should match nothing, but accept(+Inf) returned true.
More files, environment, full output307 words
The test accepts either correct outcome: the constructor rejects the empty range (what the fix does, and what the class already does for (+Inf, +Inf)), or the range does not accept +Inf. accept() is the predicate the class documents as "True if this range accepts the provided value"; the facet count and the drill-down use the same stored bounds, so the end-to-end symptom follows from it. No index and no randomness; the seed only feeds the test runner.
Left out on purpose: the mirror case [-Inf, -Inf) and the two copies (rangeonrange.DoubleRange, DimRange.fromDoubles / fromFloats) are the same idiom at parallel fix sites; the fix covers all of them. The copy in src/ is the same test with the Apache license header and two lint fixes that ./gradlew check asks for (a comment line shortened to 100 columns, the unused catch parameter written as _). The unnamed _ needs Java 22 or later: fine on main (Java 25), but branch_10_6 has minJava = "21", and its ecj lint, like main's, rejects an unused named catch parameter, so a 10.x backport needs another form of the test (for example one that asserts the caught exception's message); none was written or built there.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/TestDoubleRangeExclusiveInfinityBound.java $LUCENE/lucene/facet/src/test/org/apache/lucene/facet/range/
cd $LUCENE && ./gradlew :lucene:facet:test --tests TestDoubleRangeExclusiveInfinityBound -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
TestDoubleRangeExclusiveInfinityBound > testExclusiveMinAtPositiveInfinityMatchesNothing FAILED
java.lang.AssertionError: (+Inf, +Inf] should match nothing, but accept(+Inf) returned true
at __randomizedtesting.SeedInfo.seed([DEADBEEF:E81052A4E03D596]:0)
...
at org.apache.lucene.facet.range.TestDoubleRangeExclusiveInfinityBound.testExclusiveMinAtPositiveInfinityMatchesNothing(TestDoubleRangeExclusiveInfinityBound.java:15)
:lucene:facet:test (FAILURE): 1 test, 1 failure
# the same with -Ptests.asserts=false; the same on the 10.6.0 release jars (assertions on and off)
# with the fix: :lucene:facet:test (SUCCESS): 1 test (assertions on and off)- The constructors take explicit inclusive flags, and "value > +Infinity" matches no double.
- The class rejects every other empty range (
testUselessRange;(+Inf, +Inf)throws "matches nothing"), andLongRangerejects the same case forLong.MAX_VALUEsince LUCENE-5371. - +Infinity is an ordinary value here:
[1.5, +Inf)excludes the +Infinity document.
The full argument279 words
- Contract. The constructors own the exclusive meaning:
rangeonrange.DoubleRangedocuments "@param minInclusiveif the minimum is inclusive" and "@param maxInclusiveif the maximum is inclusive" (rangeonrange/DoubleRange.java:35,:37). An exclusive min of +Infinity means "value > +Infinity". - Empty ranges are an error in this package.
Range.failNoMatch()is "Invoke this for a useless range" (range/Range.java:37-40);testUselessRangeasserts thatnew DoubleRange("useless", 7.0, true, 7.0, false)throws (TestRangeFacetCounts.java:288-295). - The upstream oracle is strict.
testRandomDoublesSingleValuedexpectsvalues[i] > minfor an exclusive min andvalues[i] < maxfor an exclusive max (TestRangeFacetCounts.java:1166-1174; multi-valued:1346,:1351). Its bounds arenextDouble()values in [0, 1) or earlier boundaries, never an infinity, which is why the tests miss it. - History. LUCENE-4965 (
3d60bd10d48d, 2013) also wroteLongRangewith the integer form of the same saturation,if (!minInclusive && min != Long.MAX_VALUE) min++;. LUCENE-5371 (8e1879225a35; branch_4x merge7477fde1cb06, first release 4.7.0) replaced it withfailNoMatch()and, in the same commit, gaveDoubleRangeonly themin > maxcheck. Executed:LongRange (MAX, MAX]is accepted on 4.4.0-4.6.1 and rejected from 4.7.0;DoubleRange (+Inf, +Inf]is accepted on every release run. The recorded searches and our code reading found no javadoc, CHANGES entry, test, issue or PR that calls the double case intended. - What this does not settle. The evidence shows the membership is wrong; it does not settle whether the constructor should throw. The scalar constructor's javadoc is only "Create a DoubleRange." (
range/DoubleRange.java:51); onlyrangeonrange.DoubleRangedocuments the inclusive flags. Throwing follows the package's convention for empty ranges; matching nothing is a defensible alternative a maintainer may prefer (see the fixes).
The devil's-advocate check222 words
The strongest case for "intended": Lucene's documented recipe for exclusive float/double bounds is to step to the next value and accept saturation. DoublePoint.newRangeQuery says "Ranges are inclusive. For exclusive ranges, pass nextUp(lowerValue)" (core document/DoublePoint.java:210), TestPointQueries pins DoublePoint.nextUp(+Inf) == +Inf (TestPointQueries.java:2401), and on LUCENE-7092 (#8148, 2016) Robert Muir wrote "The range facets in lucene are also inclusive too" and "If its float/double use Math.nextUp/nextDown". So the facet DoubleRange could be called "consistent with core".
Why it does not hold, in our reading: in core the caller does the stepping and the helper's return value is what is pinned; these constructors instead take an exclusive flag and promise the exclusive meaning themselves, reject every other empty range, and LUCENE-5371 removed the identical saturation for longs; the LUCENE-7092 remark was about the idiom in general, not an exclusive bound that is itself an infinity. As the cross-model intended-lens review put it, "the helper's saturation being intentional does not make the resulting membership semantics intentional." A sentinel reading (+Inf = "unbounded") does not apply either: the class excludes +Inf from [x, +Inf), and "> unbounded" has no meaning. The core convention is still real evidence for a maintainer who would rather document the degenerate input as unsupported; it does not justify silently including an excluded value. Verdict: not intended, a defect.
Recommended Reject an exclusive bound at the infinity on its own side
Reject an exclusive +Infinity min and an exclusive -Infinity max before stepping: failNoMatch() in both DoubleRange constructors, IllegalArgumentException in DimRange.fromDoubles / fromFloats, as LongRange and DimRange.fromLongs already do (3 files, +24 lines). The repro and all 307 facet tests pass with assertions on and off, and :lucene:facet:check passes.
if (!minInclusive) {
+ if (minIn == Double.POSITIVE_INFINITY) {
+ failNoMatch(); // nothing is > +Infinity (Math.nextUp(+Infinity) == +Infinity)
+ }
minIn = Math.nextUp(minIn);
}
...
if (!maxInclusive) {
+ if (maxIn == Double.NEGATIVE_INFINITY) {
+ failNoMatch(); // nothing is < -Infinity
+ }
// Why no Math.nextDown?
maxIn = Math.nextAfter(maxIn, Double.NEGATIVE_INFINITY);All options and trade-offs3 options · 471 words
One option was built and run in a scratch worktree of 645d4fe14a20; the two alternatives are not recommended. The bug is the wrong membership; throwing is the recommended trade-off, not the only correct reaction, and a maintainer may prefer an empty range (option 2).
The finder's fix, two 3-line guards per constructor or factory. range.DoubleRange and rangeonrange.DoubleRange call failNoMatch(); DimRange.fromDoubles and fromFloats throw IllegalArgumentException("Invalid min input: " + min) / ("Invalid max input: " + max), the messages fromLongs uses. Shown for range.DoubleRange:
if (!minInclusive) {
+ if (minIn == Double.POSITIVE_INFINITY) {
+ failNoMatch(); // nothing is > +Infinity (Math.nextUp(+Infinity) == +Infinity)
+ }
minIn = Math.nextUp(minIn);
}
...
if (!maxInclusive) {
+ if (maxIn == Double.NEGATIVE_INFINITY) {
+ failNoMatch(); // nothing is < -Infinity
+ }
// Why no Math.nextDown?
maxIn = Math.nextAfter(maxIn, Double.NEGATIVE_INFINITY);Trade-offs
- Matches
LongRange,rangeonrange.LongRange,DimRange.fromLongsand the package's "a useless range throws" convention. No per-document matching or counting cost: the checks run once per range construction. - Behaviour change: code that today builds
(+Inf, +Inf]or[-Inf, -Inf)from computed bounds gets anIllegalArgumentExceptioninstead of a (wrong) count, so a CHANGES note is appropriate. - The sandbox facet cutters of #13568 read
DoubleRange's adjusted bounds, so they are covered too (by reading; not run).
What the checks validated
- Scratch worktree of
645d4fe14a20, gradle, JDK 25: stock + test fails with assertions on and off; + fix: the test passes with assertions on and off. - + fix and the test:
:lucene:facet:checkBUILD SUCCESSFUL (all 40 facet test classes, 307 tests, 7 skipped, 0 failures; google-java-format, ecj lint, forbidden APIs, license checks);:lucene:facet:testwith assertions off: 307 tests, 0 failures. - Earlier, on main
ea57360912edwith the fix shadowed in: the finder's, the independent reproduction's, the cross-model review's and the intended-behaviour check's tests all pass, and 56 of 56 upstream facet range, range-on-range and facet-set tests pass. - Not run: the full Lucene test suite, nightly tests, and any build or test of the fix on branch_10_6 / 10.6.0 (same constructor lines; a 10.x backport needs a Java 21 form of the test, see the repro).
Represent the empty range explicitly (a flag, or bounds that accept() and the counters treat as empty). Not built.
Trade-offs
Avoids the behaviour change for callers that compute bounds from data, but contradicts the package convention that an empty range throws (testUselessRange), and DoubleRangeFacetCounts converts every range to an inclusive LongRange, which itself rejects min > max, so it touches more code.
A javadoc note that an exclusive bound at the infinity on its own side is not supported.
Trade-offs
Leaves the silent wrong result in place.
If an application uses a computed "value > threshold" drill-down as a filter and the threshold comes out as +Infinity, the filter lets the +Infinity documents through instead of matching nothing.
A Z3 floating-point model of the range constructor and accept: the safety claim "accept equals the requested predicate whenever the constructor does not throw" was expected to hold, and the solver returned a counterexample at -Infinity. A second query proved the two infinity cases are the only mismatches; a public-API JUnit then confirmed it.
The method in full151 words
Method: SMT (Z3) floating-point model checking of the facet range classes, a hunt unit (facet taxonomy and ranges), over raw 64-bit patterns. Obligation O2a, "DoubleRange.accept equals the requested IEEE predicate whenever the constructor does not throw", was posed as an expected-UNSAT safety proof; the solver returned SAT with the counterexample [-Inf, -Inf), x = -Inf. Obligation O2a2 then proved (UNSAT) that the mismatch set is exactly (min = +Inf exclusive, x = +Inf) or (max = -Inf exclusive, x = -Inf). A bounded-exhaustive run over the public API (411,854 checks) found these two infinity classes as the only accept-versus-requested mismatches (its other classes belong to a separate signed-zero candidate). The finder then wrote a public-API JUnit on the stock main jars; the DimRange copy was pointed out by the cross-model refute review and the range-on-range copy came from code reading. This doc's test is a one-method form of it.
No prior report found: 185 queries on 2026-10-26 (GitHub issues, PRs and commits, downstream projects, four mailing lists, the JIRA archive, the web) and a scan of all open PRs; refreshed 2026-10-30. Related: #13568 reviewed the step-to-next-double idiom without the infinity case; LUCENE-5371 (#6435) fixed the long twin.
Searches4 searches
Related, not reports of this bug: #13568 (sandbox "compute facets while collecting"; in review the nextUp/nextAfter conversion to inclusive bounds was defended without noticing the infinity case); #841 and #11901 (introduced the DimRange and range-on-range copies; infinities never came up); #6435 / LUCENE-5371 (the LongRange failNoMatch() precedent); #6403 / LUCENE-5339 (a 2013 code-style remark about the nextUp/nextDown direction constant); #8148 / LUCENE-7092 and #8645 / LUCENE-7594 (point-query exclusive bounds; DoublePoint.nextUp(+Inf) == +Inf is intended there).
Coverage gaps: GitHub's search rate limit ran out during the first run (rerun to completion, plus GraphQL searches); inline review comments were read directly only for #841, #11901 and #13568.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs, commits; the .NET port, Elasticsearch, OpenSearch, Solr, all of GitHub) | 2026-10-26 | a scripted prior-report search (16 issue/PR, 3 commit, 2 downstream queries) and 100 GraphQL searches: DoubleRange / DimRange / rangeonrange with infinity, nextUp, exclusive, failNoMatch, "matches nothing" | no report; #13568, #841, #11901, #6435, #6403, #8148, #8645 are related |
| Open apache/lucene PRs | 2026-10-26 | changed-path lists of all 411 open PRs | only #16847 touches range/DoubleRange.java, for maxDoc only |
| Mailing lists and JIRA archive | 2026-10-26 | dev@, java-user@, issues@, commits@ (a scripted prior-report search with 20 queries, 24 lists.apache.org queries); JIRA archive (4 + 9 queries) | no report |
| Refresh (GitHub) | 2026-10-30 | commits to the three files on upstream main since 2026-10-20; issue search for DoubleRange infinity, DoubleRange nextUp, DimRange fromDoubles, exclusive POSITIVE_INFINITY facet, failNoMatch, updated since 2026-10-25 | nothing new; upstream main 716d6f216f52 has the three files unchanged |
Introduced with the API by LUCENE-4965 (3d60bd10d48d, 2013-04-30, first release 4.4.0); copied into DimRange (#841, 9.3.0) and the range-on-range DoubleRange (#11901, 9.5.0). Every executed release from 4.4.0 to 10.6.0 and main fails; not a regression.
Versions tested8 rows
LUCENE-4965 "add dynamic numeric range faceting" (Mike McCandless; trunk 3d60bd10d48d, svn r1477562; branch_4x d1fcf26e425b; migrated as GitHub issue #6029; SVN era, no PR) created facet.range.DoubleRange and FloatRange with the nextUp / nextAfter adjustment. LUCENE-5371 (8e1879225a35, merged as 9d0b60388da8 on trunk and 7477fde1cb06 on branch_4x, which first shipped in 4.7.0) fixed the LongRange twin with failNoMatch() and gave DoubleRange only a min > max check. Later copies: DimRange.fromDoubles/fromFloats from #841 (LUCENE-10274, f6bb9d218c0e, backport #981, 2022-06-25) and rangeonrange.DoubleRange from #11901 (GITHUB#11869, cbfed77fd33c, 2022-12-30), a copy of the constructor's bound handling (the same NaN checks, nextUp / nextAfter steps, "Why no Math.nextDown?" comment and min > max check). git log -L shows no later change to the adjustment.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the test fails through gradle, assertions on and off |
| 10.6.0 (release jars) | affected | the test fails, assertions on and off |
| 10.5.2, 9.12.3, 9.0.0 | affected | range.DoubleRange accepts +Inf, facet count 1, drill-down 1 hit; DimRange and range-on-range copies also fail (9.12.3, 10.5.2) |
| 9.5.0 / 9.3.0 | copies | first releases of rangeonrange.DoubleRange / DimRange: both fail from their first release |
| 4.7.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4 | affected | accept true, counts (+Inf, +Inf] (1), [-Inf, -Inf) (1) |
| 4.4.0, 4.5.1, 4.6.1 | affected | DoubleRange and FloatRange fail (FloatRange removed in 4.7.0 by the LUCENE-5339 / LUCENE-5371 facet rework) |
| 4.3.1 | API absent | no facet.range package (compile error) |
with the fix (main 645d4fe14a20) | not affected | the test passes; :lucene:facet:check passes (307 tests) |
The finder, the independent reproduction, the intended-behaviour check and an adjudication rate it real (adjudication: confirmed, high), as do the intended-lens and root-cause cross-model reviews (reading only); the refute-lens review, also reading only, returned a low-confidence, procedural "not real" but agreed on the root cause.
Checks and verdicts322 words
The reviewer from another model family (OpenAI GPT-5.6) could not execute code in any lens; the executed evidence comes from the other checks.
| Check | Verdict | What it found |
|---|---|---|
| finder (a hunt unit) | candidate | Z3 counterexample and the O2a2 proof; bounded-exhaustive run (411,854 checks); JUnit fails 3 of 4 on main with -ea and -da, on 10.5.2 and 9.12.3; 4 of 4 and 56 of 56 upstream facet tests pass with the fix. |
| cross-model review, refute lens | not real, low, procedural | Could not run its own repro; agreed on the root cause and that the inputs are in contract; pointed out the DimRange copy. |
| cross-model review, intended lens | real, medium (reading only) | "The helper's saturation being intentional does not make the resulting membership semantics intentional." |
| Independent reproducer | real | Own public-API JUnit: 5 of 6 fail on main (-ea, -da), 10.5.2 and 9.12.3; a 2,048-check sweep finds exactly the two infinity mismatches; all three copies end to end; accept() fails on 8.11.4 and 4.10.4. |
| Cross-model review, root-cause lens | real, medium (reading only) | Root cause agrees, inputs in contract, intended argument weak; a separate JUnit fails 4 of 5 on main, 10.5.2 and 9.12.3 and passes 5 of 5 with the fix. |
| Devil's advocate | real | The "nextUp is the idiom" defence fails (see above); reach probe fails 3 of 5 (overflow bucket, range-on-range [5, +Inf], DimRange), 5 of 5 pass with the fix. |
| adjudication | confirmed, high | Re-ran the independent reproduction's test with a fresh seed: 5 of 6 fail with -ea and -da, 0 of 6 with the fix; low practical impact. |
| this doc | reproduces | The one-method test on main 645d4fe14a20 through gradle (-ea, -da) and on the 10.6.0 release jars (-ea, -da); with the fix in a scratch worktree the test passes and :lucene:facet:check passes (307 tests). |
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 492 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,446 words
- Registry title
- Facet DoubleRange, range-on-range DoubleRange and DimRange.fromDoubles/fromFloats do not exclude an exclusive bound that is an infinity: the empty ranges (+Inf, +Inf] and [-Inf, -Inf) are accepted and match the +Infinity / -Infinity documents instead of matching nothing
- Area
- facet / range, rangeonrange and facetset (exclusive-bound adjustment of double and float ranges: range.DoubleRange, rangeonrange.DoubleRange, facetset.DimRange.fromDoubles/fromFloats)
- Priority
- Low, rank 577: Facet double ranges with an exclusive infinite bound match the infinity documents instead of nothing, another boundary-value range row next to LFV-310.
- Impact class
- silent wrong results
- Impact note
- Applications that build a facet range whose exclusive bound is itself an infinity, 'value > +Infinity' (minInclusive=false, min=+Infinity) or 'value < -Infinity' (maxInclusive=false, max=-Infinity), for example from a threshold computed over empty data (the minimum of no values is +Infinity), get a range that should match nothing but instead counts, and through range.DoubleRange.getQuery drills down to, exactly the documents whose value is that infinity; with range-on-range INTERSECTS it matches every indexed range whose upper end is +Infinity (the documented open-ended form [x, +Inf]; the mirror case every range whose lower end is -Infinity); default IndexWriterConfig, codec and searcher, any scale, -ea and -da alike, every release since 4.4.0 (the DimRange copy since 9.3.0, the range-on-range copy since 9.5.0); the adjudication and all verifiers rate the practical impact low because only this degenerate, always-empty range shape is affected.
- Summary
- You build a facet range with an exclusive bound that is an infinity, for example new DoubleRange("> +Inf", Double.POSITIVE_INFINITY, false, Double.POSITIVE_INFINITY, true), meaning 'value > +Infinity', or the mirror [-Inf, -Inf) 'value < -Infinity'. No double is greater than +Infinity, so you expect a range that matches nothing, rejected with IllegalArgumentException 'matches nothing' as the class does for other empty ranges and as LongRange and DimRange.fromLongs do for an exclusive Long.MAX_VALUE bound. Instead the constructor makes the bound inclusive with Math.nextUp (or Math.nextAfter / Math.nextDown for the max), which leaves an infinity unchanged, so the range becomes [+Inf, +Inf]: DoubleRangeFacetCounts counts and the drill-down query matches every +Infinity document, and the copies in rangeonrange.DoubleRange and DimRange.fromDoubles/fromFloats count the same documents.
- Introduced
- 3d60bd10d48d9111b2825d4246f5234059ecdbb1 (trunk svn r1477562; branch_4x d1fcf26e425b462322607d1d6947eb5e448e029e, r1477560), 'LUCENE-4965: add dynamic numeric range faceting' by mikemccand (CHANGES.txt credits Mike McCandless and Shai Erera), JIRA LUCENE-4965 migrated as apache/lucene#6029; SVN era, no pull request. It created facet.range.DoubleRange and FloatRange with the 'if (!minInclusive) min = Math.nextUp(min)' / 'max = Math.nextAfter(max, Double.NEGATIVE_INFINITY)' adjustment, so the defect is as old as the API (not a regression). LUCENE-5371 (#6435; 8e1879225a35, merged as 9d0b60388da8 / 7477fde1cb06, 2014-01-04, first release 4.7.0) made LongRange reject the analogous exclusive Long.MAX_VALUE / Long.MIN_VALUE bound with failNoMatch() but gave DoubleRange only a 'min > max' check, which cannot see min == max == the infinity. Later copies of the idiom: DimRange.fromDoubles/fromFloats from #841 (LUCENE-10274, main f6bb9d218c0e, backport #981 0e1502c12ba9, mdmarshmallow with shaie and gsmiller, 2022-06-25, first release 9.3.0) and rangeonrange.DoubleRange from #11901 (GITHUB#11869, main cbfed77fd33c, branch_9x d9fc418914ae, mdmarshmallow, 2022-12-30, first release 9.5.0), a verbatim copy of the constructor. [read: git log --follow, git log -L and git blame -w on the constructors, git log -S 'Why no Math.nextDown', git tag --contains; exec at release level: the release before each commit lacks the API (compile error), the first release after fails with all controls passing; the commits themselves were not built] (2013-04-30 (LUCENE-4965); copies 2022-06-25 (#841, DimRange) and 2022-12-30 (#11901, rangeonrange.DoubleRange))
- First affected version
- 4.4.0 [exec] (range.DoubleRange and range.FloatRange; 4.3.1 has no facet.range package, compile error [exec]); DimRange.fromDoubles/fromFloats 9.3.0 [exec] (9.2.0 has no facetset package [exec]); rangeonrange.DoubleRange 9.5.0 [exec] (9.4.2 has no rangeonrange package [exec])
- Affected versions
- range.DoubleRange: every release from 4.4.0 through 10.6.0 and main ea57360912ed; not fixed upstream (upstream main e1c4184cb983 has the three files byte-identical, 2026-10-26). [exec] the history probes (public API, main() mode, all controls passing) fail on 4.4.0 (-ea and -da), 4.5.1, 4.6.1, 4.7.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and main (-ea and -da); range.FloatRange (same defect, removed by LUCENE-5345 in 4.7.0) fails on 4.4.0, 4.5.1 and 4.6.1; DimRange.fromDoubles/fromFloats fail on 9.3.0 (-ea and -da), 9.12.3, 10.5.2 and main (-ea and -da); rangeonrange.DoubleRange fails on 9.5.0 (-ea and -da), 9.12.3, 10.5.2 and main (-ea and -da); the finder's JUnit fails 3 of 4 on main (-ea and -da), 10.5.2, 9.12.3 and, as a 9.x port, 9.3.0; the independent reproduction's JUnit fails 5 of 6 on main (-ea and -da), 10.5.2 and 9.12.3, and the independent reproduction's accept() main() fails on 8.11.4 and 4.10.4. [read] every release tag from 4.4.0 on in the history clone contains the introducing commit (8.9.0-8.11.4 are not tagged there; 8.11.4 was executed); git log -L shows no change to the adjustment between the executed versions. 10.6.0 (released 2026-10-29) fails the bug doc's one-method repro on its release jars (4ae946e5a9b9, -ea and -da) [exec]; main has the three files unchanged and branch_10x the same range.DoubleRange constructor code [read]. All runs on JDK 25.0.3, aarch64 Linux, not each release's own JDK.
- 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
- formal: Z3; also reached by exhaustive check, code reading
- Upstream and prior reports
- none found (2026-10-26, follow-up prior-report record verdict new): a scripted prior-report search (52 queries: 16 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive, 7 web; 160 hits, every high and medium one read), 100 GitHub GraphQL searches (apache/lucene, the .NET port, Elasticsearch, OpenSearch, Solr, all of GitHub), 24 lists.apache.org and 9 JIRA-archive queries, a changed-path scan of all 411 open apache/lucene PRs (only #16847 touches range/DoubleRange.java, and only for maxDoc), the finder's earlier scripted prior-report search (94 hits) and the independent reproduction/the intended-behaviour check searches; the three files on upstream main e1c4184cb983 are identical to the tested copies. Closest items, none a report: #13568 (a review exchange defended DoubleRange's nextUp/nextAfter conversion to inclusive bounds without noticing the infinity case), #841 and #11901 (introduced the copies; nothing on infinities), #6435 / LUCENE-5371 (the LongRange failNoMatch precedent), #6403 / LUCENE-5339 (a 2013 code-style remark about nextUp/Down), #8148 / LUCENE-7092 and #8645 / LUCENE-7594 (point-query exclusive bounds; DoublePoint.nextUp(+Inf) == +Inf is the study's intended-behaviour row R3-X2). Coverage gaps: GitHub's search rate limit was exhausted (rerun to completion plus GraphQL), and inline review comments were read directly only for #841, #11901 and #13568.
- Lucene commit tested
- main
645d4fe14a20(commit of 2026-10-28): the repro test through gradle in a scratch worktree, assertions on and off, stock and with the fix, plus the facet module'scheckwith the fix; the 10.6.0 release jars (released 2026-10-29) through the standalone runner, assertions on and off. Upstream main716d6f216f52(2026-10-30) has the three affected files byte-identical. The hunt's earlier runs (finder, verifiers, adjudication, follow-up, the release history back to 4.4.0) used mainea57360912ed, where every cited facet source and test file is identical.
- Finding record
lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:42 (@lucene.experimental), :61-78 (NaN checks, Math.nextUp(minIn) at :65, Math.nextAfter(maxIn, -Inf) at :73, 'if (minIn > maxIn) failNoMatch()' at :76-77), :85-87 (accept), :335 and :351 (getQuery)lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/DoubleRange.java:33-37 (@param minInclusive 'if the minimum is inclusive'), :43-60 (same adjustment at :47 and :55, check at :58)lucene/facet/src/java/org/apache/lucene/facet/facetset/DimRange.java:34-52 (fromLongs throws for an exclusive Long.MAX_VALUE min or Long.MIN_VALUE max), :60-78 (fromDoubles: nextUp :66, nextDown :73), :84-102 (fromFloats: nextUp :90, nextDown :97)lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java:59-75 (failNoMatch for an exclusive Long.MAX_VALUE min / Long.MIN_VALUE max); range/Range.java:38-40 (failNoMatch 'matches nothing')
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:42, :52-82, :85-87, :179-196, :335, :351a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java:255-270a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/DoubleRange.java:30-64a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/facetset/DimRange.java:20-104a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/LongRange.java:53-80; range/Range.java:37-40; rangeonrange/LongRange.java:39-60
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:42; range/LongRange.java:41; facetset/DimRange.java:26 (@lucene.experimental)
- Minimal repro
a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:84-87
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/DoubleRange.java:30-38; range/DoubleRange.java:51; range/Range.java:37-40a Lucene main checkout (645d4fe14a20) lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:288-295, :1080, :1115-1138, :1166-1174, :1250, :1346, :1351a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/DoublePoint.java:210; lucene/core/src/test/org/apache/lucene/search/TestPointQueries.java:2399-2402
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java:255-270; range/LongRange.java:74-76
- TestDoubleRangeExclusiveInfinityBound.java (lucene/facet, one test):
repro/LFV-313/src/org/apache/lucene/facet/range/TestDoubleRangeExclusiveInfinityBound.java(19 lines)
It is made in parallel with this doc; every value on screen comes from the code at 645d4fe14a20 or the diagram's executed public-API probe.
assets/LFV-313/bug.png(492 KB)
![Summary card for the example: five documents with values -Infinity, -2.5, 1.5, 7 and +Infinity and the range new DoubleRange("> +Inf", +Inf, false, +Inf, true). Expected 0, main gives 1 for its facet count, its drill-down hits, the mirror "< -Inf" and the range-on-range "> +Inf" against [5, +Inf]; the control "> 7" counts 1 on both. A number line shows Math.nextUp(7.0) = 7.000000000000001 but Math.nextUp(+Inf) = +Inf, so "> +Inf" is stored as [+Inf, +Inf] and the check min > max does not fire.](../assets/LFV-313/bug.png)