All findings · Lucene finding · LFV-306 · repeating-intervals-width-sign

Repeating a multi-word piece in Intervals.ordered or unordered breaks maxgaps: the width is computed with the sign flipped

When Intervals.ordered repeats a piece wider than one word back to back (or unordered anywhere), each copy's width is computed as start - end + 1, not end - start + 1. gaps() is too large, so maxgaps(0, ordered(phrase(new, york), phrase(new, york))) silently finds nothing in new york new york.

Summary

Priority
Medium, rank 194Since 8.5, interval matches are silently missed when ordered or unordered repeats a multi-position sub-source under maxgaps, which is rarer than the before() row.
Impact
silent wrong results
Since
8.5.0 (LUCENE-9099, 2020)introduced 2020-02-06 · about 6 years
Affected
8.5.0 to 10.6.0 (all 52 releases to 10.5.2 run; 10.6.0 by reading) and main
Status
documented
Found by
code reading, confirmed by a bounded-exhaustive check against a reference semantics

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

The finding in 30 seconds

Interval queries match runs of positions. ordered(a, b) needs a then b; gaps() counts the positions between the pieces; maxgaps(n, ...) keeps intervals with at most n gaps. When the pieces are equal, ordered/unordered merge them into one repeating source that reports the copies' summed width, so that gaps = span - width.

Diagram of the repeated-phrase bug: the document new york new york at positions 0 to 3 and the query maxgaps(0, ordered(ny, ny)) with ny = phrase(new, york). Expected count 1, gaps 0, width 4; actual count 0, gaps 4, width 0, and Weight#matches disagrees with the count. Inside the repeating source the cache holds start and end of each copy (0, 1, 2, 3); the iterator adds start - end + 1 = 0 per copy instead of end - start + 1 = 2, so width is 0 and gaps 4, while the Matches side adds end - start + 1. The fix swaps the operands. Repeats of single words are unaffected; gaps are only ever too large.
The one-document example: two back-to-back copies of new york, the start and end cached for each copy, the flipped subtraction that gives width 0 and gaps 4 so maxgaps(0) drops the match, and the Matches side that gets gaps 0. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index one document, new york new york (positions 0 to 3). Search Intervals.maxgaps(0, Intervals.ordered(ny, ny)) with ny = Intervals.phrase("new", "york"). The two phrases sit back to back, so you expect 1 hit with 0 gaps.
  2. You get 0 hits, with assertions on or off. Yet Weight#matches for the same query reports a match in that document, and wrapping the second copy in a no-op maxwidth(2, ny) makes the query match.
  3. Why: ordered merges the two equal phrases into one repeating source, with gaps = span - summed width. Each copy, [0, 1] and [2, 3], should add end - start + 1 = 2, but the code adds start - end + 1 = 0. Width 0 instead of 4 gives gaps 4, and maxgaps(0) drops the match.
  4. A repeated single word is unaffected (start = end gives 1 either way), which is why every upstream repeat test passes. Wider copies are worse: two copies of a three-word phrase report width -2 and gaps 8. unordered fails the same way.
  5. Plain text reaches it: Intervals.analyzedText("rock and roll and roll", analyzer, f, 1, true), with "and" as a stop word, misses the document with exactly that text. Every release since 8.5.0 has it; the fix swaps two operands.

Who is affected

Anyone whose interval query repeats a piece wider than one position (a phrase, a nested operator, or pieces analyzedText builds around stop words or from multi-word synonyms) back to back in ordered, or anywhere in unordered, under maxgaps, directly or in a parent. Default settings, any index size, no error: matches just disappear. Repeated single words are unaffected.

Mechanism, terms and numbers733 words

Intervals.ordered (for equal sub-sources next to each other) and Intervals.unordered (for equal sub-sources anywhere) merge them into one RepeatingIntervalsSource, so that two copies of the same piece cannot match the same occurrence. Its iterator reports, as width(), the sum of the copies' widths, and gaps() is the interval's span minus that sum: the positions between the copies. The iterator caches each copy's start and end and adds start - end + 1 per copy instead of end - start + 1. For a single word the two agree (both 1), so repeated words work; for a piece of len positions each copy adds 2 - len instead of len, and gaps() is too large by 2 * (len - 1) per copy. Two back-to-back copies of the phrase "new york" in "new york new york" report width 0 and gaps 4 instead of width 4 and gaps 0, so maxgaps(0, ordered(ny, ny)) and maxgaps(0, unordered(ny, ny)) find nothing, and a parent ordered/unordered that contains the repeated piece inherits the error. The Matches path of the same class, written in the same commit, uses the right formula, so Weight#matches reports a match for the document the scorer rejects. Intervals.analyzedText builds the shape from plain text with a stop-word hole or a multi-word synonym, and the flexible query parser from fn:maxgaps(0 fn:ordered("new york" "new york")). The line dates from LUCENE-9099 (2020) and every release since 8.5.0 has it. Swapping the two operands fixes it (validated with the repro, a new TestIntervals test and the whole lucene/queries module).

