All findings · Lucene finding · LFV-809 · repeating-intervals-disjunction-not-rewritten

The same OR passed twice to Intervals.unordered or ordered skips the disjunction rewrite, so maxgaps and phrase miss real matches

Pass the same Intervals.or(...) twice to unordered or ordered under maxgaps or phrase, and Lucene merges the copies into one repeat that the documented disjunction rewrite skips. Matches needing the longer alternative vanish: maxgaps(0, unordered(X, X)) with X = or(b, "c b") finds nothing in c b c b.

Summary

Priority
Medium, rank 195Silent 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
silent wrong results
Since
8.5.0 (LUCENE-9099, 2020)introduced 2020-02-06 · about 6 years
Affected
8.5.0 to 10.5.2 (6 releases run), 10.6.0 (4ae946e5a9b) and main; 8.4.1 not affected
Status
documented
Found by
modelling with a reference semantics, confirmed by bounded-exhaustive translation validation

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

The finding in 30 seconds

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.

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.
The one-document example 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.
  1. 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 search maxgaps(0, unordered(X, X)): two occurrences of X with no gap.
  2. You expect 1 hit: b at 1 is followed by c b at 2-3, two separate occurrences of X with nothing in between. Lucene documents that an or under maxgaps or phrase is rewritten so that a longer alternative is not lost.
  3. 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 ordered form is rewritten and matches positions 1-3 with 0 gaps.
  4. Why: the two equal arguments are merged into one repeat, which hides its or from the rewrite. Inside X each c b contains a b and is dropped, so the repeat sees only b at 1 and b at 3: 1 gap, and maxgaps(0) rejects it.
  5. Plain text reaches it: analyzedText("york york") (unordered, max gaps 0) with a synonym york => "new york" misses new york new york, and so does the query string fn: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).

Who is 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.

Terms

  • 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 when gaps() <= n (FilteredIntervalsSource.java:47-50).
  • Minimization. A disjunction's iterator skips any interval that contains the current one (DisjunctionIntervalsSource.java:222-247). So in or(b, phrase(c, b)) on c b, the interval of c 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 an or of maxgaps(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 through pullUpDisjunctions().
  • The repeat (LUCENE-9099). ordered merges runs of consecutive equal arguments, unordered all equal arguments, into one RepeatingIntervalsSource, so that unordered(a, a) needs two different as (OrderedIntervalsSource.java:40-57, UnorderedIntervalsSource.java:44-57).

The bug

  1. RepeatingIntervalsSource.pullUpDisjunctions() returns Collections.singleton(this) (RepeatingIntervalsSource.java:98-101). The rewrite sees one piece and leaves the or inside the repeat.
  2. Inside the repeat, X is minimized: its long alternative is dropped wherever it contains the short one.
  3. 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.

The example, step by step

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 (the fn: syntax exists since 9.1.0), and of Intervals.analyzedText with a query analyzer that gives a repeated word a multi-word alternative containing it. The query must pass the same disjunction twice or more to unordered (in any positions) or to ordered as neighbouring arguments (ordered merges only runs of equal neighbours, so ordered(X, d, X) is not affected), under maxgaps or phrase, 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)) and maxgaps(0, ordered(X, X)) on c b c b; phrase(a, ordered(X, X)) on a c b c b; maxgaps(1, unordered(X, X)) on b x c b; maxgaps(0, ordered(X, X, d)) and maxgaps(0, unordered(X, X, d)) on c b c b d; three copies; analyzedText("york york", ...) with a SynonymGraphFilter rule york => "new york" (keep original): unordered with max gaps 0 misses new york new york, and with max gaps 1 ordered and unordered miss new york x new york (ordered with max gaps 0 takes the phrase path and matches); and the query string fn: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 or behaves like the documented opt-out or(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 default IndexWriterConfig and codec, a ByteBuffersDirectory and a plain IndexSearcher misses 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 or twice 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.

Minimal repro

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.

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

import org.apache.lucene.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));
      }
    }
  }
}
Command
# 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 (b at [1,1] and c b at [2,3] are disjoint with no gap). Actual: 0 hits (expected:<1> but was:<0>).

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

More files, environment, full output39 words

Environment

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

Observed output

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

