All findings · Lucene finding · LFV-826 · intervals-disjunction-rewrite-skips-wrapped-or-inside-or

An Intervals.or inside maxwidth, fixField or ordered is not rewritten when it is one alternative of another or, so phrase, maxgaps and containedBy miss hits

phrase(x, maxwidth(5, or("b c", b)), d) finds x b c d, but phrase(x, or(maxwidth(5, or("b c", b)), y), d) (y occurs nowhere) finds 0. The rewrite looks only one level into an or, so the inner or drops its longer alternative; every minor line since 8.1.0 has it.

Summary

Priority
Low, rank 348Needs a doubly nested or() inside maxwidth, fixField, extend or ordered under a gap-sensitive parent, a rare query shape.
Impact
silent wrong results
Since
8.1.0 (LUCENE-8477, 2019)introduced 2019-03-27 · about 7 years
Affected
8.1.0 to 10.5.2 (every minor line run), 10.6.0 (branch_10_6 4ae946e5a9b intervals sources) and main; 8.0.0 has no rewrite at all
Status
documented
Found by
reference-free law checking of the real query code (semi-formal)

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

Under a phrase, Lucene splits an or into one phrase per alternative, so that a longer alternative is not lost. The split does not reach an or that sits inside a wrapper which is itself one alternative of another or.

The document x b c d at positions 0 to 3 and W = maxwidth(5, or("b c", b)). The direct query phrase(x, W, d) is split into one phrase per alternative and finds 1 hit through b c at [1,2]. The nested query phrase(x, or(W, y), d), with y in no document, is not split: the inner or keeps only b at [1,1] and drops b c, the phrase needs d at position 2, and it finds 0 hits.
The example x b c d: the direct query is split and finds 1 hit; inside another or the wrapper is not split, the inner or keeps only b, and the query finds 0. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index one document, x b c d (positions 0 to 3), with default settings. Take W = maxwidth(5, or(phrase(b, c), b)): an or with a two-word alternative b c and a one-word alternative b inside it.
  2. Search phrase(x, W, d): 1 hit. Lucene rewrites an or under a phrase into one phrase per alternative, so x [b c] d is found.
  3. Now search phrase(x, or(W, y), d), where the word y is in no document. You expect the same 1 hit, since y adds nothing. You get 0 hits, with assertions on or off, and no error.
  4. Why: the rewrite asks the outer or for its alternatives only one level deep, gets [W, y], and never splits the or inside W. That inner or keeps only minimal intervals: it drops b c [1,2] for the b [1,1] inside it, so the phrase looks for x b d.
  5. Analyzed text can supply the wrapped or: with the query-time synonym york => "new york", phrase(in, or(A, boston)) misses in new york city, where A is analyzedText("york city") (ordered, no gap limit) and phrase(in, A) finds it.

Who is affected

Anyone whose interval query puts an or with alternatives of different lengths inside maxwidth, fixField, extend, ordered or unordered and uses that as one alternative of another or under phrase, maxgaps or containedBy: Java factories, analyzedText with multi-word synonyms, or fn: query strings. Default settings, any index size, 8.1.0 to 10.5.2 (every minor line run), 10.6.0 and main. Rare, but silent.

Mechanism, terms and numbers740 words

Intervals.or promises to rewrite itself under a gap-sensitive parent (phrase, maxgaps, the big side of containedBy and containing) into one parent per alternative, because a disjunction drops any of its intervals that contains another one and a longer alternative would otherwise vanish (LUCENE-8477, 8.1.0). The rewrite asks an or for its alternatives only one level deep (Disjunctions.splitDisjunctions), so an or that sits inside maxwidth, fixField, extend, ordered or unordered, where that wrapper is itself one alternative of another or, is never split. With W = maxwidth(5, or("b c", b)), phrase(x, W, d) finds x b c d but phrase(x, or(W, y), d), with y in no document, finds nothing; the same holds for fixField, extend, ordered(or(a, "b a"), c), under maxgaps(0, ordered(...)), containedBy and containing, for the query string fn:phrase(x fn:or(fn:maxwidth(5 fn:or("b c" b)) y) d), and for analyzed text with a multi-word synonym. A plain nested or(or(...), y) is flattened and works. It reproduces on 8.1.0, 8.1.1, the latest patch of every minor line through 10.5.2 and the 10.6.0 intervals sources (4ae946e5a9b), and it is not fixed upstream. Expanding nested alternatives recursively in splitDisjunctions, with a guard against sources that list themselves, fixes every shape run and passes the queries, queryparser and highlighter test suites.

