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.
- 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. - You expect
[0,0]and[1,1], exactly whatIntervals.or(a, b)returns. - 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. - 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]. - 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.
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.
- 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 (extendis an exception near position 0: it clamps starts at 0, soextend(a, 1, 0)on "a a" returns[0,0], [0,1], executed). The factory rewrites k = n tounorderedand k > n to no match; anything else, including k = 1 with two or more sources, buildsMinimumShouldMatchIntervalsSource(Intervals.java:564-576).
- 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(), whichnextDoc()andadvance()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). - 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). - First loop of
nextInterval()(:216-227): while the proximity front still starts at the last returned start, pop it, and only ifit.nextInterval() != NO_MORE_INTERVALS(:220) put it back and pop a replacement (:221-225). An exhausted iterator is dropped and not replaced, andif (proximityQueue.size() < minShouldMatch) return start = end = NO_MORE_INTERVALS;(:228-229) ends the iteration with background iterators unread. - The minimising loop below does draw a replacement after an exhausted lead (
:242-251); only the first loop skips it. - 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, asstartis -1 afterreset()), 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 asordered(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. - 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, soNO_MORE_INTERVALS. - 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.
| query, text | expected | got |
|---|---|---|
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": hits | 1 (as with or(a, b) inside) | 0 |
parser fn:ordered(c fn:atLeast(1 a b)), "a c b": hits (main, 9.12.3) | 1 | 0 |
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": hits | 1 | 0 |
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) | 1 | 0 |
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.atLeastdirectly, or through StandardQueryParser'sfn:atLeast(since 9.1.0, #11259 / LUCENE-10223; the node callsIntervals.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_leastrule toIntervals.atLeast. Elasticsearch and OpenSearch do not exposeatLeast. That says nothing about private code. - Default or opt-in.
atLeastis an opt-in operator of the intervals package, which calls itself experimental (intervals/package-info.java:21). Nothing else is needed: a defaultIndexWriterConfig(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#matchesand highlights lose spans (executed), andIntervalScorerfreq, 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 foror(a, b)and foratLeastwith the fix (executed). Nested underordered,containingand 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.
The test shows that Intervals.atLeast(1, term a, term b) over the text "a b" stops after the first interval (TestAtLeastOneLosesLastInterval#testAtLeastOneOfTwoTerms).
/*
* 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);
}
}
}
}
# 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.
More files, environment, full output35 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- 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). TheIntervalsclass: "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'sor()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:testMinShouldMatch2reaches 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
OrderedIntervalsSourceas 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.
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.
pollBackgroundmay callnextInterval()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'sWeight#matchesprobe passes with the fix. - The assertion becomes a null check.
pollBackgroundmay return the iterator just put back (valid) or null when nothing waits any more, soassert next != nullturns intoif (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#matchesexception 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:atLeastparsing): 0 failures. :lucene:queries:tidyleaves the fix and the test unchanged;:lucene:queries:check -x testpasses (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.
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]]>).
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.
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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-26 | the 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' hits | no 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 threads | 2026-10-26 | the changed files of all 413 open PRs; the review threads of every PR that changed MinimumShouldMatchIntervalsSource.java | only #16851 (PriorityQueue#addAll) touches the file, in reset() only |
| Mailing lists and JIRA archive | 2026-10-26 | dev@, 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 web | 2026-10-26 | Elasticsearch, OpenSearch and Solr sources and issues; 10 web queries | no report; Solr's unreleased at_least rule calls Intervals.atLeast; Elasticsearch and OpenSearch do not expose it |
| Closest analogue | 2026-10-26 | read in full | #12213 / #12214: a lost interval after minimisation in OrderedIntervalsSource, fixed in 9.6.0 (different class and fix site) |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 7.6.0, 7.7.3 | no atLeast API | the port does not compile: only 'cannot find symbol: method atLeast' errors |
| 8.0.0 | first affected | 4 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) | affected | every release fails the 4 bug checks with the controls passing |
| 9.0.0 to 9.12.3 (21 releases) | affected | the 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) | affected | the same; this doc's test fails 4 of 4 on 10.5.2 |
| main ea57360912ed | affected | this doc's test fails 4 of 4 with assertions on and off; the port passes 8 of 8 with the fix shadowed |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | The 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 | real | Its 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 | reproduced | The 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 corrected | An 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. |
Finding record, Lucene and upstream sources and repro files1,420 words
- 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 atea57360912ed(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.
- 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 startlucene/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-43Lucene 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-576Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/MinimumShouldMatchIntervalsSource.java:146-156, 220-229, 242-251Lucene 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, 853Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:241-246Lucene 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
- TestAtLeastOneLosesLastInterval.java (JUnit test):
repro/LFV-286/src/org/apache/lucene/queries/intervals/TestAtLeastOneLosesLastInterval.java(53 lines)