All findings · Lucene finding · LFV-807 · ordered-repeat-dedup-allows-overlapping-copies

Intervals.ordered(X, X) lets the two copies of X overlap when X's own intervals can share a position

When an ordered interval query gets the same piece twice in a row and that piece's matches can share a position, Lucene lets the two copies share it: "a near b, twice" matches a b a, which has one b. An equivalent but unequal second copy correctly finds nothing.

Summary

Priority
Medium, rank 193A new silent over-match in every release since 8.5.0 that needs ordered or unorderedNoOverlaps to get the same multi-position self-overlapping source twice.
Impact
silent wrong results
Since
8.5.0 (LUCENE-9099, 2020); 8.2.0 to 8.4.1 give the right answerintroduced 2020-02-06 · about 6 years
Affected
8.5.0 to 10.5.2 (52 releases), 10.6.0 (4ae946e5a9b) and main
Status
documented
Found by
a bounded-exhaustive comparison with a reference semantics, then JUnit tests

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

An ordered interval query must not let its parts overlap. When two adjacent parts are equal, Lucene takes a shortcut that skips that rule, which matters only when the part's own intervals can share a position: it spans several positions and can overlap itself, or it is a term stacked twice at one position.

Diagram of the bug. Panel 1: the text 'a b a' at positions 0 to 2; the intervals of ab = Intervals.unordered(a, b) are [0,1] and [1,2] (grey), and Intervals.ordered(ab, ab) returns [0,2] (blue), using the one b in both copies: expected 0 hits, actual 1. The control Intervals.ordered(ab, Intervals.or(ab, zzz)) gives 0 hits. Panel 2, inside Lucene: deduplicate() sees that the second ab equals the first and builds one RepeatingIntervalsSource(ab, 2) named ORDERED; its single iterator caches [0,1] and [1,2] and returns [0,2] with no check that they are disjoint, while for unequal copies the ordered iterator makes the second copy start after the first ends. Panel 3: it happens for a phrase with a repeated word, a near pair, a nested ordered or a term stacked twice at one position; a repeated term with one token per position is not affected; a matching document gets wrong intervals ([0,2] and [1,3] instead of [0,3] on 'a b a b'); silent, also from query text (fn:ordered, fn:unorderedNoOverlaps) since 9.1.0.
ordered(ab, ab) on a b a: the two intervals of ab share the one b, the repeat shortcut joins them into [0,2] and reports a hit, while the unequal copy or(ab, zzz) finds none. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You search for "a near b, twice, in order": Intervals.ordered(ab, ab) with ab = Intervals.unordered(a, b), default settings, on a one-document index holding a b a (positions 0, 1, 2).
  2. You expect 0 hits: the text has one b, and the two copies may not share it. The query parser's fn:ordered docs say "Source intervals must not overlap".
  3. Instead you get 1 hit, interval [0,2]. The two arguments are equal, so Lucene merges them into one repeat source that joins two consecutive matches of a single ab iterator, [0,1] and [1,2], without checking that both use the b at position 1.
  4. Write the second copy as an equivalent but unequal source, or(ab, zzz) with zzz in no document, and you get the correct 0; Lucene 8.4.1 also gave 0. The phrase a a twice, ordered(phrase(a, a), phrase(a, a)), likewise matches a a a, and ordered(cat, cat) matches the one word cat when the analysis chain indexes it twice at one position.
  5. Matching documents get wrong intervals: on a b a b the query reports [0,2] and [1,3] instead of [0,3], and Weight#matches reports the same. Affected: every release from 8.5.0 to 10.6.0, and main.

Who is affected

Anyone whose Intervals.ordered / unorderedNoOverlaps query (or fn: string) repeats back to back a piece whose own matches can share a position: a near pair, a phrase with a repeated word, a nested operator, or a word stacked twice at one position. Default settings, any index size. Single words with one token per position are not affected.

Mechanism, terms and numbers823 words

Intervals.ordered(X, X) asks for X and then X again, and as in every ordered interval query the two parts must not overlap. When the two arguments are equal (by equals(), so also two identically built sources), OrderedIntervalsSource.build replaces them with one RepeatingIntervalsSource, the repeat handling LUCENE-9099 added in 8.5.0. Its iterator joins consecutive intervals of a single X iterator, [first start, last end], and never checks that they are disjoint. For a single term with one token per position that is harmless, since its consecutive intervals are different positions; but when X spans several positions and can overlap itself, the copies share a position: ordered(unordered(a, b), unordered(a, b)) matches a b a (one b), ordered(phrase(a, a), phrase(a, a)) matches a a a, and the same happens through Intervals.unorderedNoOverlaps(X, X) and the query parser's fn:ordered and fn:unorderedNoOverlaps. A term stacked twice at one position (position increment 0) overlaps the same way: ordered(cat, cat) matches the one-word document cat indexed through KeywordRepeatFilter and a stemmer that leaves the word unchanged. Matching documents get wrong intervals too ([0,2], [1,3] instead of [0,3]), and Weight#matches reports them. With an equivalent but unequal second argument the query correctly finds nothing, and so did 8.4.1. Every release from 8.5.0 to 10.6.0 and main are affected with default settings. Dropping the deduplication from OrderedIntervalsSource.build fixes it; the repro and the queries, queryparser and highlighter suites pass with it.

