Interval queries match runs of positions; maxgaps(n, ...) keeps matches with at most n positions between their pieces. A disjunction drops any of its matches that contains another one, so Lucene rewrites an or under maxgaps or phrase into one branch per combination of alternatives. Equal arguments of ordered/unordered are merged into a repeat that skips this rewrite.
c b c b: expected 1 hit with 0 gaps, actual 0 hits because the repeat sees only b and b; the four steps inside Lucene that keep the or closed; and when it happens. Select the still to open it at full size. Generated with AI, checked against the code.- Index one document,
c b c b(positions 0 to 3). Take X =or(b, phrase(c, b)), one alternative one word long and one two words long, and searchmaxgaps(0, unordered(X, X)): two occurrences of X with no gap. - You expect 1 hit: b at 1 is followed by
c bat 2-3, two separate occurrences of X with nothing in between. Lucene documents that anorundermaxgapsorphraseis rewritten so that a longer alternative is not lost. - You get 0 hits, with assertions on or off. Make the second X equivalent but not equal (add a word that occurs nowhere) and its
orderedform is rewritten and matches positions 1-3 with 0 gaps. - Why: the two equal arguments are merged into one repeat, which hides its
orfrom the rewrite. Inside X eachc bcontains a b and is dropped, so the repeat sees only b at 1 and b at 3: 1 gap, andmaxgaps(0)rejects it. - Plain text reaches it:
analyzedText("york york")(unordered, max gaps 0) with a synonym york => "new york" missesnew york new york, and so does the query stringfn:maxgaps(0 fn:unordered(fn:or(b "c b") fn:or(b "c b"))). It has shipped since 8.5.0 (six releases from 8.5.0 to 10.5.2 run, all affected).
Users of interval queries (the Java API, the query parser's fn: functions, or analyzedText with a multi-word synonym) that pass the same or with a multi-word alternative twice to unordered, or twice in a row to ordered, under maxgaps or phrase. Default settings, no error: matches just disappear. Repeated single words are unaffected.
Mechanism, terms and numbers644 words
Intervals.or promises that a disjunction under a gap-sensitive parent (maxgaps, phrase) is rewritten into one branch per combination of alternatives, because a disjunction drops any of its intervals that contains another one and a longer alternative would otherwise vanish (LUCENE-8477). Intervals.ordered and Intervals.unordered merge equal arguments into one RepeatingIntervalsSource (LUCENE-9099), and that source's pullUpDisjunctions() returns only itself, so the rewrite never reaches an or passed twice. With X = or(b, phrase(c, b)), maxgaps(0, unordered(X, X)) and maxgaps(0, ordered(X, X)) miss c b c b, phrase(a, ordered(X, X)) misses a c b c b, maxgaps(1, unordered(X, X)) misses b x c b, Intervals.analyzedText("york york", ...) (unordered, max gaps 0) with a synonym york => "new york" misses new york new york, and the flexible query parser's fn:maxgaps(0 fn:unordered(fn:or(b "c b") fn:or(b "c b"))) returns nothing; an equivalent but unequal second argument, or phrase(X, X), is rewritten and matches. It has shipped in every release since 8.5.0 (2020) and is not fixed upstream. A 15-line change that lets a whole repeat also expose its alternatives as ordered sequences, applied together with the LFV-807 fix (drop the ordered deduplication), fixes every whole-call case run and passes the repro and the queries, queryparser and highlighter test suites. On its own the change can add a false hit (executed), so the pair needs design review before it is proposed; a repeat inside a longer unordered still misses and needs a design decision. The ordered and phrase cases are also fixed by dropping the ordered deduplication (the sibling bug LFV-807); the unordered and analyzedText cases are unique to this bug.
- Interval and gaps. An interval is a run of positions [start, end].
gaps()counts the positions between an interval's immediate pieces, not inside them (IntervalIterator.java:62-71).Intervals.maxgaps(n, source)keeps an interval whengaps() <= n(FilteredIntervalsSource.java:47-50). - Minimization. A disjunction's iterator skips any interval that contains the current one (
DisjunctionIntervalsSource.java:222-247). So inor(b, phrase(c, b))onc b, the interval ofc b[0, 1] contains b [1, 1] and is dropped. - The disjunction rewrite (LUCENE-8477). Because minimization can drop a longer alternative that a gap filter needs, gap-sensitive sources rewrite their sub-sources so that disjunctions are pulled up:
maxgaps(0, ordered(X, Y))becomes anorofmaxgaps(0, ordered(x_i, y_j))over the alternatives (Intervals.java:47-54,:118-122;FilteredIntervalsSource.java:31-36;Disjunctions.java:33-62). Each source exposes its alternatives throughpullUpDisjunctions(). - The repeat (LUCENE-9099).
orderedmerges runs of consecutive equal arguments,unorderedall equal arguments, into oneRepeatingIntervalsSource, so thatunordered(a, a)needs two differentas (OrderedIntervalsSource.java:40-57,UnorderedIntervalsSource.java:44-57).
RepeatingIntervalsSource.pullUpDisjunctions()returnsCollections.singleton(this)(RepeatingIntervalsSource.java:98-101). The rewrite sees one piece and leaves theorinside the repeat.- Inside the repeat, X is minimized: its long alternative is dropped wherever it contains the short one.
- The repeat takes consecutive intervals of the minimized X, so it can only pair short alternatives that are further apart; the gap filter then rejects them.
c b c b (positions 0-3), X = or(b, phrase(c, b)). X's intervals are b [1, 1], c b [0, 1], b [3, 3], c b [2, 3]; minimization keeps [1, 1] and [3, 3]. The repeat pairs them into [1, 3] with width 1 + 1 = 2, so 1 gap, and maxgaps(0) drops it. The valid pair b [1, 1] + c b [2, 3] (width 3, 0 gaps) was never produced. With an unequal second argument, ordered(X, X2) is rewritten into four branches, one of them maxgaps(0, ordered(b, phrase(c, b))), which matches [1, 3] with 0 gaps.
Not affected: repeats of single words or of an or of single words (one piece after the split, Disjunctions.java:76-95), and phrase(X, X), which does not merge equal arguments and is rewritten.
Exposure and likelihood393 words
- Who is exposed. Users of the intervals API (
lucene/queries, opt-in; the package "contains experimental classes"), of the flexible query parser's interval functions (thefn:syntax exists since 9.1.0), and ofIntervals.analyzedTextwith a query analyzer that gives a repeated word a multi-word alternative containing it. The query must pass the same disjunction twice or more tounordered(in any positions) or toorderedas neighbouring arguments (orderedmerges only runs of equal neighbours, soordered(X, d, X)is not affected), undermaxgapsorphrase, and the document's only valid match must use a longer alternative. Downstream servers that expose interval queries were not run. - Shapes that reach it (all executed).
maxgaps(0, unordered(X, X))andmaxgaps(0, ordered(X, X))onc b c b;phrase(a, ordered(X, X))ona c b c b;maxgaps(1, unordered(X, X))onb x c b;maxgaps(0, ordered(X, X, d))andmaxgaps(0, unordered(X, X, d))onc b c b d; three copies;analyzedText("york york", ...)with aSynonymGraphFilterrule york => "new york" (keep original): unordered with max gaps 0 missesnew york new york, and with max gaps 1 ordered and unordered missnew york x new york(ordered with max gaps 0 takes the phrase path and matches); and the query stringfn:maxgaps(0 fn:unordered(fn:or(b "c b") fn:or(b "c b"))). - What goes wrong. In the executed probes a positive query only lost hits: the repeated default
orbehaves like the documented opt-outor(false, ...), which "can miss valid hits due to minimization". A negation of such a query (MUST_NOT,notContaining) keeps documents it should drop (executed). - Silent, default settings. No exception. A plain program with
StandardAnalyzer, the defaultIndexWriterConfigand codec, aByteBuffersDirectoryand a plainIndexSearchermisses the document; the JUnit runs with the test framework's randomized codec, similarity and locale miss it too, with assertions on and off. Every run indexed one ordinary document (no crafted index); only one-document indexes were run, and the cause is in how the query is built, not in the index. - How likely. Rare: the same multi-position
ortwice under a gap filter, plus a document whose only gap-free match mixes the alternatives. No report was found although the code has shipped since 8.5.0 (2020). For such a query and document the miss happens every time.
The test shows that maxgaps(0, unordered(X, X)) with X = or(b, phrase(c, b)) misses c b c b, because the repeated X becomes a RepeatingIntervalsSource whose pullUpDisjunctions() hides the or() from the gap-sensitive rewrite.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.queries.intervals;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestUnorderedRepeatedOrMaxGaps extends LuceneTestCase {
public void testMaxGapsRewritesOrRepeatedInUnordered() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(new StandardAnalyzer()))) {
Document doc = new Document();
doc.add(new TextField("f", "c b c b", Field.Store.NO));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IntervalsSource x = Intervals.or(Intervals.term("b"), Intervals.phrase("c", "b"));
IntervalQuery q = new IntervalQuery("f", Intervals.maxgaps(0, Intervals.unordered(x, x)));
// b [1,1] and "c b" [2,3] are disjoint with no gap: expected 1 hit, actual 0
assertEquals(q + " on 'c b c b'", 1, new IndexSearcher(reader).count(q));
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-809/, 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 DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.queries.intervals.TestUnorderedRepeatedOrMaxGaps
- Expected vs actual
- Expected: 1 hit (
bat [1,1] andc bat [2,3] are disjoint with no gap). Actual: 0 hits (expected:<1> but was:<0>).
More files, environment, full output39 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: f:MAXGAPS/0(UNORDERED(or(BLOCK(c,b),b),or(BLOCK(c,b),b))) on 'c b c b' expected:<1> but was:<0>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.apache.lucene.queries.intervals.TestUnorderedRepeatedOrMaxGaps.testMaxGapsRewritesOrRepeatedInUnordered(TestUnorderedRepeatedOrMaxGaps.java:44)- The
Intervalsjavadoc promises the rewrite by default; onlyor(false, ...)may "miss valid hits due to minimization". - An equivalent but unequal second argument, or
phrase(X, X), is rewritten and matches: hidden object equality decides. - The repeat came with a 2020 fix for repeated terms that never mentions the rewrite; maintainers call the rewrite "the key for correctness".
The full argument354 words
- The default promises the rewrite. Class javadoc: "by default, sources that are sensitive to internal gaps (e.g.
PHRASEandMAXGAPS) will rewrite their sub-sources so that disjunctions of different lengths are pulled up ... to ensure that documents containing "b c" are matched"; the opt-out is for when "you care more about speed than about accuracy" (Intervals.java:47-54).Intervals.or: "Automatically rewrites if wrapped by an interval source that is sensitive to internal gaps" (:118-122). Onlyor(false, ...)"can miss valid hits due to minimization" (:127-135). - CHANGES calls the miss a bug. LUCENE-8477: "Interval disjunctions could miss valid hits if some of the clauses of the disjunction are minimized away. We now rewrite intervals if a source contains a disjunction and the internal gaps matter for matching" (
lucene/CHANGES.txt:6502-6506). No javadoc, CHANGES entry or test exempts equal arguments. - Equal objects decide.
ordered(X, X2)with X2 = X plus an alternative that never occurs is rewritten and matches;phrase(X, X)matches; the hand-expanded rewrite matches. Users cannot see the deduplication. - A side effect, not a decision.
RepeatingIntervalsSourceand itssingleton(this)came with LUCENE-9099 (2020), a fix for repeated terms. Its commit message (also its PR body; one approving review, no inline comments) mentions disjunctions only for a separate fix toDisjunctionIntervalIterator#matches(), and its one repeated-ortest,testMaxGapsWithOnlyRepeats(maxgaps(1, ordered(or(pease, hot), or(pease, hot)))), uses single-term alternatives, where the rewrite has nothing to do. None of them mentions the LUCENE-8477 rewrite or a disjunction whose alternatives have different lengths. Before it, 8.4.1 rewrote and matched the two-argumentorderedandphrasecases. - Maintainers on the rewrite (LUCENE-9204, #10244): "Intervals automatically rewrite disjunctions (and have done since LUCENE-8477) so that queries behave as your or I would expect them to"; and "there's nothing else to compare with since it's the key for correctness ... If it's not correct, it's a bug, not a feature".
- Nothing pins the current behaviour. Upstream repeat tests use single terms or an
orof single terms; all 117 upstream intervals tests pass with fix A, and with A together with the LFV-807 fix.
The devil's-advocate check202 words
The strongest case for "works as designed" (built or weighed by the cross-model reviews, the intended-behaviour check and the adjudication; the cross-model reviews rated it moderate): intervals use minimal-interval semantics (the class javadoc cites the Vigna paper); during LUCENE-8477 a reviewer warned that "we can find other pathological cases due to minimum interval semantics" and suggested documenting rather than rewriting; the rewrite was never total (atLeast is also opaque); the query parser docs say "the algorithm is not required to return or highlight all matching interval alternatives"; and keeping the repeat closed avoids a k^n expansion.
Why it fails: the merged design rewrites by default, and later maintainer statements treat a miss as a bug; the parser note is about which intervals of a matching document are reported, while here the document is not matched at all; unequal arguments and phrase(X, X) are already expanded, bounded by the clause limit; and the answer should not depend on whether two equivalent arguments are equal objects. The unordered half has a real design question (may the copies share positions?), which shapes the fix, not whether c b c b should match: its witness pair does not overlap. Verdict: a defect (adjudication: confirmed, high).
Recommended A whole repeat also exposes its ordered sequences, applied together with the LFV-807 fix (candidate, needs design review)
Candidate, needs design review: in RepeatingIntervalsSource.pullUpDisjunctions, let a whole repeat also return every ordered sequence of its copies' alternatives (15 lines), together with the LFV-807 fix. The pair passes the repro, an over-match probe and the queries, queryparser and highlighter tests. Alone, the 15-line change can add a false hit. A repeat inside a longer unordered stays open.
public Collection<IntervalsSource> pullUpDisjunctions() {
if (name != null) { // a whole ordered() or unordered() of equal sub-sources
List<IntervalsSource> sequences =
Disjunctions.pullUp(Collections.nCopies(childCount, in), OrderedIntervalsSource::build);
if (sequences.size() > 1) {
List<IntervalsSource> pulledUp = new ArrayList<>(sequences.size() + 1);
pulledUp.add(this); // keep every match the repeat has today
pulledUp.addAll(sequences);
return pulledUp;
}
}
return Collections.singleton(this);
}All options and trade-offs4 options · 1,426 words
The candidate change makes a whole ordered(X, X) / unordered(X, X) behave like its unequal twin under a gap-sensitive parent: the parent sees the repeat itself plus every sequence of the copies' alternatives built as ordered. The copies stand for distinct occurrences, so, as long as each sequence is a real ordered (whose sub-intervals never overlap), these sequences are exactly "n occurrences without overlap", the construction Intervals.unorderedNoOverlaps uses. That condition needs the LFV-807 fix: without it, ordered folds a sequence of two equal alternatives back into a repeat whose copies may overlap. With W = or(b, unordered(a, b)), phrase(z, ordered(W, W)) then matches z a b a, although no two occurrences of W there are disjoint (stock 0, the change alone 1, the change with the LFV-807 fix 0; executed with assertions on and off). With both fixes, keeping the repeat itself keeps every match it has today, and the added sequences should only add documents that the unfiltered query also matches (reasoning, checked on the cases below). These runs cover the listed examples and the existing test suites, not the general semantics.
| case | document | expected | main | A | finder sketch | LFV-807 fix | A + LFV-807 |
|---|---|---|---|---|---|---|---|
maxgaps(0, ordered(X, X, d)) | c b c b d | 1 | 0 | 0 | 0 | 1 | 1 |
maxgaps(0, unordered(X, X, d)) | c b c b d | 1 | 0 | 0 | 0 | 0 | 0 |
maxgaps(0, unordered(X, X, d)) | b x c b d | 0 | 0 | 0 | 0 | 0 | 0 |
maxgaps(0, unordered(X, X, X)) | b c b c b | 1 | 0 | 0 (1 with LFV-306) | 1 | 0 | 1 |
maxgaps(0, unordered(X, X, X)) | c b c b | 0 | 0 | 0 | 1 | 0 | 0 |
maxgaps(0, unordered(X, X)) | c b | 0 | 0 | 0 | 1 | 0 | 0 |
maxgaps(0, unordered(X, X)) | b x c b | 0 | 0 | 0 | 1 | 0 | 0 |
maxgaps(1, unordered(X, X)) | b x c b | 1 | 0 | 1 | 1 | 0 | 1 |
workaround maxgaps(0, ordered(X, X + zzz)) | c b c b | 1 | 1 | 1 | 1 | 1 | 1 |
phrase(z, ordered(W, W)), W = or(b, unordered(a, b)) | z a b a | 0 | 0 | 1 | 1 | 0 | 0 |
With A alone the three-copy case stays 0 because the sequence ordered(b, c b, c b) regroups the two c b into a repeat whose width has the LFV-306 sign error; with LFV-306's one-line fix also applied it gives 1. Two copies of or("a b", "b c") on a b c, which share the b, give the same as main under every fix. The phrase(z, ordered(W, W)) row is the over-match of A alone described above. With A + LFV-807 every row gives the expected value except unordered(X, X, d).
Not covered by any fix tried: a run of copies inside a longer unordered, such as unordered(X, X, d). Expanding it needs "the copies must not overlap each other but may overlap d": rebuilding the copies with unordered over-matches, and nesting an ordered of the copies hides that ordered's inner gaps from the parent, which would match b x c b d. No existing operator expresses this; it is the design question to raise upstream.
Workaround until a fix ships: make the second argument unequal with an alternative that never occurs and use ordered: maxgaps(0, ordered(X, or(b, "c b", zzz))) matches c b c b and not c b or b x b. Not with unordered: unequal unordered arguments may share a position, so that form also matches a lone b.
A whole repeat also exposes its ordered sequences, applied together with the LFV-807 fix (candidate, needs design review) (recommended)
In RepeatingIntervalsSource.pullUpDisjunctions(), for a repeat that is a whole ordered/unordered call (name != null), return the repeat plus Disjunctions.pullUp(nCopies(childCount, in), OrderedIntervalsSource::build) when that yields more than one sequence (+15 lines in one method, comment included; abridged below). Apply it together with option B (the LFV-807 fix); alone it can over-match (trade-offs). Under maxgaps each returned source gets its own filter; under phrase the pieces of extent 1 (the repeat itself and, without B, the folded ordered(b, b)) are grouped into one or first. An or of single words still splits into one piece, so the tested single-word case builds exactly as before. The diff's comment ("ordered sub-intervals never overlap", "only adds matches") holds only with B.
What was run (the listed examples and the existing suites; not a proof of the general semantics):
- Scratch worktree of
a9779ef3c193, gradle, JDK 25: stock + the new test, 4 of 5 fail with assertions on and off. - + A:
lucene/queries52 suites, 517 tests (122 in the intervals package, 5 of them new), 0 failures with assertions on and off (5 and 6 skipped);lucene/queryparser30 suites, 533 tests, andlucene/highlighter34 suites, 722 tests, 0 failures (assertions on);:lucene:queries:check -x test(checkGoogleJavaFormat, ecjLint, forbiddenApis) passes. - + A + B: the same three suites, 0 failures (
lucene/querieswith assertions on and off);:lucene:queries:checkwas not re-run for the pair. Standalone runner: the new test 5 of 5 and the over-match probe as expected on every line, with assertions on and off. - Standalone runner, A alone: the new test 5 of 5 (on and off), the finder's 3 of 3 (on and off), the reproducer's 7 of 8 (only the longer-
orderedscope test fails), the real-synonym probe all 1, and thefn:query string matches withWeight#matchesagreeing. The multi-case query-parser probe it comes from still reports 5 failing lines, all from other bugs (they fail on stock too).
public Collection<IntervalsSource> pullUpDisjunctions() {
if (name != null) { // a whole ordered() or unordered() of equal sub-sources
List<IntervalsSource> sequences =
Disjunctions.pullUp(Collections.nCopies(childCount, in), OrderedIntervalsSource::build);
if (sequences.size() > 1) {
List<IntervalsSource> pulledUp = new ArrayList<>(sequences.size() + 1);
pulledUp.add(this); // keep every match the repeat has today
pulledUp.addAll(sequences);
return pulledUp;
}
}
return Collections.singleton(this);
}Trade-offs
- Over-match without B (executed).
OrderedIntervalsSource.buildfolds a sequence of equal alternatives back into a repeat, and that repeat lets its copies overlap (LFV-807). With W =or(b, unordered(a, b)),phrase(z, ordered(W, W))onz a b agives 1 with A alone, 0 on stock and 0 with A + B; the unequal control gives 0. Found by the cross-model fact check from the source, then run with assertions on and off. - Open:
nameandequals. A letsnamedecide whatpullUpDisjunctions()returns, butRepeatingIntervalsSource.equalsandhashCodeignorename, so a named whole-call repeat and an otherwise equal unnamed repeat inside a longerordered/unorderedcompare equal but rewrite differently. Resolve before proposing (read, not executed). - Cost, like the documented rewrite of unequal arguments: k^n + 1 branches for n copies of k pieces, with the clause limit applied at query construction. An
orof 32 two-word phrases passed twice builds 1,025 branches; 33 throwIllegalArgumentException: Too many disjunctions to expand, where today the repeated form builds unexpanded and the unequal form already throws from 32. So a query that builds today can be rejected. - Does not cover a run inside a longer
ordered(fixed by LFV-807's change) or a longerunordered(open design question). - Scores and
Weight#matchessee the extra branches only where they add intervals; no benchmark was run.
The ordered iterator already keeps sub-intervals from overlapping, so equal ordered arguments need no repeat. Fixes the ordered and phrase cases, ordered(X, X, d) and LFV-807.
Trade-offs
Alone it does nothing for unordered, which keeps the repeat so that unordered(a, a) does not match one a: with it the unordered and analyzedText tests still fail (executed). Together with A it also removes A's over-match, because A's sequences are then real ordered sources (executed).
Distribute the alternatives over the copies and rebuild with unordered for an unordered repeat.
Trade-offs
Passes the three headline cases but adds false hits under the filter: maxgaps(0, unordered(X, X)) matches c b and b x c b, and three copies match c b c b, none of which the unfiltered query matches (executed). Rebuilt unordered lets c b pair with the b inside it. It also gives 1 for phrase(z, ordered(W, W)) on z a b a and for maxgaps(0, unordered(W, W)) on a b a (executed).
Say in the Intervals.or and ordered/unordered javadocs that equal arguments are not rewritten.
Trade-offs
Keeps silent misses for analyzedText and query-parser users, who cannot see or control the deduplication.
Downstream relevance: an exclusion built from such a query (a MUST_NOT clause or notContaining) silently keeps the documents it should drop.
While modelling how an or is distributed under gap filters, with an independently written reference semantics, the finder saw that a repeated disjunction is never distributed. A bounded-exhaustive comparison (about 1.1 million checks over every short document of a three-symbol alphabet) traced the last remaining mismatches to this spot, and a one-document test reproduced it.
The method in full202 words
Method: modelling with a reference semantics, then bounded-exhaustive translation validation on the stock main jars (the hunt's "semi-formal" tier).
- A hunt unit on interval repeats and disjunction pull-ups wrote a reference semantics for interval queries from the javadoc (
IvRef290). While modelling an earlier lead (howoris distributed under gap-sensitive parents, with repeat groups taken from the user's arguments), the finder noticed that a repeated disjunction is never distributed and confirmed it with a direct probe. - Its harness (
IvTV290) ran the stock interval iterators against the reference on every non-empty document of 1 to 4 positions over a three-symbol alphabet (120 documents), for 6,856 expressions of depth up to 3 over term, ordered, unordered, or, phrase, maxgaps(0|1) and atLeast(2 of 3) with repeated arguments: 1,100,295 checks and 23,917 failures on stock main. With switchable shadow fixes for known bugs and for the unit's other candidates, applied one by one (1,100,515 to 1,100,533 checks per configuration), the failures fell to 17,058, 13,789, 13,469, 3,596 and 1,098, and the last 1,098 went to 0 only with this fix site. - The finder reduced it to a one-document public-API JUnit; independent AI tools then reproduced, reviewed and judged it (see the checks).
No prior report found (2026-10-27): four tool runs of about 51 queries each over GitHub issues, PRs and commits, the mailing lists, the JIRA archive, downstream repositories and the web, plus manual searches and the changed files of all 409 open PRs.
Searches4 searches
No issue, PR, review comment, JIRA ticket, list thread or downstream report describes a repeated disjunction skipping the gap-sensitive rewrite. RepeatingIntervalsSource.pullUpDisjunctions still returns singleton(this) on upstream main and branch_10_6, and no open PR touches the fix site.
Closest, not this bug: #10141 / lucene-solr#1097 (LUCENE-9099, the introducing change; single-position repeats only: terms and an or of single terms); #9523 / lucene-solr#620 (LUCENE-8477, the rewrite and its contract, before repeats existed); #10244 (LUCENE-9204, maintainers on the rewrite); #11177 (LUCENE-10140: a remark that REPEATING "cannot produce all the possible spans between sub-intervals", about highlight positions of a proposed rewrite, not disjunctions or missed documents); #13819 (flattening), #16899 (merged 2026-10-26, multi-term over-expansion) and #16851 (open, iterator refactor). OpenSearch#3321 and elastic/elasticsearch#137950 report the rewrite expanding too much, the opposite symptom.
Limits: GitHub search does not index review comments (the lucene-solr #620 and #1097 reviews were read through the API); several web queries were throttled; private reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits: apache/lucene, the archived apache/lucene-solr, Elasticsearch, OpenSearch, Solr | 2026-10-27 | a scripted prior-report search, four runs (identifiers; trigger and symptom; generic): 16 issue/PR, 3 commit and 2 downstream queries each; 54 manual GraphQL queries | only the design issues #10141, #9523, #10244 and their PRs name RepeatingIntervalsSource or pullUpDisjunctions |
| dev@, java-user@, issues@, commits@ lists; Solr lists; JIRA archive; web | 2026-10-27 | 20 list and 4 JIRA queries per tool run; 30 extra list and 7 extra JIRA queries; 12 completed web queries (3 throttled) | notification mirrors of the design issues and unrelated threads only |
| Open PRs and upstream code | 2026-10-27 | changed files of all 409 open apache/lucene PRs; titles of the 34 PRs updated since 2026-10-25, the 19 items created since 2026-10-25 and the 14 issues updated since 2026-10-26; the fix site on upstream main and branch_10_6 | no open PR touches the fix site; the method is unchanged since 7c1ba1aebee |
| Verifier passes | 2026-10-27 | The independent reproduction, the cross-model review, the intended-behaviour check and the adjudication, read-only GitHub, list and web searches | no report |
Introduced by LUCENE-9099 (aa916bac3c3 on branch_8x, 7c1ba1aebee on master, 2020-02-06), first released in 8.5.0. The finder's test fails on every version run from 8.5.0 to 10.5.2, on branch_10_6 and on main, and passes on 8.4.1.
Versions tested9 rows
Introduced by LUCENE-9099 "Correctly handle repeats in ORDERED and UNORDERED intervals" (@romseygeek; apache/lucene-solr#1097, the JIRA issue is now apache/lucene#10141; one approving review): aa916bac3c3 on branch_8x, the commit first released, and 7c1ba1aebee on master, both 2020-02-06. It added the deduplication and RepeatingIntervalsSource with pullUpDisjunctions() returning singleton(this); git log -L of the method lists only this commit.
Regression, with a caveat: the two-argument ordered and phrase cases were right in 8.4.1. The unordered case and a run inside a longer ordered matched in 8.4.1 only because the two copies could share a position, which also matched a lone b (the over-match LUCENE-9099 fixed). So the unordered case was introduced with LUCENE-9099 rather than "worked in 8.4.1".
Caveats: every run used JDK 25.0.3; 8.x and 9.0.0 ran the test with two test-framework imports moved to the old packages; the 8.8.2-8.11.4 tags are not in the local history clone (8.11.4 was executed).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the brief's test: 4 of 5 fail (gradle and the standalone runner, assertions on and off); the finder's 3 of 3 |
branch_10_6 4ae946e5a9b (10.6.0, released 2026-10-29) | affected | the finder's test 3 of 3 fail, assertions on and off |
| 10.5.2 | affected | 3 of 3 fail, assertions on and off; the reproducer's 4 bug tests fail |
| 10.0.0, 9.12.3, 9.0.0, 8.11.4 | affected | 3 of 3 fail |
| 8.5.0 | first | 3 of 3 fail; the reproducer's 4 bug tests fail |
aa916bac3c3 vs parent 3246b260586 (on the 8.5.0 jars) | bisect | the introducing commit fails 3 of 3; its parent passes 3 of 3 |
| 8.4.1 | before | 3 of 3 pass (the unordered case only through the over-match LUCENE-9099 fixed) |
| main + fix A | not affected | the repro passes; queries 517, queryparser 533 and highlighter 722 tests, 0 failures; but A alone over-matches phrase(z, ordered(W, W)) on z a b a (executed) |
| main + fix A + the LFV-807 fix | not affected | the repro passes (on and off); queries 517 (on and off), queryparser 533 and highlighter 722 tests, 0 failures; the over-match probe as expected |
An independent reproducer, a devil's advocate and reviews from another model family agree on the cause; the reproducer and the devil's advocate reproduced it with their own tests, and the adjudication confirmed it (high). This doc's brief added a 5-test repro and tested a fix (with the LFV-807 fix) on three modules; fact checks followed.
Checks and verdicts459 words
Every verifier (the finder, the independent reproduction, separate JUnits, the intended-behaviour check, the adjudication and the registry re-run) ran its repro with assertions on and off. The cross-model reviews (an independent reviewer from another model family, OpenAI GPT-5.6) ran read-only and could not run Java; a separate run re-ran the finder's JUnit (3 of 3 fail, assertions off). No cross-model review that executes code was done.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Modelling with a reference semantics, then the bounded-exhaustive harness (about 1.1 million checks per configuration; with the unit's other fixes 1,098 failures remain, and 0 only with this fix site); the 3-test public-API JUnit fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3. |
| cross-model review, refute lens | procedural "not real" (low) | Read-only, could not run Java, so it voted not real on procedure only; root cause agrees, inputs in contract, intended case moderate; its notes say the static evidence strongly favours a real defect. |
| Independent reproducer | real | Own 8-test JUnit: the 4 bug tests fail and 4 controls pass on main (-ea, -da), 10.5.2, 9.12.3, 8.11.4 and 8.5.0; passes on 8.4.1; found the longer-ordered scope and that the unordered absent-alternative control is weak. |
| Cross-model review, intended lens | real (medium) | Read-only review, intended case moderate and rejected; separate JUnits fail on main (two seeds, -ea, -da) and 10.5.2, and still fail with the LFV-306 fix, so the bug is independent of it. |
| Devil's advocate | real | Built the minimal-interval / atLeast / k^n case and rejected it; found the analyzedText synonym reach; the repro fails on main with -da. |
| adjudication | confirmed (high) | Fresh seed: 3 of 3 fail with -ea and -da; with the LFV-807 fix applied only the unordered test still fails, so a distinct root cause. |
| registry re-run and audit | still reproduces | Seeds 0D15C0DE and A4D17E55: 3 of 3 fail on a9779ef3c193 with assertions on and off; an independent audit probe confirms the ordered, unordered and analyzedText misses. |
| this doc's brief | reproduced; fix A + B tested | New 5-test repro (4 of 5 fail, gradle and standalone, on and off); fix A, and A with the LFV-807 fix, tested with the queries, queryparser and highlighter tests; scope table across five fix configurations; over-match probe. |
| fact checks of the brief | bug real as described; wording fixed | A fact check re-ran the documented gradle command (4 of 5 fail, on and off) and the fix; a read-only review by an independent reviewer from another model family (OpenAI GPT-5.6) found from the source that fix A alone can over-match, which was then executed. The corrections are listed in the hunt's record. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 462 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,374 words
- Registry title
- Intervals.unordered/ordered given the same disjunction twice merge it into one repeated source that the gap-sensitive disjunction rewrite does not look into (RepeatingIntervalsSource.pullUpDisjunctions returns itself), so maxgaps(0, unordered(X, X)) with X = or(b, phrase(c, b)) misses 'c b c b', and maxgaps or phrase over ordered(X, X) miss the same way
- Area
- queries / intervals (gap-sensitive disjunction rewrite of LUCENE-8477 vs repeated equal sub-sources: RepeatingIntervalsSource.pullUpDisjunctions, Ordered/UnorderedIntervalsSource.deduplicate)
- Priority
- Medium, rank 195: Silent missed interval matches since 8.5.0 with the same profile as LFV-306, and it heads the new RepeatingIntervalsSource siblings because it is also reachable through analyzedText when a repeated word has a multi-word synonym.
- Impact class
- silent wrong results
- Impact note
- Anyone whose interval query passes the same disjunction, with an alternative longer than one position (a phrase, or a multi-word synonym that Intervals.analyzedText turns into or(BLOCK(new, york), york)), twice to Intervals.unordered or Intervals.ordered under a gap-sensitive parent (maxgaps, phrase, analyzedText with maxGaps), through the Java factories, StandardQueryParser fn: strings or analyzedText, with default writer, codec, analyzer and searcher settings, at any index size, with -ea and -da, in every release from 8.5.0 to 10.6.0 and on main, silently misses documents that have a valid gap-free match; repeated disjunctions of single terms (the case upstream tests) are not affected, and nothing is written to the index.
- Summary
- You build an interval query that uses the same OR twice, for example maxgaps(0, unordered(X, X)) with X = or(b, phrase(c, b)), and expect what the Intervals.or javadoc promises ('Automatically rewrites if wrapped by an interval source that is sensitive to internal gaps'), so that 'c b c b' matches through b at position 1 followed by the phrase 'c b' at positions 2-3, which do not overlap and leave no gap. Instead, because the two arguments are equal, Lucene merges them into one RepeatingIntervalsSource whose pullUpDisjunctions() returns only itself, so the rewrite never runs, the longer 'c b' alternative is minimized away inside X, and the query returns no hits. maxgaps(0, ordered(X, X)) and phrase(a, ordered(X, X)) miss in the same way, and so does Intervals.analyzedText for the query text 'york york' with a synonym york -> 'new york' (unordered, maxGaps 0) on the document 'new york new york'.
- Introduced
- 7c1ba1aebeea540b67ae304deee60162baee2e12 (master, released from 9.0.0) and its branch_8x backport aa916bac3c3369a461afa06e384e070657c32973 (the first released form, in 8.5.0), both 2020-02-06: 'LUCENE-9099: Correctly handle repeats in ORDERED and UNORDERED intervals' (apache/lucene-solr#1097 by romseygeek, one approving review by jimczi; the JIRA issue is now apache/lucene#10141). It added Ordered/UnorderedIntervalsSource.deduplicate, which folds equal sub-sources into RepeatingIntervalsSource, and RepeatingIntervalsSource.pullUpDisjunctions returning Collections.singleton(this) [read: git log -L of pullUpDisjunctions (only 7c1ba1aebee), git blame on main] [exec: the intervals package at the backport's parent 3246b260586, shadowed on the 8.5.0 jars, passes 3 of 3; the package at aa916bac3c3 fails 3 of 3] (2020-02-06 (7c1ba1aebee and the branch_8x backport aa916bac3c3))
- First affected version
- 8.5.0 [exec] (8.4.1, the last release before LUCENE-9099, passes [exec]; the commit-level A/B on the 8.5.0 jars pins it to aa916bac3c3 [exec]). For the unordered shape and a run of equal sources inside a longer ordered, 8.4.1 returned the hit only through the pre-LUCENE-9099 over-match (the two copies shared one position, which also matched a lone 'b'), so those shapes were never right; the two-argument ordered and phrase shapes are a clean regression
- Affected versions
- every release from 8.5.0 through 10.5.2 [exec: the finder JUnit fails 3 of 3 on 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (-ea and -da on 10.5.2; 8.x and 9.0.0 with the two test-framework imports moved to the old packages); the history probe misses on 8.5.0, 9.0.0, 10.5.2 and main; the independent reproduction's own JUnit fails on 8.5.0, 8.11.4, 9.12.3 and 10.5.2] [read: no intervals change between them touches the fix site]; branch_10_6 head 4ae946e5a9b [exec, -ea and -da: fails 3 of 3], so the released 10.6.0 has it; main a9779ef3c193 [exec, -ea and -da]; upstream main 20ced71c1e6 [read: the 4 newer commits touch no intervals file]. Not affected: 8.4.1 [exec]. Reachable from query text through StandardQueryParser fn: functions (fn:maxgaps, fn:unordered, fn:or) on main [exec]; the fn: syntax arrived in 9.1.0 (LUCENE-10223) [read, sibling follow-up]; before that, through the Java Intervals API only
- 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; also reached by exhaustive check
- Upstream and prior reports
- none found (follow-up prior-report record verdict new, searched 2026-10-27 plus the finder's scripted prior-report search and its web leg, read-only): four scripted prior-report searches (identifiers, trigger and symptom, generic; about 51 queries each over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), manual GitHub searches over apache/lucene and apache/lucene-solr, a changed-file scan of all 409 open apache/lucene PRs, the 34 PRs updated since 2026-10-25, the intervals commit history on main and branch_10_6, 30 extra mailing-list queries over six Lucene and Solr lists, extra JIRA-archive queries, downstream GitHub queries (Elasticsearch, OpenSearch, Solr) and 12 completed web queries (3 attempts throttled). Nearest items, none the same bug: #10141 / LUCENE-9099 and lucene-solr#1097 (introduced RepeatingIntervalsSource; single-term repeats only; no word on disjunctions or the rewrite), #9523 / LUCENE-8477 and lucene-solr#620 (the rewrite and its contract; before repeats existed), #10244 / LUCENE-9204 (maintainers on the rewrite as needed for correctness), #11177 / LUCENE-10140 (repeat sub-positions for highlighting; a proposed rewrite never implemented; another fix site), #13819 (flattening), #16899 (merged 2026-10-26, TooManyClauses on multi-term over-expansion) and #16851 (open, PriorityQueue refactor in the iterators). Not fixed upstream: RepeatingIntervalsSource.pullUpDisjunctions still returns singleton(this) on upstream main 20ced71c1e6 and branch_10_6 head 4ae946e5a9b, and no open PR touches the fix site. Re-check (2026-10-27, read-only GitHub API): the 65 apache/lucene issues and PRs updated since 2026-10-25 were listed by title and none concerns intervals except the merged #16899 (unrelated); 'RepeatingIntervalsSource' still finds only #10141, and a PR search for 'pullUpDisjunctions' finds none; one web query at this step was throttled.
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27 and 2026-10-28) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off. The finder's test also ran on the 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 release jars, on the branch_10_6 head4ae946e5a9b, and on the 8.5.0 jars with the intervals package of the introducing commitaa916bac3c3and of its parent3246b260586. Fix A, and fix A together with the LFV-807 fix, were each run in a scratch worktree: the new test, the wholelucene/queriesmodule with assertions on and off, and thelucene/queryparserandlucene/highlightermodules with assertions on;:lucene:queries:check -x testwas run for A.
- Finding record
lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:98-101 (pullUpDisjunctions returns Collections.singleton(this)); UnorderedIntervalsSource.java:44-57 and OrderedIntervalsSource.java:40-56 (deduplicate folds equal sub-sources into RepeatingIntervalsSource.build); FilteredIntervalsSource.java:31-36 (maxGaps maps over in.pullUpDisjunctions()); Disjunctions.java:43-45 (expansion capped at IndexSearcher.getMaxClauseCount); Intervals.java:47-54 (class javadoc: gap-sensitive sources rewrite disjunctions of different lengths by default; or(boolean, ...) for speed over accuracy), :118-122 (or: 'Automatically rewrites if wrapped by an interval source that is sensitive to internal gaps'), :127-135 (or(false, ...) 'can miss valid hits due to minimization'); all files except Intervals.java are identical at Lucene main ea57360912ed
- The finding in 30 seconds
lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:98-101; (at a9779ef3c193) OrderedIntervalsSource.java:40-57, :77-80; UnorderedIntervalsSource.java:44-57, :77-80lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteredIntervalsSource.java:31-36, (at a9779ef3c193) :47-50; BlockIntervalsSource.java:30-35; Disjunctions.java:33-62, :76-95; DisjunctionIntervalsSource.java:222-247; IntervalIterator.java:62-71; Intervals.java:47-54, :118-135
- Who is affected
lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21; (at a9779ef3c193) Intervals.java:127-135
- Why it looks unintended
lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:47-54, (at a9779ef3c193) :118-135; MinimumShouldMatchIntervalsSource.java:117-120lucene/CHANGES.txt:6502-6506; (at a9779ef3c193) lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:347-349
- Suggested fix
lucene/queries/src/java/org/apache/lucene/queries/intervals/OrderedIntervalsSource.java:140-153; (at a9779ef3c193) Intervals.java:445-447; IntervalIterator.java:62-71lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:93-96, (at a9779ef3c193) :103-113; OrderedIntervalsSource.java:29-55; Disjunctions.java:79-94
- TestUnorderedRepeatedOrMaxGaps.java (JUnit test):
repro/LFV-809/src/org/apache/lucene/queries/intervals/TestUnorderedRepeatedOrMaxGaps.java(48 lines)
A step-by-step picture of the repro's first case, made in parallel with this doc from the executed repro on main a9779ef3c193 and the code; the PNG is the final summary card. The alt text describes the intended content.
assets/LFV-809/bug.png(462 KB)
![Summary card. Title: passing the same Intervals.or twice to unordered hides it from the maxgaps rewrite. Panel 1, the example: the document c b c b at positions 0 to 3, the query Intervals.maxgaps(0, Intervals.unordered(x, x)) with x = or(b, phrase(c, b)); expected 1 hit, the or pulled up, gaps 0 for [1,3]; actual 0 hits, not pulled up, gaps 1 because the repeat sees b + b. Panel 2, inside Lucene: equal x, x become one RepeatingIntervalsSource(x, 2); its pullUpDisjunctions() returns singleton(this); so maxgaps keeps the repeat whole; in x, c b contains a b, so x gives [1,1] and [3,3]; the repeat [1,3] has gaps 3 - 2 = 1 and maxgaps(0) drops it; control: phrase(x, x) is pulled up and matches [1,3]. Panel 3, when it happens: the same or with an alternative longer than one position given twice to unordered, or twice in a row to ordered, under maxgaps or phrase, also via the query parser; not affected: a repeated or of single words and arguments that are not equal; plain query text too, through analyzedText with the synonym york to new york; silently fewer hits, and inside notContaining or a MUST_NOT clause the miss becomes extra hits; fix: 15 lines plus dropping the ordered deduplication pass the repro and module tests, and it needs design review. Footnote: executed on main a9779ef3c193 with assertions on and off; earlier runs: 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 fail, 8.4.1 passes.](../assets/LFV-809/bug.png)