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.
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.- Index one document,
x b c d(positions 0 to 3), with default settings. Take W =maxwidth(5, or(phrase(b, c), b)): anorwith a two-word alternativeb cand a one-word alternativebinside it. - Search
phrase(x, W, d): 1 hit. Lucene rewrites anorunder a phrase into one phrase per alternative, sox [b c] dis found. - 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. - Why: the rewrite asks the outer
orfor its alternatives only one level deep, gets [W, y], and never splits theorinside W. That innerorkeeps only minimal intervals: it dropsb c[1,2] for theb[1,1] inside it, so the phrase looks forx b d. - Analyzed text can supply the wrapped
or: with the query-time synonym york => "new york",phrase(in, or(A, boston))missesin new york city, where A isanalyzedText("york city")(ordered, no gap limit) andphrase(in, A)finds it.
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.
- 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)returnsb[1,1] and dropsb c[1,2], which contains it. - The rewrite (LUCENE-8477, 8.1.0): a gap-sensitive parent (
phrase,maxgaps, the big side ofcontainedByandcontaining) splits anoramong its arguments into one parent per alternative.phrase(x, or("b c", b), d)becomesor(phrase(x, "b c", d), phrase(x, b, d))and findsx b c d. The class javadoc promises this;or(false, ...)is the documented, lossy opt-out. - Wrappers:
maxwidth,fixField,extend,orderedandunordereddo not split at construction. They hand their alternatives up when a parent asks (pullUpDisjunctions()).
phrase(...)asksDisjunctions.splitDisjunctionsfor the alternatives of each argument (BlockIntervalsSource.java:34,Disjunctions.java:39).splitDisjunctionstakessource.pullUpDisjunctions()once (Disjunctions.java:82). Foror(W, y)that is[W, y](DisjunctionIntervalsSource.java:146-152); W's own alternatives are never asked for, althoughMaxWidth.pullUpDisjunctions()would split them (FilteredIntervalsSource.java:72-74; likewiseExtendedIntervalsSource.java:134-143,FixedFieldIntervalsSource.java:59-68,OrderedIntervalsSource.java:78-80).- Two paths. (a) W has minExtent 1 (its alternative
b), so it is grouped with y into oneor(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 asordered(or(a, "b a"), c), gets its own branch but is still not split. - The query is built as
BLOCK(x,or(MAXWIDTH/5(or(BLOCK(b,c),b)),y),d). At search time the inneroryields onlyb[1,1], the phrase needsdto start at position 2, butdis at 3, so it returns nothing. - A plain nested
or(or("b c", b), y)works:DisjunctionIntervalsSource.simplifyflattens a directorchild (DisjunctionIntervalsSource.java:58-68). Any non-orwrapper in between stops it, and so does an inneror(false, ...), which lists only itself, as the documented opt-out intends.
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
Intervalsfactories, ofIntervals.analyzedText, and of the flexibleStandardQueryParserfn:functions (from 9.1.0;fn:hasor,maxwidth,extend,ordered,unordered,phrase,maxgapsandcontainedBy, but nofixField). No special settings (the probes use a plainIndexWriterConfigwith the default codec and a plainIndexSearcher; 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)intoORDERED(or(BLOCK(new,york),york),city)(IntervalBuilder.java:227builds theorof synonym paths;:143-146returns a bareorderedwhen maxGaps is -1). Onin new york city,phrase(in, A)finds 1 hit, whilephrase(in, or(A, boston))andmaxgaps(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
orwhose short alternative can sit inside its long one, inside a non-orwrapper that is itself an alternative of anotheror, 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
4ae946e5a9bis affected, so the released 10.6.0 has it.
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.
/*
* 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));
}
}
}
}
# 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 (
yoccurs nowhere, andphrase(x, maxwidth(5, or(phrase(b, c), b)), d)matches). Actual: 0 hits (expected:<1> but was:<0>).
More files, environment, full output39 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: f: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)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
testNestedMaxWidthasserts the rewrite throughmaxwidth; 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
Intervalsclass 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", withor(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 throughmaxwidthandfixField; no upstream test puts such a wrapper inside anotheror. The repro'stestNestedMaxWidthInsideOristestNestedMaxWidth'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, whilephrase(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.
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 returnsthisplus the alternative sequences for a wholeunordered(X, X). With that fix and the unguarded option B,phrase(z, unordered(X, X))throwsStackOverflowError; 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 withequals; 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/unorderedwhen 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 ofcontainedBy/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,TestDisjunctionRewrites12 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 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)).
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.
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 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.
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,containedByis a subset ofoverlapping, andoris 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 tocontainedByoveror(fixField(g, or(BLOCK(a, a), z)), a), generalised it with a probe tophrase,maxwidthandextend, and wrote a one-document public-API JUnit on the stock jar. - Independent AI tools then reproduced, reviewed and judged it (see the checks).
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits: apache/lucene, the archived apache/lucene-solr, Elasticsearch, OpenSearch, Solr | 2026-10-28 | three runs of a scripted prior-report search (identifiers; symptoms and query parser; downstream) plus manual searches; 295 queries in all | only the design issues #9523 and #10244 name splitDisjunctions or pullUpDisjunctions |
| dev@, java-user@, issues@ lists; JIRA archive; web | 2026-10-28 | list and JIRA queries per tool run; manual web queries | notification mirrors of the design issues and unrelated threads only |
| Open PRs and upstream code | 2026-10-28 | changed 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_10x | no PR touches the fix site; the newest intervals commit (#16899) is unrelated and already in a9779ef3c193 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this 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) | affected | the finder's test 4 of 4 fail (its intervals sources over the 10.5.2 jars) |
| 10.5.2, 9.12.3 | affected | the 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) | affected | history 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 | first | probe and JUnit port: controls pass, nested forms fail |
2571bf355f8 vs parent b08566cacc7 (on the 8.1.0 jars) | bisect | the commit shows the bug in 4 of 4 shapes; its parent has no rewrite (both forms 0) |
| 8.0.0 | no rewrite | no rewrite at all: the direct form misses too, so the inconsistency does not exist |
| main + fix A | not affected | this doc's 6 tests and the query-text test pass; the queries, queryparser and highlighter suites pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Law-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 | real | Own 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 | keep | Finder's JUnit with a fresh seed: 4 of 4 fail; own probe adds an unordered shape. |
| this doc's brief | confirmed | 6-test JUnit (gradle and standalone), synonym, scope and shape probes, the fix-interaction table, and the gradle suites with fix A. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 461 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,417 words
- 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_64ae946e5a9b(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 commit2571bf355f8and its parent (their intervals sources over the 8.1.0 jars). The fix was validated ona9779ef3c193.
- 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-227lucene/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-6506lucene/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
- TestPhraseOverOrOfWrappedOr.java (JUnit test):
repro/LFV-826/src/org/apache/lucene/queries/intervals/TestPhraseOverOrOfWrappedOr.java(50 lines)
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.
assets/LFV-826/bug.png(461 KB)
![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.](../assets/LFV-826/bug.png)