Terms

  • Interval: a run of positions [start, end] in a field; IntervalIterator#width() is by default end() - start() + 1 (IntervalIterator.java:73-76).
  • Gaps: IntervalIterator#gaps() "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:62-71). Intervals.maxgaps(n, source) keeps an interval when gaps() <= n (FilteredIntervalsSource.java:47-50).
  • Repeating source: Intervals.ordered merges consecutive equal sub-sources, Intervals.unordered equal sub-sources anywhere, into one RepeatingIntervalsSource (OrderedIntervalsSource.java:40-57, UnorderedIntervalsSource.java:44-57). Its iterator "sums up the widths of the individual sub-iterators, rather than just returning the full span" (RepeatingIntervalsSource.java:38-40), and its gaps() is the span minus that sum (:166-169).

The bug

  1. The cache. For each copy the iterator stores cache[2i] = start and cache[2i + 1] = end (RepeatingIntervalsSource.java:197-198).
  2. The flipped subtraction. width() adds cache[pos * 2] - cache[pos * 2 + 1] + 1, that is start - end + 1, per copy (:156-164, line 161). A copy of len positions adds 2 - len instead of len.
  3. Gaps too large. gaps() = span - width (:166-169) is too large by 2 * (len - 1) per copy: +4 for two copies of a two-word phrase, +8 for a three-word phrase (where the width is -2). It is never too small. The width is negative only when the copies are long enough: a three-position copy next to a one-position copy (equal sources such as an or of a phrase and a word can emit both) gives 0.
  4. Who reads it. maxgaps directly, and any parent ordered/unordered/atLeast, which computes its own gaps as its span minus each child's width() (OrderedIntervalsSource.java:166-171, UnorderedIntervalsSource.java:158-161, MinimumShouldMatchIntervalsSource.java:235-237).
  5. Matches disagrees. DuplicateMatchesIterator, the Matches side of the same class, adds endPosition - startPosition + 1 (:306-322), so Weight#matches keeps what the scorer drops.

The example, step by step

"new york new york": ny matches [0, 1] and [2, 3]. ordered(ny, ny) is one repeating source with interval [0, 3], span 4. Correct width: (1 - 0 + 1) + (3 - 2 + 1) = 4, gaps 0. Computed width: (0 - 1 + 1) + (2 - 3 + 1) = 0, gaps 4. maxgaps(0) rejects the interval, so the document is not a hit.

Not affected: repeats of single words (start = end) and maxwidth (FilteredIntervalsSource.java:66-69, which uses end - start + 1). The scorer does not read this width either (IntervalScorer.java:65), but it sums its frequency over the intervals that survive the filter (IntervalScorer.java:60-68), so where intervals are dropped, a document that still matches through other intervals scores differently. Without a gaps consumer the hits are unchanged; only the reported gaps and width are wrong.

