Interval queries compute a parent's gaps as its span minus the widths of its children. A whole ordered(a, a) or unordered(a, a) is built as an internal repeat source whose width is computed per copy (1 + 1 = 2 for two one-position terms), not over its span, so a parent counts the gap inside the pair as its own.
- An interval query returns position ranges:
ordered(a, b)ona x breturns [0,2]. Itswidth()is normally the number of positions it spans, here 3. gaps()of an ordered or unordered interval is its span minus the widths of its immediate children:ordered(ordered(a, b), a)ona x b agives 4 - (3 + 1) = 0. Gaps inside a child do not count.maxgaps(n, ...)keeps only intervals with at most n gaps. Since 10.1.0 (#13819) nested ordered and unordered sources stay separate children of their parent, so their inner gaps stay hidden.
![On the left, the query maxgaps(0, ordered(ordered(a, a), a)) is drawn as a tree over the document "a x a a": child 0, ordered(a, a) at [0,2] and outlined in red, and child 1, a at [3,3], each pass their interval and width() up to the parent ordered, which passes [0,3] and its gaps() up to the maxgaps(0) filter; on the right, an orange 'expected' panel shows child 0 reporting its span 3, so the parent has 4 - 3 - 1 = 0 gaps and keeps the document, and a blue 'actual' panel shows child 0, built as a RepeatingIntervalsSource, reporting 2 (one per copy of a), so the x at position 1 is counted as a gap, the parent has 1 gap and maxgaps(0) drops the document.](../assets/LFV-810/concept.png)
maxgaps(0, ordered(ordered(a, a), a)) on a x a a: the inner pair [0,2] reports width 2 instead of its 3-position span, so the parent computes gaps 4 - (2 + 1) = 1 and drops the document; ordered(a, b) reports 3, and a x b a matches. Select the still to open it at full size. Generated with AI, checked against the code.- Index one document,
a x a a(positions 0-3), and searchmaxgaps(0, ordered(ordered(a, a), a)): the pair "athena", followed bya, with no gap between those two parts. - You expect 1 hit: the pair matches [0,2] and the last
asits at 3, right after it.IntervalIterator#gapscounts only the gaps between a parent's immediate parts, not inside them, andordered(ordered(a, b), a)ona x b adoes give 1 hit. - Instead you get 0 hits. Two equal arguments become one internal repeat source whose
width()is 1 + 1 = 2 instead of its 3-position span, so the parent computes gaps = 4 - (2 + 1) = 1 andmaxgaps(0)drops the document. - The same happens for
ordered(unordered(a, a), b)ona x a b(since 8.5.0),unordered(ordered(a, a), b)andordered(x, ordered(a, a)), also fromfn:query strings. A no-opmaxwidth(100, ...)around the pair brings the hit back; used underMUST_NOT, the document is wrongly kept.
Anyone who nests a whole ordered or unordered of identical sources (ordered(a, a), unordered(a, a), unorderedNoOverlaps(a, a), or analyzedText of a phrase with a repeated word and no gap limit) inside another ordered or unordered, with maxgaps above it, on documents where the repeats are not adjacent. Default settings.
Mechanism, terms and numbers699 words
Intervals.ordered and Intervals.unordered fold equal sub-sources into an internal RepeatingIntervalsSource. When every argument is equal (ordered(a, a), unordered(a, a)), the whole operator is replaced by that repeat source, whose iterators compute width() per copy (1 + 1 = 2 for the two one-position copies in a x a) instead of over the interval's span (3). Counting per copy is right for a run inside a larger operator (ordered(a, a, b)), but when the repeat stands for a whole operator nested in another ordered or unordered, the parent, which computes its gaps as its span minus its children's widths, counts the child's inner gap as its own. maxgaps(0, ordered(ordered(a, a), a)) therefore misses a x a a while maxgaps(0, ordered(ordered(a, b), a)) matches a x b a; ordered(unordered(a, a), b) misses a x a b in every release since 8.5.0. Weight#matches agrees with the scorer, used as an exclusion the query keeps documents it should remove, and a no-op maxwidth(100, ...) around the pair restores the hit. Not fixed upstream; a role-aware width() plus role-aware equals/hashCode in one class fixes it.
- The collapse.
OrderedIntervalsSource.buildpasses its arguments throughdeduplicate(OrderedIntervalsSource.java:29-38), which turns each run of equal sub-sources intoRepeatingIntervalsSource.build(source, count)(:40-57). When everything collapses into one repeat source, it getsris.setName("ORDERED")(:53-55) and is returned as the whole operator (:34-35).UnorderedIntervalsSourcedoes the same with a count map (UnorderedIntervalsSource.java:33-57,setName("UNORDERED")at:53-55). The name is read only bytoString()(RepeatingIntervalsSource.java:123-125). - The summed width. The class javadoc: the iterators have "a specialized
IntervalIterator#width()implementation that sums up the widths of the individual sub-iterators, rather than just returning the full span of the iterator" (RepeatingIntervalsSource.java:38-40).DuplicateIntervalIterator.width()adds one term per copy (:156-164):cache[pos * 2] - cache[pos * 2 + 1] + 1(:161), that is start - end + 1, which is the copy's width only for a one-position copy such as a term (for longer copies the sign is wrong: the separate LFV-306; the repro uses terms, so its numbers do not depend on it).DuplicateMatchesIterator.width()sumsend - start + 1per copy forWeight#matches(:315-322); neither looks at the name. - The parent reads child widths. The ordered iterator starts from
end - start + 1and subtracts every child'swidth()(OrderedIntervalsSource.java:166-172);gaps()returns the result (:185-186). The unordered iterator does the same (UnorderedIntervalsSource.java:158-161), and so doesatLeast(MinimumShouldMatchIntervalsSource.java:235-238, from reading only). - Equality hides the nesting too.
hashCode/equalsignore the name (RepeatingIntervalsSource.java:103-114):ordered(x, ordered(a, a)).equals(ordered(x, a, a))is true, while the(a, b)pair is not equal. A query cache cannot tell the nested query from the flat one. - Why wrappers hide it.
IntervalFilter(behindmaxwidthandmaxgaps) does not overridewidth(), so a filtered child reports its span.or()andextend()hide it too: their interval iterators (DisjunctionIntervalIterator,DisjunctionIntervalsSource.java:154, andExtendedIntervalIterator) do not overridewidth()either, so they report the span, andmaxgaps(0, ordered(or(ordered(a, a), z), a))andmaxgaps(0, ordered(extend(ordered(a, a), 0, 0), a))both finda x a a(executed). Only the matches iterator ofextend()passes the child's width through (ExtendedIntervalsSource.java:70).
| query, document | child widths | parent span | parent gaps | maxgaps(0) hits |
|---|---|---|---|---|
ordered(ordered(a, a), a), a x a a | 2 (span 3) + 1 | 4 | 1 | 0 |
ordered(ordered(a, b), a), a x b a | 3 + 1 | 4 | 0 | 1 |
ordered(unordered(a, a), b), a x a b | 2 (span 3) + 1 | 4 | 1 | 0 |
ordered(maxwidth(100, ordered(a, a)), a), a x a a | 3 + 1 | 4 | 0 | 1 |
Same root, part of the fix scope: a top-level ordered(a, a) reports width() 2 for [0,2], where ordered(a, b) reports 3, against IntervalIterator#width's "The width of the current interval" (IntervalIterator.java:73). For intervals that are evaluated as matches, scoring and maxwidth use end - start + 1 (IntervalScorer.java:65, FilteredIntervalsSource.java:67-69), not width(); the bug changes whether a document matches at all.
Exposure and likelihood235 words
- Trigger paths (executed). The Java factories (
ordered(ordered(a, a), a),ordered(x, ordered(a, a)),ordered(unordered(a, a), b),unordered(ordered(a, a), b));StandardQueryParserstrings such asfn:maxgaps(0 fn:ordered(fn:ordered(a a) a)), which missesa x a a;Intervals.analyzedTextof a phrase with a repeated word andmaxGaps-1, which returns a bareORDERED(bora,bora)(IntervalBuilder.java:143-146), somaxgaps(0, ordered(analyzedText("bora bora", ..., -1, true), island))missesbora x bora islandwhile the strictermaxGaps5 finds it;Intervals.unorderedNoOverlaps(a, a), which buildsORDERED(a,a)(Intervals.java:445-447). - Default settings. Default codec, analyzer and searcher, assertions on and off (the runs used a one-document in-memory index). Nothing is written to the index; only query results change.
- Silent and consistent. No exception;
Weight#matchesreturns null like the scorer, which is consistent with match-based highlighters also seeing no match (no highlighter was run). - Both directions. As a filter it loses documents; as an exclusion it keeps them:
MatchAll MUSTplusIntervalQuery(maxgaps(0, ordered(ordered(a, a), a))) MUST_NOT, andIntervals.notContainedBy(x, maxgaps(0, ordered(ordered(a, a), a))), return 1 hit ona x a ainstead of 0. - How likely. Rare: it needs a nested whole repeat, a gap limit above it and non-adjacent repeats (on
a a bthere is no inner gap to leak). The recorded searches found no report of it, although it has shipped since 8.5.0 (2020). For such a query and document the miss happens every time.
One JUnit test, one method, public API: one document a x a b, core StandardAnalyzer, a plain IndexSearcher. It uses ordered(unordered(a, a), b), the shape that fails on every release since 8.5.0 and does not depend on how ordered deduplicates. It fails with assertions on and off and passes with the fix.
/*
* 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 TestNestedRepeatMaxGaps extends LuceneTestCase {
public void testMaxGapsZeroOverNestedRepeat() 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", "a x a b", Field.Store.NO));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IntervalsSource a = Intervals.term("a");
IntervalsSource pair = Intervals.unordered(a, a); // [0,2] in 'a x a b'
IntervalQuery q =
new IntervalQuery("f", Intervals.maxgaps(0, Intervals.ordered(pair, Intervals.term("b"))));
// b (position 3) directly follows the pair [0,2]: 0 gaps between the two parts, expected 1 hit
assertEquals(q + " on 'a x a b'", 1, new IndexSearcher(reader).count(q));
}
}
}
}
./gradlew :lucene:queries:test --tests org.apache.lucene.queries.intervals.TestNestedRepeatMaxGaps -Ptests.seed=DEADBEEF
- Expected
- 1 hit:
b(position 3) directly follows the pair [0,2], so the parent interval [0,3] has 0 gaps. - Observed
- 0 hits (
expected:<1> but was:<0>) with assertions on and off: the parent reports gaps 1.
More files, environment, full output333 words
The test sits in org.apache.lucene.queries.intervals only for locality; it uses only public API (Intervals, IntervalQuery). ByteBuffersDirectory, core StandardAnalyzer (no stop words), one document, a plain IndexSearcher, nothing random: deterministic, single thread. The same test is the suggested regression test.
Why this shape and not ordered(ordered(a, a), a): the sibling bug LFV-807's fix (no deduplication in ordered) would also remove the ordered(a, a)-as-child shapes, but unordered must keep deduplicating, so this test keeps failing until this bug is fixed (run with LFV-807's fix alone: still fails; with both fixes: passes).
On 9.0.0 and earlier, LuceneTestCase lives in org.apache.lucene.util: change that one import. On 8.4.1 the test passes for another reason: there unordered(a, a) matched a single a (the bug LUCENE-9099 fixed), so the hit came from [2,3].
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2, 10.1.0, 10.0.0, 9.12.3, 9.1.0, 9.0.0, 8.11.4 and 8.5.0; passes on 8.4.1 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp $REPRO/TestNestedRepeatMaxGaps.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/intervals/
cd $LUCENE
./gradlew :lucene:queries:test --tests org.apache.lucene.queries.intervals.TestNestedRepeatMaxGaps -Ptests.seed=DEADBEEF
# production setting (assertions off): add -Ptests.asserts=false
# gradle, main 5adff78bb168, seed DEADBEEF, assertions on (the same with -Ptests.asserts=false)
TestNestedRepeatMaxGaps > testMaxGapsZeroOverNestedRepeat FAILED
java.lang.AssertionError: f:MAXGAPS/0(ORDERED(UNORDERED(a,a),b)) on 'a x a b' expected:<1> but was:<0>
# with the recommended fix: BUILD SUCCESSFUL, assertions on and off
# JUnit runner, seed DEADBEEF, assertions on: the same failure on 10.6.0, 10.5.2, 10.1.0, 10.0.0, 9.12.3, 9.1.0,
# 9.0.0, 8.11.4 and 8.5.0; 8.4.1 passes (unordered(a, a) then matched a single a)
# the 11-test probe of the independent reproducer on 5adff78bb168 (seed 5ADFF78B, assertions on and off): 4 of 11 fail
ordered(ordered(a,a),a) on 'a x a a' expected 0-3/0 | maxgaps0.hits=1 matches=non-null, got 0-3/1 | hits=0 matches=null
ordered(x,ordered(a,a)) on 'x a y a' same
ordered(unordered(a,a),b) on 'a x a b' same
unordered(ordered(a,a),b) on 'a x a b' same
controls (the four with (a,b)/(a,c), the no-op maxwidth wrapper, flat ordered(x,a,a), top-level ordered(a,a)): correct
EQUALITY repeated nested.equals(flat)=true [ORDERED(x,ORDERED(a,a)) vs ORDERED(x,a,a)]; unequal nested.equals(flat)=falseIntervalIterator#gapsexcludes "the gaps inside those sub-intervals" of a parent's immediate parts.- #13819 (10.1.0) made nested ordered and unordered sources hide their inner gaps; with unequal terms, or a no-op
maxwidth(100, ...)around the pair, the query matches. - LUCENE-9099, which added this code, rejected nesting duplicates as
ORDERED(a, a)because that "breaks MAXGAPS filtering".
The full argument271 words
- Contract. "Note that this returns the number of gaps between the immediate sub-intervals of this interval, and does not include the gaps inside those sub-intervals." (
IntervalIterator.java:65-66). Package docs:maxgaps"Filters out intervals that have more than a set number of gaps between their constituent sub-intervals" (package-info.java:55-57). - Design text. LUCENE-8597 (#9643), which added
gaps(): ordered/unordered combinations "would return the number of positions separating their immediate children. Note that this does not include the gaps within any of those children themselves." - Maintainer intent for nesting. #13819 (jimczi, merged 2024-10-03): "ordered(\"a\", ordered(\"b\", \"c\")) should result in a different gap compared to ordered(\"a\", \"b\", \"c\")"; "The flattening that is currently applied prevent some valid queries to match". Its tests
testOrderedWithNoGapandtestUnorderedWithNoGap(TestIntervalQuery.java:375-385,:341-351) use distinct terms;TestIntervals#testNestedMaxGaps(TestIntervals.java:751-768) treats a nested ordered as one sub-interval of its full span. - LUCENE-9099's own issue text (#10141): rewriting
UNORDERED(a, b, c, a, b)toUNORDERED(ORDERED(a, a), ORDERED(b, b), c)"then breaks MAXGAPS filtering", i.e. a nestedORDERED(a, a)hides its inner gaps from the parent. - No consistent semantics explains the current answers. The unequal twin matches; a filter that removes nothing (
maxwidth(100, ...),maxgaps(1000, ...)) around the pair flips 0 hits to 1; a stricteranalyzedText(maxGaps5) finds a document the unrestricted one (-1) misses; and whether two arguments compare equal changes the result. - No upstream test pins the current value. The repeat tests (
TestIntervals.java:729-749,TestSimplifications.java:41-68) cover runs inside a larger operator and top-level repeats; all 1,768 tests of the queries, queryparser and highlighter modules pass with the fix.
The devil's-advocate check248 words
The strongest case for "intended": the RepeatingIntervalsSource javadoc sums the widths on purpose, and the LUCENE-9099 commit says this "preserves maxgaps filtering on ordered and unordered sources that contain repeats"; when LUCENE-9099 landed, nested ordered sources were flattened anyway, so ordered(ordered(a, a), a) meaning ordered(a, a, a) was the semantics of the day; the package is "experimental" (package-info.java:21); #13819's tests use only distinct terms; and a user could write ordered(a, a, a).
Why it fails: the javadoc is on a package-private class and describes the run role (ordered(a, a, b), where each copy is an immediate sub-interval of the parent); the class cannot tell that role from the whole-operator role, whose only marker, the name, nothing reads. #13819 replaced the flattening semantics with exactly the rule this violates. Flattening never crossed operator types, so ordered(unordered(a, a), b) leaking while ordered(unordered(a, c), b) did not was inconsistent even before 10.1.0, and that query has no flat equivalent. "Experimental" is about API stability, not wrong hit counts. The reviews did not all agree in form: the devil's-advocate verifier, which ran its own probes, rejected the argument (real, high confidence that it is not intended); read-only reviews by an independent reviewer from another model family (OpenAI GPT-5.6), which could not run code, both rated the argument moderate. The intended-behaviour review rated the bug real (medium); the refute review returned "not real" (low) because no test could run, and noted that the static evidence favours a defect.
Recommended A. Role-aware width and equality in RepeatingIntervalsSource (validated)
Let a repeat source that stands for a whole operator report its full span from width(), keep gaps() as span minus the copies' widths, and make equals/hashCode tell the two roles apart (+31/-12 in RepeatingIntervalsSource). The repro passes with assertions on and off, and so do all 1,768 queries, queryparser and highlighter tests.
@Override
public int width() {
return wholeSource ? super.width() : copyWidths(); // copyWidths(): the old summing loop
}
@Override
public int gaps() {
return super.width() - copyWidths();
}All options and trade-offs3 options · 574 words
Three options. A was built and validated on main 5adff78bb168; B was not built; C (the sibling LFV-807's fix) was run and fixes only part of this bug. Any fix should also update the RepeatingIntervalsSource class javadoc, and it composes with LFV-306's one-line sign fix in the same width() loop (that fix alone leaves this bug).
A repeat source with a name (it stands for a whole ordered/unordered) passes wholeSource = true to its iterators: DuplicateIntervalIterator.width() returns super.width() (the span) and gaps() keeps subtracting the copies' widths; DuplicateMatchesIterator.width() returns endPosition() - startPosition() + 1. equals adds (this.name != null) == (o.name != null) and hashCode adds name != null. The class javadoc explains the two roles. One file, +31/-12.
@Override
public int width() {
return wholeSource ? super.width() : copyWidths(); // copyWidths(): the old summing loop
}
@Override
public int gaps() {
return super.width() - copyWidths();
}Trade-offs
- The equality part is needed: with a width-only change and the test framework's query cache on,
ordered(x, ordered(a, a))was served the cached result of the equal flatordered(x, a, a)and still had 0 hits. - Keeps the collapse and what
TestSimplificationsasserts (ordered(a, a)still equalsunorderedNoOverlaps(a, a)); relies on the name, set after construction, as the role marker. - A top-level
ordered(a, a)now reportswidth()3 for [0,2] instead of 2.width()is read only by parent operators (ordered, unordered, atLeast) and by matches iterators (every.width()call in the intervals package); scoring andmaxwidthuseend - start + 1. - What the validation does not show: the runs show that the change makes the repro pass and breaks none of the module tests; they say nothing about how maintainers will judge the approach (a role flag set after construction), about API compatibility beyond these tests, or about performance (no benchmark was run).
What the checks validated
- Gradle in a scratch worktree of
5adff78bb168, OpenJDK 25.0.3, seed DEADBEEF: stock, the repro fails with assertions on and off; with the fix it passes, on and off. - All tests of the queries, queryparser and highlighter modules pass with the fix and the repro in place: 116 classes, 1,768 tests, 0 failures, 6 skipped.
checkGoogleJavaFormat,ecjLint,forbiddenApisMainandrenderJavadocoflucene/queriespass.- The independent reproducer's 11-test probe passes 11 of 11 with the same approach applied.
When deduplicate returns a single repeat source, wrap it in an OrderedIntervalsSource / UnorderedIntervalsSource with that one child instead of returning it, so the wrapper reports its span and the repeat its inner gaps.
Trade-offs
No role flag, and ordered(x, ordered(a, a)) stops equalling ordered(x, a, a) without touching equals. But it adds an iterator layer per nested repeat, the one-child conjunction path is untested, and the toString/equality assertions in TestSimplifications need checking.
Removing deduplicate from OrderedIntervalsSource.build (proposed for LFV-807) removes the ordered(a, a)-as-child shapes, but unordered must keep deduplicating so that unordered(a, a) does not match a single a.
Trade-offs
Fixes LFV-807 and part of this bug; use it together with A, not instead of it.
What the checks validated
Run on 5adff78bb168: the repro still fails with C alone and passes with A and C together.
Products that build interval queries from user rules inherit this when a rule with a repeated word and no gap limit is nested under a rule with a gap limit, and an application that uses such a query as an exclusion lets the document through; no downstream product was run for this doc.
Semi-formal. A bounded-exhaustive comparison ran Lucene's interval iterators against an independently written reference semantics: every document of up to 4 positions over 3 terms and 6,856 query expressions (1,100,295 checks). Gap mismatches for nested repeated pairs pointed at this code; a public-API JUnit test on the stock jar confirmed it.
The method in full127 words
Method: semi-formal (bounded-exhaustive translation validation). A reference semantics for intervals was written from the javadoc, independently of Lucene's code. A test program enumerated every document of 1 to 4 positions over {a, b, U+10428} and 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, and compared exact minimal intervals, gaps, IntervalQuery hits and Weight#matches with the stock main jar: 1,100,295 checks.
- The mismatches were then attributed with switchable fixes: with the fixes of the related repeat bugs applied, 13,469 mismatches remained; adding this fix brought them down to 3,596 (the rest belong to other defects).
- The case was reduced to a public-API JUnit test on the stock jar, which failed with assertions on and off.
New: the recorded searches found no Lucene issue, PR, JIRA entry, list thread or commit, and nothing downstream, that describes this defect (searched 2026-10-27, re-checked 2026-11-03 and 2026-11-04). Related: #13819 fixed nested-gap leakage for unequal sub-sources by ending flattening; LUCENE-9099 (#10141) added the repeat code to fix other defects.
Searches3 searches
No report of this defect was found (re-checked 2026-11-03 in two independent passes, and again on 2026-11-04). #13819 is about the flattening of unequal sub-sources and never touched the repeat code, so it is related but about a different defect. Related prior items, none of them this defect:
- #13819, "Prevent flattening of ordered and unordered interval sources" (jimczi, merged 2024-10-03, 10.1.0; downstream trackers elastic/elasticsearch#113554 and #114234). It is about flattening: a nested ordered/unordered with unequal sub-sources was merged into its parent, which changed the gaps the parent saw. It fixed that by no longer flattening. It never mentions repeats and did not change
RepeatingIntervalsSource,width()or thededuplicate()method (at the call site it replaceddeduplicate(flatten(sources))withdeduplicate(sources)). This defect is a different code path, the repeat collapse, that still produces the effect for equal sub-sources; its cross-operator form (ordered(unordered(a, a), b)) has failed since 8.5.0, independently of flattening. - #10141 (LUCENE-9099) and apache/lucene-solr#1097 introduced the summed width and the collapse, to fix two other repeat defects (an extra interval from
ORDERED(a, a, b), andUNORDERED(a, a)matching a singlea). Design context, not a report. - #9643 (LUCENE-8597) defines
gaps(). Design context. - Items checked and set aside: elastic/elasticsearch#58576 (a different ordered-matching defect), opensearch-project/OpenSearch#23520 (a feature request), #11113 and other intervals issues (other defects).
Limits of the searches: keyword searches can miss a report worded differently and return capped result lists, inline review comments are not all indexed, the 3 web searches run by hand kept no raw results, and private reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub apache/lucene issues, PRs and commits (incl. the migrated JIRA archive); dev@, java-user@, issues@, commits@; JIRA; Elasticsearch, OpenSearch and Solr trackers; web | 2026-10-27 | 261 logged read-only queries (three runs of the prior-report tool, 49 manual GitHub searches, list and JIRA searches, web queries) and a changed-file scan of 426 PRs; every plausible hit read, including the review comments of #13819 and lucene-solr#1097 | only the related #13819 and the design-context items |
| Re-check: the same sources | 2026-11-03 | two independent passes over the earlier hits | the items found earlier are related but about a different defect |
| Re-check for this doc: GitHub issues, PRs and commits, downstream trackers, the four lists, JIRA, web, upstream file history | 2026-11-04 | the prior-report tool with a new spec (45 queries run, the spec's 6 web queries left to the manual pass; 2,168 distinct hits, the closest #9643 and #10141); GitHub searches for intervals items updated since 2026-10-26 and for maxgaps; 3 web queries run by hand; the commit history of the three source files | no report of this defect; not fixed upstream (main f72004515ec2 has the same files) |
Introduced in 8.5.0 by LUCENE-9099 (7c1ba1aebee, branch_8x aa916bac3c3, 2020-02-06), which added the summed width and the collapse. The repro fails on every release run from 8.5.0 to 10.6.0 and on main; 8.4.1 passes. The same-operator shapes count only from 10.1.0. Not fixed upstream.
Versions tested6 rows
Introduced by LUCENE-9099, "Correctly handle repeats in ORDERED and UNORDERED intervals" (apache/lucene-solr#1097 by romseygeek, approved by jimczi; issue #10141), merged 2020-02-06: 7c1ba1aebee on master (first released in 9.0.0) and aa916bac3c3 on branch_8x (first released in 8.5.0). It added IntervalIterator#width(), made ordered/unordered gaps "span minus the children's widths", and added RepeatingIntervalsSource with the summed width and the whole-operator collapse. A commit-level run on the 8.4.1 jars with only the intervals package of aa916bac3c3 flips unordered(ordered(a, a), b) from pass to fail.
Two shape families. The cross-operator shapes (ordered(unordered(a, a), b), unordered(ordered(a, a), b)) fail from 8.5.0 on with their unequal controls passing. The same-operator shapes (ordered(ordered(a, a), a), ordered(x, ordered(a, a))) count only from 10.1.0: before #13819 their unequal controls failed too, because nested sources were flattened. #13819 did not cause this bug.
Not fixed upstream: the three source files were last changed by dfd536186fe4 (2026-04-01, a refactor), so upstream main f72004515ec2 (2026-11-03) has the same code.
| Version or commit | Verdict | Result |
|---|---|---|
| 8.2.0 to 8.4.1 | not affected | not affected in this form; the repro passes on 8.4.1 (there unordered(a, a) matched a single a, which LUCENE-9099 fixed) |
| 8.5.0 to 10.0.0 (41 releases) | cross-operator shapes | the repro fails (run on 8.5.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3 and 10.0.0); the same-operator shapes fail too, but so do their unequal controls (flattening) |
| 10.1.0 to 10.5.2 (11 releases) | affected | all four shapes fail, every control passes (repro run on 10.1.0 and 10.5.2) |
| 10.6.0 | affected | the repro fails |
main 5adff78bb168 | affected | the repro fails, assertions on and off (gradle and JUnit runner); 4 of 11 probe tests fail |
main 5adff78bb168 + fix A | not affected | the repro and the queries, queryparser and highlighter tests pass |
Verifiers with their own tests and an intended-behaviour review by a model from another family (OpenAI GPT-5.6) rated it real; a refute review that could not run code said "not real" on procedure only, agreeing on the root cause; the adjudication confirmed it. This doc's runs reproduced it on 5adff78bb168 and validated the fix.
Checks and verdicts297 words
The reviews, verifiers and judge ran on main a9779ef3c193; the intervals package is identical at 5adff78bb168. A re-verification on e1faeba08bde (2026-11-02) and this doc's runs on 5adff78bb168 reproduced it. The cross-model reviews ran read-only (they could not execute code); the executed evidence comes from the other checks.
| Check | Verdict | What it found |
|---|---|---|
| finder | found | Bounded-exhaustive comparison, then a 2-test public-API JUnit failing with assertions on and off on main and 10.5.2; passes with the fix sketch. |
| refute review (OpenAI GPT-5.6, read-only) | not real (low), procedural | Could not run code; agreed on the root cause and that the inputs are legal; its point that ordered(x, ordered(a, a)) equals ordered(x, a, a) was confirmed and added to the fix. |
| independent reproducer | real | Own 11-test public-API probe outside the intervals package: 4 of 11 fail on main (assertions on and off), 10.5.2 and 10.1.0, every control passes; its own fix passes 11 of 11; showed that a fix must also change equals. |
| intended-behaviour review (OpenAI GPT-5.6, read-only) + executed checks | real (medium) | Intended-behaviour argument moderate; the executed probe fails on main and 10.5.2 and passes with the fix; 117 upstream intervals tests pass stock and fixed. |
| devil's-advocate verifier | real (high) | Built the strongest by-design case and rejected it with the contracts, #13819 and LUCENE-9099's own issue text; executed no-op-wrapper, analyzedText and fn: probes. |
| adjudication | confirmed (high) | Re-ran the independent probe with a new seed (7A11C0DE): 4 of 11 fail with assertions on and off, 11 of 11 pass with the fix. |
| this doc | reproduced; fix validated | One-method repro fails on main 5adff78bb168 (gradle and JUnit runner) and on 10.6.0 back to 8.5.0, passes on 8.4.1; fix A passes the repro and 1,768 module tests. |
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 542 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,418 words
- Registry title
- A nested Intervals.ordered(a, a) or unordered(a, a) passes its inner gap up to its parent, so maxgaps(0, ordered(ordered(a, a), a)) misses 'a x a a' while maxgaps(0, ordered(ordered(a, b), a)) matches 'a x b a'
- Area
- queries / intervals (a whole ordered/unordered of equal sub-sources nested in another operator: RepeatingIntervalsSource width(), Ordered/UnorderedIntervalsSource.deduplicate)
- Priority
- Medium, rank 196: Silent missed interval matches since 8.5.0 in the same repeat family as LFV-807, LFV-306 and LFV-809, but it needs a nested whole repeat under maxgaps with non-adjacent repeats.
- Impact class
- silent wrong results
- Impact note
- Anyone whose interval query nests a whole ordered or unordered of identical sub-sources (ordered(a, a), unordered(a, a), unorderedNoOverlaps(a, a), or analyzedText of a phrase with a repeated word and no gap limit) inside another ordered or unordered (atLeast too, by code reading) and filters with maxgaps above it, through the Java factories or StandardQueryParser fn: strings, with default writer, codec, analyzer and searcher settings (the runs used a one-document index), with -ea and -da, in every release since 8.5.0 (all four shapes since 10.1.0), silently loses documents that the gaps() contract says match, and keeps them wrongly when that query is used as an exclusion (MUST_NOT, notContainedBy); Weight#matches agrees with the scorer, scores and maxwidth are unaffected, and nothing is written to the index.
- Summary
- You nest a repeated pair inside another interval operator, for example ordered(ordered(a, a), a), and filter it with maxgaps(0, ...): the IntervalIterator#gaps javadoc counts only the gaps between the parent's immediate sub-intervals, so 'a x a a' (sub-intervals [0,2] and [3,3]) should match, just as maxgaps(0, ordered(ordered(a, b), a)) matches 'a x b a'. Instead, because the two arguments of the inner ordered(a, a) are equal, Lucene builds it as a RepeatingIntervalsSource whose width() is the sum of its two copies (2) rather than its span (3), so the parent counts the inner 'x' as its own gap (gaps 1) and maxgaps(0) silently drops the document. The same happens for ordered(unordered(a, a), b), unordered(ordered(a, a), b) and ordered(x, ordered(a, a)), while wrapping the inner pair in a no-op filter such as maxwidth(100, ...) brings the hit back.
- Introduced
- 7c1ba1aebeea540b67ae304deee60162baee2e12 (master, first released in 9.0.0) and its branch_8x backport aa916bac3c3369a461afa06e384e070657c32973 (the same day; first released in 8.5.0): '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 changed ordered/unordered gaps() to span minus the sum of the children's width(), added IntervalIterator#width() and RepeatingIntervalsSource with a summed width(), and made deduplicate() replace a whole ordered(a, a)/unordered(a, a) by a RepeatingIntervalsSource that only setName("ORDERED"/"UNORDERED") marks [read: git blame on main, git log -S] [exec: the 8.4.1 jars plus only the intervals package of aa916bac3c33 flip unordered(ordered(a, a), b) from pass to fail, and the package of its parent 3246b2605869 behaves like 8.4.1]. For the same-operator shapes the defect shows as its own bug only from 10.1.0, after #13819 (82b08045fd3c on main, 0f2fac699a35 on branch_10x) removed flattening; #13819 did not introduce it [exec: 10.0.0 plus only the 0f2fac699a35 package gives the 10.1.0 pattern] (2020-02-06 (7c1ba1aebee and the branch_8x backport aa916bac3c3))
- First affected version
- 8.5.0 [exec] for the cross-operator shapes ordered(unordered(a, a), b) and unordered(ordered(a, a), b); 10.1.0 [exec] for the same-operator shapes ordered(ordered(a, a), a) and ordered(x, ordered(a, a)), whose unequal-term controls also fail before 10.1.0 because of the flattening that #13819 removed; 8.4.1 and earlier are not affected in this form [exec]
- Affected versions
- every release from 8.5.0 through 10.6.0 (the 52 releases to 10.5.2 each executed once with the independent reproduction's 11-test per-shape probe, -ea): 8.5.0 to 10.0.0 show the two cross-operator shapes failing while their unequal-term controls pass; 10.1.0 to 10.5.2 show all four shapes failing while every control passes [exec]; main a9779ef3c193 [exec, -ea and -da]; branch_10_6 [exec: its intervals package compiled over the 10.5.2 jars gives the 10.1.0 pattern; the upstream head 4ae946e5a9b adds no intervals change, read]; the released 10.6.0 has it [exec, 2026-11-04: the minimal test fails on the 10.6.0 jars, -ea]; upstream main 20ced71c1e6e [read: RepeatingIntervalsSource, OrderedIntervalsSource and UnorderedIntervalsSource byte-identical to a9779ef3c193, checked 2026-10-27]. Not affected [exec]: 8.2.0 to 8.4.1 (there ordered(unordered(a, a), b) is wrong in another way, the single-'a' match that LUCENE-9099 fixed). Reachable from query text: fn:maxgaps(0 fn:ordered(fn:ordered(a a) a)) misses 'a x a a' on main and 10.5.2 [exec, the intended-behaviour check and the cross-model review probes]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- related item only, no report of this defect (searched 2026-10-27, plus the finder's search the same day, read-only; re-checked 2026-11-03 in two passes): 261 recorded queries (scripted prior-report searches A, B and C over GitHub issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 49 manual GitHub GraphQL searches; AI tool web queries) and a changed-file scan of 426 PRs (all 409 open plus every PR updated since 2026-10-25). Related, a different defect: #13819 'Prevent flattening of ordered and unordered interval sources' (jimczi, merged 2024-10-03, 10.1.0; downstream twins elastic/elasticsearch#113554 and #114234) fixed the same symptom class, a nested ordered/unordered passing its inner gaps to the parent, for unequal sub-sources by removing flattening; it never mentions repeats and did not touch RepeatingIntervalsSource or deduplicate(). Design context, not reports: #10141 / LUCENE-9099 and lucene-solr#1097 (introduced the summed width and the collapse), #9643 / LUCENE-8597 (defines gaps()). Not fixed upstream: main 20ced71c1e6e, branch_10x 657014b3c579 and branch_10_6 4ae946e5a9b9 keep the summed width() and the setName collapse. Re-check (2026-10-27, read-only GitHub API and web search): RepeatingIntervalsSource finds only #10141; 'maxgaps nested ordered', 'intervals nested repeated gaps' and 'ordered intervals repeated term maxgaps' find nothing; none of the 65 apache/lucene issues and PRs updated since 2026-10-25 is about this (#16913 touches no intervals file; #16899, merged, changed only the expansion limits in Intervals.java and MultiTermIntervalsSource.java); upstream main's three source files are byte-identical to a9779ef3c193; two web searches found only items already classified (elastic/elasticsearch#58576, the downstream report of #10458, a different ordered-matching defect).
- Lucene commit tested
- main
5adff78bb168(gradle in a scratch worktree and the JUnit runner, assertions on and off, stock and with the fix) and the released 10.6.0, 10.5.2, 10.1.0, 10.0.0, 9.12.3, 9.1.0, 9.0.0, 8.11.4, 8.5.0 and 8.4.1 jars, 2026-11-04. Earlier runs: maina9779ef3c193(the intervals package is identical at5adff78bb168) ande1faeba08bde; a 57-release history sweep from 8.2.0 to 10.5.2.
- Finding record
lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:33-41 (class javadoc: width() 'sums up the widths of the individual sub-iterators, rather than just returning the full span'), :54 and :61 (name, setName), :104-114 (hashCode/equals ignore name), :157-164 (DuplicateIntervalIterator.width sums the copies), :167-169 (gaps = span - width()), :316-322 (DuplicateMatchesIterator.width sums the copies); OrderedIntervalsSource.java:33 and :40-57 (deduplicate; a whole repeat gets setName("ORDERED") at :53-55), :166-172 (gaps = span - sum of child width()); UnorderedIntervalsSource.java:37, :44-56 (setName("UNORDERED") at :53-55), :158-161; MinimumShouldMatchIntervalsSource.java:235-237 (atLeast subtracts child widths too); IntervalIterator.java:62-71 (gaps(): 'does not include the gaps inside those sub-intervals'), :74 (default width()); Intervals.java:389 (maxgaps), :445 (unorderedNoOverlaps); the files are identical at Lucene main ea57360912ed
- The finding in 30 seconds
lucene/queries/src/java/org/apache/lucene/queries/intervals/OrderedIntervalsSource.java:29-57, (at 5adff78bb168) :166-172, :185-186lucene/queries/src/java/org/apache/lucene/queries/intervals/UnorderedIntervalsSource.java:33-57, (at 5adff78bb168) :158-161lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:33-41, (at 5adff78bb168) :103-114, :123-125, :156-169, :315-322lucene/queries/src/java/org/apache/lucene/queries/intervals/MinimumShouldMatchIntervalsSource.java:235-238; (at 5adff78bb168) IntervalIterator.java:62-76; DisjunctionIntervalsSource.java:154, :421-422; ExtendedIntervalIterator.java; ExtendedIntervalsSource.java:70; IntervalScorer.java:65; FilteredIntervalsSource.java:67-69
- Who is affected
lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalBuilder.java:131-149; (at 5adff78bb168) Intervals.java:445-447
- Why it looks unintended
lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalIterator.java:62-71; (at 5adff78bb168) package-info.java:21, :55-57lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervalQuery.java:341-351, (at 5adff78bb168) :375-385; TestIntervals.java:729-768; TestSimplifications.java:41-68https://github.com/apache/lucene/pull/13819 (PR body, read 2026-11-04); https://github.com/apache/lucene/issues/10141 (issue body); https://github.com/apache/lucene/issues/9643 (issue body)
- Suggested fix
lucene/queries/src/test/org/apache/lucene/queries/intervals/TestSimplifications.java:41-68 (at 5adff78bb168)
- Prior reports
https://github.com/apache/lucene/pull/13819 and https://github.com/apache/lucene/issues/10141 (read with gh api, 2026-11-04)
- Diagrams
lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:156-164; (at 5adff78bb168) OrderedIntervalsSource.java:166-172
- TestNestedRepeatMaxGaps.java (one test method):
repro/LFV-810/TestNestedRepeatMaxGaps.java(50 lines)
A walk-through of the headline example (a x a a against its unequal twin a x b a), made from the code at 5adff78bb168; the PNG is the summary still. The minimal repro uses the cross-operator shape ordered(unordered(a, a), b) on a x a b, which fails through the same width().
assets/LFV-810/bug.png(542 KB)
![Diagram of the bug: the document a x a a at positions 0 to 3 and the query maxgaps(0, ordered(ordered(a, a), a)); the inner ordered(a, a) is built as a repeat source covering [0,2] whose width is 1 + 1 = 2 instead of 3, so the parent interval [0,3] gets gaps 4 - (2 + 1) = 1 and maxgaps(0) returns 0 hits, while ordered(ordered(a, b), a) on a x b a gets gaps 0 and 1 hit.](../assets/LFV-810/bug.png)