Why it looks unintended

  • The Intervals javadoc promises the rewrite by default; only or(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. PHRASE and MAXGAPS) 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). Only or(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. RepeatingIntervalsSource and its singleton(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 to DisjunctionIntervalIterator#matches(), and its one repeated-or test, 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-argument ordered and phrase cases.
  • 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 or of 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).

Suggested fix

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.

casedocumentexpectedmainAfinder sketchLFV-807 fixA + LFV-807
maxgaps(0, ordered(X, X, d))c b c b d100011
maxgaps(0, unordered(X, X, d))c b c b d100000
maxgaps(0, unordered(X, X, d))b x c b d000000
maxgaps(0, unordered(X, X, X))b c b c b100 (1 with LFV-306)101
maxgaps(0, unordered(X, X, X))c b c b000100
maxgaps(0, unordered(X, X))c b000100
maxgaps(0, unordered(X, X))b x c b000100
maxgaps(1, unordered(X, X))b x c b101101
workaround maxgaps(0, ordered(X, X + zzz))c b c b111111
phrase(z, ordered(W, W)), W = or(b, unordered(a, b))z a b a001100

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/queries 52 suites, 517 tests (122 in the intervals package, 5 of them new), 0 failures with assertions on and off (5 and 6 skipped); lucene/queryparser 30 suites, 533 tests, and lucene/highlighter 34 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/queries with assertions on and off); :lucene:queries:check was 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-ordered scope test fails), the real-synonym probe all 1, and the fn: query string matches with Weight#matches agreeing. 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.build folds 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)) on z a b a gives 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: name and equals. A lets name decide what pullUpDisjunctions() returns, but RepeatingIntervalsSource.equals and hashCode ignore name, so a named whole-call repeat and an otherwise equal unnamed repeat inside a longer ordered/unordered compare 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 or of 32 two-word phrases passed twice builds 1,025 branches; 33 throw IllegalArgumentException: 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 longer unordered (open design question).
  • Scores and Weight#matches see the extra branches only where they add intervals; no benchmark was run.

Drop the deduplication in ordered (the LFV-807 fix): partial alone, apply it with A

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

Rebuild each combination with the owning factory (the finder's sketch), not recommended

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

Document the limitation

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 note

Downstream relevance: an exclusion built from such a query (a MUST_NOT clause or notContaining) silently keeps the documents it should drop.

How it was found

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 (how or is 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).

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits: apache/lucene, the archived apache/lucene-solr, Elasticsearch, OpenSearch, Solr2026-10-27a scripted prior-report search, four runs (identifiers; trigger and symptom; generic): 16 issue/PR, 3 commit and 2 downstream queries each; 54 manual GraphQL queriesonly the design issues #10141, #9523, #10244 and their PRs name RepeatingIntervalsSource or pullUpDisjunctions
dev@, java-user@, issues@, commits@ lists; Solr lists; JIRA archive; web2026-10-2720 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 code2026-10-27changed 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_6no open PR touches the fix site; the method is unchanged since 7c1ba1aebee
Verifier passes2026-10-27The independent reproduction, the cross-model review, the intended-behaviour check and the adjudication, read-only GitHub, list and web searchesno report

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe 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) affectedthe finder's test 3 of 3 fail, assertions on and off
10.5.2 affected3 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 affected3 of 3 fail
8.5.0 first3 of 3 fail; the reproducer's 4 bug tests fail
aa916bac3c3 vs parent 3246b260586 (on the 8.5.0 jars) bisectthe introducing commit fails 3 of 3; its parent passes 3 of 3
8.4.1 before3 of 3 pass (the unordered case only through the over-match LUCENE-9099 fixed)
main + fix A not affectedthe 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 affectedthe repro passes (on and off); queries 517 (on and off), queryparser 533 and highlighter 722 tests, 0 failures; the over-match probe as expected

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateModelling 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 realOwn 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 realBuilt 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 reproducesSeeds 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 testedNew 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 fixedA 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,374 words

Finding record

LFV-809, snapshot of 2026-11-06

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 head 4ae946e5a9b, and on the 8.5.0 jars with the intervals package of the introducing commit aa916bac3c3 and of its parent 3246b260586. Fix A, and fix A together with the LFV-807 fix, were each run in a scratch worktree: the new test, the whole lucene/queries module with assertions on and off, and the lucene/queryparser and lucene/highlighter modules with assertions on; :lucene:queries:check -x test was run for A.

Lucene and upstream sources

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-80
  • lucene/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-120
  • lucene/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-71
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:93-96, (at a9779ef3c193) :103-113; OrderedIntervalsSource.java:29-55; Disjunctions.java:79-94

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

Repro files

About the diagrams

A step-by-step picture of the 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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.