All findings · Lucene finding · LFV-286 · atleast-exhausted-sub-iterator-not-replaced

Intervals.atLeast stops early when the sub-iterator it just used runs out

Intervals.atLeast(1, a, b) on "a b" returns only [0,0], while or(a, b) returns [0,0] and [1,1]: when the source it just used runs out, atLeast stops instead of switching to another. Nested, it misses documents (ordered(c, atLeast(1, a, b)) finds 0 hits on "a c b"); since 8.0.0.

Summary

Priority
Medium, rank 44AtLeast silently loses intervals, and once nested it misses whole documents since 8.0, but no downstream engine exposes it.
Impact
silent wrong results
Since
8.0.0 (LUCENE-8622, 2019)introduced 2019-01-07 · about 7 years
Affected
8.0.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
bounded-exhaustive translation validation against a reference semantics

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

The finding in 30 seconds

Intervals.atLeast(k, sources...) should return every minimal stretch of text where at least k of the sources occur; with k = 1 it means the same as or(). It keeps k sources active and the rest waiting, and each time it retires an active source it must bring in a waiting one.

  1. Index one document, "a b", and read the intervals of Intervals.atLeast(1, a, b): places where at least one of the terms a and b occurs.
  2. You expect [0,0] and [1,1], exactly what Intervals.or(a, b) returns.
  3. You get [0,0] only. atLeast keeps 1 active source and a waiting pile, here a@0 and b@1. To move on it retires a; a has no more matches, so nothing is taken from the pile, and it reports "no more intervals" with b@1 unread.
  4. Nested, documents go missing: ordered(c, atLeast(1, a, b)) finds 0 hits on "a c b" instead of 1. With 2 of 3 sources a multi-position source does it too: atLeast(2, ordered(a, c), b, d) on "a b c d" gives [0,2] and loses [1,3].
  5. Second symptom of the same hand-over: atLeast(1, b, phrase(a, b)) on "a b b" returns [1,1], then [0,1] (out of order, and it contains [1,1]), and loses [2,2]. No error anywhere.

Who is affected

Anyone using Intervals.atLeast or the query parser's fn:atLeast with k = 1 over two or more sources, or sometimes k >= 2 with a multi-position sub-source or stacked synonyms on any index with default settings. At top level the document still matches, but highlights and scores lose intervals; nested queries can miss documents. Releases 8.0.0 to 10.6.0 (10.6.0 by reading) and main.

Mechanism, terms and numbers908 words

Intervals.atLeast(k, sources...) with k below the number of sources keeps k sub-iterators in a proximity queue and the others in a background queue. To move past the interval it just returned it pops the front sub-iterator and, as its class comment says, should replace it with the top of the background queue. The first loop of nextInterval() does that only when the popped sub-iterator still has an interval; when it is exhausted nothing is brought in, the proximity queue is short and the iterator ends while background sub-iterators still hold intervals. With k = 1 this happens whenever the source of the last interval runs out; with k >= 2 it can happen when a sub-source spans several positions or tokens are stacked. The same hand-over never advances waiting sub-iterators past the current start, so a stale one can come back with an earlier, non-minimal interval. At top level the document still matches with fewer intervals (fewer highlights, lower scores); nested, documents can be missed; under negation, documents that should be excluded are kept. Every release from 8.0.0 to 10.6.0 (10.6.0 by reading) and main; no error is raised.

Terms

  • Interval: a [start, end] range of token positions in one field of one document. Intervals.term("a") gives one interval per occurrence of a.
  • Intervals.atLeast(k, sources...): "Return intervals that span combinations of intervals from minShouldMatch of the sources" (Intervals.java:562). Every source exposes "the minimum intervals" (IntervalsSource.java:36-37); for the sources in these examples that means no returned interval contains another (extend is an exception near position 0: it clamps starts at 0, so extend(a, 1, 0) on "a a" returns [0,0], [0,1], executed). The factory rewrites k = n to unordered and k > n to no match; anything else, including k = 1 with two or more sources, builds MinimumShouldMatchIntervalsSource (Intervals.java:564-576).