Terms

  • Minimal intervals: an interval query returns position ranges per document. A disjunction returns only the intervals of its alternatives that contain no other of its intervals. In x b c d, or("b c", b) returns b [1,1] and drops b c [1,2], which contains it.
  • The rewrite (LUCENE-8477, 8.1.0): a gap-sensitive parent (phrase, maxgaps, the big side of containedBy and containing) splits an or among its arguments into one parent per alternative. phrase(x, or("b c", b), d) becomes or(phrase(x, "b c", d), phrase(x, b, d)) and finds x b c d. The class javadoc promises this; or(false, ...) is the documented, lossy opt-out.
  • Wrappers: maxwidth, fixField, extend, ordered and unordered do not split at construction. They hand their alternatives up when a parent asks (pullUpDisjunctions()).

The bug

  1. phrase(...) asks Disjunctions.splitDisjunctions for the alternatives of each argument (BlockIntervalsSource.java:34, Disjunctions.java:39).
  2. splitDisjunctions takes source.pullUpDisjunctions() once (Disjunctions.java:82). For or(W, y) that is [W, y] (DisjunctionIntervalsSource.java:146-152); W's own alternatives are never asked for, although MaxWidth.pullUpDisjunctions() would split them (FilteredIntervalsSource.java:72-74; likewise ExtendedIntervalsSource.java:134-143, FixedFieldIntervalsSource.java:59-68, OrderedIntervalsSource.java:78-80).
  3. Two paths. (a) W has minExtent 1 (its alternative b), so it is grouped with y into one or (Disjunctions.java:83-92), against the comment's assumption that "all combinatorial sources have a minExtent > 1" (:78). (b) An alternative with minExtent 2, such as ordered(or(a, "b a"), c), gets its own branch but is still not split.
  4. The query is built as BLOCK(x,or(MAXWIDTH/5(or(BLOCK(b,c),b)),y),d). At search time the inner or yields only b [1,1], the phrase needs d to start at position 2, but d is at 3, so it returns nothing.
  5. A plain nested or(or("b c", b), y) works: DisjunctionIntervalsSource.simplify flattens a direct or child (DisjunctionIntervalsSource.java:58-68). Any non-or wrapper in between stops it, and so does an inner or(false, ...), which lists only itself, as the documented opt-out intends.

The numbers

One-document indexes, default settings, main a9779ef3c193, assertions on and off. Every direct form finds 1 hit; every nested form, with an extra alternative that occurs nowhere, finds 0. With the recommended fix every nested form finds 1.