Exposure and likelihood420 words
  • Who is exposed. Users of the intervals API (lucene/queries, opt-in, marked experimental) and of the flexible query parser's interval functions, when ordered repeats a piece wider than one position back to back (it merges only consecutive equal sub-sources, so ordered(ny, nj, ny) is not affected), or unordered repeats it anywhere, under maxgaps, directly or above a parent that contains it. Downstream servers that expose interval queries with a max-gaps option were not run.
  • Shapes that reach it (all executed). The same phrase twice; a repeated nested ordered/unordered (repeated inside ordered only since 10.1.0, when #13819 stopped flattening nested sources); Intervals.analyzedText with maxGaps > 0 or unordered on text whose repeated word follows a stop-word hole ("rock and roll and roll" becomes rock, extend(roll, 1, 0), extend(roll, 1, 0)) or with a repeated multi-word synonym ("ny ny" with ny => new york); and the query string fn:maxgaps(0 fn:ordered("new york" "new york")).
  • What goes wrong. Gaps are only ever too large, so the built-in maxgaps, directly or above a parent, only rejects intervals it should keep: every positive query run lost hits (custom filters and deeper compositions were not analysed). A negation over such a query keeps documents it should drop (executed for a MUST_NOT clause and notContainedBy; other negating operators were not run). Weight#matches, which the highlighters use, disagrees with the hit list (neither highlighter was run end to end). Scores are not computed from the bad width but change where intervals are dropped. Code that reads gaps()/width() sees wrong values even without maxgaps.
  • How big. The error is fixed: +4 for two copies of a two-word phrase. So for the non-overlapping "new york" copies run here, maxgaps(0) to maxgaps(3) never match; maxgaps(5) still catches true gaps 0-1 and misses 2-5, so spot checks can pass. Copies that overlap (not run) would have a shorter span.
  • Silent, default settings. No exception. A plain main() with IndexWriterConfig on StandardAnalyzer (the no-argument default), the default codec, a ByteBuffersDirectory and a plain IndexSearcher misses the document, with assertions on and off; the JUnit runs with the test framework's randomized codec, similarity and locale miss it too. One document is enough; nothing is written to the index.
  • How likely. Rare: it needs a repeated multi-position piece plus maxgaps, and no report was found in the recorded searches although the code has shipped for about six and a half years; but for such a query the affected documents are missed every time.

Minimal repro

The test shows that maxgaps(0, unordered(phrase(new, york), phrase(new, york))) misses "new york new york". RepeatingIntervalsSource's iterator sums start - end + 1 per repeated copy, so width() is 0 and gaps() is 4.

TestRepeatedPhraseMaxGaps.java (JUnit test)32 lines
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 TestRepeatedPhraseMaxGaps extends LuceneTestCase {

  public void testMaxGapsZeroOverRepeatedPhrase() 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", "new york new york", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IntervalsSource ny = Intervals.phrase("new", "york");
        IntervalQuery q = new IntervalQuery("f", Intervals.maxgaps(0, Intervals.unordered(ny, ny)));
        // the two "new york" intervals are back to back (0 gaps): expected 1 hit, actual 0
        assertEquals(q + " on 'new york new york'", 1, new IndexSearcher(reader).count(q));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-306/, 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.TestRepeatedPhraseMaxGaps
Expected vs actual
Expected 1 hit (the two phrases are back to back, 0 gaps); actual 0 (expected:<1> but was:<0>).

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

More files, environment, full output39 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: f:MAXGAPS/0(UNORDERED(BLOCK(new,york),BLOCK(new,york))) on 'new york new york' 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.TestRepeatedPhraseMaxGaps.testMaxGapsZeroOverRepeatedPhrase(TestRepeatedPhraseMaxGaps.java:28)

Why it looks unintended

  • The class promises a width that "sums up the widths of the individual sub-iterators"; 0 for two 2-position copies is not that.
  • Its Matches side, from the same commit, uses end - start + 1: Weight#matches reports a document the scorer rejects.
  • The commit says the summed width exists to "preserve maxgaps filtering"; a no-op maxwidth wrapper restores the match.
The full argument216 words
  • The class javadoc. "The generated 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); a width is end() - start() + 1 (IntervalIterator.java:73-76).
  • The gaps contract. gaps() counts "the gaps between the immediate sub-intervals of this interval" (IntervalIterator.java:62-71); two back-to-back copies have none.
  • The twin uses the right sign. DuplicateMatchesIterator (:306-322), added in the same commit, sums endPosition - startPosition + 1; IntervalMatchesIterator#gaps()/width() refer to the IntervalIterator definitions, and Weight#matches must return null "if the document does not match the parent query" (Weight.java:67-69).
  • The introducing commit's own goals. LUCENE-9099: "RepeatingIntervalsSource instead returns the sum of the widths of its child iterators. This preserves maxgaps filtering on ordered and unordered sources that contain repeats", and checks were added "to ensure that the same intervals are returned by both iterator and matches". The bug breaks both.
  • An internal optimization decides. maxgaps(0, ordered(ny, maxwidth(2, ny))), whose copies are not equal and so not merged, matches (executed).
  • Nothing pins the current value. Every upstream repeat test repeats single words, and checkIntervals compares iterator and Matches on start and end only; all 117 upstream intervals tests pass with the fix.
The devil's-advocate check145 words

The strongest case for "works as designed" (rated weak by the devil's advocate and by the reviews from another model family): the package "contains experimental classes" (package-info.java:21), the class is package-private, its javadoc says it "is useful for repeated terms within an unordered interval" (RepeatingIntervalsSource.java:34-36), and LUCENE-9099's examples and every repeat test use single words. On that reading, repeating multi-word pieces was outside the design, and the width is an internal detail.

Why it fails: "experimental" covers API stability, not wrong results. The public ordered/unordered factories merge any equal sub-sources without a word about terms, analyzedText and the query parser build multi-position repeats from plain text, and the same commit wrote the correct formula for the Matches side. The class promises a sum of widths, and a negative width has no meaning. The fix changes nothing for single-word repeats.

Suggested fix

Recommended Swap the operands in DuplicateIntervalIterator.width()

In the repeating source's iterator width(), swap the two operands: width += cache[pos * 2 + 1] - cache[pos * 2] + 1;, the formula its Matches side already uses. The repro, a new 12-line TestIntervals test and the whole lucene/queries module (520 tests) pass with assertions on and off. No cost; single-word repeats are unchanged.

       int width = 0;
       for (int i = 0; i < cacheLength; i++) {
         int pos = (cacheBase + i) % cacheLength;
-        width += cache[pos * 2] - cache[pos * 2 + 1] + 1;
+        width += cache[pos * 2 + 1] - cache[pos * 2] + 1;
       }
       return width;
     }
All options and trade-offs4 options · 730 words

The fix is one line: the iterator's width() gets the formula of IntervalIterator#width() and of the Matches twin, so iterator and Matches agree again and gaps() counts only the positions between the copies. Summing each copy's own width() instead (cached next to the endpoints, which start() and end() still need) would count the inner gaps of a repeated nested repeat, against the gaps() contract and the Matches twin. Merging only single-position sources would bring back what LUCENE-9099 fixed: unmerged equal copies can match the same occurrence. Until a fix ships, an ordered query can make its copies unequal with a no-op filter (ordered(ny, maxwidth(2, ny))); there is no such workaround for unordered.

Swap the operands in DuplicateIntervalIterator.width() (validated by the checks) (recommended)

In RepeatingIntervalsSource.DuplicateIntervalIterator.width() (line 161), add end - start + 1 per copy instead of start - end + 1 (+1/-1 in one file). Add a regression test that repeats a multi-position source; no upstream test does. A 12-line test for TestIntervals (after testMaxGapsWithOnlyRepeats) reuses that suite's fixture: maxgaps(1, ordered(phrase(pease, porridge), phrase(pease, porridge))) and the unordered twin must give intervals [0, 4] and [3, 7] with gaps 1 in documents 1, 2 and 4.

       int width = 0;
       for (int i = 0; i < cacheLength; i++) {
         int pos = (cacheBase + i) % cacheLength;
-        width += cache[pos * 2] - cache[pos * 2 + 1] + 1;
+        width += cache[pos * 2 + 1] - cache[pos * 2] + 1;
       }
       return width;
     }

Trade-offs

  • Same arithmetic, no cost.
  • Results change only for repeated multi-position sources, where they were wrong: gaps only go down, so with the built-in maxgaps the positive queries run here gain their missing hits and the negations run here exclude more (custom filters and deeper compositions were not analysed), and the scores of documents whose intervals were dropped change. Single-word repeats give the same value as before.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: stock + the new test, 6 of 7 fail with assertions on and off; stock + the TestIntervals test, it fails ("wrong number of endpoints in doc 1 expected:<4> but was:<0>") and the other 43 TestIntervals tests pass.
  • + the fix: the whole lucene/queries module, 52 suites, 520 tests (125 in the intervals package, 8 of them new), 0 failures with assertions on and off (5 and 6 tests skipped); :lucene:queries:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis) passes.
  • Standalone runner: 7 of 7 with assertions on and off.
  • Earlier: the finder's, the reproducer's and a reviewer's tests, the devil's advocate's probe, an 8-test reach probe and the portable history probe (on 8.5.0 and main) all pass with the same change.

