All findings · Lucene finding · LFV-313 · doublerange-exclusive-infinity-bound-not-excluded

Facet DoubleRange with an exclusive bound at an infinity is not empty: "value > +Infinity" matches the +Infinity documents

A facet DoubleRange for "value > +Infinity" should match nothing, but it counts, and drills down to, every document whose value is +Infinity; "value < -Infinity" likewise matches the -Infinity documents. Every release since 4.4.0 (2013) is affected, 10.6.0 included.

Summary

Priority
Low, rank 577Facet double ranges with an exclusive infinite bound match the infinity documents instead of nothing, another boundary-value range row next to LFV-310.
Impact
silent wrong results
Since
4.4.0 (LUCENE-4965, 2013-04-30)introduced 2013-04-30 · over 13 years
Affected
every release from 4.4.0 to 10.6.0, and main (13 releases run; no change to the code in between)
Status
documented
Found by
a Z3 model of the range constructor, then a JUnit

AI-assisted page: this finding was first signalled by a formal tool's own output 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

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.

Background: how a facet range stores its bounds

  1. DoubleRange(label, min, minInclusive, max, maxInclusive) describes one bucket for facet counting (DoubleRangeFacetCounts) and drill-down (getQuery). "price > 7" is (7, false, +Inf, true).
  2. 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].
  3. A range that can match nothing, such as [7, 7), is rejected by the constructor: IllegalArgumentException: range "..." matches nothing.
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.
Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index five documents whose double doc-values field holds -Infinity, -2.5, 1.5, 7 and +Infinity. Count them with DoubleRangeFacetCounts and two ranges: new DoubleRange("> 7", 7, false, +Inf, true) and new DoubleRange("> +Inf", +Inf, false, +Inf, true).
  2. 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) and LongRange("> Long.MAX_VALUE").
  3. Instead "> +Inf" also counts 1, and its drill-down query returns 1 hit, the +Infinity document. No error.
  4. Why: the constructor turns an exclusive bound into the next double, Math.nextUp(min), and Math.nextUp(+Infinity) is +Infinity. So "> +Inf" is stored as [+Inf, +Inf], and the only emptiness check, min > max, does not fire.
  5. The same step is copied into the range-on-range DoubleRange (a "> +Inf" range intersects an indexed [5, +Inf]) and DimRange.fromDoubles / fromFloats.

Who is affected

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

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

  1. Matching is inclusive on the stored bounds. accept(value) is value >= min && value <= max (:85-87). DoubleRangeFacetCounts.getLongRanges() counts with an inclusive LongRange over doubleToSortableLong(min/max) (DoubleRangeFacetCounts.java:255-270); the drill-down getQuery(...) (:335) matches values.advanceExact(approximation.docID()) && range.accept(values.doubleValue()) (:194-196); for a multi-valued source (:351) the query calls the same range.accept per value (:299).
  2. Only the half-open shapes reach it. (+Inf, +Inf) is rejected, because the exclusive max steps down to Double.MAX_VALUE; [x, +Inf) excludes +Inf for the same reason. A Z3 model of the scalar range.DoubleRange constructor and accept proved that, for that class, the two cases (+Inf, +Inf] with x = +Inf and [-Inf, -Inf) with x = -Inf are the only mismatches between accept and the requested predicate, with no false negatives. The copies below are not in that model; they were tested separately.
  3. Copies. rangeonrange.DoubleRange repeats the constructor's bound handling: the same NaN checks, nextUp / nextAfter steps, "Why no Math.nextDown?" comment and min > max check (rangeonrange/DoubleRange.java:43-60, written minInclusive == false instead of !minInclusive); a "> +Inf" query range then INTERSECTS every indexed range whose upper end is +Inf. DimRange.fromDoubles (facetset/DimRange.java:60-81) and fromFloats (:84-104) use Math.nextUp / Math.nextDown with the same min > max check.
  4. The long siblings guard it. LongRange calls failNoMatch() for an exclusive Long.MAX_VALUE min or Long.MIN_VALUE max (range/LongRange.java:58-72), as do rangeonrange.LongRange and DimRange.fromLongs (DimRange.java:34-49, IllegalArgumentException("Invalid min input: ...")).

Executed example (main 645d4fe14a20, public API)

Five documents with a DoubleDocValuesField value of -Infinity, -2.5, 1.5, 7 and +Infinity:

