All findings · Lucene finding · LFV-469 · atleast-nonpositive-min-should-match-accepted

Intervals.atLeast accepts a count of 0, then searches that evaluate it throw NullPointerException

Intervals.atLeast(0, a, b), or the query text fn:atLeast(0 a b), is accepted, but a search that evaluates it then throws an internal NullPointerException (ArrayIndexOutOfBoundsException on a segment without the terms) instead of the 0 being rejected. Every release from 8.0.0 to 10.6.0 and main.

Summary

Priority
Low, rank 657Intervals.atLeast(0, ...) and fn:atLeast(0 ...) are accepted and then throw NullPointerException or ArrayIndexOutOfBoundsException on every search, which is loud, fails one request at a time, and needs a value that means nothing for atLeast.
Impact
wrong error
Since
8.0.0 (LUCENE-8622, 2019); query text since 9.1.0introduced 2019-01-07 · about 7 years
Affected
8.0.0 to 10.6.0 and main
Status
documented
Found by
a sweep of degenerate arguments over the Intervals factories

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Intervals.atLeast(n, sources...) returns stretches of text that contain at least n of the sources, for example at least 2 of three words. fn:atLeast(n ...) is the same operator in the flexible query parser's syntax, so the count can come straight from what a user types.

Diagram of Intervals.atLeast(0, a, b) over the one-document index "a b c": the factory checks only count == 2 (unordered) and count > 2 (no match), so 0 builds a MinimumShouldMatchIntervalsSource whose proximity queue has capacity 0; IndexSearcher.count then throws NullPointerException because PriorityQueue.top() is null.
Intervals.atLeast(0, a, b) on the one-document text "a b c": the factory lets the 0 through, and the first search asks an empty queue for its top. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index one document, "a b c", and build Intervals.atLeast(0, Intervals.term("a"), Intervals.term("b")): at least 0 of the 2 terms. Typing fn:atLeast(0 a b) into the flexible StandardQueryParser builds the same query.
  2. You expect an IllegalArgumentException when the query is built, or a search that runs. The factory already handles the other odd counts: 2 of 2 becomes unordered(a, b), 3 of 2 a source that matches nothing.
  3. You get a query that builds fine; then searcher.count(query) throws NullPointerException: ... the return value of "PriorityQueue.top()" is null. Top-k and sorted search, Weight#matches and explain fail the same way, with assertions on or off.
  4. Why: atLeast keeps the sources it is combining in a queue sized by the count. With 0 that queue can hold nothing; its loop test "queue size == count" reads 0 == 0, true, so it asks the empty queue for its top and gets null. On a segment where neither term occurs, an empty document queue throws ArrayIndexOutOfBoundsException instead.

Who is affected

Code that passes 0 to Intervals.atLeast, for example a count computed from a percentage of a short clause list or forwarded from a client, and search boxes on the flexible StandardQueryParser (9.1.0 and later), where anyone can type fn:atLeast(0 ...). Default settings and one document suffice. Each such request fails; no wrong hits, no hang.

Mechanism, terms and numbers760 words

Intervals.atLeast(minShouldMatch, sources...) checks only two special counts: equal to the number of sources (rewritten to unordered) and larger (a source that matches nothing). A count of 0 with one or more sources, passed directly or typed as fn:atLeast(0 a b) into the flexible StandardQueryParser, therefore builds a MinimumShouldMatchIntervalsSource whose queue of active sources has capacity 0. On the first candidate document its nextInterval() loop test proximityQueue.size() == minShouldMatch is true on the empty queue and proximityQueue.top().start() throws NullPointerException; on a segment that holds none of the source terms the iterator, built anyway, throws ArrayIndexOutOfBoundsException from its empty document queue. So count, top-k and sorted search, Weight#matches and explain fail wherever they evaluate it, with an internal exception instead of an argument error when the query is built. A negative count fails late with IllegalArgumentException from PriorityQueue. Default IndexWriterConfig and searcher, one document, assertions on or off; every release from 8.0.0 (query text from 9.1.0) to 10.6.0 and main. Nothing else is affected: no wrong hits, no hang, no result cached for the broken query, nothing written.

Terms

  • Interval: a [start, end] range of token positions in one field of one document; Intervals.term("a") gives one per occurrence of a.
  • Intervals.atLeast(minShouldMatch, sources...): "Return intervals that span combinations of intervals from minShouldMatch of the sources" (Intervals.java:563-565). Query text: fn:atLeast(min sources...), where min is "an integer specifying minimum number of sub-interval arguments that must match" (intervalfn/package-info.java:231).
  • Proximity queue: the iterator keeps the minShouldMatch sub-iterators it is combining right now in a priority queue of that capacity, and the rest in a background queue (MinimumShouldMatchIntervalsSource.java:146-156, :186-195).