Sum each copy's own width() instead of its span

Cache in.width() per copy as well as its start and end (the composite start() and end() still need the cached endpoints after the single delegated iterator moves on), and sum those. Not built.

Trade-offs

A literal reading of the class javadoc ("sums up the widths of the individual sub-iterators") supports it; it costs one more cached value and one width() call per copy. Identical for phrases and words. It differs when the repeated piece itself reports a summed width (a repeated nested repeat): it would then count the inner gaps in the parent's gaps, against IntervalIterator#gaps() ("does not include the gaps inside those sub-intervals") and against the Matches twin, which uses the span, so the Matches side would need the same decision.

Merge only single-position sub-sources

Leave phrases and other multi-position pieces unmerged. Not built.

Trade-offs

Brings back what LUCENE-9099 fixed ("UNORDERED(a, a) will match on documents that just containg a single a"): unordered(ny, maxwidth(2, ny)), whose copies are not merged, matches the document "new york" alone (executed). It is also hard to build: IntervalsSource exposes only a minimum extent (minExtent()), so neither source equality nor the existing API tells whether every interval a sub-source emits is one position wide (an or of a word and a phrase emits both).

Workaround until a fix ships (ordered only)

Make the copies unequal with a no-op filter: maxgaps(0, ordered(ny, maxwidth(2, ny))) finds "new york new york" and not "new york x new york" (executed).

Trade-offs

Not for unordered: unmerged copies can match the same occurrence, so maxgaps(0, unordered(ny, maxwidth(2, ny))) also matches "new york" alone and "new york x new york" (executed).

Downstream note

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

How it was found

Reading the repeating source, the finder saw its iterator and its Matches side subtract in opposite directions. A bounded-exhaustive check against an independent reference semantics (1.4 billion checks on short documents) found iterator and Matches disagreeing on gaps only where a repeated source was involved, apart from a benign OR tie-break on stacked synonyms. A one-document test reproduced it.

The method in full208 words

