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.
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.- Index one document,
new york new york(positions 0 to 3). SearchIntervals.maxgaps(0, Intervals.ordered(ny, ny))withny = Intervals.phrase("new", "york"). The two phrases sit back to back, so you expect 1 hit with 0 gaps. - You get 0 hits, with assertions on or off. Yet
Weight#matchesfor the same query reports a match in that document, and wrapping the second copy in a no-opmaxwidth(2, ny)makes the query match. - Why:
orderedmerges 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, andmaxgaps(0)drops the match. - 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.
unorderedfails the same way. - 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.
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).
- Interval: a run of positions [start, end] in a field;
IntervalIterator#width()is by defaultend() - 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 whengaps() <= n(FilteredIntervalsSource.java:47-50). - Repeating source:
Intervals.orderedmerges consecutive equal sub-sources,Intervals.unorderedequal sub-sources anywhere, into oneRepeatingIntervalsSource(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 itsgaps()is the span minus that sum (:166-169).
- The cache. For each copy the iterator stores
cache[2i] = startandcache[2i + 1] = end(RepeatingIntervalsSource.java:197-198). - The flipped subtraction.
width()addscache[pos * 2] - cache[pos * 2 + 1] + 1, that isstart - end + 1, per copy (:156-164, line 161). A copy oflenpositions adds2 - leninstead oflen. - Gaps too large.
gaps()= span - width (:166-169) is too large by2 * (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 anorof a phrase and a word can emit both) gives 0. - Who reads it.
maxgapsdirectly, and any parentordered/unordered/atLeast, which computes its own gaps as its span minus each child'swidth()(OrderedIntervalsSource.java:166-171,UnorderedIntervalsSource.java:158-161,MinimumShouldMatchIntervalsSource.java:235-237). - Matches disagrees.
DuplicateMatchesIterator, the Matches side of the same class, addsendPosition - startPosition + 1(:306-322), soWeight#matcheskeeps what the scorer drops.
"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, whenorderedrepeats a piece wider than one position back to back (it merges only consecutive equal sub-sources, soordered(ny, nj, ny)is not affected), orunorderedrepeats it anywhere, undermaxgaps, 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 insideorderedonly since 10.1.0, when #13819 stopped flattening nested sources);Intervals.analyzedTextwithmaxGaps > 0or unordered on text whose repeated word follows a stop-word hole ("rock and roll and roll" becomesrock, extend(roll, 1, 0), extend(roll, 1, 0)) or with a repeated multi-word synonym ("ny ny" with ny => new york); and the query stringfn: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 aMUST_NOTclause andnotContainedBy; 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 readsgaps()/width()sees wrong values even withoutmaxgaps. - 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)tomaxgaps(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()withIndexWriterConfigonStandardAnalyzer(the no-argument default), the default codec, aByteBuffersDirectoryand a plainIndexSearchermisses 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.
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.
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));
}
}
}
}
# 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>).
More files, environment, full output39 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: f: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)- 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#matchesreports a document the scorer rejects. - The commit says the summed width exists to "preserve maxgaps filtering"; a no-op
maxwidthwrapper 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 isend() - 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, sumsendPosition - startPosition + 1;IntervalMatchesIterator#gaps()/width()refer to theIntervalIteratordefinitions, andWeight#matchesmust 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
checkIntervalscompares 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.
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.
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
maxgapsthe 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 + theTestIntervalstest, it fails ("wrong number of endpoints in doc 1 expected:<4> but was:<0>") and the other 43TestIntervalstests pass. - + the fix: the whole
lucene/queriesmodule, 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.
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.
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).
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 relevance: an exclusion built from such a query (a MUST_NOT clause, or notContainedBy over it) silently keeps the documents it should drop.
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()andDuplicateMatchesIterator.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
orderedover a repeated source and 59,751 times forunordered; Matches was non-null where the iterator had no interval 7,653 times undermaxgaps(0)and 8,301 times undermaxgaps(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).
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).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs: apache/lucene, the archived apache/lucene-solr, Elasticsearch, OpenSearch, Solr | 2026-10-26 | a 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 queries | only the introducing issue #10141, PR lucene-solr#1097 and commit 7c1ba1aebee |
| dev@, java-user@, issues@, commits@ lists; JIRA archive; web | 2026-10-26 | 20 list queries, 6 JIRA queries, 6 tool web queries and 5 manual web searches | LUCENE-9099 notifications and unrelated items only |
| Upstream code state and open PRs | 2026-10-26 | REST 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 line | the line is unchanged since 7c1ba1aebee; no open PR touches the file |
| Earlier passes | 2026-10-26 | the finder's JIRA and list searches; the devil's advocate's read-only GitHub searches | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the 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 | affected | the brief's test: 6 of 7 fail (assertions on and off); the finder's and the reproducer's tests fail |
| 9.12.3 | affected | the 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) | affected | the portable main(): the same 5 checks fail (counts 0, gaps 4 and 8), 3 controls pass |
aa916bac3c3, 7c1ba1aebee (LUCENE-9099) | introducing | first released in 8.5.0; the line is unchanged since |
| 8.5.0 with only the sign fixed | not affected | all 8 checks pass |
| 8.2.0 to 8.4.1 | before | no repeating source; ordered gaps came from sub-iterator positions; all 8 checks pass |
| 10.0.0 vs 10.1.0 | shape | nested ordered(X, X) inside ordered: passes on 10.0.0 (flattened), fails from 10.1.0 |
| main + fix | not affected | the repro passes; the whole lucene/queries module, 520 tests, 0 failed (5 and 6 skipped) |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Code 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 | real | Own 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 reproduces | The 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 validated | A 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 464 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,584 words
- 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 portablemain()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 mainea57360912ed, which differs froma9779ef3c193in the intervals package only inMultiTermIntervalsSourceand theIntervalsjavadoc). The fix was validated in the scratch worktree: the new test, a newTestIntervalsregression test, the wholelucene/queriesmodule (52 suites, 520 tests, 5 and 6 skipped) with assertions on and off, and:lucene:queries:check -x test.
- 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-237lucene/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-76lucene/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-237lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteredIntervalsSource.java:47-50, (at a9779ef3c193) :66-69; IntervalScorer.java:65lucene/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-38lucene/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-322lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalIterator.java:62-76; (at a9779ef3c193) IntervalMatchesIterator.java:31-42; package-info.java:21lucene/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)
- TestRepeatedPhraseMaxGaps.java (JUnit test):
repro/LFV-306/src/org/apache/lucene/queries/intervals/TestRepeatedPhraseMaxGaps.java(32 lines)
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.
assets/LFV-306/bug.png(464 KB)
