All findings · Lucene finding · LFV-810 · repeating-intervals-nested-width-leaks-inner-gaps

A nested Intervals.ordered(a, a) or unordered(a, a) passes its inner gap to its parent, so maxgaps misses matches

When an interval query nests a whole ordered(a, a) or unordered(a, a) in another ordered or unordered, Lucene counts the gap inside the pair as the parent's own: maxgaps(0, ordered(ordered(a, a), a)) misses a x a a, while (a, b) finds a x b a. Silent, since 8.5.0.

Summary

Priority
Medium, rank 196Silent 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
silent wrong results
Since
8.5.0 (LUCENE-9099, 2020); 8.4.1 gives the right countintroduced 2020-02-06 · about 6 years
Affected
every release from 8.5.0 to 10.6.0, and main (the same-operator shapes from 10.1.0)
Status
documented
Found by
a bounded-exhaustive comparison with a reference semantics, then JUnit tests (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

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.

Background in three steps

  1. An interval query returns position ranges: ordered(a, b) on a x b returns [0,2]. Its width() is normally the number of positions it spans, here 3.
  2. gaps() of an ordered or unordered interval is its span minus the widths of its immediate children: ordered(ordered(a, b), a) on a x b a gives 4 - (3 + 1) = 0. Gaps inside a child do not count.
  3. 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.
A parent interval operator counts its gaps as its span minus the widths its children report, and a nested ordered(a, a) reports the summed width of its two copies (2) instead of its span (3), so the gap inside it is counted as the parent's. Generated with AI, checked against the code.
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.
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.

The finding, step by step

  1. Index one document, a x a a (positions 0-3), and search maxgaps(0, ordered(ordered(a, a), a)): the pair "a then a", followed by a, with no gap between those two parts.
  2. You expect 1 hit: the pair matches [0,2] and the last a sits at 3, right after it. IntervalIterator#gaps counts only the gaps between a parent's immediate parts, not inside them, and ordered(ordered(a, b), a) on a x b a does give 1 hit.
  3. 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 and maxgaps(0) drops the document.
  4. The same happens for ordered(unordered(a, a), b) on a x a b (since 8.5.0), unordered(ordered(a, a), b) and ordered(x, ordered(a, a)), also from fn: query strings. A no-op maxwidth(100, ...) around the pair brings the hit back; used under MUST_NOT, the document is wrongly kept.

Who is affected

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 mechanism (main 5adff78bb168, lucene/queries/.../intervals/)

  1. The collapse. OrderedIntervalsSource.build passes its arguments through deduplicate (OrderedIntervalsSource.java:29-38), which turns each run of equal sub-sources into RepeatingIntervalsSource.build(source, count) (:40-57). When everything collapses into one repeat source, it gets ris.setName("ORDERED") (:53-55) and is returned as the whole operator (:34-35). UnorderedIntervalsSource does the same with a count map (UnorderedIntervalsSource.java:33-57, setName("UNORDERED") at :53-55). The name is read only by toString() (RepeatingIntervalsSource.java:123-125).
  2. 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() sums end - start + 1 per copy for Weight#matches (:315-322); neither looks at the name.
  3. The parent reads child widths. The ordered iterator starts from end - start + 1 and subtracts every child's width() (OrderedIntervalsSource.java:166-172); gaps() returns the result (:185-186). The unordered iterator does the same (UnorderedIntervalsSource.java:158-161), and so does atLeast (MinimumShouldMatchIntervalsSource.java:235-238, from reading only).
  4. Equality hides the nesting too. hashCode/equals ignore 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.
  5. Why wrappers hide it. IntervalFilter (behind maxwidth and maxgaps) does not override width(), so a filtered child reports its span. or() and extend() hide it too: their interval iterators (DisjunctionIntervalIterator, DisjunctionIntervalsSource.java:154, and ExtendedIntervalIterator) do not override width() either, so they report the span, and maxgaps(0, ordered(or(ordered(a, a), z), a)) and maxgaps(0, ordered(extend(ordered(a, a), 0, 0), a)) both find a x a a (executed). Only the matches iterator of extend() passes the child's width through (ExtendedIntervalsSource.java:70).

The numbers

query, documentchild widthsparent spanparent gapsmaxgaps(0) hits
ordered(ordered(a, a), a), a x a a2 (span 3) + 1410
ordered(ordered(a, b), a), a x b a3 + 1401
ordered(unordered(a, a), b), a x a b2 (span 3) + 1410
ordered(maxwidth(100, ordered(a, a)), a), a x a a3 + 1401

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)); StandardQueryParser strings such as fn:maxgaps(0 fn:ordered(fn:ordered(a a) a)), which misses a x a a; Intervals.analyzedText of a phrase with a repeated word and maxGaps -1, which returns a bare ORDERED(bora,bora) (IntervalBuilder.java:143-146), so maxgaps(0, ordered(analyzedText("bora bora", ..., -1, true), island)) misses bora x bora island while the stricter maxGaps 5 finds it; Intervals.unorderedNoOverlaps(a, a), which builds ORDERED(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#matches returns 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 MUST plus IntervalQuery(maxgaps(0, ordered(ordered(a, a), a))) MUST_NOT, and Intervals.notContainedBy(x, maxgaps(0, ordered(ordered(a, a), a))), return 1 hit on a x a a instead of 0.
  • How likely. Rare: it needs a nested whole repeat, a gap limit above it and non-adjacent repeats (on a a b there 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.

Minimal repro

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.

TestNestedRepeatMaxGaps.java (one test method)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 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));
      }
    }
  }
}
Command
./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.

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

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

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
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