direct form (1 hit)nested form (0 hits)document
phrase(x, maxwidth(5, or("b c", b)), d)phrase(x, or(maxwidth(..), y), d)x b c d
phrase(x, fixField(g, or("b c", b)), d)phrase(x, or(fixField(..), y), d)x b c d
containedBy(c, W), containing(W, c)containedBy(c, or(W, y)), containing(or(W, y), c)x b c d
maxgaps(0, ordered(x, W, d))maxgaps(0, ordered(x, or(W, y), d))x b c d
phrase(q, ordered(or(a, "b a"), c), e)phrase(q, or(ordered(..), y), e)q b a c e
phrase(a, maxwidth(4, or(ordered(b, c), ordered(d, e))), f) (upstream's testNestedMaxWidth)the same inside or(.., y)a b d e c f
phrase(in, A), A = analyzedText("york city") with synonym york => "new york"phrase(in, or(A, boston))in new york city
fn:phrase(x fn:maxwidth(5 fn:or("b c" b)) d)fn:phrase(x fn:or(fn:maxwidth(5 fn:or("b c" b)) y) d)x b c d
Exposure and likelihood246 words
  • Who. Users of the Intervals factories, of Intervals.analyzedText, and of the flexible StandardQueryParser fn: functions (from 9.1.0; fn: has or, maxwidth, extend, ordered, unordered, phrase, maxgaps and containedBy, but no fixField). No special settings (the probes use a plain IndexWriterConfig with the default codec and a plain IndexSearcher; the JUnit uses the test framework's randomized ones); any index size; assertions on or off. Nothing is written to the index, so a fixed Lucene fixes old indexes too.
  • A realistic trigger. A query-time synonym york => "new york" (original kept) turns Intervals.analyzedText("york city", analyzer, "f", -1, true) into ORDERED(or(BLOCK(new,york),york),city) (IntervalBuilder.java:227 builds the or of synonym paths; :143-146 returns a bare ordered when maxGaps is -1). On in new york city, phrase(in, A) finds 1 hit, while phrase(in, or(A, boston)) and maxgaps(0, ordered(in, or(A, boston))) find 0. That is "a multi-word synonym inside one alternative of an any-of, under a zero-gap ordered parent".
  • Silent. Fewer hits, no exception. Under a negation the miss could become an extra hit; that was not run.
  • Rare. It needs an or whose short alternative can sit inside its long one, inside a non-or wrapper that is itself an alternative of another or, under a gap-sensitive parent. No report was found since it shipped in 2019. For such a query and document it misses every time.
  • Not yet fixed. branch_10_6 4ae946e5a9b is affected, so the released 10.6.0 has it.

Minimal repro

The test shows that phrase(x, or(maxwidth(5, or(phrase(b, c), b)), y), d) misses x b c d, because the disjunction rewrite splits the outer or() only one level deep and never splits the or() inside maxwidth.

TestPhraseOverOrOfWrappedOr.java (JUnit test)50 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 TestPhraseOverOrOfWrappedOr extends LuceneTestCase {

  public void testPhraseRewritesOrInsideMaxWidthInsideOr() 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", "x b c d", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IntervalsSource inner = Intervals.or(Intervals.phrase("b", "c"), Intervals.term("b"));
        IntervalsSource alt = Intervals.or(Intervals.maxwidth(5, inner), Intervals.term("y"));
        IntervalQuery q =
            new IntervalQuery("f", Intervals.phrase(Intervals.term("x"), alt, Intervals.term("d")));
        // y occurs nowhere and phrase(x, maxwidth(5, inner), d) matches: expected 1 hit, actual 0
        assertEquals(q + " on 'x b c d'", 1, new IndexSearcher(reader).count(q));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-826/, 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.TestPhraseOverOrOfWrappedOr
Expected vs actual
Expected: 1 hit (y occurs nowhere, and phrase(x, maxwidth(5, or(phrase(b, c), b)), d) matches). Actual: 0 hits (expected:<1> but was:<0>).

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

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:BLOCK(x,or(MAXWIDTH/5(or(BLOCK(b,c),b)),y),d) on 'x b c d' 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.TestPhraseOverOrOfWrappedOr.testPhraseRewritesOrInsideMaxWidthInsideOr(TestPhraseOverOrOfWrappedOr.java:46)

Why it looks unintended

  • Intervals.or "Automatically rewrites if wrapped by an interval source that is sensitive to internal gaps"; or(false, ...) is documented to "miss valid hits".
  • Upstream testNestedMaxWidth asserts the rewrite through maxwidth; its query plus one never-matching alternative loses the hit.
  • or(W, y) has the same intervals as W when y occurs nowhere, so adding y must not remove a hit.
The full argument232 words
  • Contract. The Intervals class javadoc (Intervals.java:47-54): gap-sensitive sources "will rewrite their sub-sources so that disjunctions of different lengths are pulled up to the top of the interval tree", with or(boolean, ...) for those who "care more about speed than about accuracy". Intervals.or (:121): "Automatically rewrites if wrapped by an interval source that is sensitive to internal gaps". or(false, ...) (:130-131): "can miss valid hits due to minimization". CHANGES 8.1.0, LUCENE-8477: "Interval disjunctions could miss valid hits if some of the clauses of the disjunction are minimized away. We now rewrite intervals ...".
  • Tests. TestDisjunctionRewrites#testNestedMaxWidth (:138-154) and #testNestedFixField (:156-166) assert the rewrite through maxwidth and fixField; no upstream test puts such a wrapper inside another or. The repro's testNestedMaxWidthInsideOr is testNestedMaxWidth's query plus one outer alternative, and it loses its hit.
  • Consistency. The nested query with the default rewrite behaves like the documented-lossy or(false, W, y): both 0 hits, while phrase(x, W, d) finds 1.
  • Maintainers. On LUCENE-8477 (#9523), romseygeek gave the reason to rewrite inside the library: "disjunctions can end up being wrapped multiple times". On 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" (romseygeek) and "it's the key for correctness ... If it's not correct, it's a bug, not a feature" (jimczi).
The devil's-advocate check184 words

The strongest case for "intended": the comment above splitDisjunctions (Disjunctions.java:75-78) describes a one-level split; before the LUCENE-8477 PR jimczi suggested documenting "other pathological cases due to minimum interval semantics" instead of rewriting; with minimal-interval semantics W alone only ever yields one-position intervals; the Expert hook pullUpDisjunctions() promises nothing about depth (IntervalsSource.java:66-71); and the package is marked experimental.

Why it fails: the comment states an assumption ("all combinatorial sources have a minExtent > 1") that these wrappers break, and path (b), with minExtent 2, fails without any grouping, so it does not describe a deliberate limit. The project chose to rewrite by default and to document or(false, ...) as the lossy option; nothing documents a depth limit, users rely on the Intervals javadoc rather than the Expert hook, and the built-in wrappers already answer correctly when asked (whether to recurse into custom hooks is a maintainer decision). Minimization is exactly what the rewrite exists to work around, and the direct form is rewritten for that reason. "Experimental" is an API-stability label, not permission for wrong results. Verdict: not intended, a defect.

Suggested fix

Recommended Expand nested alternatives recursively in splitDisjunctions, with a guard

In Disjunctions.splitDisjunctions, expand an alternative that has disjunctions of its own, recursively, and skip a source that lists itself (one private method in Disjunctions.java). The repro and the queries, queryparser and highlighter suites pass. Without the guard, the candidate fix of the sibling bug LFV-809 makes the recursion overflow the stack.

All options and trade-offs4 options ยท 733 words

Option A was built, formatted and tested in a scratch worktree of a9779ef3c193; option B is the finder's sketch, tested with the standalone runner; C is untested; D is a workaround for users.

Expand nested alternatives recursively in splitDisjunctions, with a guard (validated by the checks) (recommended)

The change is +29/-1 lines in Disjunctions.java, one private method. Before the existing minExtent grouping, an alternative whose own pullUpDisjunctions() returns more than one source is replaced by those sources, recursively. A source already being expanded (one that lists itself or an ancestor among its disjuncts) is kept whole. The nested form then builds the direct form's branches, with y added to the one-position branch. Sources that return one disjunct (terms, phrases, or(false, ...), atLeast) are untouched, so the opt-out stays lossy.

List<IntervalsSource> disjuncts = new ArrayList<>();
expandDisjunctions(source, source.pullUpDisjunctions(), new HashSet<>(), disjuncts);
for (IntervalsSource disj : disjuncts) { ... }   // the existing minExtent grouping

private static void expandDisjunctions(IntervalsSource source,
    Collection<IntervalsSource> disjuncts, Set<IntervalsSource> expanding,
    List<IntervalsSource> out) {
  expanding.add(source);
  for (IntervalsSource disj : disjuncts) {
    Collection<IntervalsSource> inner =
        expanding.contains(disj) ? Collections.emptyList() : disj.pullUpDisjunctions();
    if (inner.size() > 1) {
      expandDisjunctions(disj, inner, expanding, out);
    } else {
      out.add(disj);
    }
  }
  expanding.remove(source);
}

Trade-offs

  • Why the guard. pullUpDisjunctions() is a public Expert hook. LFV-809's candidate fix returns this plus the alternative sequences for a whole unordered(X, X). With that fix and the unguarded option B, phrase(z, unordered(X, X)) throws StackOverflowError; with option A it finds its hit, and LFV-809's own tests pass with both (so they would not catch it). The guard compares with equals; a custom source that invents new unequal sources forever would still recurse. The hook's javadoc says nothing about depth, so recursing into custom implementations at all is a question for maintainers.
  • Cost. More branches only for the affected nested shapes. Where the split runs in the list overload of Disjunctions.pullUp (phrase, overlapping, ordered/unordered when asked), the clause guard (Disjunctions.java:43-45) checks the product and nested alternatives now count toward it, so a query near the limit can now throw "Too many disjunctions to expand". The single-source overload (:66-73: maxwidth, the big side of containedBy/containing, notContainedBy/notContaining) has no clause guard, today or with the fix (read, not run). No benchmark was run.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: :lucene:queries:check :lucene:queryparser:check -x test (format, ecjLint, forbiddenApis) passes; the formatter leaves the fix unchanged.
  • The 6-test repro passes 6 of 6 and the query-text test 1 of 1 (on stock: 5 of 6 and 1 of 1 fail).
  • :lucene:queries:test :lucene:queryparser:test :lucene:highlighter:test (seed DEADBEEF, assertions on): 0 failures; queries 52 suites, 518 tests (123 in the intervals package, TestDisjunctionRewrites 12 of 12), 5 skipped; queryparser 31 suites, 534 tests, 1 skipped; highlighter 34 suites, 722 tests.
  • :lucene:queries:test (seed 0D15C0DE, assertions off): 52 suites, 518 tests, 0 failures.
  • The synonym, scope and shape probes give 1 hit for every nested form; the finder's tests, the independent reproducer's and the audit's tests pass and the devil's-advocate probe matches, as with option B. With LFV-809's candidate fix also applied, every probe line matches and LFV-809's tests pass.

The finder's sketch: the same recursion without the guard

The change is +18/-1 lines. Passes the finder's 4 tests, the fn: test, 117 upstream interval tests and 23 query-parser tests (standalone runner), the independent verifiers' tests, and this doc's 6 tests.

Trade-offs

Shorter, but it recurses without end when a source lists itself among several disjuncts: executed with LFV-809's candidate fix, StackOverflowError for phrase(z, unordered(X, X)).

Flatten wrapped alternatives when the or is built (not tested)

DisjunctionIntervalsSource.simplify (:58-68) could replace any sub-source whose pullUpDisjunctions() has more than one entry, as it already does for nested ors.

Trade-offs

Changes every rewriting or tree, also outside gap-sensitive parents (toString, equals, cache keys, matches and highlighting), needs the same guard, and reverses the wrappers' "do not pull up at construction time" design.

Workaround until a fix ships

Distribute by hand so that each wrapper sits directly under the gap-sensitive parent: or(phrase(x, W, d), phrase(x, y, d)) instead of phrase(x, or(W, y), d); or use a plain nested or without the wrapper where the wrapper is not needed.

Trade-offs

The application must know which alternatives contain an or; analyzed text hides it.

Downstream note

Downstream servers that build interval queries from user text, for example a multi-word synonym inside one alternative of an any-of, can produce this shape; no downstream product was tested.

How it was found

An automated check of Lucene's query code against algebraic laws that need no reference implementation (for example, each interval comes back from exactly one of a relation and its negation; or is commutative), over 30,000 random query pairs on 363 short documents. One mismatch class that no known bug explained was reduced by hand to this case.

The method in full161 words

Method: reference-free translation validation of the real jar against algebraic laws (the hunt's "semi-formal" tier).

  • A hunt unit on interval relations wrote a harness (ComplementLawsTV) that runs real interval sources on an index of 363 short documents and checks laws that need no reference semantics: each minimal interval of X is returned by exactly one of a relation and its negation, containedBy is a subset of overlapping, and or is commutative and idempotent. Every known interval fix was shadowed in, so that known bugs would not show up.
  • A run of 30,000 random depth-3 pairs (24,510 checked) left one class it could not attribute (within0:neither:OTHER, 142 rows). The finder reduced it by hand to containedBy over or(fixField(g, or(BLOCK(a, a), z)), a), generalised it with a probe to phrase, maxwidth and extend, and wrote a one-document public-API JUnit on the stock jar.
  • 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-28): 295 queries over GitHub issues, PRs and commits, the archived lucene-solr repository, the mailing lists, the JIRA archive, downstream repositories and the web, plus the changed files of all 411 open PRs.

Searches3 searches

No issue, PR, review comment, JIRA ticket, list thread or downstream report describes an or inside a wrapper inside another or escaping the rewrite. splitDisjunctions and the wrappers' pullUpDisjunctions are unchanged in substance since LUCENE-8477 on upstream main, branch_10_6 and branch_10x, and no open PR touches them.

Closest, not this bug: #9523 / lucene-solr#620 (LUCENE-8477, the rewrite and the one-level split; the only inline review comment asked why single-position sources are grouped); #10244 (LUCENE-9204, maintainers on the rewrite as needed for correctness); #9243 (LUCENE-8196, the pre-rewrite problem that adding an OR clause can lose a match); #11442 (LUCENE-10406, an extra match with or and extend, another fixed iterator bug); #10100 (LUCENE-9058, which field matches() reports for or(fixField), won't fix); elastic/elasticsearch#65201 (use_field, not a bug); #16899 and #16851 (unrelated).

Limits: keyword search only; GitHub search reads the first page of hits; one late search hit the rate limit (an earlier scan of all open PRs covers it); 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-28three runs of a scripted prior-report search (identifiers; symptoms and query parser; downstream) plus manual searches; 295 queries in allonly the design issues #9523 and #10244 name splitDisjunctions or pullUpDisjunctions
dev@, java-user@, issues@ lists; JIRA archive; web2026-10-28list and JIRA queries per tool run; manual web queriesnotification mirrors of the design issues and unrelated threads only
Open PRs and upstream code2026-10-28changed files of all 411 open apache/lucene PRs; the 74 items updated since 2026-10-25; intervals commit history on main, branch_10_6 and branch_10xno PR touches the fix site; the newest intervals commit (#16899) is unrelated and already in a9779ef3c193

History

Introduced by LUCENE-8477 (2571bf355f8 on branch_8x, f1782d0dd11 on master, 2019-03-27), which added the rewrite itself; first released in 8.1.0. The latest patch of every minor line from 8.1 to 10.5, 10.6.0 (branch_10_6 4ae946e5a9b, its intervals sources) and main are affected; 8.0.0 has no rewrite at all.

Versions tested8 rows

Introduced by LUCENE-8477 "Automatically rewrite disjunctions when internal gaps matter" (@romseygeek; apache/lucene-solr#620, the JIRA issue is now apache/lucene#9523): 2571bf355f8 on branch_8x (first released in 8.1.0) and f1782d0dd11 on master (released from 9.0.0), both 2019-03-27. It added the rewrite, the one-level splitDisjunctions with its minExtent-1 grouping, the wrappers' lazy pull-ups and the javadoc promise; later commits reformat or move these lines, and LUCENE-8828 (67677d995e8) put return subSources under the flag that or(false, ...) clears, leaving the default path as it was.

An incomplete fix, not a regression: before 8.1.0 nothing was rewritten, so the direct form missed too; the nested form missed in every release run.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test: 5 of 6 fail (gradle and standalone, assertions on and off); the finder's 4 of 4
branch_10_6 4ae946e5a9b (10.6.0, released 2026-10-29) affectedthe finder's test 4 of 4 fail (its intervals sources over the 10.5.2 jars)
10.5.2, 9.12.3 affectedthe finder's test 4 of 4 fail; the independent reproducer's 5 of 6
10.4.0 ... 9.1.0, 9.0.0, 8.11.4 ... 8.1.1 (latest patch of every minor line) affectedhistory probe: the bug in 4 of 4 shapes; the query-text test also fails on 9.1.0 (where fn: intervals arrived) and 10.5.2
8.1.0 firstprobe and JUnit port: controls pass, nested forms fail
2571bf355f8 vs parent b08566cacc7 (on the 8.1.0 jars) bisectthe commit shows the bug in 4 of 4 shapes; its parent has no rewrite (both forms 0)
8.0.0 no rewriteno rewrite at all: the direct form misses too, so the inconsistency does not exist
main + fix A not affectedthis doc's 6 tests and the query-text test pass; the queries, queryparser and highlighter suites pass

Independent checks

An independent reproducer, a devil's advocate and reviews from another model family, all with their own tests, agree on the cause, and the adjudication confirmed it (high). This doc added a 6-test repro, probes for analyzed text and more parents, and a guarded fix tested on three modules.

Checks and verdicts265 words

Every verifier ran its repro with assertions on and off. The cross-model reviews (an independent reviewer from another model family, OpenAI GPT-5.6) ran with command execution on main ea57360912ed, whose Disjunctions.java and wrapper sources are identical to a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateLaw-checking harness, then a 4-test public-API JUnit: 4 of 4 fail on main (-ea, -da), 10.5.2, 9.12.3, the branch_10_6 sources, and with every other known interval fix applied; the fn: test fails too.
cross-model review, refute lens real (high)Own JUnit fails with 4 seeds (one -da), on 10.5.2 and 9.12.3, and passes with the fix sketch; 117 interval tests pass.
Independent reproducer realOwn 6-test JUnit outside the package: 5 fail on main (-ea, -da, 20 seeds), 10.5.2 and 9.12.3; found the minExtent-2 path.
Cross-model review, intended lens real (high)Intended argument weak; own JUnit fails on main, 10.5.2 and 9.12.3 and passes with the fix sketch.
Intended-behaviour devil's advocate real (high)Built the strongest "intended" case and rejected it; its probe found the ordered and maxgaps shapes.
adjudication confirmed (high)The independent reproduction's JUnit with a fresh seed: 5 of 6 fail with -ea and -da; not a duplicate; security none.
credibility audit keepFinder's JUnit with a fresh seed: 4 of 4 fail; own probe adds an unordered shape.
this doc's brief confirmed6-test JUnit (gradle and standalone), synonym, scope and shape probes, the fix-interaction table, and the gradle suites with fix A.

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

Finding record

LFV-826, snapshot of 2026-11-06

Registry title
The intervals disjunction rewrite looks only one level into an or(), so an or() inside a maxwidth, fixField or extend wrapper (or inside an ordered()) that is itself one alternative of another or() is never split, and phrase, maxgaps or containedBy miss hits: phrase(x, or(maxwidth(5, or("b c", b)), y), d) misses 'x b c d', which phrase(x, maxwidth(5, or("b c", b)), d) matches
Area
queries / intervals (gap-sensitive disjunction rewrite of LUCENE-8477: Disjunctions.splitDisjunctions and Disjunctions.pullUp vs an alternative of an or() that has disjunctions of its own: maxwidth, extend, fixField, ordered)
Priority
Low, rank 348: Needs a doubly nested or() inside maxwidth, fixField, extend or ordered under a gap-sensitive parent, a rare query shape.
Impact class
silent wrong results
Impact note
Anyone whose interval query puts an or() with alternatives of different lengths inside maxwidth, fixField or extend (or inside an ordered()) and uses that as one alternative of another or() under a gap-sensitive parent (phrase, maxgaps, or containedBy over it as the reference), through the Java Intervals factories or StandardQueryParser fn: strings, with default writer, codec, analyzer and searcher settings, at any index size, with -ea and -da, in every release from 8.1.0 to 10.6.0 and on main, silently misses documents that the same query without the outer or() matches; a plain nested or(or(...), y) and the directly wrapped form are not affected, and nothing is written to the index.
Summary
You build phrase(x, or(W, y), d) with W = maxwidth(5, or(phrase(b, c), b)) and expect the hit on 'x b c d' that phrase(x, W, d) finds, because Intervals.or 'Automatically rewrites if wrapped by an interval source that is sensitive to internal gaps' and adding an alternative y that occurs in no document cannot remove a match. Instead the rewrite (Disjunctions.splitDisjunctions) asks the outer or() for its alternatives only one level deep, so W reaches the phrase whole (here grouped with y into one minimizing or(), because W's shortest match is one position), the inner or() drops the interval 'b c' in favour of the 'b' it contains, and the query returns no hits. The same happens with fixField(g, or(...)) or extend(or(...), 0, 0) in place of maxwidth, with containedBy or maxgaps as the parent, and with an ordered() alternative whose own or() has alternatives of different lengths.
Introduced
f1782d0dd1195c823c79aab87529ebd7e8217b95 (master, released from 9.0.0) and its branch_8x backport 2571bf355f82e193d50ec2509f83ba698b262562 (the first released form, in 8.1.0), both 2019-03-27: 'LUCENE-8477: Automatically rewrite disjunctions when internal gaps matter' (apache/lucene-solr#620 by romseygeek; the JIRA issue is now apache/lucene#9523). It added the gap-sensitive rewrite with the one-level Disjunctions.splitDisjunctions, its minExtent-1 grouping and the wrapper pull-ups [read: git blame on main; every line of splitDisjunctions and the 'return subSources' line of DisjunctionIntervalsSource.pullUpDisjunctions come from f1782d0dd11, later touches are formatting or refactoring only] [exec: the intervals package at the backport's parent b08566cacc7, shadowed on the 8.1.0 jars, has no rewrite at all (direct and nested forms both miss); the package at 2571bf355f8 shows the bug signature 4 of 4]. An incomplete fix, not a regression: before it no form was rewritten (2019-03-27 (f1782d0dd11 and the branch_8x backport 2571bf355f8))
First affected version
8.1.0 [exec] (8.1.0 and 8.1.1 shipped the intervals package in lucene-sandbox as org.apache.lucene.search.intervals; LUCENE-8856 moved it to the queries module in 8.2.0). 8.0.0 [exec] has no rewrite at all, so there the directly wrapped form misses too and the inconsistency does not exist; the nested query has returned no hit in every release
Affected versions
every release from 8.1.0 through 10.5.2 and 10.6.0 (its intervals sources at branch_10_6 RC1 4ae946e5a9b [exec], below) [exec: the portable history probe shows the bug signature 4 of 4 (maxwidth, extend and fixField under phrase, maxwidth under containedBy) on 8.1.0, 8.1.1 and the latest patch of every minor line from 8.2 to 10.5 (8.2.0, 8.3.1, 8.4.1, 8.5.2, 8.6.3, 8.7.0, 8.8.2, 8.9.0, 8.10.1, 8.11.4, 9.0.0, 9.1.0, 9.2.0, 9.3.0, 9.4.2, 9.5.0, 9.6.0, 9.7.0, 9.8.0, 9.9.2, 9.10.0, 9.11.1, 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0, 10.5.2); the finder JUnit fails 4 of 4 on 8.1.0, 8.11.4 and 9.0.0 (ports with the old package or test-framework imports) and unchanged on 9.12.3 and 10.5.2; the independent reproduction's own JUnit fails 5 of 6 on 10.5.2 (-ea and -da) and 9.12.3; the cross-model reviewers' own JUnits fail on 10.5.2 and 9.12.3]; branch_10_6 head 4ae946e5a9b [exec: its intervals package sources shadowed over 10.5.2 core fail 4 of 4], so the released 10.6.0 has it; main a9779ef3c193 [exec, -ea and -da]; upstream main 20ced71c1e6 [read: the intervals package was last changed by f4cc6ac2097 (#16899), an ancestor of a9779ef3c193; Disjunctions.java last changed by #16056]. Reachable from query text through StandardQueryParser fn: functions from 9.1.0 (LUCENE-10223) [exec on 9.1.0, 10.5.2 and main]; 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 agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (follow-up prior-report record verdict new, searched 2026-10-28 plus the finder's scripted prior-report search and its web leg, read-only; 295 queries in all): three scripted prior-report searches (identifiers; symptoms and query parser; downstream) over apache/lucene issues and PRs, commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@ and issues@ lists, the JIRA archive and the web, manual GitHub searches over apache/lucene and apache/lucene-solr, a changed-file scan of all 411 open apache/lucene PRs, the 74 items updated since 2026-10-25, and the intervals commit history on main, branch_10_6 and branch_10x. Nearest items, none the same bug: #9523 / LUCENE-8477 and lucene-solr#620 (introduced the rewrite and the one-level split; the only inline review comment asked why single-position sources are grouped), #10244 / LUCENE-9204 (maintainers on the rewrite as needed for correctness), #9243 / LUCENE-8196 (the pre-rewrite problem that adding an OR clause can lose a match), #11442 / LUCENE-10406 (disjunction with extend: another, fixed iterator bug), #10100 / LUCENE-9058 (highlighting of or(fixField), won't fix), elastic/elasticsearch#65201 (use_field, not a bug), #16899 (merged 2026-10-26, TooManyClauses on multi-term over-expansion) and #16851 (open, PriorityQueue refactor in the iterators). Not fixed upstream: splitDisjunctions and the wrappers' pullUpDisjunctions are unchanged since f1782d0dd11 on main 20ced71c1e6, branch_10_6 and branch_10x. Re-check (2026-10-28): GitHub search finds no apache/lucene issue or PR about intervals created since 2026-10-26, splitDisjunctions or pullUpDisjunctions only in #9523 and #10244, and the newest commit on the intervals package is still f4cc6ac2097 (#16899, author and commit date 2026-10-26, already in a9779ef3c193); a broader search of PRs updated since 2026-10-26 hit the GitHub rate limit and was not repeated (the follow-up's scan of all open PRs covers it)
Lucene commit tested
main a9779ef3c193 (11.0.0-SNAPSHOT, 2026-10-26) with JDK 25.0.3, assertions on and off, through gradle in a scratch worktree and through a standalone runner; the finder's test (or an import-only port) also on 8.0.0, 8.1.0, 8.11.4, 9.0.0, 9.12.3, 10.5.2 and branch_10_6 4ae946e5a9b (its intervals sources over the 10.5.2 jars), the history probe with the same four shapes on 8.1.1 and the latest patch of every other minor line from 8.2 to 10.4, and also on the introducing commit 2571bf355f8 and its parent (their intervals sources over the 8.1.0 jars). The fix was validated on a9779ef3c193.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Disjunctions.java:28-95 (pullUp and the one-level splitDisjunctions with the comment 'we know that all combinatorial sources have a minExtent > 1'); DisjunctionIntervalsSource.java:146-152 (pullUpDisjunctions returns subSources); FilteredIntervalsSource.java:72-74, ExtendedIntervalsSource.java:134-143, FixedFieldIntervalsSource.java:59-68 (the wrappers expose inner disjunctions only when asked directly); BlockIntervalsSource.java:34 and ContainedByIntervalsSource.java:29 (Disjunctions.pullUp at construction); Intervals.java:47-56 (class javadoc on pulling disjunctions up) and 118-136 (or and or(boolean, ...) javadoc)
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Disjunctions.java:33-95; (at a9779ef3c193) DisjunctionIntervalsSource.java:58-68, 137-152; FilteredIntervalsSource.java:72-74, 128-130; ExtendedIntervalsSource.java:134-143; FixedFieldIntervalsSource.java:59-68; OrderedIntervalsSource.java:78-80; BlockIntervalsSource.java:30-35; Intervals.java:47-54, 121
Who is affected
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalBuilder.java:130-148, (at a9779ef3c193) 215-227
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/parser/StandardSyntaxParser.jj:173-192 (at a9779ef3c193) (fn: functions; no fixField)
Why it looks unintended
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:47-54, (at a9779ef3c193) 118-136; package-info.java:21; lucene/CHANGES.txt:6502-6506
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestDisjunctionRewrites.java:138-166 (at a9779ef3c193)
Suggested fix
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Disjunctions.java:43-45; (at a9779ef3c193) IntervalsSource.java:66-71; DisjunctionIntervalsSource.java:58-68

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