The mechanism (main ea57360912ed, MinimumShouldMatchIntervalsSource.java)

  1. The iterator keeps a proximity queue of k sub-iterators, ordered by start (equal starts: the larger end first), and a background queue of the rest, ordered by end (equal ends: the larger start first) (:186-195). reset(), which nextDoc() and advance() call, moves each sub-iterator positioned on the current document to its first interval and puts the k with the smallest end into the proximity queue (:270-290, :309-321).
  2. The class comment: "when an iterator is popped from the proximity queue, it is inserted back into the background queue, and replaced by the top iterator from the background queue" (:146-156).
  3. First loop of nextInterval() (:216-227): while the proximity front still starts at the last returned start, pop it, and only if it.nextInterval() != NO_MORE_INTERVALS (:220) put it back and pop a replacement (:221-225). An exhausted iterator is dropped and not replaced, and if (proximityQueue.size() < minShouldMatch) return start = end = NO_MORE_INTERVALS; (:228-229) ends the iteration with background iterators unread.
  4. The minimising loop below does draw a replacement after an exhausted lead (:242-251); only the first loop skips it.
  5. When the first loop runs: the minimising loop returns at once when the front's end equals the window's right edge (:239-241), and the next call then starts in the first loop. With k = 1 that is every call after the first (the first call skips the loop, as start is -1 after reset()), so the iterator ends as soon as the source of the last interval runs out. With k >= 2 the front must reach the right edge: stacked tokens, or a multi-position sub-source such as ordered(a, c) = [0,2] around b = [1,1]. With single-position terms at distinct positions the minimising loop does the hand-over, and the exhaustive harness's level-1 run over plain terms (documents up to 5 positions) found no k = 2 failure.
  6. Hand trace, atLeast(1, a, b) on "a b": proximity {a@[0,0]}, background {b@[1,1]}. Call 1 returns [0,0]. Call 2 pops a; a is exhausted, nothing is drawn, size 0 < 1, so NO_MORE_INTERVALS.
  7. Second symptom: no code advances background iterators past the current start. atLeast(1, b, phrase(a, b)) on "a b b": call 1 returns b@[1,1]; call 2 retires b (now at [2,2]), and the background pop returns the phrase at [0,1] (smallest end), which starts before [1,1] and contains it; call 3 retires the exhausted phrase with no replacement, so b@[2,2] is lost.

The numbers (executed on main, 10.5.2 and 9.12.3 unless the row says otherwise)

query, textexpectedgot
atLeast(1, a, b), "a b"[0,0], [1,1] (as or(a, b))[0,0]
atLeast(1, a, b), "a b a b"[0,0], [1,1], [2,2], [3,3][3,3] lost
ordered(c, atLeast(1, a, b)), "a c b": hits1 (as with or(a, b) inside)0
parser fn:ordered(c fn:atLeast(1 a b)), "a c b": hits (main, 9.12.3)10
atLeast(2, ordered(a, c), b, d), "a b c d"[0,2], [1,3][0,2]
containing(atLeast(2, ordered(a, c), b, d), d), "a b c d": hits10
atLeast(2, a, b, c), stacked tokens {a,b} {c} {b}[0,0], [1,2][0,0]
parser fn:ordered(z fn:atLeast(2 "a b" b c)), "a b z b c": hits (main, 10.5.2, 9.12.3)10
atLeast(1, b, phrase(a, b)), "a b b"[1,1], [2,2] (as or(b, phrase(a, b)))[1,1], [0,1]

Not affected: atLeast(2, a, b, c) on "a b c" gives [0,1], [1,2], as expected.