Method: code reading, then bounded-exhaustive translation validation on the stock main jars.

  • The hunt unit on spans and intervals noticed that DuplicateIntervalIterator.width() and DuplicateMatchesIterator.width() in the same file subtract in opposite directions.
  • Its harness (IvTV) ran the stock interval iterators against an independently written reference semantics (IvRef) on every document of up to 6 positions over {a, b, c} and up to 4 positions with stacked synonyms, for every one- and two-level expression over 24 interval operators: 1,401,702,324 checks.
  • The check that the Matches API agrees with the iterator, gaps and width included, failed 59,751 times for ordered over a repeated source and 59,751 times for unordered; Matches was non-null where the iterator had no interval 7,653 times under maxgaps(0) and 8,301 times under maxgaps(1). These categories are 0 in a smaller re-run (287,753,697 checks) with all six fixes of that unit applied. The only gap/width disagreements without a repeated source were a separate tie-break between OR branches on stacked synonyms (47,642 and 2,020 checks), where both values are valid witnesses; it is not reported and stays with all fixes applied.
  • The finder reduced it to a one-document public-API JUnit; independent AI tools then reproduced, reviewed and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): 84 logged searches over GitHub issues, PRs and commits, downstream repositories, the mailing lists, the JIRA archive and the web, plus the changed files of all 413 open PRs. The nearest items are the introducing issue (single-word repeats only) and a different duplicate-source Matches bug fixed in 9.0.

Searches4 searches

No issue, PR, JIRA ticket, list thread or downstream report describes the flipped sign in the repeating iterator's width(), wrong gaps()/maxgaps for repeated multi-position sources, or the Matches-versus-scorer disagreement it causes. On 2026-10-26 the line is unchanged on upstream main, branch_10x, branch_10_6 and branch_10_5, and no open PR touches the file.

Closest, not this bug: #10141 / lucene-solr#1097 (LUCENE-9099, the introducing change; only single-word repeats are discussed or tested); #11177 (LUCENE-10140, duplicate sub-sources but a Matches sub-match position bug, fixed in 9.0); #13819 (stops flattening nested sources; relevant only to a separate nested-source behaviour, not to the sign); #9643 (LUCENE-8597, added gaps()/maxgaps), #9523 (LUCENE-8477), elastic/elasticsearch#58576 / LUCENE-9418 and #12213 / #12214 (other gaps or ordered-minimization issues); apache/solr#5224 (open; exposes interval gaps to scripts, so it would surface the wrong value).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs: apache/lucene, the archived apache/lucene-solr, Elasticsearch, OpenSearch, Solr2026-10-26a scripted prior-report search, two runs: 32 issue/PR queries (class and method names, maxgaps with repeated/phrase/ordered/unordered/duplicate/synonym, symptom phrases, LUCENE-9099), 3 commit-message queries, 2 downstream queriesonly the introducing issue #10141, PR lucene-solr#1097 and commit 7c1ba1aebee
dev@, java-user@, issues@, commits@ lists; JIRA archive; web2026-10-2620 list queries, 6 JIRA queries, 6 tool web queries and 5 manual web searchesLUCENE-9099 notifications and unrelated items only
Upstream code state and open PRs2026-10-26REST API: file history and contents on main, branch_10x, branch_10_5; the changed files of all 413 open apache/lucene PRs; local git log -S of the linethe line is unchanged since 7c1ba1aebee; no open PR touches the file
Earlier passes2026-10-26the finder's JIRA and list searches; the devil's advocate's read-only GitHub searchesno report

History

Introduced with the repeating source by LUCENE-9099 (aa916bac3c3 on branch_8x, 7c1ba1aebee on master, 2020-02-06), first released in 8.5.0; not a regression. A portable probe fails on all 52 releases from 8.5.0 to 10.5.2 and on main, and passes on 8.4.1 and on 8.5.0 with only the sign fixed.

Versions tested9 rows