Terms

  • Interval: a span of positions [start, end] in a field where a sub-query matches. Intervals.term("a") on a b a gives [0,0] and [2,2]; Intervals.unordered(a, b) ("a and b in any order, as close as possible") gives [0,1] and [1,2], which share position 1; Intervals.phrase("a", "a") on a a a gives [0,1] and [1,2].
  • Stacked token: two tokens at one position (position increment 0), for example a word and its unchanged stem from KeywordRepeatFilter and a stemmer. If both are a, the postings keep both and Intervals.term("a") gives [0,0] twice.
  • ordered(X, Y): an interval that contains an X interval followed by a Y interval that starts after the X interval ends. fn:ordered in StandardQueryParser maps to it; unorderedNoOverlaps(X, Y) is built as or(ordered(X, Y), ordered(Y, X)).

The bug

  1. OrderedIntervalsSource.build calls deduplicate (OrderedIntervalsSource.java:29-38), which turns each run of consecutive equals() sub-sources into RepeatingIntervalsSource.build(source, runLength) (:40-57); a single run is returned on its own, renamed "ORDERED" (:53-55).
  2. RepeatingIntervalsSource.intervals wraps one iterator of the sub-source in a DuplicateIntervalIterator (RepeatingIntervalsSource.java:66-72). It caches n consecutive intervals of that iterator and reports the first cached start and the last cached end (:145-154, :172-200). Nothing compares one cached interval's start with the previous one's end.
  3. For ab = unordered(a, b) on a b a the cache holds [0,1] and [1,2], so ordered(ab, ab) reports [0,2]: 1 hit with a single b.
  4. For unequal sub-sources each gets its own iterator, and OrderedIntervalIterator advances each one while currentStart <= prevEnd (OrderedIntervalsSource.java:144-151), so the copies can never share a position. That is why ordered(ab, or(ab, zzz)) gives 0.
  5. RepeatingIntervalsSource.matches combines the sub-matches the same way (:75-86), so Weight#matches reports the same wrong interval (executed); highlighters built on it would show it too (read, not run).
  6. A single term is safe only while each position holds it once. TermIntervalsSource returns every posting position (TermIntervalsSource.java:114-120, 127-135), so a term stacked twice at position 0 yields [0,0] twice, the cache joins them into [0,0], and ordered(a, a) matches a document with a single position.

The numbers