Exposure and likelihood343 words
  • Who is exposed. Applications that build Intervals.atLeast directly, or through StandardQueryParser's fn:atLeast (since 9.1.0, #11259 / LUCENE-10223; the node calls Intervals.atLeast, intervalfn/AtLeast.java:39), with k = 1 over two or more sources, or k >= 2 when a sub-source spans several positions (ordered, phrase, unordered, ...) or tokens are stacked (index-time synonyms, word-delimiter output); these shapes make it possible, not certain: the front interval must reach the window's right edge and a remaining interval must matter.
  • Downstream (read, not run). Solr's new intervals query parser (SOLR-13764, on Solr main and branch_10x, not in the 10.0.0 release) maps its at_least rule to Intervals.atLeast. Elasticsearch and OpenSearch do not expose atLeast. That says nothing about private code.
  • Default or opt-in. atLeast is an opt-in operator of the intervals package, which calls itself experimental (intervals/package-info.java:21). Nothing else is needed: a default IndexWriterConfig (so the default codec, as in the release sweep's port) and searcher, assertions on or off. The JUnit fails the same way under the test framework's randomized codec and directory.
  • Scale and frequency. One document of 2 to 5 tokens; deterministic. No prevalence was measured. In the finder's bounded synthetic enumeration (every tiny document over {a, b, c}, with empty positions, a stacked-token tier and source lists with repeats such as atLeast(1, a, a, b)) the exact interval list was wrong in 131,652 of 221,130 k = 1 checks (60%) and in 20,070 of 110,565 k = 2 checks: a rate over the enumeration, not over real queries or text.
  • What you see. No error. Top level: fewer intervals, so Weight#matches and highlights lose spans (executed), and IntervalScorer freq, which sums over every interval (IntervalScorer.java:60-69), and with it the score drop: atLeast(1, a, b) on "a b" scores 0.5 against 0.6666666 for or(a, b) and for atLeast with the fix (executed). Nested under ordered, containing and similar operators: documents can be missed. Under negation: documents that should be excluded are kept (see the Downstream note). No hang, crash or index change.

Minimal repro

The test shows that Intervals.atLeast(1, term a, term b) over the text "a b" stops after the first interval (TestAtLeastOneLosesLastInterval#testAtLeastOneOfTwoTerms).

TestAtLeastOneLosesLastInterval.java (JUnit test)53 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 java.util.ArrayList;
import java.util.List;
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.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestAtLeastOneLosesLastInterval extends LuceneTestCase {

  public void testAtLeastOneOfTwoTerms() 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", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IntervalsSource source = Intervals.atLeast(1, Intervals.term("a"), Intervals.term("b"));
        IntervalIterator it = source.intervals("f", reader.leaves().get(0));
        assertEquals(0, it.nextDoc());
        List<String> intervals = new ArrayList<>();
        while (it.nextInterval() != IntervalIterator.NO_MORE_INTERVALS) {
          intervals.add("[" + it.start() + "," + it.end() + "]");
        }
        // expected: [0,0] and [1,1], as or(a, b) returns; actual: [1,1] ("b") is lost
        assertEquals(List.of("[0,0]", "[1,1]"), intervals);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-286/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed 5EED0286
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=5EED0286 \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.queries.intervals.TestAtLeastOneLosesLastInterval
Expected vs actual
Expected intervals [0,0] and [1,1] (what or(a, b) returns); actual only [0,0], with -ea and -da.

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

More files, environment, full output35 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed 5EED0286: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<[[0,0], [1,1]]> but was:<[[0,0]]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.queries.intervals.TestAtLeastOneLosesLastInterval.testAtLeastOneOfTwoTerms(TestAtLeastOneLosesLastInterval.java:49)

Why it looks unintended

  • Every source exposes "the minimum intervals"; atLeast "intervals that span combinations of intervals from minShouldMatch of the sources". The lost [1,1] is one.
  • The class comment: a retired iterator is "replaced by the top iterator from the background queue"; one of the two loops skips that.
  • With k = 1, atLeast should return what or() returns: the missing intervals.
The full argument331 words
  • The contracts. IntervalsSource#intervals: "Create an IntervalIterator exposing the minimum intervals defined by this IntervalsSource" (IntervalsSource.java:36-37). Intervals.atLeast: "Return intervals that span combinations of intervals from minShouldMatch of the sources" (Intervals.java:562). The Intervals class: "These sources implement minimum-interval algorithms taken from the paper ... Minimal-Interval Semantics" (Intervals.java:43-45). CHANGES for LUCENE-8622: "Adds a minimum-should-match interval function that produces intervals spanning a subset of a set of sources." (lucene/CHANGES.txt:6767-6768).
  • The code contradicts itself. The class comment describes the replacement (MinimumShouldMatchIntervalsSource.java:146-156); the minimising loop draws it after an exhausted lead (:242-251); the first loop skips it (:220-229).
  • k = 1 should behave like or(). That follows from the minimal-interval reading of the javadoc; no javadoc states the equivalence outright. The factory accepts k = 1 unchanged (Intervals.java:564-576). The author, @romseygeek, described atLeast on dev@ in 2020 as "a mixture of a disjunction and an unordered interval". The test's or() controls return exactly the intervals atLeast loses.
  • Upstream tests. TestIntervals.testMinimumShouldMatch (:770, 5 sources, k = 3) expects complete minimal-interval lists; none of the four atLeast tests (it, :813, :837, :853), nor any other of the 117 upstream interval tests, pops an exhausted sub-iterator while another one is still waiting in the background queue (an instrumented run: testMinShouldMatch2 reaches the exhausted pop once, at its natural end, with the background queue empty). All 117 pass with the fix, so none pins the truncated output.
  • History. The loop was copied in 2019 from the unordered iterator (LUCENE-8196), where stopping on an exhausted sub-iterator is correct because every sub-iterator is required; LUCENE-8622 made both loops swap through the background queue, but only the second draws a replacement when the popped iterator is exhausted; the first kept the unordered iterator's guard. Its one review (by @dsmiley) was about naming and comments.
  • Precedent. #12213 / #12214 (9.6.0) fixed a lost interval after minimisation in OrderedIntervalsSource as a bug.
The devil's-advocate check345 words

The strongest case for "intended" was built by the devil's advocate and reviews by an independent reviewer from another model family (OpenAI GPT-5.6, reading only): the intervals package is experimental; atLeast(1, ...) is degenerate and users should write or(); Intervals.or(false, ...) documents that it "can miss valid hits due to minimization" (Intervals.java:131, :147); the fn:atLeast parser doc illustrates overlapping intervals, one of them [1,8] (intervalfn/package-info.java:241-246); the parser docs say the algorithm "is not required to return or highlight all matching interval alternatives" (intervalfn/package-info.java:347-349, :381-383); no javadoc states that intervals come in start order; and a top-level atLeast still matches the document.

Why it fails: experimental status allows API changes, not wrong results, and the package keeps getting correctness fixes (#12213, LUCENE-9099, LUCENE-10140). The factory and the parser accept k = 1 unchanged (LUCENE-9744's "malformed" remark was about atLeast(1, singleTerm), which the factory now rewrites), and k = 2 fails too. The or(false) caveat is for or(false) only: ordered(c, or(false, a, b)) still matches "a c b", and the lost intervals are minimal. Neither stock nor the fix produces [1,8] for the parser example (both give [1,7], [2,8]), and the upstream highlighter tests for it pass with the fix. Start order is indeed not stated, so only the out-of-order wording rests on it: [0,1] is still not minimal and [2,2] is still lost (the devil's advocate's confidence is high for the truncation, moderate for this symptom alone). The parser docs' "not required to return ... all matching interval alternatives" is about fn:ordered and fn:unordered over an fn:or, where the alternative left out is the longer, non-minimal match; the lost intervals here are minimal. Scores and highlights still change, and some nested and negated uses go wrong (the executed ordered, containing, MUST_NOT and negation cases), even when the top-level document matches. On the other side: the atLeast javadoc is a single sentence, nobody reported this in the seven years since 8.0.0, and a fix changes the highlights and scores of existing atLeast queries.

Suggested fix

Recommended A: always draw a replacement, and skip stale background intervals

Bring in a waiting sub-iterator even when the retired one is exhausted, and take it through a new pollBackground(start) that first advances waiting iterators stuck at or before the current start (+29/-3 in MinimumShouldMatchIntervalsSource). The test passes, and so do the whole queries module (assertions on and off) and the highlighter and parser suites.

// first loop: replace the popped iterator even if it is exhausted
if (it != null && it.nextInterval() != NO_MORE_INTERVALS) backgroundQueue.add(it);
IntervalIterator next = pollBackground(start); // was: backgroundQueue.pop(), inside the if
if (next != null) { proximityQueue.add(next); updateRightExtreme(next); }

private IntervalIterator pollBackground(int minStart) throws IOException {
  while (true) {
    IntervalIterator it = backgroundQueue.pop();
    if (it == null || it.start() > minStart) return it;
    while (it.start() <= minStart) {
      if (it.nextInterval() == NO_MORE_INTERVALS) { it = null; break; }
    }
    if (it != null) backgroundQueue.add(it);
  }
All options and trade-offs3 options · 866 words

One fix was validated in a scratch worktree at ea57360912ed; option B was run and is not enough on its own; option C was not run. Until a fix ships, users can write Intervals.or(...) for k = 1, and, when the sources are pairwise distinct, an or of unordered subsets for k >= 2 (the number of subsets grows quickly). With a repeated source the two differ: unordered() folds equal sources into one that needs separate occurrences, while atLeast gives every argument its own iterator, so atLeast(2, a, a, b) matches a document with a single "a" and or(unordered(a, a), unordered(a, b), unordered(a, b)) does not (executed).

A: always draw a replacement, and skip stale background intervals (validated by the checks) (recommended)

The finder's sketch (+29/-3, MinimumShouldMatchIntervalsSource.java only). The first loop draws the replacement even when the popped iterator is exhausted, and both loops take it through pollBackground(start), which advances (or drops, when exhausted) background iterators whose interval starts at or before the current start. Why skipping is safe (by reasoning under an assumption, not a proof): if each sub-source returns intervals with strictly increasing starts and ends, as minimal-interval sources do, every part of a later answer starts after the current start. IntervalIterator does not promise that order, and extend near position 0 breaks it; on that shape the fix still agrees with or() (atLeast(1, extend(a, 1, 0), b) on "a a b": [0,0], [2,2] with the fix and with or(), [0,0] on stock; executed). The bounded tests support the fix; they do not prove it.

// first loop: replace the popped iterator even if it is exhausted
if (it != null && it.nextInterval() != NO_MORE_INTERVALS) backgroundQueue.add(it);
IntervalIterator next = pollBackground(start); // was: backgroundQueue.pop(), inside the if
if (next != null) { proximityQueue.add(next); updateRightExtreme(next); }

private IntervalIterator pollBackground(int minStart) throws IOException {
  while (true) {
    IntervalIterator it = backgroundQueue.pop();
    if (it == null || it.start() > minStart) return it;
    while (it.start() <= minStart) {
      if (it.nextInterval() == NO_MORE_INTERVALS) { it = null; break; }
    }
    if (it != null) backgroundQueue.add(it);
  }

Trade-offs

  • Cost. pollBackground may call nextInterval() on waiting iterators earlier than today, but every call moves an iterator forward, so the work per document stays bounded by the sub-sources' intervals. No benchmark was run.
  • Highlights change with it. The Matches path drives the same iterator (MinimumShouldMatchIntervalsSource.java:80-91); the upstream match highlighter tests pass, and the devil's advocate's Weight#matches probe passes with the fix.
  • The assertion becomes a null check. pollBackground may return the iterator just put back (valid) or null when nothing waits any more, so assert next != null turns into if (next != null). Which sub-iterator Matches report when two have exactly equal intervals was not reviewed separately.
  • Not reviewed by a committer. The window's right edge (queueEnd) is still only ever raised, as today. The bounded-exhaustive harness found no failure with the fix, but it covers documents of at most 6 positions.
  • Not covered. A separate Weight#matches exception under some atLeast shapes (lead M2) has a different fix site and is not fixed by this patch.

What the checks validated

  • Stock: the test fails 4 of 4 with assertions on and off.
  • Fix: every interval suite of lucene/queries, 8 classes, 121 tests (117 upstream plus the 4 new), 0 failures.
  • Fix, the whole :lucene:queries:test: 52 classes, 516 tests, 0 failures, with assertions on and off (5 and 6 randomized skips).
  • Fix, the match and interval highlighter suites (164 tests) and TestStandardQPEnhancements (23 tests, fn:atLeast parsing): 0 failures.
  • :lucene:queries:tidy leaves the fix and the test unchanged; :lucene:queries:check -x test passes (google-java-format, ecjLint, forbiddenApis).
  • Standalone harness with the fix shadowed in: the test 4/4 (assertions on and off), the independent reproducer's 13 tests 13/13, the devil's advocate's probe 6/6, the k = 2 parser test 4/4, the security probe 6/6.
  • Bounded-exhaustive harness: the atLeast-only tiers go from 15,840 and 8,082 failing checks to 0, and the local atLeast failures of three random depth-3 seeds disappear.

B: only draw a replacement for an exhausted iterator (run; not enough) (validated by the checks)

Move backgroundQueue.pop() out of the if in the first loop and null-check it.

Trade-offs

Smallest diff. Fixes the truncation and the missed nested matches (3 of the 4 tests pass), but leaves the stale re-entry: atLeast(1, b, phrase(a, b)) on "a b b" then gives [1,1], [0,1], [2,2], still with the non-minimal, out-of-order [0,1].

What the checks validated

Executed with the variant shadowed in: 4 run, 1 failed (expected:<[[1,1], [2,2]]> but was:<[[1,1], [0,1], [2,2]]>).

C: rewrite atLeast(1, ...) to or(...) in the factory (untested, partial)

Intervals.atLeast already rewrites k = n and k > n; it could also map k = 1 to or(sources).

Trade-offs

Fixes the k = 1 cases, the most common trigger, without touching the iterator, but leaves every k >= 2 case. It also changes toString(), equals() and hashCode() of such queries, and since or() can hand its branches to a parent's rewrite (pullUpDisjunctions()) while atLeast returns only itself, parents may rewrite differently and Matches may report different sub-matches; minExtent() stays the same. A complement to option A at most.

Downstream note

Inside an exclusion the lost intervals become extra results: MUST_NOT ordered(c, atLeast(1, a, b)) keeps "a c b" (2 hits instead of 1). An application that builds deny rules from atLeast therefore lets documents through whose own text triggers the bug.

How it was found

Bounded-exhaustive translation validation: a harness ran Lucene's real interval iterators over every tiny document and every expression of its 1-2 level grammar (1.4 billion checks) and compared each interval list with an independent reference semantics. atLeast's lists were wrong in 60% of the k = 1 checks of that synthetic enumeration; reading the code then located the loop.

The method in full171 words

Method: bounded-exhaustive translation validation (the hunt's semi-formal tier; no model checker, SMT solver or prover was involved).

  • A hunt unit (spans and intervals), 2026-10-26. The harness ran the real interval sources of the stock main jars over every document of up to 6 positions over {a, b, c}, and up to 4 positions with stacked tokens, against every expression of the harness's grammar (level 1: every operator over terms; level 2: one child from a fixed pool of composite sources, the others terms; and binary operators with both children from that pool), and compared each interval list with an independent exact reference semantics: 1,401,702,324 checks.
  • The exact-list check failed for atLeast: k = 1 in 131,652 of 221,130 checks, k = 2 in 20,070 of 110,565. Random depth-3 expressions with local-failure attribution added the stale-iterator cases.
  • The finder, an AI tool, located the root cause by reading the code and wrote a deterministic public-API JUnit. With its fix sketch shadowed in, the atLeast-only tiers of the harness drop to 0 failures.

Prior reports, history and checks

Prior reports

No prior report: 223 read-only queries over GitHub issues and PRs, commits, the mailing lists, the JIRA archive, downstream sources and the web (2026-10-26); 23 hits read, none this bug. Earlier fixes in this class were in the Matches path; #12213 is the closest analogue, in another class.

Searches5 searches

The searches found no issue, PR, commit, list thread, JIRA entry, downstream issue or web page about Intervals.atLeast losing intervals when the popped sub-iterator is exhausted, or about stale background iterators re-entering behind the current start. Keyword searches cannot prove absence, and private security reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-26the scripted prior-report search (class and method names, Intervals.atLeast, fn:atLeast, symptom phrasings) and manual searches listing every issue and PR with 'interval' or 'intervals' in the title and all 72 'atLeast' hitsno report; #10783 / LUCENE-9744 (Matches NPE, fixed in 8.8.2) and #521 / LUCENE-10229 (lead = null) are earlier Matches-path fixes in the same class
Open PRs and review threads2026-10-26the changed files of all 413 open PRs; the review threads of every PR that changed MinimumShouldMatchIntervalsSource.javaonly #16851 (PriorityQueue#addAll) touches the file, in reset() only
Mailing lists and JIRA archive2026-10-26dev@, java-user@, issues@, commits@; LUCENE and Solr JIRA (class names, atLeast, minShouldMatch with intervals)no report; a 2020 dev@ thread in which the author calls atLeast 'a mixture of a disjunction and an unordered interval'
Downstream and web2026-10-26Elasticsearch, OpenSearch and Solr sources and issues; 10 web queriesno report; Solr's unreleased at_least rule calls Intervals.atLeast; Elasticsearch and OpenSearch do not expose it
Closest analogue2026-10-26read in full#12213 / #12214: a lost interval after minimisation in OrderedIntervalsSource, fixed in 9.6.0 (different class and fix site)

History

Wrong since the API was added by LUCENE-8622 (2019-01-07); the loop was copied from the unordered iterator, where stopping is correct. All 60 releases from 8.0.0 to 10.5.2 and main fail the same checks; not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-8622 (#9668), "Minimum-should-match interval function" by @romseygeek, committed 2019-01-07 as e015afadaa698a0eb39574198dce31b9a7292a62 on master (which became 9.0) and bfbe80472ef330f3ad264eda7a9ef48eb91b58cc on branch_8x (the released one); a JIRA patch, no GitHub PR. git log -L on the loop shows only that commit and the LUCENE-9570 reformat; git blame -w -M -C -C shows it was copied from the unordered iterator of LUCENE-8196, where every sub-iterator is required; LUCENE-8622 made both loops swap through the background queue, but only the second draws a replacement when the popped iterator is exhausted. Later commits to the file, among them LUCENE-8633, LUCENE-3041, LUCENE-8477, LUCENE-8856, LUCENE-9099, -9744, -10140, -10229 and #14761, leave the loop alone (LUCENE-10229 added lead = null above it). branch_10x and branch_10_6 have the same loop (read); like every release they keep the original lessThan comparators, which order by the same keys as main's and differ only in breaking exact (start, end) ties. Upstream main 49483e82b357 (2026-10-26, re-checked) has five commits after ea57360912ed, none touching intervals.

The sweep ran a test-framework-free main() port with 4 bug checks (the k = 1 truncation, the nested miss, the stale case, the k = 2 multi-position case) and 4 controls on every release; the introducing commit itself was not built, and old releases ran on JDK 25.

Versions and branches executed, with results
Version or commitVerdictResult
7.6.0, 7.7.3 no atLeast APIthe port does not compile: only 'cannot find symbol: method atLeast' errors
8.0.0 first affected4 of 4 bug checks fail, 4 controls pass (intervals in the sandbox module until 8.1.1)
8.1.0 to 8.11.4 (26 releases) affectedevery release fails the 4 bug checks with the controls passing
9.0.0 to 9.12.3 (21 releases) affectedthe same; the finder's JUnit also fails 3 of 3 on 9.1.0 and 9.12.3; this doc's test fails 4 of 4 on 9.12.3
10.0.0 to 10.5.2 (12 releases) affectedthe same; this doc's test fails 4 of 4 on 10.5.2
main ea57360912ed affectedthis doc's test fails 4 of 4 with assertions on and off; the port passes 8 of 8 with the fix shadowed

Independent checks

An independent reproducer, a devil's advocate, an OpenAI GPT-5.6 reviewer arguing 'works as designed' (reading only) and an adjudication say real; an earlier GPT-5.6 refute review said 'not real' only because it could not run code. Fact checks of this doc found no error that changes the conclusion. No reviewer from another model family 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 3-test JUnit fails 3 of 3 on main (assertions on and off), 10.5.2 and 9.12.3, and passes with the fix sketch; the atLeast-only harness tiers drop to 0 failures with the 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 called the intended-behaviour case weak. It saw only the truncation tests.
Independent reproducer realIts own 13-test JUnit, in a package outside Lucene, fails 7 of 13 on main, 10.5.2 and 9.12.3 with all 6 controls passing; its own main() fails on 8.11.3; 13 of 13 pass with the fix.
'works as designed' reviewer (OpenAI GPT-5.6, reading only) real (medium)No documented exclusion permits the early stop. A separate k = 2 JUnit, including a parser query, fails 3 of 4 on main and 10.5.2 and passes 4 of 4 with the fix.
Devil's advocate real (high for the truncation; moderate for the out-of-order symptom alone)Its probe fails 4 of 6 on main, 10.5.2 and 9.12.3, Weight#matches included, and passes with the fix; all 117 upstream interval tests and 28 highlighter tests pass with the fix.
Adjudication confirmed (high)Re-ran the independent reproduction's test with seed 5EED1234 (7 of 13 fail, assertions on and off; 13 of 13 with the fix) and hand-traced the root cause.
Follow-up and doc re-runs reproducedThe 60-release sweep; the security probe; the finder's JUnit with fresh seeds; this doc's test through gradle and the harness on main, 10.5.2 and 9.12.3, and with the fix.
facts hold; wording correctedAn independent fact check re-ran the test (main with two seeds, assertions on and off, 10.5.2, 9.12.3), the fix, option B, its own public-API probe and the security probe (3 wording errors). A read-only fact check by an independent reviewer from another model family (OpenAI GPT-5.6) found 6 errors, 6 overclaims and 8 missing caveats, none changing the conclusion. The found 0 errors and 9 minor items. Every correction is in this doc.

Sources and provenance

Finding record, Lucene and upstream sources and repro files1,420 words

Finding record

LFV-286, snapshot of 2026-11-06

Registry title
Intervals.atLeast stops early when the sub-iterator it just used runs out, so it loses minimal intervals and misses documents when nested; the same hand-over also lets a stale sub-iterator re-enter and return a non-minimal interval out of order
Area
queries / intervals (Intervals.atLeast: MinimumShouldMatchIntervalsSource)
Priority
Medium, rank 44: AtLeast silently loses intervals, and once nested it misses whole documents since 8.0, but no downstream engine exposes it.
Impact class
silent wrong results
Impact note
Applications that run Intervals.atLeast (directly, through StandardQueryParser's fn:atLeast, or through Solr's intervals query parser at_least rule) with minShouldMatch=1 over two or more sources, or with minShouldMatch>=2 when a sub-source spans several positions or tokens are stacked (index-time synonyms), hit it on ordinary text with the default codec, IndexWriterConfig and searcher, at any index size, with -ea and -da, in every release from 8.0.0 through 10.6.0 (10.6.0 by reading) and on main: a top-level atLeast still matches the document but returns too few intervals (fewer Matches [exec] and so fewer highlights, lower IntervalScorer freq and so lower scores [read]), and atLeast nested under ordered, containing and similar operators misses whole documents [exec]; minShouldMatch>=2 over single-position terms at distinct positions is not affected.
Summary
You index 'a b' and read the intervals of Intervals.atLeast(1, term a, term b), or search ordered(c, atLeast(1, a, b)) over 'a c b'; you expect [0,0] and [1,1], exactly what or(a, b) returns, and one hit. Instead atLeast returns only [0,0] and the ordered query finds nothing: when the sub-iterator just popped from atLeast's proximity queue has no further interval, nextInterval() does not pull a replacement from its background queue and ends, although the other source still has intervals. The same hand-over never advances waiting sub-iterators past the current start, so atLeast(1, b, phrase(a, b)) on 'a b b' returns [1,1] then [0,1] (out of order, and [0,1] contains [1,1]) and loses [2,2].
Introduced
e015afadaa698a0eb39574198dce31b9a7292a62 (master, which became 9.0) and its branch_8x copy 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 log -L and git blame -w -M -C -C on the first loop of nextInterval() in the full-history clone show only this commit and the LUCENE-9570 reformat 8ef6a0da568; exec: 7.6.0 and 7.7.3 have no atLeast API, 8.0.0 fails]. The loop was copied from the UNORDERED iterator of LUCENE-8196 (00eab54f9d6, 2018-04-03), where ending on an exhausted sub-iterator is correct because every sub-iterator is required; LUCENE-8622 adapted the second (minimising) loop to draw a replacement but left the first loop unchanged. Not a regression: wrong since the API was added (2019-01-07)
First affected version
8.0.0 [exec] (intervals were then in the sandbox module, package org.apache.lucene.search.intervals; 8.2.0 [exec] is the first release with intervals in the queries module). 7.6.0 (last release before the commit) and 7.7.3 (last 7.x) have no atLeast API: the port fails to compile with only 'cannot find symbol: method atLeast' errors [exec]
Affected versions
every release from 8.0.0 through 10.6.0 (10.6.0 by reading) and main ea57360912ed; not fixed upstream. [exec] all 60 releases 8.0.0-10.5.2: a test-framework-free main() port fails its 4 bug checks (B1 atLeast(1,a,b) on 'a b' gives [[0,0]]; B2 ordered(c, atLeast(1,a,b)) on 'a c b' counts 0; B3 the stale case gives [[1,1],[0,1]]; B4 atLeast(2, ordered(a,c), b, d) on 'a b c d' gives [[0,2]]) with all 4 controls passing on each, and passes 8 of 8 on main with the fix shadowed; the finder's JUnit fails 3 of 3 on 9.1.0, 9.12.3, 10.5.2 and main (-ea and -da); the independent reproduction's JUnit fails 7 of 13 on main (-ea, -da), 10.5.2 and 9.12.3 and its separate main() fails on 8.11.3. [read] branch_10x and branch_10_6 have the same nextInterval() loop (they differ from main only in comparator syntax, #14761); upstream main e1c4184cb983 (2026-10-26) adds two commits after ea57360912ed and neither touches intervals. Caveats: JDK 25 only, old releases run on a modern JDK; 8.0.0-8.1.1 had intervals in the sandbox module; the introducing commit itself was not built
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check; also reached by code reading
Upstream and prior reports
none found (2026-10-26, the prior-report record, verdict new: 223 recorded read-only queries, namely 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, manual GitHub searches that enumerate every apache/lucene issue and PR with 'interval' or 'intervals' in the title and all 72 'atLeast' hits, the changed files of all 413 open apache/lucene PRs, the review threads of every PR that changed MinimumShouldMatchIntervalsSource.java, Solr JIRA, the Elasticsearch, OpenSearch and Solr sources, and 10 web queries; 23 hits classified, none the same bug); not fixed upstream: the only open PR that touches MinimumShouldMatchIntervalsSource.java, #16851 (PriorityQueue#addAll), changes reset() only, and upstream main e1c4184cb983 (re-checked 2026-10-26) adds two commits after ea57360912ed (#16895, #16898) that touch no intervals file. Closest items, not reports: the earlier Matches-path fixes in the same class, #10783 / LUCENE-9744 (NPE for a degenerate single-source atLeast, fixed in 8.8.2 by a factory rewrite) and #521 / LUCENE-10229 ('lead = null' for sub-match reporting), and #11177 / LUCENE-10140 (sub-match positions); the analogue #12213 / #12214 (an interval lost after minimisation in OrderedIntervalsSource, fixed in 9.6.0: same defect family, different class and fix site, citable as precedent that a lost interval is a bug); the introduction #9668 / LUCENE-8622; a 2020 dev@ thread in which @romseygeek describes atLeast as 'a mixture of a disjunction and an unordered interval'
Lucene commit tested
main ea57360912ed (commit of 2026-10-24; tested 2026-10-26), seed DEADBEEF, assertions on and off, through gradle and through a standalone JUnit harness; this doc's test also on the Maven Central jars of 10.5.2 and 9.12.3; a test-framework-free port on all 60 releases from 8.0.0 to 10.5.2; the fix in a scratch worktree at ea57360912ed (the test, every interval suite, the whole queries module with assertions on and off, the match highlighter and query parser suites, :lucene:queries:check -x test), removed afterwards; the verifiers' tests and the security probe with the fix shadowed in.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/MinimumShouldMatchIntervalsSource.java:146-156 (at ea57360912ed) (class comment: a popped iterator 'is inserted back into the background queue, and replaced by the top iterator from the background queue')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/MinimumShouldMatchIntervalsSource.java:214-229 (at ea57360912ed) (nextInterval(): the first loop draws a replacement from backgroundQueue only inside 'it.nextInterval() != IntervalIterator.NO_MORE_INTERVALS' (:220-226), then returns NO_MORE_INTERVALS when proximityQueue.size() < minShouldMatch (:228-229)), :231-253 (minimisation loop: replacement drawn even when the lead is exhausted, :242-251); no code advances background iterators past the current start
  • 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'; MinimumShouldMatchIntervalsSource is built only when minShouldMatch < sources.length)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalsSource.java:35-37 (at ea57360912ed) ('Create an IntervalIterator exposing the minimum intervals defined by this IntervalsSource')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalScorer.java:60-69 (at ea57360912ed) (freq sums over every interval of the document; score effect by reading only)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/AtLeast.java:38-39 (at ea57360912ed) (fn:atLeast calls Intervals.atLeast)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/MinimumShouldMatchIntervalsSource.java:146-156 (class comment), 186-195 (the two queues), 216-229 (first loop; replacement only inside :220-226; early end :228-229), 239-241 (early return), 242-251 (minimising loop replaces an exhausted lead), 270-290 (reset)
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:562 (atLeast javadoc), 564-576 (factory rewrites)
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalsSource.java:36-37 ('the minimum intervals')
Who is affected
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/AtLeast.java:38-43
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21; IntervalScorer.java:60-69 (freq sums over every interval)
Why it looks unintended
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalsSource.java:36-37; Intervals.java:43-45, 131, 147, 562, 564-576
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/MinimumShouldMatchIntervalsSource.java:146-156, 220-229, 242-251
  • Lucene main ea57360912ed: lucene/CHANGES.txt:6767-6768 (LUCENE-8622)
  • Lucene main ea57360912ed: lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:770, 813, 837, 853
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:241-246
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:347-349, 381-383 ('not required to return or highlight all matching interval alternatives')
Suggested fix
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/MinimumShouldMatchIntervalsSource.java:80-91 (Matches path uses the same iterator)
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:564-576 (factory rewrites, for option C)
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/UnorderedIntervalsSource.java:33-56 (equal sources folded into RepeatingIntervalsSource); MinimumShouldMatchIntervalsSource.java:53-58 (one iterator per argument), 104-120 (minExtent, pullUpDisjunctions), 140-144 (hashCode); DisjunctionIntervalsSource.java:138-151 (minExtent, pullUpDisjunctions); ExtendedIntervalIterator.java:61

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

Repro files

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.