rangeexpectedmain
("> 7", 7, false, +Inf, true)11 (doc 4, +Inf)
("> +Inf", +Inf, false, +Inf, true)0 or rejectedstored [+Inf, +Inf]; count 1, drill-down 1 hit (doc 4)
("< -Inf", -Inf, true, -Inf, false)0 or rejectedstored [-Inf, -Inf]; count 1, drill-down 1 hit (doc 0)
("(+Inf, +Inf)", +Inf, false, +Inf, false)rejectedrejected: matches nothing
("[1.5, +Inf)", 1.5, true, +Inf, false)22 (1.5 and 7)
("> MAX_VALUE", MAX_VALUE, false, MAX_VALUE, true); LongRange("> Long.MAX_VALUE", ...)rejectedrejected
range-on-range "> +Inf", INTERSECTS, indexed [5, +Inf]01

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

Minimal repro

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.

TestDoubleRangeExclusiveInfinityBound.java (lucene/facet, one test)19 lines
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));
  }
}
Command
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"), or accept(+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.

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

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.

Environment

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

Observed output

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)

Why it looks unintended

  • 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"), and LongRange rejects the same case for Long.MAX_VALUE since 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.DoubleRange documents "@param minInclusive if the minimum is inclusive" and "@param maxInclusive if 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); testUselessRange asserts that new DoubleRange("useless", 7.0, true, 7.0, false) throws (TestRangeFacetCounts.java:288-295).
  • The upstream oracle is strict. testRandomDoublesSingleValued expects values[i] > min for an exclusive min and values[i] < max for an exclusive max (TestRangeFacetCounts.java:1166-1174; multi-valued :1346, :1351). Its bounds are nextDouble() values in [0, 1) or earlier boundaries, never an infinity, which is why the tests miss it.
  • History. LUCENE-4965 (3d60bd10d48d, 2013) also wrote LongRange with the integer form of the same saturation, if (!minInclusive && min != Long.MAX_VALUE) min++;. LUCENE-5371 (8e1879225a35; branch_4x merge 7477fde1cb06, first release 4.7.0) replaced it with failNoMatch() and, in the same commit, gave DoubleRange only the min > max check. 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); only rangeonrange.DoubleRange documents 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.

Suggested fix

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

Reject an exclusive bound at the infinity on its own side (validated by the checks) (recommended)

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.fromLongs and 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 an IllegalArgumentException instead 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:check BUILD SUCCESSFUL (all 40 facet test classes, 307 tests, 7 skipped, 0 failures; google-java-format, ecj lint, forbidden APIs, license checks); :lucene:facet:test with assertions off: 307 tests, 0 failures.
  • Earlier, on main ea57360912ed with 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).

Keep accepting the input, but make it match nothing

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.

Document it

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.

Downstream note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; the .NET port, Elasticsearch, OpenSearch, Solr, all of GitHub)2026-10-26a 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 PRs2026-10-26changed-path lists of all 411 open PRsonly #16847 touches range/DoubleRange.java, for maxDoc only
Mailing lists and JIRA archive2026-10-26dev@, 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-30commits 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-25nothing new; upstream main 716d6f216f52 has the three files unchanged

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe test fails through gradle, assertions on and off
10.6.0 (release jars) affectedthe test fails, assertions on and off
10.5.2, 9.12.3, 9.0.0 affectedrange.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 copiesfirst 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 affectedaccept true, counts (+Inf, +Inf] (1), [-Inf, -Inf) (1)
4.4.0, 4.5.1, 4.6.1 affectedDoubleRange and FloatRange fail (FloatRange removed in 4.7.0 by the LUCENE-5339 / LUCENE-5371 facet rework)
4.3.1 API absentno facet.range package (compile error)
with the fix (main 645d4fe14a20) not affectedthe test passes; :lucene:facet:check passes (307 tests)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (a hunt unit) candidateZ3 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, proceduralCould 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 realOwn 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 realThe "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, highRe-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 reproducesThe 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).

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

Finding record

LFV-313, snapshot of 2026-11-06

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's check with the fix; the 10.6.0 release jars (released 2026-10-29) through the standalone runner, assertions on and off. Upstream main 716d6f216f52 (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 main ea57360912ed, where every cited facet source and test file is identical.

Lucene and upstream sources

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, :351
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java:255-270
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/rangeonrange/DoubleRange.java:30-64
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/java/org/apache/lucene/facet/facetset/DimRange.java:20-104
  • a 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-40
  • a Lucene main checkout (645d4fe14a20) lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:288-295, :1080, :1115-1138, :1166-1174, :1250, :1346, :1351
  • a 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

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

Repro files

About the diagrams

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.

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.