One-document index, main a9779ef3c193, assertions on and off (the tests use the test framework's randomized config; the probes use a plain IndexWriterConfig and give the same answers):

querytextexpectedactual
ordered(ab, ab), ab = unordered(a, b)a b a0 hits1 hit, [0,2]
ordered(ab, ab)a b a b[0,3][0,2], [1,3]
ordered(P, P), P = phrase(a, a)a a a01, [0,2]
ordered(P, P)a a a a[0,3][0,2], [1,3]
unorderedNoOverlaps(P, P)a a a01
ordered(ordered(a, a), ordered(a, a))a a a01
fn:ordered("a a" "a a"), fn:unorderedNoOverlaps(fn:phrase(a a) fn:phrase(a a))a a a01
ordered(a, a), unorderedNoOverlaps(a, a), a stackeda twice at position 001, [0,0]
ordered(a, a), a stacked at 0, then a at 1three tokens, two positions[0,1][0,0], [0,1]
ordered(cat, cat), KeywordRepeatFilter + PorterStemFiltercat01, [0,0]
controls: ordered(ab, or(ab, zzz)), ordered(P, or(P, zzz)), ordered(a, a, a, a), the documented fn:ordered(fn:phrase(brown fox) fn:phrase(fox jumps)), ordered(a, or(a, zzz)) on the stacked a, ordered(cat, cat) with RemoveDuplicatesTokenFilterthe same texts; the fox sentence00

Two separately built unordered(a, b) sources are equal, so they take the same path; fn:ordered("a a" "a a") builds two equal phrases from query text.

Exposure and likelihood337 words
  • Who. Users of Intervals.ordered or Intervals.unorderedNoOverlaps (Java API since 8.5.0) or of StandardQueryParser's fn:ordered / fn:unorderedNoOverlaps (query text since 9.1.0) whose query gives the same sub-source twice in a row, where that sub-source spans several positions and its consecutive intervals can share one: a near pair (unordered(a, b)), a phrase whose words repeat (phrase(a, a); phrase(ha, ha) on ha ha ha also matches), or a nested ordered / unordered. ordered(X, X) is a direct way to ask for "X twice". Also anyone who repeats a single word that their analysis chain stacks twice at one position, for example KeywordRepeatFilter before a stemmer that leaves the word unchanged, without RemoveDuplicatesTokenFilter: ordered(cat, cat) then matches the one-word document cat.
  • Effect. Silent extra hits, and wrong intervals on documents that do match. Weight#matches returns the same wrong positions (executed), which can carry over into match-based highlighting and enclosing interval filters; of those only maxgaps was tested directly (containing, containedBy, notContaining follow from the intervals, read). No exception, no hang, nothing written to the index.
  • Default vs opt-in. Nothing to enable: a plain IndexWriterConfig with the default codec, the default searcher, any index size, assertions on and off (executed by the probes); the JUnit tests use the test framework's randomized config and fail the same way.
  • Not affected. Repeats of single words when each position holds the word once (ordered(a, a) on a a a), phrases that cannot overlap themselves (phrase(new, york): not by this overlap, though their gaps are still wrong through LFV-306), and unordered(P, P) matching a a a, which is correct because unordered sources "may overlap" (Intervals.java:432).
  • How likely. It needs that query shape and a document where two of its intervals share a position; for such a pair the false hit happens every time. The trigger is structurally narrow and no report was found although it has shipped since 8.5.0 (2020); how often it happens in practice is unknown.

Minimal repro

The test shows that Intervals.ordered(phrase(a, a), phrase(a, a)) matches the document a a a, although its only two a a intervals, [0,1] and [1,2], overlap (the two equal sub-sources are merged into a RepeatingIntervalsSource).

TestOrderedRepeatedPhraseOverlap.java (JUnit test)48 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 TestOrderedRepeatedPhraseOverlap extends LuceneTestCase {

  public void testOrderedCopiesOfPhraseMustNotOverlap() 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 a a", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IntervalsSource aa = Intervals.phrase("a", "a");
        IntervalQuery q = new IntervalQuery("f", Intervals.ordered(aa, aa));
        // "a a" occurs in 'a a a' only at [0,1] and [1,2], which overlap: expected 0 hits, actual 1
        assertEquals(q + " on 'a a a'", 0, new IndexSearcher(reader).count(q));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-807/, 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.TestOrderedRepeatedPhraseOverlap
Expected vs actual
Expected: 0 hits, as for any ordered query whose sub-intervals would have to overlap. Actual: 1 hit (expected:<0> but was:<1>).

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

More files, environment, full output38 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:ORDERED(BLOCK(a,a),BLOCK(a,a)) on 'a a a' expected:<0> but was:<1>
       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.TestOrderedRepeatedPhraseOverlap.testOrderedCopiesOfPhraseMustNotOverlap(TestOrderedRepeatedPhraseOverlap.java:44)

Why it looks unintended

  • The fn:ordered docs say "Source intervals must not overlap"; unorderedNoOverlaps says its sources "do not overlap".
  • An equivalent but unequal second copy, or(X, zzz) with zzz in no document, gives 0 hits; only equal arguments take the overlapping path.
  • A regression: 8.4.1 gives 0, 8.5.0 gives 1. LUCENE-9099 added the shortcut for repeated single-position sources.
The full argument266 words
  • The contract. fn:ordered: "Source intervals must not overlap", with the example fn:ordered(fn:phrase(brown fox) fn:phrase(fox jumps)) "(Sources overlap, no matches.)" (intervalfn/package-info.java:328-356, LUCENE-10223, 9.1.0); upstream TestMatchHighlighter asserts no hit for it and for fn:unorderedNoOverlaps of the same phrases (TestMatchHighlighter.java:396-397). Intervals.unorderedNoOverlaps: "Returns intervals in which both the subsources appear and do not overlap" (Intervals.java:440-447).
  • The cited algorithm. The Intervals class javadoc says "These sources implement minimum-interval algorithms taken from the paper Efficient Optimally Lazy Algorithms for Minimal-Interval Semantics" (Boldi and Vigna, Intervals.java:43-45); there the ordered operator is the "ordered non-overlapping conjunction operator" and "the intervals must be nonoverlapping".
  • Design note. LUCENE-8828 (#9872, 2019, by the later author of LUCENE-9099): "NO_OVERLAPS(a, a) will end up as ORDERED(a, a)".
  • Self-consistency. ordered(ab, or(ab, zzz)) (0) and ordered(ab, ab) (1) differ on a b a though their sub-intervals are the same; ordered(a, a, a, a) gives 0 on a a a; the span analogue (SpanNearQuery in order over two near(a a)) gives 0.
  • History. No evidence was found that LUCENE-9099 (#10141, lucene-solr#1097, 7c1ba1aebee) considered this case. It fixed two term cases: ORDERED(a, a, b) returning an extra interval and UNORDERED(a, a) matching a single a. Its CHANGES entry says "repeated subterms", the class javadoc "repeated terms within an unordered interval", and every test it added repeats a single-position source: a term, or in testMaxGapsWithOnlyRepeats the disjunction or(pease, hot) (TestIntervals.java:739-749). One approving review, no inline comments; nothing relaxes the no-overlap rule. 8.4.1 gives 0, 8.5.0 gives 1, and 8.5.0 with only the deduplicate() call removed gives 0 again.
The devil's-advocate check137 words

The intended-behaviour checker, the second cross-model review and the adjudication built the strongest "intended" case: the Intervals.ordered javadoc alone says only "intervals in which the subsources all appear in the given order" (Intervals.java:415-421), not "non-overlapping"; LUCENE-9099 sent equal sources through RepeatingIntervalsSource on purpose, which could mean "N consecutive occurrences of X" with its own semantics; and the package is experimental.

Why it fails: the paper the class javadoc cites, the fn:ordered docs, the unorderedNoOverlaps javadoc and LUCENE-8828 all state the no-overlap rule; the equal and unequal forms of one query disagree; no record shows LUCENE-9099 discussing sub-sources whose intervals can overlap each other; and "experimental" is about API stability, not wrong results. Verdict: not intended, a regression. Expect the bare ordered javadoc to come up first; answer with the fn:ordered sentence and the unequal-copy control.

Suggested fix

Recommended Drop the deduplication in OrderedIntervalsSource.build (validated)

Drop the deduplication from OrderedIntervalsSource.build (+4/-24): the ordered iterator already gives every sub-source its own iterator and never lets two of them overlap. With it the 7 repro tests pass (assertions on and off, 10 iterations), as do the queries, queryparser and highlighter suites (1,774 tests) and the lint, format and javadoc checks.

  static IntervalsSource build(List<IntervalsSource> sources) {
    if (sources.size() == 1) {
      return sources.get(0);
    }
    // No deduplication: OrderedIntervalIterator gives every sub-source its own iterator and never
    // lets two sub-intervals overlap, so equal sub-sources need no special handling. Merging them
    // into a RepeatingIntervalsSource would let the copies of a multi-position source overlap.
    return new OrderedIntervalsSource(new ArrayList<>(sources));
  }
All options and trade-offs5 options · 711 words

One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle; the alternatives were not run. Land it before, or together with, the fix for the repeat width sign (LFV-306): that one-line fix alone makes maxgaps(0, ordered(P, P)) match a a a, because the overlapping interval's gaps turn from 3 to -1.

Drop the deduplication in OrderedIntervalsSource.build (validated) (recommended)

deduplicate() goes away; build returns new OrderedIntervalsSource(new ArrayList<>(sources)) for two or more sources. Equal sub-sources then get separate iterators like unequal ones. UnorderedIntervalsSource keeps its own use of RepeatingIntervalsSource. The copy keeps what the old code passed (a fresh list, not the caller's varargs view). The hunt's version passed sources directly: the same results in every run, but it keeps the Arrays.asList view of the caller's array (Intervals.java:422-424), so changing that array later would change the query. The code comment's "multi-position source" could also name a term stacked at one position.

  static IntervalsSource build(List<IntervalsSource> sources) {
    if (sources.size() == 1) {
      return sources.get(0);
    }
    // No deduplication: OrderedIntervalIterator gives every sub-source its own iterator and never
    // lets two sub-intervals overlap, so equal sub-sources need no special handling. Merging them
    // into a RepeatingIntervalsSource would let the copies of a multi-position source overlap.
    return new OrderedIntervalsSource(new ArrayList<>(sources));
  }

Trade-offs

  • ordered(t, t) of a term now opens two postings iterators instead of one (small, not benchmarked). toString stays ORDERED(...), and TestSimplifications (unorderedNoOverlaps(term, term) equals ordered(term, term)) still passes.
  • LUCENE-9099's own examples keep their answers: ordered(a, a, b) on a a b gives only [0,2], and containing(ordered(x, y), ordered(a, a, b)) on a x a b y gives 0.
  • It also removes the ordered() half of LFV-306 (repeat width sign) and the ordered manifestations of three sibling findings in the same class (minExtent, nested width, disjunction rewrite); their unordered() halves need their own fixes.
  • A PR also needs a CHANGES entry; no benchmark was run.

What the checks validated

  • In a scratch worktree at a9779ef3c193, gradle: the 7 repro tests pass with assertions on and off, and 50 of 50 plus 20 of 20 over 10 iterations (seed C0FFEE34); lucene/queries 517 tests (including TestIntervals 43 and TestSimplifications 12), lucene/queryparser 535 and lucene/highlighter 722 (including TestMatchHighlighter), 0 failures, the same skips as stock; on stock the same suites fail only on the 7 repro tests; ecjLint, checkGoogleJavaFormat and renderJavadoc pass; tidy changes nothing.
  • Earlier, by shadowing the class on main: the hunt's repro, the independent reproducer's 11 tests and upstream TestIntervals 43 of 43 pass; in the hunt's bounded-exhaustive harness the ordered-repeat mismatches disappear only with this fix.
  • The stacked-term probe with the patched class shadowed on main: 0 of 12 checks bad, assertions on and off.
  • The full validation is on main only; the 8.5.0 A/B ran only the four bug checks, so a backport to an older branch needs its own test run.

Keep the repeat source only for single-position sub-sources (not run)

A serious alternative a committer may prefer: deduplicate only sub-sources whose intervals are always one position wide, such as terms, keeping the one-iterator saving for ordered(a, a). On its own it does not fix the stacked-term case, whose intervals are one position wide: the repeat iterator would also have to skip a cached interval that starts at or before the previous one ends.

Trade-offs

Needs a reliable "single-position" test; minExtent() is only a lower bound (or(a, phrase(b, c)) has minExtent 1 and two-position intervals), plus the same-position skip. More code for a small saving.

Make DuplicateIntervalIterator skip overlapping intervals (not run)

Advance the shared iterator until the next cached interval starts after the previous one ends.

Trade-offs

With one shared iterator it cannot go back to an interval it skipped, which the next match may need, so it would need a buffer: a re-implementation of what OrderedIntervalIterator already does with separate iterators.

Document that equal sub-sources may overlap (not run)

A javadoc note on Intervals.ordered.

Trade-offs

Contradicts the fn:ordered docs and the unorderedNoOverlaps javadoc, and keeps the equal-vs-unequal disagreement.

Workaround today

Make the second copy unequal but equivalent: ordered(X, or(X, term-that-never-occurs)) (executed as the control in every test).

Trade-offs

Relies on an implementation detail (equality); an extra disjunction per query.

Downstream note

Products that build Intervals.ordered or unorderedNoOverlaps from user-written rules inherit this whenever a rule repeats the same self-overlapping piece back to back (a multi-position piece, or a word their analysis chain stacks at one position); no downstream product was read or run for this doc.

How it was found

A bounded-exhaustive comparison ran Lucene's real interval iterators against an independently written reference semantics on every short document and thousands of interval expressions with repeated arguments. Extra intervals for ordered queries over equal arguments pointed at the deduplication; public-API JUnit tests on the stock jar confirmed it.

The method in full258 words

Method: bounded-exhaustive translation validation on the real jar (semi-formal tier), in two hunt units on interval queries.

  • First signal (2026-10-26): a unit on span and interval queries ran the stock main jar's interval iterators against an independently written reference semantics on every small document and recorded extra and missing intervals for ordered with repeated arguments as a lead: "ordered(X, X) is built as a RepeatingIntervalsSource, which lets the two X intervals overlap".
  • Reduction (2026-10-27, main a9779ef3c193): a second unit wrote its own reference and harness: 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, on every document of up to 6 positions over a 3-letter alphabet, with switchable fixes for every suspected fix site. At 4 positions (about 1.1 million checks per run) the mismatches fell from 3,596 to 1,098 when this fix was added to the others, and to 0 with all of them; at 6 positions (11,287,592 checks) 0 remained with all seven fixes.
  • Then a public-API JUnit test on the stock jar, controls first; checked by an independent reproducer, an intended-behaviour devil's advocate, reading-only reviews by a model from another family (OpenAI GPT-5.6) and an adjudication, then a history sweep and a prior-report search.
  • The stacked-term case (2026-10-28) came from a cross-model fact check of this doc, which inferred it from the code; a public-API probe then executed it on main, 8.4.1, 8.5.0 and 10.5.2. The harness's documents hold one token per position, so it could not reach this case.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27: 223 recorded queries across GitHub issues and PRs, all 409 open PRs, JIRA, the mailing lists, downstream repositories and the web). Closest: LUCENE-9099 (#10141), which introduced the shortcut for single-position repeats.

Searches6 searches

Verdict new (high confidence, not certain): 29 hits judged, none the same bug. Nearest: #10141 / LUCENE-9099 and lucene-solr#1097 (introduced it; single-position repeats only); #11177 / LUCENE-10140 (Matches sub-positions for repeated sub-iterators, fixed in 9.0, another fix site); #9872 / LUCENE-8828, #9347 / LUCENE-8300, #9243 / LUCENE-8196 (contract evidence from before the deduplication); #13819, #13871, #13937 (flattening and speedups that left it alone). Not fixed upstream: main 20ced71c1e6 and branch_10_6 still deduplicate, and no open PR touches either class. Keyword search cannot prove absence.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs, open and closed2026-10-2770 queries (the hunt's prior-report tool in three runs plus 22 manual phrasings: class, method, symptom and contract)no report; origin and contract items only (#10141, #9872, #9347, #9243)
Open apache/lucene PRs; recent activity2026-10-27changed files of all 409 open PRs; PRs updated since 2026-10-25 (51) and issues since 2026-10-24 (16)none touches OrderedIntervalsSource or RepeatingIntervalsSource; #16899 (expansion limits) and #16913 (sloppy phrase) unrelated
Commits and branches2026-10-2711 history checks on main, branch_10x and branch_10_6the deduplication is unchanged since LUCENE-9099
JIRA archive (LUCENE); mailing lists2026-10-2720 JIRA queries; 69 list queries over dev@, java-user@, issues@, commits@ and two Solr listsno report
Downstream repositories (Elasticsearch, OpenSearch, Solr); the web2026-10-2714 repository queries; 36 web queries recorded (18 run)precursors of LUCENE-9099 and unrelated features only
apache/lucene issues and PRs: the stacked-term case2026-10-2810 read-only queries on stacked tokens, synonyms, KeywordRepeatFilter and RemoveDuplicatesTokenFilter with intervals, and RepeatingIntervalsSourceno report; only #10141 names RepeatingIntervalsSource

History

Introduced in 8.5.0 by LUCENE-9099 (7c1ba1aebee, 2020-02-06), which added the repeat shortcut. Executed: 8.2.0 to 8.4.1 pass, all 52 releases from 8.5.0 to 10.5.2 fail, and 8.5.0 with only the deduplicate() call removed passes.

Versions tested6 rows

Introduced by LUCENE-9099 "Correctly handle repeats in ORDERED and UNORDERED intervals" (now #10141; PR apache/lucene-solr#1097 by romseygeek, one approving review by jimczi): master 7c1ba1aebee and the branch_8x backport aa916bac3c3, both 2020-02-06; the backport is the form first released, in 8.5.0 (7c1ba1aebee itself first ships in 9.0.0). It created RepeatingIntervalsSource and OrderedIntervalsSource.deduplicate(). Later changes (LUCENE-9418, LUCENE-9570, LUCENE-10140, #12214, #13819, #13871, #13937, #15889) left the deduplication unchanged. All three shapes (the repeated phrase, the repeated unordered pair and the repeated nested ordered) fail from 8.5.0 on, including the nested one: before #13819 (10.1.0) flatten() inlined only OrderedIntervalsSource children, and ordered(a, a) was a RepeatingIntervalsSource. Reachable from query text since 9.1.0 (fn: functions, LUCENE-10223). Releases before 8.2.0 (sandbox package) were not run; they have no deduplication. The stacked-term probe gives the same boundary: 8.4.1 passes, 8.5.0, 10.5.2 and main fail.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's 7 tests fail via gradle, assertions on and off; the hunt's test 4 of 4 with four seeds
branch_10_6 head 4ae946e5a9b affecteda hybrid run, not a full build of the branch: its intervals classes shadowed over the 10.5.2 jars, 4 of 4 fail; the two files involved are blob-identical in the branch and 10.5.2, so the released 10.6.0 has it
8.5.0 to 10.5.2 (all 52 releases) affectedprobe: the 4 bug checks fail, every control passes; the JUnit fails 4 of 4 on 8.5.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.1.0 and 10.5.2
8.5.0 without the deduplicate() call root-cause A/B4 of 4 pass
8.2.0, 8.3.0, 8.3.1, 8.4.0, 8.4.1 not affected0 hits for every repeated-copy query; 8.4.1 also with assertions off and on the stacked-term probe
fix (fix.diff) on main not affectedthis doc's 7 tests pass; queries, queryparser and highlighter suites pass; the stacked-term probe passes

Independent checks

An independent reproducer (7 of 11 own tests fail, 4 controls pass) and an intended-behaviour devil's advocate reproduced it; reading-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause; an adjudication confirmed it with a fresh seed, and this doc's gradle runs reproduce it on a9779ef3c193.

Checks and verdicts412 words

Every executing check wrote or re-ran its own test on stock main; every control passed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateExhaustive comparison pinned to one fix site; JUnit 4 of 4 fail on main with assertions on and off, and on 10.5.2 and 9.12.3; the fix passes.
independent reproducer realOwn tests from the claim only: 7 of 11 fail on main (assertions on and off), 10.5.2 and 9.12.3; query-parser leg 3 of 5 fail; 8.4.1 passes; 11 of 11 pass with the fix; confirmed the LFV-306 coupling.
cross-model reviews (refute, intended) not real (low, procedural); real (high)An independent reviewer from another model family (OpenAI GPT-5.6) reviewed by reading only, since it could not run Java. The lenses agreed on the root cause; the refute lens pointed to the fn:ordered contract, which was added. A separate run ran code: the finder's test fails 4 of 4 on main with assertions on and off and on 10.5.2 and passes with the fix; a probe shows 4 bad and 6 good cases on stock and none bad with the fix. Its LUCENE-9099 probe gives identical output with and without the fix, but one of its five expectations is wrong (ordered(a, a, b) on a b a b legitimately matches [0,3]).
intended-behaviour devil's advocate realBuilt the strongest intended case and showed why it fails (the cited paper, the fn:ordered docs, LUCENE-8828, the span analogue); the finder's test fails on main and 10.5.2; LUCENE-9099's examples keep their answers with the fix.
adjudication confirmed (high), silent wrong resultsRe-ran the finder's test with a fresh seed (1A2B3C4D): 4 of 4 fail with assertions on and off.
Follow-up and audit keepHistory sweep over 57 releases and the 8.5.0 A/B, which also corrected the nested shape's first affected version to 8.5.0; prior-report search (new); an audit re-ran the test with seed A0D17F4 (4 of 4 fail).
this doc reproduced; fix validated7 tests fail on stock main via gradle (assertions on and off); with the fix they pass, and so do the queries, queryparser and highlighter suites. Fact checks of this doc: one re-ran the tests on a fresh extraction of main and its own public-API probe on 8.4.1, 8.5.0, 10.5.2 and main; the other, by a model from another family, read the code and the recorded runs and raised the stacked-term case, which was then executed (probes/stacked-term/).

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

Finding record

LFV-807, snapshot of 2026-11-06

Registry title
Intervals.ordered(X, X) with two equal sub-sources that span several positions lets the two copies of X overlap, so ordered(phrase(a, a), phrase(a, a)) matches 'a a a' and ordered(unordered(a, b), unordered(a, b)) matches 'a b a' (one b), while the same query with an equivalent but unequal second source finds nothing
Area
queries / intervals (Intervals.ordered and unorderedNoOverlaps with equal consecutive sub-sources: OrderedIntervalsSource.deduplicate, RepeatingIntervalsSource)
Priority
Medium, rank 193: A new silent over-match in every release since 8.5.0 that needs ordered or unorderedNoOverlaps to get the same multi-position self-overlapping source twice.
Impact class
silent wrong results
Impact note
Anyone whose interval query gives Intervals.ordered (or unorderedNoOverlaps, which is built from ordered) the same sub-source twice in a row, where that sub-source spans several positions and its consecutive intervals can share a position (a phrase with a repeated word such as phrase(ha, ha), an unordered or ordered pair such as unordered(a, b), or a nested ordered), through the Java factories or StandardQueryParser fn:ordered / fn:unorderedNoOverlaps strings, with default writer, codec, analyzer and searcher settings, at any index size, with -ea and -da, in every release since 8.5.0, gets extra matching documents and wrong match intervals (also seen by Weight#matches, highlighting and enclosing interval filters); repeats of single terms, the common case, and phrases that cannot overlap themselves are not affected, and nothing is written to the index.
Summary
You ask for two occurrences of X in order with Intervals.ordered(X, X), for example X = unordered(a, b) ('a near b', twice) or X = phrase(a, a), and expect the usual ordered rule that the sub-intervals do not overlap (the fn:ordered documentation says 'Source intervals must not overlap'). Instead, because the two arguments are equal, OrderedIntervalsSource.build merges them into a RepeatingIntervalsSource, which joins consecutive intervals of a single sub-iterator without checking that they are disjoint, so ordered(unordered(a, b), unordered(a, b)) matches 'a b a', which has only one b, and ordered(phrase(a, a), phrase(a, a)) matches 'a a a'. The same query with an equivalent but unequal second source, ordered(X, or(X, a term that occurs nowhere)), correctly finds nothing, and so did Lucene 8.4.1.
Introduced
7c1ba1aebeea540b67ae304deee60162baee2e12 (master) and its branch_8x backport aa916bac3c3369a461afa06e384e070657c32973 (the same day; the first released form, 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, no inline review comments; the JIRA issue is now apache/lucene#10141). It created RepeatingIntervalsSource and added OrderedIntervalsSource.deduplicate(), called from build() for every ordered() source [read: git log -S 'deduplicate(' (only hit), git log --follow RepeatingIntervalsSource.java (created there), git log -L of deduplicate, git blame at 7cecf795236] [exec: 8.4.1 passes, 8.5.0 fails, and 8.5.0 with only the deduplicate() call removed passes]. Later commits to the two files (LUCENE-9418, LUCENE-10140, #12214, #13819, #13871, #13937, #15889) left the deduplication unchanged [read] (2020-02-06 (7c1ba1aebee and the branch_8x backport aa916bac3c3))
First affected version
8.5.0 [exec] (8.4.1, the last release before LUCENE-9099, passes [exec]; 8.5.0 with only the deduplicate() call removed passes [exec])
Affected versions
every release from 8.5.0 through 10.5.2 (52 releases: 8.5.0 to 8.11.4, all 9.x and all 10.x) [exec: a portable main() probe with the JUnit's assertions fails 4 of 4 bug checks with every control passing on all 52, -da; the unchanged JUnit fails 4 of 4 on 9.1.0, 9.12.3 (-ea and -da), 10.0.0, 10.1.0 and 10.5.2 (-ea and -da), and the same JUnit with its two test-framework imports moved to the pre-9.1 packages fails 4 of 4 on 8.5.0 (-ea and -da), 8.11.4 and 9.0.0]; main a9779ef3c193 [exec, -ea and -da]; branch_10_6 head 4ae946e5a9b [exec: its intervals package, shadowed on the 10.5.2 jars, fails 4 of 4], so the released 10.6.0 has it; upstream main 20ced71c1e6 [read: intervals package identical to a9779ef3c193]. Not affected [exec]: 8.2.0, 8.3.0, 8.3.1, 8.4.0 and 8.4.1. Reachable from query text (fn:ordered, fn:unorderedNoOverlaps) since 9.1.0 (LUCENE-10223); 8.5.0 to 9.0.0 through the Java Intervals API only. All three shapes (repeated phrase, repeated unordered pair, repeated nested ordered) fail from 8.5.0 on [exec]
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
none found (follow-up prior-report record verdict new, confidence high, searched 2026-10-27 plus the finder's run and its web leg, read-only): 223 recorded queries, 205 executed: 70 GitHub issue and PR queries over apache/lucene (scripted prior-report searches plus 22 manual phrasings), a changed-file scan of all 409 open apache/lucene PRs, the PRs updated since 2026-10-25 (51) and issues updated since 2026-10-24 (16), 11 commit and branch history checks (main, branch_10x, branch_10_6), 20 LUCENE JIRA-archive queries, 69 mailing-list queries (dev@, java-user@, issues@, commits@ and two Solr lists), 14 downstream GitHub queries (Elasticsearch, OpenSearch, Solr) and 36 web queries. Nearest items, none the same bug: #10141 / LUCENE-9099 and lucene-solr#1097 (they introduced the deduplication; single-term repeats only), #11177 / LUCENE-10140 (Matches sub-positions for repeated sub-iterators, fixed in 9.0, other fix site), #9872 / LUCENE-8828, #9347 / LUCENE-8300 and #9243 / LUCENE-8196 (contract evidence from before the deduplication), #13819, #13871 and #13937 (flattening and speedups, deduplication unchanged), #16899 (merged, interval expansion limits) and #16913 (SloppyPhraseMatcher). Not fixed upstream: upstream main 20ced71c1e6 and branch_10_6 head still deduplicate in OrderedIntervalsSource.build, and no open PR touches OrderedIntervalsSource or RepeatingIntervalsSource. Re-check (2026-10-27, read-only GitHub API): the 51 apache/lucene PRs updated since 2026-10-25 and the 8 issues created since 2026-10-24 were listed and none is about this (the changed files of #16904, #16913, #16919 and #16923 touch no intervals code); searches for RepeatingIntervalsSource (only #10141), 'OrderedIntervalsSource deduplicate' (none), unorderedNoOverlaps (only #9872) and 'fn:ordered overlap' (none).
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): this doc's two public-API test classes (7 tests) through gradle in a scratch worktree, stock and with the fix, assertions on and off and 10 iterations, plus the whole queries, queryparser and highlighter modules with the fix and, as a baseline, without it (2026-10-28). Hunt runs on main a9779ef3c193 and the 10.5.2 and 9.12.3 jars; a history sweep over the 57 releases from 8.2.0 to 10.5.2 and the branch_10_6 head 4ae946e5a9b (its intervals classes over the 10.5.2 jars); upstream main 20ced71c1e6 has a blob-identical intervals package (read). A stacked-term main() probe on main (assertions on and off, stock and with the fix), 8.4.1, 8.5.0 and 10.5.2 (2026-10-28).

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/OrderedIntervalsSource.java:29-38 (build calls deduplicate), :40-57 (deduplicate folds equal consecutive sub-sources into RepeatingIntervalsSource.build), :140-153 (OrderedIntervalIterator advances while currentStart <= prevEnd, so unequal sub-sources never overlap); RepeatingIntervalsSource.java:33-41 (class javadoc: 'useful for repeated terms within an unordered interval ... ensuring that multiple iterators do not match on a single term'), :172-200 (nextInterval/cacheNextInterval cache consecutive intervals of one sub-iterator with no disjointness check); Intervals.java:415-424 (ordered: 'intervals in which the subsources all appear in the given order'), :440-447 (unorderedNoOverlaps: 'appear and do not overlap', built as or(ordered(a, b), ordered(b, a))); the two source files are identical at Lucene main ea57360912ed
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:328-356 (fn:ordered: 'Source intervals must not overlap'; example fn:ordered(fn:phrase(brown fox) fn:phrase(fox jumps)) '(Sources overlap, no matches.)'); Ordered.java:35-41 (fn:ordered maps to Intervals.ordered); lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestMatchHighlighter.java:396-397 (no hits expected for the overlapping fn:ordered and fn:unorderedNoOverlaps examples)
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/OrderedIntervalsSource.java:29-57, 140-153 @ a9779ef3c193
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:33-86, 129-200 @ a9779ef3c193
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/MinimizingConjunctionIntervalsSource.java:41-51; Intervals.java:422-424, 445-447
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/Ordered.java:36-41
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/TermIntervalsSource.java:114-120, 127-135; lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/PositionIncrementAttribute.java:30-34
Who is affected
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:426-438 (unordered: 'The subsources may overlap')
Why it looks unintended
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:328-356
  • lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestMatchHighlighter.java:396-397
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:43-45, 415-447; RepeatingIntervalsSource.java:33-36
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:739-749 (testMaxGapsWithOnlyRepeats repeats or(pease, hot))
Suggested fix
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestSimplifications.java:63-68
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:422-424 (ordered passes Arrays.asList(subSources))

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

Repro files

About the diagrams

Made in parallel with this doc from a probe run on main a9779ef3c193 (assertions on and off); the values on screen come from the probe's log. Line numbers on the diagram are at a9779ef3c193. Re-rendered on 2026-10-28 after the fact checks: the stacked-term trigger (from the stacked-term probe) was added and "not affected" narrowed to a term with one token per position.

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.