The mechanism (main 645d4fe14a20)

  1. No lower bound. The factory handles minShouldMatch == sources.length (returns unordered(sources), Intervals.java:567-569) and minShouldMatch > sources.length (returns a NoMatchIntervalsSource, :570-576); any smaller count, 0 and negatives included, becomes new MinimumShouldMatchIntervalsSource(sources, minShouldMatch) (:577). Its constructor only does assert minShouldMatch < sources.length (MinimumShouldMatchIntervalsSource.java:46), which 0 satisfies. (With no sources at all, 0 is the == case and goes to unordered().)
  2. An iterator is always built. intervals() returns null only if (iterators.size() < minShouldMatch) (:60), never true for 0 (:51-64).
  3. A queue that holds nothing. The proximity queue is created with capacity minShouldMatch (:186-190); for capacity 0 PriorityQueue allocates a 2-slot heap whose top() returns heap[1], always null (PriorityQueue.java:117-119, :246-251). reset() moves minShouldMatch sub-iterators into it (MinimumShouldMatchIntervalsSource.java:279-288): none.
  4. The NPE. nextInterval() begins while (this.proximityQueue.size() == minShouldMatch && proximityQueue.top().start() == start) (:217-218): 0 == 0, and top() is null. The scorer's two-phase check calls it on the first candidate document (IntervalScorer.java:80-81), so count, search and explain throw there; Weight#matches reaches the same loop through MinimumShouldMatchIntervalsSource.matches, which builds its own iterator and calls nextInterval() (:80-88).
  5. No source term in the segment. Then the iterator has no sub-iterators; its DisiPriorityQueue (the intervals package's own copy) has length 0 (MinimumShouldMatchIntervalsSource.java:175), and the bulk scorer's first docID() reads heap[0] (DisiPriorityQueue.java:56-58): ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0.
  6. Negative counts reach PriorityQueue's check "maxSize must be >= 0" (PriorityQueue.java:121-124), but only when the iterator is created at search time (by the scorer, or by matches for Weight#matches).
  7. Query text. StandardSyntaxParser.jj:411-422 reads <NUMBER> (no sign: digits with an optional fraction, :160) and returns new AtLeast(parseInt(minShouldMatch), sources); AtLeast.toIntervalSource passes the number unchanged to Intervals.atLeast (intervalfn/AtLeast.java:38-44). So 0 can be typed; a negative cannot (fn:atLeast(-1 a b) is a ParseException).

What each count does (one document, "a b c")

sourceresult on main
atLeast(1, a, b) (control)count 1
atLeast(2, a, b)rewritten to unordered(a, b): count 1
atLeast(3, a, b)NOMATCH(Too few sources to match minimum of [3]: [a, b]): count 0
atLeast(0, a, b), atLeast(0, a), fn:atLeast(0 a b), fn:or(c fn:atLeast(0 a b))NullPointerException at MinimumShouldMatchIntervalsSource.java:218
atLeast(0, x, y) (no source term in the segment)ArrayIndexOutOfBoundsException at DisiPriorityQueue.java:57
atLeast(-1, a, b)builds; IllegalArgumentException: maxSize must be >= 0 and < 2147483631; got: -1 at search time

Inside a Boolean OR, a non-scoring count may never ask the atLeast clause once another clause has matched: c OR fn:atLeast(0 a b) counted 1 on "a b c", but top-k search, index-order sorted search, explain and Weight#matches on the same query threw the NPE. Over an empty index there is no segment to evaluate it on, and count returns 0.

Exposure and likelihood342 words
  • Who is exposed. (a) Code that passes a computed or forwarded count to Intervals.atLeast. (b) Applications that hand end-user text to the flexible StandardQueryParser with interval functions (9.1.0 and later; the node calls Intervals.atLeast unchanged, intervalfn/AtLeast.java:38-44). The maintainers foresaw (b) for a sibling case: "I realize the query itself is malformed but such corner cases will happen if intervals are available from the query parser" (Dawid Weiss, on #10783 / LUCENE-9744).
  • Downstream (read, not run). Solr's intervals query parser (apache/solr#4582, SOLR-13764) was released in Solr 10.1.0 (2026-10-27, built on Lucene 10.4.0, which has the same atLeast code) and is also on Solr main. Its at_least rule rejects only min_should_match < 0 and passes 0 to Intervals.atLeast, and the parser is registered by default. Elasticsearch and OpenSearch do not expose atLeast. That says nothing about private code.
  • Default or opt-in. atLeast is an opt-in operator of a package that calls itself experimental (intervals/package-info.java:21). Nothing else is needed: the default IndexWriterConfig and searcher, assertions on or off. Only the seeded test codecs were run; the failing code is in the query's own iterator, not in the codec, so other codecs should behave the same (read, not run).
  • Scale. One document; deterministic.
  • What you see. An unchecked NullPointerException (or ArrayIndexOutOfBoundsException) from count, top-k and sorted search, Weight#matches and explain, where an argument error was due when the query was built. An application that maps IllegalArgumentException to a "bad request" reports an internal error instead; how a downstream reports it is up to it.
  • What does not happen (executed probe): no wrong or extra hits (189 of 189 calls threw, with the clause in MUST, FILTER, MUST_NOT, constant-score and parsed positions on four searchers, including concurrent and always-caching ones), no hang, no result cached for the broken query (a repeat on an always-caching searcher threw again), the index untouched.
  • Likelihood: rare. A constant 0 fails loudly on the first search, so testing catches it; it takes a computed, forwarded or typed 0, and atLeast is a less-used operator.

Minimal repro

One JUnit test on the public API: index "a b c", build Intervals.atLeast(0, a, b) and count it. It passes if the 0 is rejected when the source is built or if the search runs, and fails only on the NullPointerException. Deterministic; it fails with assertions on and off.

TestIntervalsAtLeastZero.java (1 test)55 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.queries.intervals;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIntervalsAtLeastZero extends LuceneTestCase {

  public void testMinShouldMatchZero() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new TextField("f", "a b c", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        IntervalsSource source;
        try {
          source = Intervals.atLeast(0, Intervals.term("a"), Intervals.term("b"));
        } catch (IllegalArgumentException e) {
          return; // rejecting minShouldMatch 0 when the source is built is fine
        }
        try {
          new IndexSearcher(r).count(new IntervalQuery("f", source));
        } catch (NullPointerException e) {
          throw new AssertionError(
              "expected atLeast(0, a, b) to be rejected or to search, got " + e, e);
        }
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or a later commit where Intervals.atLeast is unchanged)
cp $REPRO/TestIntervalsAtLeastZero.java lucene/queries/src/test/org/apache/lucene/queries/intervals/
./gradlew :lucene:queries:test --tests org.apache.lucene.queries.intervals.TestIntervalsAtLeastZero -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false
Expected
The 0 is rejected with an IllegalArgumentException when the source is built, or count runs and returns a number.
Observed
count throws NullPointerException: "the return value of PriorityQueue.top() is null", at MinimumShouldMatchIntervalsSource.java:218.

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

More files, environment, full output326 words

Put the file in the queries module's org.apache.lucene.queries.intervals test package and run the command. It uses new IndexWriterConfig() and a ByteBuffersDirectory, and calls only public API (Intervals, IntervalQuery, IndexSearcher). It asserts no hit count, so it accepts either fix: an IllegalArgumentException when the source is built (the test returns) or a search that runs (the test passes).

Under the test framework the default codec is randomized per seed (DEADBEEF picks Asserting(Lucene104)); the failing code does not depend on the codec, and seeds DEADBEEF and 5EED1234 fail the same way. The query-text path, fn:atLeast(0 a b) through StandardQueryParser, ends in the same Intervals.atLeast call and was left out on purpose; it fails the same way (executed).

Before using it as the regression test in a PR, rename the unused catch parameter e (catch (IllegalArgumentException e), line 43) to _: Lucene's ecj lint treats an unused exception parameter as an error, so ./gradlew :lucene:queries:check fails on the file as is. With _ (Lucene's own idiom, e.g. FSDirectory.java:230) the check passes with either fix, and the test still fails on main and passes with the fix (executed).

Environment

lucenemain 645d4fe14a20: affected; also the 10.6.0, 10.5.2, 9.12.3 and 9.1.0 release jars with this test
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

# main 645d4fe14a20, ./gradlew :lucene:queries:test --tests ...TestIntervalsAtLeastZero -Ptests.seed=DEADBEEF -Ptests.asserts=true
# (-Ptests.asserts=false: the same failure); middle stack frames elided
TestIntervalsAtLeastZero > testMinShouldMatchZero FAILED
    java.lang.AssertionError: expected atLeast(0, a, b) to be rejected or to search, got java.lang.NullPointerException: Cannot invoke "org.apache.lucene.queries.intervals.IntervalIterator.start()" because the return value of "org.apache.lucene.util.PriorityQueue.top()" is null
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:5DDA7A9C86BE18FD]:0)
        at org.apache.lucene.queries.intervals.TestIntervalsAtLeastZero.testMinShouldMatchZero(TestIntervalsAtLeastZero.java:49)

        Caused by:
        java.lang.NullPointerException: Cannot invoke "org.apache.lucene.queries.intervals.IntervalIterator.start()" because the return value of "org.apache.lucene.util.PriorityQueue.top()" is null
            at org.apache.lucene.queries.intervals.MinimumShouldMatchIntervalsSource$MinimumShouldMatchIntervalIterator.nextInterval(MinimumShouldMatchIntervalsSource.java:218)
            at org.apache.lucene.queries.intervals.IntervalScorer$1.matches(IntervalScorer.java:81)
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.Weight$DefaultBulkScorer.scoreTwoPhaseIterator(Weight.java:311)
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.Weight$DefaultBulkScorer.score(Weight.java:282)
            ...
            at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.IndexSearcher.count(IndexSearcher.java:528)
            at org.apache.lucene.queries.intervals.TestIntervalsAtLeastZero.testMinShouldMatchZero(TestIntervalsAtLeastZero.java:47)