Introduced by LUCENE-9099 "Correctly handle repeats in ORDERED and UNORDERED intervals" (@romseygeek; apache/lucene-solr#1097, the JIRA issue is now apache/lucene#10141; one approving review, no inline comments): aa916bac3c3 on branch_8x, the commit first released, and 7c1ba1aebee on master, both 2020-02-06. The commit created RepeatingIntervalsSource, the faulty line and the correct Matches twin; git log -S of the line and git log -L of width() list only this commit.

Shape history: #13819 (first released in 10.1.0) stopped flattening nested ordered sources, so since then a nested ordered(X, X) repeated inside ordered also reaches the bug (10.0.0 passes, 10.1.0 fails); the phrase form and nested-in-unordered form are affected from 8.5.0. #13819 did not touch the file.

Caveats: every run used JDK 25.0.3; the release sweep ran with assertions on (main also with them off); 8.x and 9.0.0 were run with the portable probe, not the JUnit (the test-framework package was renamed in 9.1.0); the 8.8.2 and 8.9.0-8.11.4 tags are not in the local history clone, so their status rests on execution alone.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe brief's test: 6 of 7 fail (gradle and the standalone runner, assertions on and off); the finder's 2 of 2
10.5.2 affectedthe brief's test: 6 of 7 fail (assertions on and off); the finder's and the reproducer's tests fail
9.12.3 affectedthe brief's test: 6 of 7 fail (assertions on; off in the independent fact check); the finder's and the reproducer's tests fail
8.5.0 to 10.5.2 (all 52 releases) affectedthe portable main(): the same 5 checks fail (counts 0, gaps 4 and 8), 3 controls pass
aa916bac3c3, 7c1ba1aebee (LUCENE-9099) introducingfirst released in 8.5.0; the line is unchanged since
8.5.0 with only the sign fixed not affectedall 8 checks pass
8.2.0 to 8.4.1 beforeno repeating source; ordered gaps came from sub-iterator positions; all 8 checks pass
10.0.0 vs 10.1.0 shapenested ordered(X, X) inside ordered: passes on 10.0.0 (flattened), fails from 10.1.0
main + fix not affectedthe repro passes; the whole lucene/queries module, 520 tests, 0 failed (5 and 6 skipped)

Independent checks

An independent reproducer, a devil's advocate and reviews from another model family agree on the cause; the reproducer and the devil's advocate reproduced it with their own tests, and the adjudication confirmed it (high). This doc's brief added a 7-test repro and validated the fix with the whole module.

Checks and verdicts522 words

Every verifier (the finder, the independent reproduction, a separate JUnit, the intended-behaviour check, the adjudication and the registry re-run) ran its main repro with assertions on and off; the two separate runs re-ran the finder's JUnit with assertions on only. The cross-model reviews ran read-only (they could not run Java) on the previous main ea57360912ed, which differs from a9779ef3c193 in the intervals package only in MultiTermIntervalsSource and the Intervals javadoc (#16899); separate runs then ran the tests. No cross-model review that executes code was done.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateCode reading, then the bounded-exhaustive harness (1.4 billion checks; apart from a benign OR tie-break on stacked synonyms, iterator and Matches disagreed on gaps only where a repeated source was involved); the 2-test public-API JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3 and passes with the fix.
cross-model review, refute lens procedural "not real" (low)An independent reviewer from another model family (OpenAI GPT-5.6) ran read-only and could not run Java, so it voted not real on procedure only; the root cause agrees, the inputs are in contract and the intended case is weak, and its notes say the static evidence strongly favours a real defect. A separate run re-ran the finder's JUnit: 2 of 2 fail.
Independent reproducer realOwn JUnit from the claim only, outside org.apache.lucene: 7 of 9 fail on main (-ea, -da), 10.5.2 and 9.12.3 (its run directories also ran the split-off span class: 11 tests, 8 failures on main and 10.5.2, 9 on 9.12.3); both controls pass; 9 of 9 and 117 upstream intervals tests pass with its own one-line fix.
Cross-model review, intended lens real (medium)OpenAI GPT-5.6, read-only, intended case weak; a separate JUnit fails 2 of 3 on main (-ea, -da): ordered(ab, ab, c) gaps 4, maxgaps(1, unordered(ab, ab)) 0 of 2 documents; 3 of 3 with the fix.
Devil's advocate real (the sign)Built the "experimental, single-word scope" case and rejected it; its probe shows iterator [0,3 g4 w0] vs Matches [0,3 g0 w4], and the query-parser string gets 0 hits while the control hits; 117 upstream tests pass with only the sign fix.
adjudication confirmed (high)Re-ran the independent reproduction's JUnit (7 of 9 fail, -ea and -da), the finder's (2 of 2 fail) and the independent reproduction's with the fix (9 of 9 pass). Scope: the sign only; a separate nested-source behaviour was split off.
registry re-run and refresh still reproducesThe finder's JUnit fails 2 of 2 with -ea and -da and passes with the fix on ea57360912ed, and fails 2 of 2 on a9779ef3c193 with -ea and the same messages; later -da re-runs on a9779ef3c193 fail 2 of 2 too.
this doc's brief reproduced, fix validatedA new 7-test JUnit fails 6 of 7 on main (gradle and the standalone runner, -ea, -da), 10.5.2 and 9.12.3; with the fix it passes, and so does the whole lucene/queries module with assertions on and off.

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

Finding record

LFV-306, snapshot of 2026-11-06

Registry title
RepeatingIntervalsSource's iterator computes width() with the sign inverted (start - end + 1 per repeated sub-interval), so when Intervals.ordered/unordered repeat a sub-source wider than one position (for example the same phrase twice) gaps() is over-reported, maxgaps drops real matches and Weight#matches disagrees with the scorer
Area
queries / intervals (Intervals.ordered/unordered with equal sub-sources: RepeatingIntervalsSource, maxgaps)
Priority
Medium, rank 194: Since 8.5, interval matches are silently missed when ordered or unordered repeats a multi-position sub-source under maxgaps, which is rarer than the before() row.
Impact class
silent wrong results
Impact note
Anyone who runs an interval query in which Intervals.ordered gets two or more equal sub-sources next to each other in its argument list, or Intervals.unordered gets two or more equal sub-sources anywhere, that span more than one position (the same phrase twice, a repeated nested ordered/unordered, or the extend()/multi-word-synonym pieces that Intervals.analyzedText builds from text with a stop-word hole or a multi-word synonym; also reachable from StandardQueryParser fn:ordered/fn:unordered strings) under a maxgaps filter, with default writer, codec, analyzer and searcher settings, at any index size and in every release since 8.5.0, silently loses real matches (maxgaps(0, ordered(phrase(new,york), phrase(new,york))) misses 'new york new york'), and Weight#matches then reports a match for a document the scorer rejects; repeats of single terms and maxwidth are not affected, scores are not computed from the bad width but change where intervals are dropped, and nothing is written to the index.
Summary
You search with an interval query that repeats a multi-word piece, for example maxgaps(0, ordered(phrase(new, york), phrase(new, york))) on the text 'new york new york', and expect a match because the two phrases sit back to back with no gap. Instead it finds nothing: Lucene turns equal sub-sources into a RepeatingIntervalsSource whose iterator adds start - end + 1 instead of end - start + 1 for each copy, so width() is 0 instead of 4, gaps() is 4 instead of 0, and maxgaps rejects the interval. The Matches path of the same class uses the right formula, so Weight#matches returns a match for the very document the scorer rejected; repeats of single words are unaffected because their start equals their end.
Introduced
aa916bac3c3369a461afa06e384e070657c32973 (branch_8x, the commit first released, in 8.5.0) and 7c1ba1aebeea540b67ae304deee60162baee2e12 (master, the same patch on the same day, first released in 9.0.0): 'LUCENE-9099: Correctly handle repeats in ORDERED and UNORDERED intervals' (apache/lucene-solr#1097, the JIRA issue is now apache/lucene#10141; author romseygeek; one approving review by jimczi, no inline review comments) [read: git log -S on the line text and git log -L of width() list only 7c1ba1aebee; git blame at 7cecf795236c attributes lines 157-169 to it; no later commit touched them] [exec: 8.4.1 not affected, 8.5.0 affected, 8.5.0 with only the sign corrected not affected]. The same commit added the Matches twin DuplicateMatchesIterator.width() with the correct end - start + 1. Present since the class was created, not a regression; #13819 (first released in 10.1.0, stops flattening nested ordered sources) later widened the query shapes that reach it without touching the file (2020-02-06 (aa916bac3c3 and 7c1ba1aebee))
First affected version
8.5.0 [exec] (8.4.1, the last release before LUCENE-9099, is not affected [exec]; 8.5.0 with only the sign of its line 159 corrected passes every check [exec]; JIRA fixVersion 8.5 and the 8.5.0 CHANGES entry for LUCENE-9099 [read])
Affected versions
every release from 8.5.0 through 10.6.0 (10.6.0 by reading; the 52 releases through 10.5.2: all 8.5.x to 8.11.x, 9.x and 10.x) and main ea57360912ed [exec] by the follow-up history probe RepeatWidthHistoryProbe (a portable main() probe with the JUnit's assertions; 5 checks fail and 3 controls pass, with byte-identical failing-check text on 8.5.0, 9.0.0, 10.5.2 and main; -ea, and main also -da); the finder's JUnit fails 2 of 2 on 9.1.0, 9.12.3, 10.5.2 and main (-ea and -da), the independent reproduction's JUnit 7 of 9 on main (-ea and -da), 10.5.2 and 9.12.3, and the security probe 6 of 8 on main (-ea and -da), 10.5.2 and 9.12.3. Not affected [exec]: 8.2.0, 8.3.0, 8.3.1, 8.4.0 and 8.4.1 (no RepeatingIntervalsSource; ordered/unordered gaps were computed from sub-iterator positions). Not affected [read]: releases before 8.2.0 (intervals lived in the sandbox and the class did not exist). Trigger shapes: the phrase form and a nested ordered repeated inside unordered from 8.5.0; a nested ordered(X, X) repeated inside ordered only from 10.1.0, because before #13819 it was flattened into ordered(a, b, a, b) [exec: 10.0.0 passes, 10.1.0 fails]. 8.8.2 and 8.9.0 to 8.11.4 were executed but their tags are not in the local history clone. Not fixed: the line is unchanged on upstream main, branch_10x, branch_10_6 and branch_10_5, and no open PR touches the file [read, 2026-10-26].
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: code reading; also reached by exhaustive check
Upstream and prior reports
none found (follow-up prior-report record verdict new, searched 2026-10-26, read-only): 84 logged queries: 69 from a scripted prior-report search in two runs (59 and 10: 32 GitHub issue/PR queries over apache/lucene, the archived apache/lucene-solr and the downstream repositories Elasticsearch, OpenSearch and Solr; 3 commit-message queries; 2 downstream queries; 20 mailing-list queries over dev@, java-user@, issues@ and commits@; 6 JIRA-archive queries; 6 web queries) and 15 manual searches (GitHub REST and GraphQL, local git history, JIRA, lists, 5 web searches); the changed-file lists of all 413 open apache/lucene PRs were checked; the finder's JIRA and list searches and the intended-behaviour check's GitHub searches found nothing either. Nearest items, none the same bug: LUCENE-9099 / #10141 / lucene-solr#1097 / 7c1ba1aebee (the introducing change; only single-term repeats are discussed or tested); LUCENE-10140 / #11177 (duplicate sub-sources, Matches sub-match positions for non-adjacent duplicates; a different defect, fixed in 9.0); #13819 (stops flattening nested ordered/unordered sources; relevant only to the split-off 'span' manifestation, not to the sign); LUCENE-8597 / #9643, LUCENE-8477 / #9523, LUCENE-9418 / elastic/elasticsearch#58576 and #12213 / #12214 (other gaps or ordered-minimization issues); apache/solr#5224 (open; exposes interval gaps to scripts, so it would surface the wrong value). Not fixed upstream: on 2026-10-26 the line is still on upstream main (line 161), branch_10x and branch_10_5 (line 160); the file was last changed on 2026-04-01 (#15889, an equals clean-up). Gaps: GitHub search is keyword based, so a report that uses none of the searched class names or symptom words could be missed; Stack Overflow and downstream forums only through general web search; private security reports cannot be searched
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 release jars (assertions on and off) and 9.12.3 (on; off in the independent fact check). A portable main() probe ran on all 52 releases from 8.5.0 to 10.5.2, on 8.2.0 to 8.4.1 and on 8.5.0 with only the sign fixed (2026-10-26, against the then-current main ea57360912ed, which differs from a9779ef3c193 in the intervals package only in MultiTermIntervalsSource and the Intervals javadoc). The fix was validated in the scratch worktree: the new test, a new TestIntervals regression test, the whole lucene/queries module (52 suites, 520 tests, 5 and 6 skipped) with assertions on and off, and :lucene:queries:check -x test.

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'), :157-164 (DuplicateIntervalIterator.width(), line 161 'width += cache[pos * 2] - cache[pos * 2 + 1] + 1'), :166-169 (gaps() = super.width() - width()), :197-198 (cache[2i] = start, cache[2i+1] = end), :307-322 (DuplicateMatchesIterator.gaps()/width() with endPosition - startPosition + 1)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalIterator.java:62-67 (gaps(): 'the number of gaps between the immediate sub-intervals of this interval'), :74-76 (default width() = end() - start() + 1)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/OrderedIntervalsSource.java:33, :40-56 (deduplicate builds RepeatingIntervalsSource for consecutive equal sub-sources), :166-171 (parent gaps subtract each sub-iterator's width()); UnorderedIntervalsSource.java:37, :44-56 (deduplicate by count), :158-161; MinimumShouldMatchIntervalsSource.java:235-237
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteredIntervalsSource.java:49 (maxgaps accepts when it.gaps() <= maxGaps); IntervalScorer.java:65 (scores use end - start + 1, unaffected); Intervals.java:387, :420, :434 (maxgaps, ordered, unordered: no restriction on equal sub-sources), :615-636 (analyzedText); package-info.java:21 ('experimental classes')
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:33-41 (at a9779ef3c193) (class javadoc), :144-154 (start/end), :156-164 (width(), line 161), :166-169 (gaps()), :192-200 (cacheNextInterval, :197-198), :306-322 (DuplicateMatchesIterator gaps/width)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalIterator.java:62-71, (at a9779ef3c193) :73-76
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/OrderedIntervalsSource.java:29-57, (at a9779ef3c193) :166-171; UnorderedIntervalsSource.java:33-57, :158-161; MinimumShouldMatchIntervalsSource.java:235-237
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteredIntervalsSource.java:47-50, (at a9779ef3c193) :66-69; IntervalScorer.java:65
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalScorer.java:60-68 (at a9779ef3c193) (ensureFreq sums over the surviving intervals)
Who is affected
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalBuilder.java:131-149, (at a9779ef3c193) :151-170; package-info.java:21; lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/AnalyzedText.java:34-38
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:405; (at a9779ef3c193) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldOffsetStrategy.java:147 (Weight#matches callers)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalScorer.java:60-68 (at a9779ef3c193)
Why it looks unintended
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:33-41, (at a9779ef3c193) :306-322
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalIterator.java:62-76; (at a9779ef3c193) IntervalMatchesIterator.java:31-42; package-info.java:21
  • lucene/core/src/java/org/apache/lucene/search/Weight.java:67-69 (at a9779ef3c193)
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:129-193 (at a9779ef3c193) (checkIntervals: start/end only), :330, :407, :425, :729, :739; TestSimplifications.java:41, :55
Suggested fix
  • lucene/CHANGES.txt:420 (at a9779ef3c193) (Lucene 10.7.0 section)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:144-154, (at a9779ef3c193) :172-200 (start()/end() read the cached endpoints); IntervalsSource.java:64 (minExtent() is the only extent in the API)

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

Repro files

About the diagrams

A step-by-step picture of the repro's first case, made in parallel with this doc from the executed repro on main a9779ef3c193 and the code; the PNG is the final summary card. The alt text describes the intended content.

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