Observed output

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

Why it looks unintended

  • IntervalIterator#gaps excludes "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 testOrderedWithNoGap and testUnorderedWithNoGap (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) to UNORDERED(ORDERED(a, a), ORDERED(b, b), c) "then breaks MAXGAPS filtering", i.e. a nested ORDERED(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 stricter analyzedText (maxGaps 5) 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.

Suggested fix

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. Role-aware width and equality in RepeatingIntervalsSource (validated) (recommended)

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 flat ordered(x, a, a) and still had 0 hits.
  • Keeps the collapse and what TestSimplifications asserts (ordered(a, a) still equals unorderedNoOverlaps(a, a)); relies on the name, set after construction, as the role marker.
  • A top-level ordered(a, a) now reports width() 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 and maxwidth use end - 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, forbiddenApisMain and renderJavadoc of lucene/queries pass.
  • The independent reproducer's 11-test probe passes 11 of 11 with the same approach applied.

B. Do not return the bare repeat source for a whole operator (not built)

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.

C. Drop deduplication from ordered only (LFV-807's fix; partial) (validated by the checks)

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.

Downstream note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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 the deduplicate() method (at the call site it replaced deduplicate(flatten(sources)) with deduplicate(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), and UNORDERED(a, a) matching a single a). 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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub apache/lucene issues, PRs and commits (incl. the migrated JIRA archive); dev@, java-user@, issues@, commits@; JIRA; Elasticsearch, OpenSearch and Solr trackers; web2026-10-27261 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#1097only the related #13819 and the design-context items
Re-check: the same sources2026-11-03two independent passes over the earlier hitsthe 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 history2026-11-04the 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 filesno report of this defect; not fixed upstream (main f72004515ec2 has the same files)

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
8.2.0 to 8.4.1 not affectednot 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 shapesthe 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) affectedall four shapes fail, every control passes (repro run on 10.1.0 and 10.5.2)
10.6.0 affectedthe repro fails
main 5adff78bb168 affectedthe repro fails, assertions on and off (gradle and JUnit runner); 4 of 11 probe tests fail
main 5adff78bb168 + fix A not affectedthe repro and the queries, queryparser and highlighter tests pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder foundBounded-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), proceduralCould 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 realOwn 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 validatedOne-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.

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

Finding record

LFV-810, snapshot of 2026-11-06

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: main a9779ef3c193 (the intervals package is identical at 5adff78bb168) and e1faeba08bde; a 57-release history sweep from 8.2.0 to 10.5.2.

Lucene and upstream sources

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-186
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/UnorderedIntervalsSource.java:33-57, (at 5adff78bb168) :158-161
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:33-41, (at 5adff78bb168) :103-114, :123-125, :156-169, :315-322
  • lucene/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-57
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervalQuery.java:341-351, (at 5adff78bb168) :375-385; TestIntervals.java:729-768; TestSimplifications.java:41-68
  • https://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

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

Repro files

About the diagrams

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

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.