:lucene:queries:test (FAILURE): 1 test, 1  failure

# with fix A: 1 test, passes (the 0 is rejected when the source is built); with fix B: passes (count runs)

Why it looks unintended

  • Both entry points accept 0; no doc sets a lower bound or an exception, and an internal NPE from an empty queue is no designed outcome.
  • The factory already makes the other odd counts safe (equal: unordered; larger: no match), fixing a sibling NPE, #10783.
  • Elsewhere the package rejects bad numbers up front ("pivot must be > 0").
The full argument252 words
  • The contract. Intervals.atLeast: "Return intervals that span combinations of intervals from minShouldMatch of the sources" (Intervals.java:563-565), with no stated range and no @throws. Parser docs: fn:atLeast "Matches documents that contain at least the provided number of source intervals"; min is "an integer specifying minimum number of sub-interval arguments that must match" (intervalfn/package-info.java:223, :231). No document says 0 must throw either: rejecting it (fix A) or giving it a meaning (fix B) is the maintainers' choice; the bug is that accepted input later fails with an internal exception.
  • Degenerate counts are normalised on purpose. The == and > shortcuts (Intervals.java:567-576) came from LUCENE-9744 (#10783, 8.8.2), "NPE on a degenerate query in MinimumShouldMatchIntervalsSource" (lucene/CHANGES.txt:5445-5446). Its fix was, in Alan Woodward's words, "a fix in the factory method that ensures we don't ever actually get a degenerate min-should-match interval". 0 is the case that fix did not cover.
  • Validation elsewhere in the package. IntervalScoreFunction throws "pivot must be > 0" and "exp must be > 0" when built (IntervalScoreFunction.java:28, :35, :38).
  • The same parser gives 0 a meaning elsewhere. Its Boolean min-should-match operator (a b)@0 parses and counts 1 on "a b c" (executed).
  • Tests. No upstream test uses atLeast with 0 or a negative count; TestSimplifications.testMinShouldMatchSimplifications pins only the == and > rewrites (TestSimplifications.java:115-122).
  • History. The missing bound and the loop guard date from the class's first commit (LUCENE-8622, 2019); the guard was only re-wrapped since (LUCENE-9570).
The devil's-advocate check155 words

The strongest case for "intended" (built by the devil's advocate and reviews by an independent reviewer from another model family, OpenAI GPT-5.6, reading only): atLeast(0, ...) is meaningless, and the LUCENE-9744 reporter themselves called such a query "malformed"; the Intervals factories generally validate little (maxgaps(-1, ...) and extend(a, -1, 0) are accepted, unordered() with no sources throws a raw exception); MinimumShouldMatchIntervalsSource is package-private and its design comment assumes a count of at least 1; negative counts already end in an IllegalArgumentException.

Why it fails: the "malformed" LUCENE-9744 input was fixed anyway, for exactly the query-parser reason; the silently accepted numeric arguments return results, not exceptions, and the zero-source cases cannot be written as query text, while fn:atLeast(0 ...) can; the entry points are public and documented; and the negative value fails late and is not the headline. No checker rated the intended-behaviour argument better than weak. Verdict: not intended; a missing argument check.

Suggested fix

Recommended A: reject a count below 1 when the source is built

Reject a count below 1 in Intervals.atLeast with an IllegalArgumentException (3 lines). The repro then passes, and so do all 513 tests of the queries module and the 161 flexible-parser tests. Alternative: treat 0 as "no minimum" and return or(sources), as BooleanQuery does.

public static IntervalsSource atLeast(int minShouldMatch, IntervalsSource... sources) {
  if (minShouldMatch < 1) {
    throw new IllegalArgumentException("minShouldMatch must be >= 1, got: " + minShouldMatch);
  }
  if (minShouldMatch == sources.length) {
All options and trade-offs4 options · 662 words

Two fixes were run through gradle in a scratch worktree of 645d4fe14a20; both are one-file changes to Intervals.atLeast. The earlier checkers advised not to prescribe one: A is smaller and matches what LUCENE-9744 did for the other degenerate counts; B gives 0 the meaning BooleanQuery gives it. Until a fix ships, callers can check the count themselves.

A: reject a count below 1 when the source is built (validated by the checks) (recommended)

The change is +3 lines in Intervals.java only (the finder's fix, re-made on 645d4fe14a20). One check at the entry point removes all three failures: the NPE, the AIOOBE and the late IllegalArgumentException for negative counts. It follows IntervalScoreFunction's convention and LUCENE-9744's "fix in the factory method".

public static IntervalsSource atLeast(int minShouldMatch, IntervalsSource... sources) {
  if (minShouldMatch < 1) {
    throw new IllegalArgumentException("minShouldMatch must be >= 1, got: " + minShouldMatch);
  }
  if (minShouldMatch == sources.length) {

Trade-offs

  • A few queries that run today would fail when built. Searches with a count of 0 throw today wherever they evaluate it, but a search over an empty index (count 0), or a non-scoring count of a Boolean OR whose other clause already matched (c OR fn:atLeast(0 a b) counted 1 on "a b c"; top-k and sorted search, explain and Weight#matches on it threw), runs today. With fix A both throw when the query is built (executed).
  • Query text. StandardQueryParser.parse("fn:atLeast(0 a b)", "f") then throws a bare IllegalArgumentException instead of a QueryNodeException (executed with the finder's identical fix); a parser-side check (option C) would make it a parse error.
  • No sources. Intervals.atLeast(0) with no sources, which today fails through unordered() ("Cannot make a ConjunctionDISI of less than 2 iterators"), fails with this message instead.
  • A @throws IllegalArgumentException line in the javadoc would document the bound.

What the checks validated

  • Gradle, seed DEADBEEF, 645d4fe14a20: the repro passes with assertions on and off (and on the standalone runner).
  • :lucene:queries:test in full: 513 tests in 52 suites (the repro included), 5 skipped, 0 failures; org.apache.lucene.queryparser.flexible.*: 161 tests in 14 suites, 1 skipped, 0 failures; the intervals tests with assertions off: 118 tests, 0 failures.
  • :lucene:queries:check -x test (google-java-format, ecj lint, forbidden APIs) passes once the repro's catch parameter is renamed _ (see Minimal repro).

B: treat 0 as "no minimum" and return or(sources) (validated by the checks)

The change is +7 lines in Intervals.java only: reject negative counts, and for 0 with at least one source return or(sources), as BooleanQuery treats minimumNumberShouldMatch 0 and as the same parser treats (a b)@0.

if (minShouldMatch < 0) {
  throw new IllegalArgumentException("minShouldMatch must be >= 0, got: " + minShouldMatch);
}
if (minShouldMatch == 0 && sources.length > 0) {
  // like BooleanQuery's minimumNumberShouldMatch 0: no minimum, any one source matches
  return or(sources);
}

Trade-offs

Gives 0 a meaning, so the parser needs nothing. But it picks semantics the javadoc does not state: "at least 0 of the sources" read literally is met by any document, which or is not.

What the checks validated

  • Gradle, seed DEADBEEF, 645d4fe14a20: the repro passes (the search runs) with assertions on and off (standalone runner); the intervals tests (118, 0 failures) and org.apache.lucene.queryparser.flexible.* (161, 1 skipped, 0 failures) pass with assertions on; :lucene:queries:check -x test passes. The whole queries module was not run with B.

C: with A, a parser-side check

Reject fn:atLeast(0 ...) as a parse error, for example in the IntervalAtLeast rule of StandardSyntaxParser.jj (the generated parser then has to be regenerated).

Trade-offs

Only changes the exception type seen by parser users; not run.

Not recommended: guard the iterator's loop

For example require size() > 0 in the loop guard of MinimumShouldMatchIntervalsSource.java:217.

Trade-offs

Does not even remove the NPE: with the loop skipped, the test size() < minShouldMatch at :228 (0 < 0) is false, and proximityQueue.top().start() at :233 dereferences null again. It also leaves the ArrayIndexOutOfBoundsException and the meaning of 0 undefined. Read, not run.

Downstream note

Solr's intervals query parser, released in Solr 10.1.0 (2026-10-27, on Lucene 10.4.0), checks only for a negative min_should_match and passes 0 straight to Intervals.atLeast (code read, not run), so its search clients could hit the same exception.

How it was found

A sweep of degenerate arguments: a probe called the Intervals factories with 15 edge values (counts of 0 and -1, negative gaps and widths, no sources) on a small index. atLeast(0, a, b) built fine and then threw at search time; the finder then wrote a public-API test. No model checker or solver was involved.

The method in full184 words

Method: non-formal (a degenerate-argument sweep; no model checker, SMT solver, prover or bounded-exhaustive harness produced the signal).

  • A hunt unit (intervals builder and multi-term sources), 2026-10-26, on main ea57360912ed. While extending the unit's reference semantics to atLeast, the finder, an AI tool, wrote a probe that ran 15 degenerate calls of the Intervals factories against a small index: atLeast(0, a, b), atLeast(-1, a, b), atLeast(1, a), atLeast(0), atLeast(2) with no sources, extend(a, -1, 0), extend(a, 0, -1), maxgaps(-1, ...), maxwidth(-1, a), within(a, -1, b), notWithin(a, -2, b), and unordered(), ordered(), or(), phrase() with no sources.
  • atLeast(0, a, b) was the call that was accepted and then threw NullPointerException at search time; atLeast(-1, a, b) failed late with IllegalArgumentException. The finder read the factory and the iterator, and wrote a deterministic public-API JUnit (direct API and parsed fn:atLeast(0 a b)) and a 3-line fix.
  • Independent checkers then widened it: the ArrayIndexOutOfBoundsException on segments with none of the terms, Weight#matches, nested query text, and every release from 8.0.0.

Prior reports, history and checks

Prior reports

No prior report: 241 read-only queries over GitHub issues and PRs, commits, the mailing lists, the JIRA archive, downstream code and the web (2026-10-26), refreshed on 2026-10-30. Closest item: #10783 (LUCENE-9744), a different degenerate count that also ended in an NPE and was fixed in the same factory without a lower bound.

Searches5 searches

No issue, PR, review comment, commit, JIRA entry, list thread, downstream issue or web page reports that Intervals.atLeast or fn:atLeast accepts 0 or a negative count, or that such a query fails with these exceptions. Keyword searches cannot prove absence, and private security reports cannot be searched.

Related hunt bug, a separate report: LFV-286, in the same iterator but with a different root cause and fix site (a sub-iterator that runs out is not replaced in nextInterval); neither fix covers the other.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-26the scripted prior-report search (60 issue/PR queries: class and method names, Intervals.atLeast, fn:atLeast, minShouldMatch zero/negative, the exception texts) and 42 manual searches, including all 72 'atLeast' hitsno report; #10783 / LUCENE-9744 is the closest precedent; #13388, #13389, #13385 discuss atLeast(1) with no sources; #11113 has the same NPE wording in another class
Open PRs2026-10-26the changed files of all 408 open PRs#16851 rewrites reset() only; #16899 (since merged) touched Intervals.java for multi-term limits
Mailing lists and JIRA archive2026-10-26dev@, java-user@, issues@, commits@; LUCENE and SOLR JIRA; Solr listsno report; a 2020 dev@ design thread on atLeast semantics
Downstream and web2026-10-26Elasticsearch, OpenSearch and Solr code and issues; 9 web queriesno report; Solr's at_least rule passes 0 through (unreleased on 2026-10-26; released in Solr 10.1.0 on 2026-10-27); Elasticsearch and OpenSearch do not expose atLeast
Refresh (GitHub)2026-10-30issues and PRs mentioning atLeast created since 2026-10-25; MinimumShouldMatchIntervalsSource; fn:atLeast; open PR #16851; upstream main history and contents of the four files involvednothing new; the four files are byte-identical on upstream main 2f424fd428da

History

Wrong since atLeast was added by LUCENE-8622 (8.0.0, 2019). LUCENE-9744 (8.8.2) added the == and > shortcuts but no lower bound; LUCENE-10223 (9.1.0) made it reachable from query text. Executed from 8.0.0 to 10.6.0 and main; not fixed upstream.

Versions tested8 rows

Introduced by LUCENE-8622 (#9668, "Add a MinimumShouldMatch interval iterator") by Alan Woodward (@romseygeek), committed 2019-01-07 as "LUCENE-8622: Minimum-should-match interval function", e015afadaa698a0eb39574198dce31b9a7292a62 on master and bfbe80472ef330f3ad264eda7a9ef48eb91b58cc on branch_8x (the released one); a JIRA patch, no GitHub PR; CHANGES 8.0.0. git blame at 645d4fe14a20 puts the factory's signature and final return (Intervals.java:566, :577) and most of nextInterval() on that commit; the loop guard (MinimumShouldMatchIntervalsSource.java:217-218) blames to the LUCENE-9570 reformat 8ef6a0da5687, which only re-wrapped it. LUCENE-9744 (#10783, lucene-solr#2323, 077f8ccf708, 8.8.2) added the == and > shortcuts and the upper-bound assert, no lower bound. LUCENE-10223 (#11259, PR #429 by Dawid Weiss, f725b27e12a, 2021-11-11; CHANGES 9.1.0) added fn:atLeast to the flexible parser. Upstream main 2f424fd428da (2026-10-30) has the same code. The releases between the executed ones are affected by inference (the lines are unchanged in logic), not by a run. Old releases ran on JDK 25, newer than they were built for.

Versions and branches executed, with results
Version or commitVerdictResult
7.6.0, 7.7.3 no atLeast APIthe history port does not compile: no atLeast
8.0.0 first affecteddirect API: the NPE, assertions on and off (intervals in the sandbox module)
8.1.1, 8.2.0, 8.8.1, 8.8.2, 8.11.4 affecteddirect API: the same NPE; 8.8.2 has the LUCENE-9744 shortcuts and still fails
9.0.0 affecteddirect API fails; fn:atLeast(0 a b) still parses as a plain BooleanQuery
9.1.0 query text from heredirect API and query text fail; this doc's test fails
9.12.3, 10.0.0, 10.5.2 affectedboth fail; this doc's test fails on 9.12.3 and 10.5.2
10.6.0 affectedthis doc's test fails, assertions on and off (NPE at line 221)
main 645d4fe14a20 affectedthis doc's test fails through gradle and the standalone runner, assertions on and off; passes with either fix

Independent checks

An independent reproducer, a devil's advocate, a reviewer from another model family (OpenAI GPT-5.6, reading only) and an adjudication rated it real, and a credibility audit kept it; a second GPT-5.6 review, which could not run code, returned a low-confidence "not real" while agreeing with the root cause. No cross-model reviewer has executed code.

Checks and verdicts389 words

All checks ran on main ea57360912ed with assertions on and off unless noted; the GPT-5.6 reviews read code only.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateThe one-document JUnit (direct API and parsed text) fails on main, 10.5.2 and 9.12.3 and passes with the 3-line fix.
Refute review (OpenAI GPT-5.6, reading only) not real (low; procedural)It could not run Java, and it voted not real on procedure only; it agreed with the root cause, found the inputs in contract and the intended argument weak. A separate run re-ran the repro: fails.
Independent reproducer realIts own 6-test JUnit fails 4 of 6 on main, 10.5.2 and 9.12.3, 3 of 5 on 8.11.4, with the atLeast(1) controls passing; 6 of 6 pass with the fix. Found the ArrayIndexOutOfBoundsException case.
'works as designed' reviewer (OpenAI GPT-5.6, reading only) real (medium)Intended argument weak. Separate executed re-runs fail on main and 10.5.2 and pass with the fix; it showed the bare IllegalArgumentException from parse with the fix.
Devil's advocate real (high that it is not intended; low severity)Six atLeast-0 shapes throw on count and Weight#matches; (a b)@0 counts 1; rebutted four intended readings.
Adjudication confirmed (high)Re-ran the independent reproduction's tests with seed 5EED1234 (4 of 6 fail, assertions on and off; 6 of 6 with the fix), hand-traced the root cause and corrected the class from crash to wrong error.
Credibility audit keepRe-ran the finder's JUnit with seed C0FFEE42: fails. 'Real but small: a missing argument check.'
Follow-up, minimal repro and this doc's runs reproduced14 executed version rows, the fail-closed probe (189 of 189 calls threw), the one-method test on 645d4fe14a20 (gradle, assertions on and off) and on 10.6.0, 10.5.2, 9.12.3 and 9.1.0, and both fixes through gradle (fix A: 513 queries-module and 161 flexible-parser tests pass).
Fact checks of this doc (2026-10-30): independent, cross-model (OpenAI GPT-5.6, reading only) and diagram real; corrections appliedThe independent check re-ran the gradle test (fails with assertions on and off, passes with fix A and with fix B), the lint step and its own probe; the cross-model check read the code and the saved logs. One outdated fact (Solr's release) and several wording errors were fixed in the text and the diagrams.

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

Finding record

LFV-469, snapshot of 2026-11-06

Registry title
Intervals.atLeast accepts minShouldMatch 0 (also from query text such as fn:atLeast(0 a b)), and the query then throws NullPointerException or ArrayIndexOutOfBoundsException on every non-empty segment it searches instead of being rejected as an invalid argument
Area
queries / intervals (Intervals.atLeast: MinimumShouldMatchIntervalsSource); queryparser / flexible StandardQueryParser fn:atLeast
Priority
Low, rank 657: Intervals.atLeast(0, ...) and fn:atLeast(0 ...) are accepted and then throw NullPointerException or ArrayIndexOutOfBoundsException on every search, which is loud, fails one request at a time, and needs a value that means nothing for atLeast.
Impact class
wrong error
Impact note
Any application that passes minShouldMatch 0 to Intervals.atLeast, or hands end-user query text containing fn:atLeast(0 ...) to the flexible StandardQueryParser (from 9.1.0), gets a query that builds without error but makes count, top-k and sorted search, Weight#matches (highlighting) and explain throw an unchecked NullPointerException (segment holding a source term) or ArrayIndexOutOfBoundsException (segment holding none) instead of an IllegalArgumentException when the query is built; negative values (Java API only, the parser's number token is unsigned) fail late with IllegalArgumentException from the PriorityQueue constructor; default IndexWriterConfig, codec and searcher, -ea and -da, any index size, every release from 8.0.0 through 10.6.0 and main; the failure is per request only: no hang, no wrong or extra hits, nothing cached or written.
Summary
You build Intervals.atLeast(0, Intervals.term("a"), Intervals.term("b")), or parse 'fn:atLeast(0 a b)' with the flexible StandardQueryParser, and search an index that contains 'a b c'. You expect the 0 to be rejected with an IllegalArgumentException when the query is built (the same factory already handles a count equal to or larger than the number of sources), or else a search that runs. Instead the query is accepted and every search with it throws: NullPointerException in MinimumShouldMatchIntervalIterator.nextInterval, because top() of its empty, capacity-0 queue is null, or ArrayIndexOutOfBoundsException from an empty DisiPriorityQueue on a segment that holds none of the terms.
Introduced
e015afadaa698a0eb39574198dce31b9a7292a62 (master) and its branch_8x backport bfbe80472ef330f3ad264eda7a9ef48eb91b58cc (the released one, 8.0.0), both 'LUCENE-8622: Minimum-should-match interval function' by romseygeek; JIRA patch workflow, no GitHub PR (issue migrated as apache/lucene#9668) [read: git blame on main and in the full-history clone; exec: 7.6.0 and 7.7.3 have no atLeast API, 8.0.0 fails]. 077f8ccf708 (LUCENE-9744, apache/lucene-solr#2323, first released in 8.8.2) added the '== sources.length -> unordered' and '> sources.length -> no match' shortcuts and 'assert minShouldMatch < sources.length' but no lower bound (8.8.1 and 8.8.2 both fail [exec]); 8ef6a0da568 (LUCENE-9570) only re-wrapped the loop guard. The query-text path came with f725b27e12a (LUCENE-10223, apache/lucene#429 by dweiss, backport ff9ee28c603), whose AtLeast node passes the parsed number straight to Intervals.atLeast. Not a regression: wrong since the API was added; no commit-level bisection (2019-01-07 (LUCENE-8622); query-text exposure 2021-11-11 (LUCENE-10223))
First affected version
8.0.0 [exec] (direct Java API; intervals were then in the sandbox module, package org.apache.lucene.search.intervals; queries module from 8.2.0 [exec]); query text fn:atLeast(0 ...) through the flexible StandardQueryParser from 9.1.0 [exec] (9.0.0 has no interval functions in the parser and parses the text as an ordinary BooleanQuery [exec]); 7.6.0 and 7.7.3 have no atLeast API [exec: compile error]
Affected versions
every release from 8.0.0 through 10.6.0 and main ea57360912ed; not fixed upstream. [exec] follow-up history runs (seed DEADBEEF, -da; 8.0.0 and main also -ea): the direct API fails with NullPointerException 'PriorityQueue.top() is null' on 8.0.0, 8.1.1, 8.2.0, 8.8.1, 8.8.2, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main, with the atLeast(1, a, b) control passing before the failing call in every API run; query text fn:atLeast(0 a b) fails on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main; the independent reproduction's own JUnit fails 4 of 6 on main (-ea, -da), 10.5.2 and 9.12.3 and 3 of 5 on 8.11.4 (direct API only), and its probe shows the ArrayIndexOutOfBoundsException (no source term in the segment) and the late IllegalArgumentException for atLeast(-1, a, b) on main, 10.5.2, 9.12.3 and 8.11.4. [read] the releases in between: git blame shows the missing lower bound and the 'proximityQueue.size() == minShouldMatch && proximityQueue.top().start() == start' loop unchanged in logic since e015afadaa6; upstream main 49483e82b357 (re-checked 2026-10-26: no commit after it, and the last changes to Intervals.java and MinimumShouldMatchIntervalsSource.java predate ea57360912ed), branch_10x and branch_10_6 have the same code, and 10.6.0 (released 2026-10-29) is affected: the doc's one-method test fails on its release jars with -ea and -da [exec]. Caveats: all runs on JDK 25 (aarch64), so the old releases ran on a newer JDK than they were built for; the negative-value variant was not part of the history sweep
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-26, the prior-report record, verdict new: 241 recorded read-only queries, namely three runs of the scripted prior-report search over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the LUCENE JIRA archive, 42 manual GitHub searches including all 72 'atLeast' hits, the changed files of all 408 open apache/lucene PRs, the review threads of the introducing and fixing PRs, manual JIRA (LUCENE and SOLR) and Solr list searches, downstream code search and 9 web queries; 22 hits classified, none the same bug); not fixed upstream: upstream main 49483e82b357 (re-checked 2026-10-26) has the same factory and loop, and the only open PR touching MinimumShouldMatchIntervalsSource.java, #16851 (PriorityQueue#addAll), rewrites reset() only; #16899 touches Intervals.java for multi-term expansion limits only. Closest item, not a report of this bug: #10783 / LUCENE-9744 (closed, fixed 2021, released in 8.8.2): a different degenerate atLeast count (minShouldMatch == number of sources) that also ended in an internal NullPointerException, fixed in the same factory without a lower bound; its reporter (dweiss) wrote 'such corner cases will happen if intervals are available from the query parser'. Others read: #9668 / LUCENE-8622 (introduction), #11259 / LUCENE-10223 (fn:atLeast in the parser), #13388 / #13389 / #13385 (atLeast(1) with no sources as an undocumented no-match source), #11113 / LUCENE-10075 (same NPE wording, different class). Downstream reach (code reading, not executed, no report there): Solr's unreleased intervals query parser (apache/solr#4582, SOLR-13764, on main, branch_10x and branch_10_1) rejects only min_should_match < 0 in its at_least rule and so passes 0 to Intervals.atLeast; Elasticsearch and OpenSearch do not expose atLeast
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): this doc's one-method test through gradle with assertions on and off (seed DEADBEEF) and on the standalone runner (seeds DEADBEEF and 5EED1234); the same test on the 10.6.0, 10.5.2, 9.12.3 and 9.1.0 release jars; the hunt's tests on 8.0.0, 8.1.1, 8.2.0, 8.8.1, 8.8.2, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main ea57360912ed. Both fixes were run through gradle in a scratch worktree of 645d4fe14a20 on 2026-10-30. Upstream main 2f424fd428da (2026-10-30) has the four files involved byte-identical.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:561-576 (at ea57360912ed) (atLeast javadoc 'Return intervals that span combinations of intervals from minShouldMatch of the sources'; only 'minShouldMatch == sources.length' -> unordered and '> sources.length' -> NoMatchIntervalsSource are handled; every other value, 0 and negatives included, builds MinimumShouldMatchIntervalsSource)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/MinimumShouldMatchIntervalsSource.java:45-49 (at ea57360912ed) (constructor: only 'assert minShouldMatch < sources.length'), :51-64 (intervals(): returns null only when iterators.size() < minShouldMatch, never for 0), :213-229 (nextInterval(): 'while (proximityQueue.size() == minShouldMatch && proximityQueue.top().start() == start)' dereferences top() of the empty capacity-0 queue at :217-218)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:411-421 (at ea57360912ed) (fn:atLeast(<NUMBER> source+) passes parseInt(minShouldMatch) to new AtLeast); lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/AtLeast.java:32-44 (constructor and toIntervalSource calls Intervals.atLeast with the number unchanged)
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:563-578 (javadoc; the == and > branches; the fall-through)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/MinimumShouldMatchIntervalsSource.java:45-64 (constructor assert; intervals() null test), :146-196 (design comment; queue construction), :213-255 (nextInterval), :270-290 (reset)
  • lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:113-134 (capacity 0 -> 2-slot heap; negative -> IAE), :246-251 (top)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/DisiPriorityQueue.java:47-58; DisjunctionDISIApproximation.java:48-50; IntervalScorer.java:80-81
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:128, :160, :411-422; .../nodes/intervalfn/AtLeast.java:32-44; .../intervalfn/package-info.java:221-247
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestSimplifications.java:115-122 (the == and > rewrites, pinned upstream)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/MinimumShouldMatchIntervalsSource.java:66-91 (matches(): builds its own iterator, advance, nextInterval at :88); StandardSyntaxParser.jj:160 (NUMBER: digits, optional fraction, no sign)
Who is affected
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/AtLeast.java:38-44
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21
  • a Lucene clone tag releases/lucene/10.4.0: lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:567-578 and MinimumShouldMatchIntervalsSource.java:220-221 (the same atLeast code and loop guard)
Minimal repro
  • gradle/validation/ecj-lint/ecj.javadocs.prefs:136 (unusedExceptionParameter=error); lucene/core/src/java/org/apache/lucene/store/FSDirectory.java:230 (catch (FileAlreadyExistsException _))
Why it looks unintended
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:563-578
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:221-247
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalScoreFunction.java:26-40
  • lucene/CHANGES.txt:5445-5446 (LUCENE-9744), :6800-6801 (LUCENE-8622)
Suggested fix
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/MinimumShouldMatchIntervalsSource.java:217-233 (loop guard; size() < minShouldMatch at :228; top().start() at :233)
History
  • lucene/CHANGES.txt:4415-4417 (9.1.0), :5445-5446 (8.8.2), :6800-6801 (8.0.0)

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

Repro files

About the diagrams

A step-by-step picture of the factory's checks and the iterator's empty queue on the text "a b c", made in parallel with this doc from the code at 645d4fe14a20 and an executed probe; the PNG is the summary still.

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.