All findings · Lucene finding · LFV-805 · repeating-intervals-minextent-underreported

Intervals.ordered(a, a) reports a minimum width of 1, so an Intervals.or under phrase or maxgaps silently drops the two-word match

phrase(x, or(ordered(a, a), a), c) finds nothing in x a a c, though phrase(x, ordered(a, a), c) finds it: ordered(a, a) claims a minimum width of 1, so the OR is not split and drops the match a a. Since 8.5.0; a one-line fix, best landed with two related fixes.

Summary

Priority
Low, rank 347Needs the unusual ordered(a, a) or unordered(a, a) shape as an or() alternative under phrase or maxgaps.
Impact
silent wrong results
Since
8.5.0 (LUCENE-9099, 2020)introduced 2020-02-06 · about 6 years
Affected
8.5.0 to 10.5.2 (all 52 releases run), 10.6.0 (by reading) and main
Status
documented
Found by
a check of the default disjunction rewrites, then a bounded-exhaustive comparison 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. minExtent() is the smallest width a source can match. Under phrase and maxgaps, Lucene splits an or() whose alternatives have different widths into separate phrases, because inside one OR a wider match that contains a narrower one is dropped. Alternatives of width 1 stay together.

Diagram of the bug: the document x a a c at positions 0 to 3 and the query phrase(x, or(ordered(a, a), a), c), which should find it, as phrase(x, ordered(a, a), c) does. ordered(a, a) is one repeated source whose minExtent() is 1, the width of a single a, so the disjunction rewrite keeps or(ordered(a, a), a) as one OR instead of splitting it into or(phrase(x, ordered(a, a), c), phrase(x, a, c)). At search time the OR returns [1,1] and [2,2] and drops [1,2], which contains them, so the phrase finds no interval between x and c: 0 hits instead of 1. With two different words, or(ordered(a, b), a) has minExtent 2 and 1, is split, and matches x a b c.
Step by step through the executed example: the document x a a c and the query phrase(x, or(ordered(a, a), a), c), the rewrite that keeps one OR, the OR dropping [1,2] at search time, the phrase finding no match, why (ordered(a, a) reports the width of one a), and what the user gets. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index x a a c (positions 0 to 3) and search phrase(x, or(ordered(a, a), a), c): x, then either a a or a, then c. You expect 1 hit: phrase(x, ordered(a, a), c) alone finds this document, and or() is documented to rewrite itself under phrase so that alternatives of different lengths are not lost.
  2. You get 0 hits, with no error. With two different words it works: phrase(x, or(ordered(a, b), a), c) finds x a b c, because Lucene rewrites it into or(phrase(x, ordered(a, b), c), phrase(x, a, c)).
  3. Why: the rewrite splits out only alternatives whose minExtent(), the smallest width they can match, is above 1. ordered(a, a) is built as one repeated source that reports the width of a single a, 1, although it spans 2 positions here. So the OR stays whole.
  4. Inside the OR, the one-word matches [1,1] and [2,2] lie inside the two-word match [1,2], and minimal-interval semantics drops [1,2]. The phrase needs exactly [1,2] between x (0) and c (3), so it finds nothing.
  5. The same with unordered(a, a), under maxgaps(0, ...), containedBy and containing, with any other width-1 alternative inside the copies' span (or(ordered(a, a), b) misses x a b a c; or(ordered(a, a, a), ordered(a, a)) misses x a a a c) and from the query string fn:phrase(x fn:or(fn:ordered(a a) a) c). Since 8.5.0.

Who is affected

Anyone whose interval query (the Intervals factories, the query parser's fn: functions, or Intervals.analyzedText over text that repeats a word) puts a clause made only of copies of one word inside the default or() under phrase, maxgaps, containedBy or containing, next to a one-word or shorter repeated alternative. Default settings, any index size, no error: matches disappear.

Mechanism, terms and numbers973 words

Under a gap-sensitive parent (phrase, maxgaps), Intervals.or is documented to rewrite itself so that alternatives of different lengths are not minimized away. The rewrite decides "one position or longer" with minExtent() == 1. Since LUCENE-9099 (8.5.0), Intervals.ordered(a, a) and Intervals.unordered(a, a) are a RepeatingIntervalsSource whose minExtent() returns the inner source's value, 1 for a term, although on ordinary text each interval it returns spans at least 2 positions. So or(ordered(a, a), a) stays one OR; on x a a c that OR returns [1,1] and [2,2] and drops [1,2], and phrase(x, or(ordered(a, a), a), c) finds nothing, while phrase(x, ordered(a, a), c) alone and phrase(x, or(ordered(a, b), a), c) on x a b c match. The same happens with unordered(a, a), under maxgaps(0, ...) and on the big side of containedBy/containing, with any other alternative that also reports minExtent() 1 and falls inside the copies' span (a word between them, or a shorter repeat such as ordered(a, a) next to ordered(a, a, a)), through the flexible query parser's fn: functions and through Intervals.analyzedText("a a", ...) with gaps. Silent wrong results with the default writer, codec and searcher, in every release from 8.5.0 to 10.6.0 (10.6.0 by reading) and on main. Counting the copies in minExtent() (in.minExtent() + childCount - 1, saturated) makes the repro pass with the whole lucene/queries module green; applied alone it widens two separate bugs (LFV-291, LFV-810), so it should land with or after their fixes.

Terms

  • Interval query: matches runs of positions. Intervals.term("a") gives one-position intervals such as [1,1]; ordered(a, b) gives [start, end] of an a followed by a b; phrase(x, s, c) needs x, then s, then c back to back.
  • Minimal intervals: most operators, and or() in particular, keep only intervals that contain no other interval they found (the repeated source below is documented as "avoiding minimization" of its own copies). If one alternative matches [2,2] and another [1,2], the OR keeps [2,2] and drops [1,2].
  • The disjunction rewrite (LUCENE-8477, 8.1.0): because of that, phrase and maxgaps rewrite an inner or() whose alternatives have different widths into an OR of separate queries, so phrase(x, or(ordered(a, b), a), c) becomes or(phrase(x, ordered(a, b), c), phrase(x, a, c)). or(false, ...) turns this off and is documented to "miss valid hits due to minimization".
  • minExtent(): "the minimum possible width of an interval returned by this source". The rewrite keeps every alternative with minExtent() == 1 together in one OR, as one-position alternatives.
  • Repeated source (LUCENE-9099, 8.5.0): ordered(a, a) and unordered(a, a) are built as one RepeatingIntervalsSource(a, 2), which joins two consecutive a intervals into one.

The bug

  1. Intervals.ordered and unordered merge equal arguments into a RepeatingIntervalsSource and return it as the whole query when nothing else is left (OrderedIntervalsSource.java:29-57, UnorderedIntervalsSource.java:33-57).
  2. Its minExtent() is return in.minExtent(); (RepeatingIntervalsSource.java:93-96): 1 for a term. On distinct positions its intervals are never narrower than 2 (all 510 documents of 1 to 8 positions over {a, b}, executed).
  3. Disjunctions.splitDisjunctions (Disjunctions.java:75-95) groups every alternative with minExtent() == 1 into one OR, citing "we know that all combinatorial sources have a minExtent > 1". or(ordered(a, a), a) therefore stays one OR inside the phrase. phrase, maxgaps, containedBy, containing, notContainedBy and notContaining run this rewrite when they are built.
  4. The OR's iterator orders intervals by end, then by start descending, and drops every interval that contains the one it just returned (DisjunctionIntervalsSource.java:171-175, :230-247). On x a a c it returns [1,1] and [2,2] and drops [1,2] of ordered(a, a). The phrase needs [1,2] between x (0) and c (3): 0 hits.
  5. ordered(a, b) sums its parts (OrderedIntervalsSource.java:69-75), 2, so it is split out and matches. A clause that merely contains a repeat, ordered(a, a, b), reports 2 (true minimum 3), so it is split out too; there only the score is affected.
  6. Side effect: IntervalScorer computes the sloppy frequency as 1 / max(length - minExtent + 1, 1) (IntervalScorer.java:66), so an exact a a match of ordered(a, a) scores 0.333 where ordered(a, b) on a b scores 0.5.

The numbers (main a9779ef3c193, executed)

query on documentexpectedstockwith the fix
ordered(a, a).minExtent(), unordered(a, a).minExtent()212
phrase(x, ordered(a, a), c) on x a a c (control)111
phrase(x, or(ordered(a, b), a), c) on x a b c (control)111
phrase(x, or(ordered(a, a), a), c) on x a a c10, query stays BLOCK(x,or(ORDERED(a,a),a),c)1, or(BLOCK(x,ORDERED(a,a),c),BLOCK(x,a,c))
phrase(x, or(unordered(a, a), a), c) on x a a c101
phrase(x, or(ordered(a, a), b), c) on x a b a c101
maxgaps(0, ordered(x, or(ordered(a, a), a), c)) on x a a c101
phrase(x, or(false, ordered(a, b), a), c) on x a b c (documented opt-out)000
score: ordered(a, a) on a a / ordered(a, b) on a bequal0.333 / 0.50.5 / 0.5

With a repeated source, the default or() behaves like the opt-out or(false, ...) (the last-but-one row). Also executed with the default IndexWriterConfig and StandardAnalyzer: ordered(a, a, a) on x a a a c, the operands swapped (or(a, ordered(a, a))), Intervals.analyzedText("a a", analyzer, field, 1, true) as the alternative, and the StandardQueryParser string fn:phrase(x fn:or(fn:ordered(a a) a) c): 0 hits each, controls 1. Added after the fact checks (main and 10.5.2), 0 hits on stock and 1 with the fix each: phrase(x, or(ordered(a, a, a), ordered(a, a)), c) on x a a a c (two repeats, neither one position wide), and containedBy(b, or(ordered(a, a), a)) and containing(or(ordered(a, a), a), b) on a b a (controls with ordered(a, c) on a b c: 1).

Exposure and likelihood282 words
  • Who. Applications that build interval queries with the Intervals factories, the flexible StandardQueryParser's fn: functions, or Intervals.analyzedText over query text that repeats a word, where a clause made only of copies of one source (ordered(a, a), unordered(a, a), ordered(a, a, a), or a filter over one such as maxgaps(1, ordered(a, a))) is an alternative of the default or() under phrase or maxgaps (or on the big side of containedBy/containing), next to another alternative that reports minExtent() 1 (a word, or a shorter repeat). A natural shape is "the word doubled, or the word once".
  • Effect. Documents in which that other alternative falls inside the copies' span are missed, silently and every time, with assertions on or off. Exact repeated matches also score lower than comparable two-word matches (0.333 against 0.5). Nothing is written to the index.
  • Default vs opt-in. Default writer, codec, analyzer and searcher; the rewrite that should protect the match is the default (or() means or(true, ...)). The intervals package is documented as experimental.
  • Scale. None: one tiny document triggers it, deterministically, at any index size.
  • How likely. Uncommon: it needs a repeated-only clause as an OR alternative under a parent that rewrites. A clause that merely contains a repeat (ordered(a, a, b)) matches correctly. No report was found in the six and a half years since 8.5.0.
  • Related, separate reports in the same class (each with its own fix line): the width() sign of the same iterator (LFV-306), a repeated disjunction that is never pulled up (LFV-809), overlapping copies in ordered(X, X) (LFV-807), nested repeats leaking inner gaps (LFV-810), and notContainedBy over the same rewrite (LFV-291).

Minimal repro

The test shows that Intervals.unordered(a, a) reports minExtent() 1, so the default or() rewrite under phrase keeps or(unordered(a, a), a) as one OR and phrase(x, or(unordered(a, a), a), c) misses x a a c.

TestRepeatAlternativeUnderPhrase.java (JUnit test)49 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 java.util.List;
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 TestRepeatAlternativeUnderPhrase extends LuceneTestCase {

  public void testPhraseOverOrWithUnorderedRepeat() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      w.addDocument(List.of(new TextField("f", "x a a c", Field.Store.NO)));
      try (DirectoryReader r = DirectoryReader.open(w)) {
        IntervalsSource a = Intervals.term("a");
        IntervalsSource repeatOrA = Intervals.or(Intervals.unordered(a, a), a);
        IntervalsSource query =
            Intervals.phrase(Intervals.term("x"), repeatOrA, Intervals.term("c"));
        // expected 1 hit: the alternative unordered(a, a) covers 'a a'; actual 0
        assertEquals(
            "phrase(x, or(unordered(a, a), a), c) on 'x a a c'",
            1,
            new IndexSearcher(r).count(new IntervalQuery("f", query)));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-805/, 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.TestRepeatAlternativeUnderPhrase
Expected vs actual
Expected: 1 hit (the alternative unordered(a, a) covers a a). Actual: 0 hits.

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

More files, environment, full output43 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: phrase(x, or(unordered(a, a), a), c) on 'x a a c' 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.TestRepeatAlternativeUnderPhrase.testPhraseOverOrWithUnorderedRepeat(TestRepeatAlternativeUnderPhrase.java:42)

Why it looks unintended

  • minExtent() is "the minimum possible width"; ordered(a, a) is never 1 wide on distinct positions.
  • The rewrite exists "to ensure that documents ... are matched"; only or(false, ...) may "miss valid hits", and the bug makes or() act like it.
  • The rewrite assumes "all combinatorial sources have a minExtent > 1"; LUCENE-9099 broke that ten months later, unremarked.
The full argument268 words
  • The contract is a minimum width. IntervalsSource#minExtent: "Return the minimum possible width of an interval returned by this source" (IntervalsSource.java:63).
  • The rewrite promises this match. The Intervals class javadoc: gap-sensitive sources "will rewrite their sub-sources so that disjunctions of different lengths are pulled up to the top of the interval tree ... to ensure that documents containing "b c" are matched" (Intervals.java:47-54); or(): "Automatically rewrites if wrapped by an interval source that is sensitive to internal gaps" (:118-125). Only or(false, ...) "can miss valid hits due to minimization" (:127-136). LUCENE-8477, a bug fix in 8.1.0: "Interval disjunctions could miss valid hits if some of the clauses of the disjunction are minimized away. We now rewrite ..." (lucene/CHANGES.txt:6502-6506).
  • The code states the invariant the repeated source breaks: "we know that all combinatorial sources have a minExtent > 1" (Disjunctions.java:76-78).
  • Two changes that never met. LUCENE-8477 (2019, 8.1.0) added the grouping when it was sound: ordered(a, a) summed to 2, as executed on 8.2.0 to 8.4.1. LUCENE-9099 (2020, 8.5.0) added the repeated source with return in.minExtent();; its issue, commit message, CHANGES entry (lucene/CHANGES.txt:5996-6001) and its one approving review never mention minExtent() or the rewrite.
  • No upstream test asserts the current value. TestIntervals asserts minExtent() 20 times (for example 2 for unordered(pease, hot), TestIntervals.java:380-402); none of its repeat tests checks it, and TestDisjunctionRewrites has no repeated shape.
  • Legal input. Repeated arguments are supported since LUCENE-9099, analyzedText builds them from plain query text, and or() under phrase is the canonical LUCENE-8477 shape.
The devil's-advocate check287 words

The strongest case for "works as designed" (the cross-model review, reading only, rated it moderate; the intended-behaviour check built it and rejected it): minExtent() is only a lower bound and 1 is a valid one; for identical tokens stacked at one position (position increment 0) ordered(a, a) really returns [p, p], so 1 is then exact and the fix over-reports; and minimal-interval semantics drop containing intervals by design, in an experimental package.

Two more points a maintainer may raise: in the LUCENE-8477 issue discussion (a JIRA comment, now on #9523; not in the PR review) jimczi warned that minimization would keep producing pathological cases; and the flexible query parser's docs say, for fn:ordered/fn:unordered over an fn:or, that "the algorithm is not required to return or highlight all matching interval alternatives" (intervalfn/package-info.java:347-349, :381-383).

Why it fails: the only consumer that affects matching, splitDisjunctions, reads minExtent() == 1 as "every interval is one position", and the documented default promises the rewrite. On the stacked document every query keeps its hit with and without the fix, and the fix leaves its score unchanged (0.5), because the scorer clamps at 1 (executed). The sibling UnorderedIntervalsSource already over-reports the same way (unordered(a, b) returns [0,0] on stacked a/b, minExtent() 2, the value upstream asserts). LUCENE-8477 chose to rewrite rather than document such misses. The query parser note is about which intervals of a matching document are returned or highlighted (in its examples fn:ordered/fn:unordered do not rewrite and the document still matches); here the whole document is missed under a parent whose rewrite the Intervals note promises "to ensure that documents ... are matched". File the claim for distinct positions.

Suggested fix

Recommended Count the copies in RepeatingIntervalsSource.minExtent()

Count the copies in RepeatingIntervalsSource.minExtent(): in.minExtent() + childCount - 1, saturated (+4/-1). The OR is then split as for two different words. The repro, a new rewrite test and the whole lucene/queries module pass. Applied alone it widens two separate bugs, so land it with or after the LFV-291 and LFV-810 fixes.

  @Override
  public int minExtent() {
-    return in.minExtent();
+    // On distinct positions each copy ends after the previous one, so the copies together span at
+    // least in.minExtent() + childCount - 1 positions (stacked tokens, or an `in` whose own
+    // minExtent() over-reports, can be narrower). Saturate: extend() can report MAX_VALUE.
+    return (int) Math.min((long) in.minExtent() + childCount - 1, Integer.MAX_VALUE);
  }
All options and trade-offs3 options · 1,252 words

One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle; the alternative was not run.

Two couplings, to state in the PR. Land the fix with or after the fixes of LFV-291 and LFV-810, or say so.

  • LFV-291 (notContainedBy over the same rewrite): applied alone, the fix lets more queries reach it. notContainedBy(x, or(ordered(a, a), b)) on a x a b goes from 0 hits (correct) to 1 (wrong), because the OR is now split the way or(ordered(a, b), b) already is (that control gives 1 on stock). Executed by the devil's advocate and re-run with this fix.
  • LFV-810 (a nested repeat passes its inner gap up to its parent): applied alone, the fix pulls the repeat out of the OR, so it then sits directly inside an ordered/unordered under maxgaps(n >= 1), where LFV-810 loses correct hits. maxgaps(1, unordered(x, or(unordered(a, a), b))) on x a c c a x and maxgaps(1, ordered(x, or(ordered(a, a), b), c)) on x a x x a c give 1 hit on stock and 0 with the fix; stock is right, because IntervalIterator#gaps counts only "the gaps between the immediate sub-intervals" (IntervalIterator.java:62-71). Over all 5,460 documents of 1 to 6 positions over {x, a, b, c} and 26 query shapes, these two queries lose 87 and 10 hits and 30 documents score lower; with the LFV-810 candidate fix applied as well, 0 hits are lost, no score falls and every gain is kept (the fact check's probe; the two queries re-run for this doc).

Workarounds today (executed, also on 10.5.2): build the rewritten form by hand, or(phrase(x, ordered(a, a), c), phrase(x, a, c)) (1 hit on x a a c and on x a c); or, when the copies must be adjacent anyway, write the repeat as phrase(a, a), whose minExtent() is 2, so phrase(x, or(phrase(a, a), a), c) is rewritten and finds x a a c.

Count the copies in RepeatingIntervalsSource.minExtent() (validated by the checks) (recommended)

In RepeatingIntervalsSource.minExtent() (RepeatingIntervalsSource.java:93-96) return in.minExtent() + childCount - 1, computed in long and saturated at Integer.MAX_VALUE (+4/-1 in one file). The iterator joins childCount consecutive intervals of in; on distinct positions each copy ends after the previous one, so the joined interval spans at least in.minExtent() + childCount - 1 positions when the first copy is at least in.minExtent() wide. Tight for unordered, whose copies may overlap. Not a universal bound (executed): stacked identical tokens give [p, p], and extend() clamps starts at 0, so ordered(extend(a, 2, 0), extend(a, 2, 0)) returns [0,1] on a a while reporting 3 on stock and 4 with the fix. The saturation is local: ExtendedIntervalsSource.minExtent() returns Integer.MAX_VALUE on overflow (ExtendedIntervalsSource.java:126-132) and ordered(extend(a, 0, MAX), extend(a, 0, MAX)).minExtent() stays Integer.MAX_VALUE, but the enclosing sums in OrderedIntervalsSource, UnorderedIntervalsSource and BlockIntervalsSource are unchecked: ordered(extend(a, 0, MAX), b).minExtent() already wraps to Integer.MIN_VALUE on stock, and with the fix so does ordered(ordered(h, h), b) for h = extend(a, 0, MAX - 2) (stock: MAX_VALUE). Add the structural test to TestDisjunctionRewrites (no upstream test covers a repeated shape).

  @Override
  public int minExtent() {
-    return in.minExtent();
+    // On distinct positions each copy ends after the previous one, so the copies together span at
+    // least in.minExtent() + childCount - 1 positions (stacked tokens, or an `in` whose own
+    // minExtent() over-reports, can be narrower). Saturate: extend() can report MAX_VALUE.
+    return (int) Math.min((long) in.minExtent() + childCount - 1, Integer.MAX_VALUE);
  }

Trade-offs

  • Query size and the clause limit: the rewrite now splits alternatives it grouped before, and Disjunctions.pullUp expands them as a Cartesian product, as it already does for different words (the cost the Intervals class note describes). A phrase of 10 clauses or(ordered(ti, ti), ti) grows from 1 BLOCK to 1,024; 11 such clauses build on stock but throw IllegalArgumentException: Too many disjunctions to expand with the fix (Disjunctions.java:43-45, default max clause count 1024), as 11 clauses or(ordered(ti, ui), ti) already do on stock (executed). The rewritten query's toString(), equals/hashCode (so query-cache keys) and the number of iterators change too. minExtent() itself is one addition, called when the query is built and once per segment when the scorer is created (IntervalQuery.java:189-196). No benchmark was run.
  • Hits and scores: the queries shown here start matching. Where the rewrite does not change the query's shape, scores can only rise, because the sloppy frequency uses minExtent() (ordered(a, a) on a a: 0.333 to 0.5). Queries that the rewrite now splits can lose hits and score lower through LFV-810 until that fix lands, and LFV-291 gains wrong hits (above). Broader monotonicity was not established.
  • For identical tokens stacked at one position the new value over-reports, as UnorderedIntervalsSource already does; in every executed stacked case the hits and scores stayed the same.
  • Not covered by any validation: custom IntervalsSource implementations, payload-filtered terms at one position, and extend() near position 0 or Integer.MAX_VALUE beyond the executed cases.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: stock + the new test, 5 of 5 fail with assertions on and off; stock + the TestDisjunctionRewrites test, it fails and the other 12 pass.
  • + the fix: the whole lucene/queries module, 52 suites, 518 tests (123 in the intervals package, 6 of them new), 0 failures with assertions on and off (5 and 6 skipped); TestStandardQPEnhancements (23), TestUnifiedHighlighterTermIntervals (116), TestMatchHighlighter (6) and TestMatchRegionRetriever (22), 0 failures; :lucene:queries:check -x test (checkGoogleJavaFormat, ecjLint, forbiddenApis) passes.
  • Standalone runner: 5 of 5 pass with assertions on and off; the probe shows the rewrite now splits the OR.
  • After the fact checks the two code comments were reworded (comments only) and re-run in a new scratch worktree: checkGoogleJavaFormat passes; the intervals package passes with the fix (8 suites, 123 tests, assertions on and off); on stock the structural test and the 5 repro tests fail as before; the standalone runner passes 5 of 5.
  • Earlier, the same change: the finder (3 of 3; 404 of 412 upstream tests, the same 8 testCambridgeMA highlighter failures under every fix configuration it ran, with no stock row recorded; this doc's gradle run of TestUnifiedHighlighterTermIntervals with the fix passed 116 of 116), the independent reproducer (8 of 8; 117 of 117 upstream interval tests) and the audit (16 of 16 probe checks).

Fix the grouping in splitDisjunctions instead (not run)

Preferred by the intended-lens cross-model reviewer, who places the defect here: group only alternatives known to return one-position intervals, rather than every alternative with minExtent() == 1 (Disjunctions.java:83).

Trade-offs

Keeps minExtent() exact for stacked tokens. A conservative "always a single position" property that defaults to false is correct without any propagation: every source not known to be single-position is split, as different words are today (with the same expansion cost). Propagating it through wrappers that keep it (maxgaps, maxwidth, fixField, ...) only keeps more of today's grouping. It leaves the low score. A comment in splitDisjunctions that its grouping relies on minExtent() being a true minimum would help with either option.

Workaround: write the rewritten query by hand (executed)

or(phrase(x, ordered(a, a), c), phrase(x, a, c)) instead of phrase(x, or(ordered(a, a), a), c); 1 hit on x a a c and on x a c on stock main and 10.5.2.

Trade-offs

Needs the application to know about the bug and to expand every such OR itself; does not fix the score.

Downstream note

Downstream relevance: used as an exclusion (for example a MUST_NOT clause), such a query silently keeps the documents it fails to match.

How it was found

A check of Lucene's default disjunction rewrites flagged repeated sources inside an OR, and a probe on the stock jar showed the missed hit. A bounded-exhaustive comparison with an independent reference semantics (1.1 million checks on documents of up to 4 positions) then tied the missing matches to minExtent(), and a public-API JUnit reproduced them.

The method in full189 words

Method: bounded-exhaustive translation validation on the real jar (semi-formal tier).

  • First signal (2026-10-26): a hunt unit on spans and intervals checked whether Lucene's default disjunction rewrites keep their documented intent. It was clean except for repeated-source leads, and a probe on the stock jar showed phrase(x, or(ordered(a, a), a), c) missing x a a c while the ordered(a, b) control matched.
  • A follow-up unit (2026-10-27, main a9779ef3c193) wrote a reference semantics from the javadoc and compared it with the real classes on every document of 1 to 4 positions over three letters and 6,856 expressions of depth up to 3 (1,100,295 checks; 23,911 failures on stock, spread over several separate bugs). Switchable shadow fixes attributed them: adding only this minExtent() fix to the two known fixes removed the missing-interval failures under phrase and maxgaps over or(.. ordered(a, a) ..); with all seven fixes there were 0 failures at up to 6 positions (11,287,592 checks).
  • Then a public-API JUnit on the stock jar, checked by an independent reproducer, reading-only reviews by a model from another family (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication and a credibility audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): 207 logged queries over GitHub issues, PRs and commits, downstream repositories, the mailing lists, the JIRA archive and the web, plus the changed files of 426 PRs. The nearest items are the two changes that together created the bug, LUCENE-8477 (#9523) and LUCENE-9099 (#10141); neither discusses the interaction.

Searches6 searches

Verdict new, high confidence. Nearest: #9523 / LUCENE-8477 / lucene-solr#620 (added the rewrite and the grouping before repeated sources existed; a reviewer asked why one-position clauses are separated, and the answer is the comment this bug falsifies) and #10141 / LUCENE-9099 / lucene-solr#1097 (added the repeated source; about matching and maxgaps only). Also read, other code or other symptoms: #9679 (LUCENE-8633, added minExtent() for scoring), #11177, #11442 / #11443, #10244, #9243, #9872, #13819, #10458, #12213 and elastic/elasticsearch#58576. The only open PRs in the intervals package (#16851, #16, #16847) change other code. By source the 207 logged queries are GitHub 99, mailing lists 52, web 27, JIRA archive 15, commit messages 9, downstream repositories 4 and code history 1; by method, 138 through the hunt's tool and 69 manual. Keyword search cannot prove absence.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs: apache/lucene, the archived apache/lucene-solr, Elasticsearch, OpenSearch, Solr2026-10-27the hunt's prior-report tool, two runs: 60 issue/PR queries, 6 commit-message and 4 downstream queries; 31 manual GitHub searches; 7 reads of related issue comments and PR reviewsno report; only the two introducing changes and unrelated intervals items
Open and recently updated apache/lucene PRs2026-10-27changed files of 426 PRs (all 409 open ones and every PR updated since 2026-10-25)none touches RepeatingIntervalsSource.java or Disjunctions.java
Mailing lists (dev@, java-user@, issues@, commits@) and the JIRA archive2026-10-2740 list and 12 JIRA-archive queries through the tool; 12 manual list-archive searches; 3 JIRA REST lookupsno report
The web2026-10-2716 queries through the tool and 11 manual searchesdocumentation and unrelated issues only
Full local history2026-10-271 git log -L / -S query over the history clone (minExtent() of the repeated source; minExtent() == 1 in Disjunctions)2 commits: the LUCENE-9099 and LUCENE-8477 changes
Upstream branches2026-10-27file history (2 GitHub API listings) and current source (1 raw-file read) of both files on upstream main 20ced71c1e6e, branch_10x and branch_10_6not fixed: same minExtent() body and grouping

History

Introduced by LUCENE-9099 (aa916bac3c3 on branch_8x, 7c1ba1aebee on master, 2020-02-06), first released in 8.5.0, which broke the assumption of the older LUCENE-8477 rewrite (8.1.0). A portable probe fails on all 52 releases from 8.5.0 to 10.5.2 and on main, and shows 8.2.0 to 8.4.1 unaffected.

Versions tested7 rows

Introduced by LUCENE-9099 "Correctly handle repeats in ORDERED and UNORDERED intervals" (apache/lucene-solr#1097 by romseygeek, one approving review by jimczi, no inline comments; the JIRA issue is now apache/lucene#10141): aa916bac3c3 on branch_8x and 7c1ba1aebee on master, both 2020-02-06; first released in 8.5.0. git blame and git log -L of minExtent() name only 7c1ba1aebee.

Precondition: LUCENE-8477 (f1782d0dd119 master, 2571bf355f8 branch_8x, apache/lucene-solr#620, 2019-03-27, first in 8.1.0) added the minExtent() == 1 grouping when it was sound.

Bisection is commit-exact without building: between the 8.4.1 and 8.5.0 tags only two commits touch the intervals package, LUCENE-9099 and LUCENE-9212 (90028a7b935, only new Intervals.multiterm overloads). On 8.2.0 to 8.4.1 the unordered shape also gives 0, for the older reason LUCENE-9099 fixed (unordered(a, a) matched a single a), so the ordered shape settles those releases. Not fixed: branch_10_6, branch_10x and upstream main 20ced71c1e6e (2026-10-27) have the same code (read, not run), so the released 10.6.0 has it.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe doc's test: 5 of 5 fail (gradle and the standalone runner, assertions on and off); the finder's 3 of 3
10.5.2 affectedthe doc's test: 5 of 5 fail (assertions on and off); the probe gives the same output as main
9.12.3 affectedthe doc's test: 5 of 5 fail with assertions on, and with assertions off in the fact check's run; the finder's and the reproducer's tests fail
8.5.0 to 10.5.2 (all 52 releases) probeordered(a, a) is a RepeatingIntervalsSource with minExtent() 1; 5 of 5 bug checks fail, controls pass
8.2.0, 8.3.0, 8.3.1, 8.4.0, 8.4.1 not affectedordered(a, a) is an OrderedIntervalsSource with minExtent() 2; the ordered shape finds its hit
8.1.0 and earlier read8.1.0 has the grouping and no repeated source; before it, no automatic rewrite (sandbox package, not executed)
fix on a9779ef3c193 not affectedthe doc's 5 tests and the new rewrite test pass; lucene/queries 518 tests pass, assertions on and off

Independent checks

An independent reproducer and a devil's advocate reproduced it; an adjudication confirmed it (high); an audit re-ran it with the default analyzer and the query parser. Reading-only cross-model reviews: one agreed on the root cause; the other called it real but blamed the grouping and the patch. Fact checks then added the LFV-810 coupling.

Checks and verdicts522 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 candidateRewrite-intent lead, then the bounded-exhaustive harness and shadow-fix attribution; the 3-test public-API JUnit fails 3 of 3 on main (assertions on and off), 10.5.2 and 9.12.3; with the other hunt fixes shadowed 2 of 3 still fail; with this fix 3 of 3 pass.
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; it agreed on the root cause, found the inputs in contract and the intended case weak. Its overflow point was applied to the fix.
Independent reproducer realOwn JUnit from the claim only, outside the intervals package: 5 of 8 fail on main (assertions on and off), 10.5.2 and 9.12.3; 3 controls pass; added the maxgaps(0, ...) shape; a minExtent()-only shadow fix turns all green and keeps 117 of 117 upstream interval tests green.
Cross-model review, intended lens (premises executed) real (medium); root cause and fix disputedRead-only reviewer: the miss is a real, unintended bug, but it did not agree on the root cause or the fix (root_cause_agrees false, fix_fixes_it false). It places the defect in splitDisjunctions, which "wrongly assumes a source whose minimum is one can only emit singleton intervals", and called the minExtent() patch "not a generally sound fix", because identical tokens stacked at one position make width 1 legal. That stacked case was then executed: no hit is lost and no score changes there, with or without the fix.
Intended-behaviour devil's advocate real (high)Built the strongest intended case and rejected it; probe over all 510 documents of 1 to 8 positions over {a, b}; found the or(ordered(a, a), b) trigger, the analyzedText route and the coupling with the notContainedBy rewrite bug.
adjudication confirmed (high)Re-ran the independent reproduction's test with seed 1A2B3C4D (8 run, 5 fail, assertions on and off). Corrections applied: claim limited to distinct positions, the alternative fix site named, the coupling stated.
credibility audit keepOwn probe with the default IndexWriterConfig and StandardAnalyzer: 8 bug shapes (including analyzedText and the fn: query string) miss their hit, 0 of 8 controls fail, on main (assertions on and off) and 10.5.2; 16 of 16 pass with the fix.
this doc's runs reproduced; fix validatedA 5-test repro and a structural test, both failing on stock; with the fix the whole lucene/queries module and the interval tests of queryparser and highlighter pass, assertions on and off; the coupling re-run with this fix.
fact checks bug confirmed; fix side effects addedA fact check re-ran the doc's commands (gradle and the runner, 9.12.3 also with assertions off) and found the LFV-810 coupling with its own probe over 5,460 documents. A second, reading-only fact check by the other model family flagged the clause-limit cost, the non-universal bound, the wider trigger and local saturation. Each point was executed before the doc was changed.

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

Finding record

LFV-805, snapshot of 2026-11-06

Registry title
Intervals.ordered(a, a) and Intervals.unordered(a, a) report minExtent() 1, so the default disjunction rewrite under phrase/maxgaps leaves or(ordered(a, a), a) unsplit and phrase(x, or(ordered(a, a), a), c) misses 'x a a c'
Area
queries / intervals (RepeatingIntervalsSource.minExtent for ordered/unordered of equal sub-sources; Disjunctions rewrite under phrase/maxgaps)
Priority
Low, rank 347: Needs the unusual ordered(a, a) or unordered(a, a) shape as an or() alternative under phrase or maxgaps.
Impact class
silent wrong results
Impact note
Applications whose interval queries (Intervals factories, StandardQueryParser fn: functions such as fn:phrase(x fn:or(fn:ordered(a a) a) c), or Intervals.analyzedText over text such as 'a a') put a clause made only of copies of one source, ordered(a, a) or unordered(a, a), as an alternative of a default rewriting Intervals.or under a gap-sensitive parent (phrase or maxgaps) silently miss documents in which a single-position alternative falls on or between the copies (phrase(x, or(ordered(a, a), a), c) misses 'x a a c'; phrase(x, or(ordered(a, a), b), c) misses 'x a b a c'), with the default writer, codec, analyzer and searcher, -ea and -da, at any index size, in every release from 8.5.0 to 10.6.0 (10.6.0 by reading) and on main; nothing is written to the index, and as a side effect an exact repeated match scores lower (observed 0.33 for ordered(a, a) on 'x a a c' against 0.5 for ordered(a, b) on 'a b').
Summary
You search with Intervals.phrase(x, Intervals.or(Intervals.ordered(a, a), a), c), meaning 'x, then either a a or a, then c', on the text 'x a a c' and expect a hit, because phrase(x, ordered(a, a), c) alone matches it and Intervals.or is documented to rewrite itself under gap-sensitive parents so that alternatives of different lengths are not lost. Instead it finds nothing: ordered(a, a) and unordered(a, a) are built as a RepeatingIntervalsSource whose minExtent() returns the minExtent of a single 'a' (1) instead of 2, the rewrite assumes that only single-position sources report 1 and so keeps or(ordered(a, a), a) as one OR, and inside that OR the interval of 'a a' contains the interval of a single 'a' and is minimized away. The same query with two different words, phrase(x, or(ordered(a, b), a), c) on 'x a b c', matches as expected.
Introduced
aa916bac3c3369a461afa06e384e070657c32973 (branch_8x, the commit first released, in 8.5.0) and 7c1ba1aebeea540b67ae304deee60162baee2e12 (master, the same patch on the same day): '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), which made Intervals.ordered(a, a) and Intervals.unordered(a, a) deduplicate into a RepeatingIntervalsSource whose minExtent() is 'return in.minExtent();' [read: blame at 7cecf795236 and git log -L of minExtent() list only 7c1ba1aebee] [exec: 8.4.1 not affected, 8.5.0 affected; only two commits touch the intervals package between them and the other, LUCENE-9212 90028a7b935, changes only the multiterm factory, so the bisection is commit-exact]. It broke the stated assumption of the older LUCENE-8477 rewrite (f1782d0dd119 master / 2571bf355f8 branch_8x, apache/lucene-solr#620, 2019-03-27, first released in 8.1.0) that 'all combinatorial sources have a minExtent > 1', which held when written (ordered(a, a) reported 2 on 8.2.0 to 8.4.1 [exec]) (2020-02-06 (aa916bac3c3 and 7c1ba1aebee); precondition 2019-03-27 (LUCENE-8477, f1782d0dd119))
First affected version
8.5.0 [exec] (8.4.1, the last release before LUCENE-9099, is not affected [exec]: ordered(a, a).minExtent() is 2 there and the ordered shape finds its hit; its unordered shape already returns 0 there for the older reason LUCENE-9099 fixed, unordered(a, a) matching a single 'a')
Affected versions
every release from 8.5.0 through 10.5.2 (52 releases: 8.5.0-8.11.4 (19), 9.0.0-9.12.3 (21), 10.0.0-10.5.2 (12)) and main a9779ef3c193 [exec: the follow-up history probe MinExtentHistoryProbe (5 bug checks, 0 control failures) on all 52 releases with -ea and on main with -ea and -da; the finder's JUnit fails 3 of 3 on 8.5.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea, main also -da); the independent reproduction's JUnit 5 of 8 on main (-ea and -da), 10.5.2 and 9.12.3; the intended-behaviour check's probe 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, 8.4.1. Not affected [read]: 8.1.x (rewrite present, no RepeatingIntervalsSource; sandbox package, not executed) and earlier releases (no automatic rewrite). Not fixed [read]: branch_10_6 head 4ae946e5a9b, branch_10x and upstream main 20ced71c1e6e have the same minExtent() body and splitDisjunctions grouping, so the released 10.6.0 has it
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, read-only): 207 logged queries, from a scripted prior-report search in two runs (60 GitHub issue/PR queries over apache/lucene, the archived apache/lucene-solr and the downstream Elasticsearch, OpenSearch and Solr repositories, 6 commit-message, 4 downstream, 40 mailing-list queries over dev@, java-user@, issues@ and commits@, 12 JIRA-archive and 16 web queries) and manual searches (31 GitHub REST searches, reads of issue and PR comments and reviews, 12 list-archive, 3 JIRA REST and 11 web searches, local git history); the changed files of 426 apache/lucene PRs (all 409 open ones and every PR updated since 2026-10-25) were checked and none touches RepeatingIntervalsSource.java or Disjunctions.java. Follow-up re-check 2026-10-27 (5 GitHub searches, the 51 PRs updated since 2026-10-25 including the files of #16904, #16913 and #16919, intervals issues updated since then, 2 web searches): nothing new. Not fixed: upstream main 20ced71c1e6e, branch_10x and branch_10_6 keep both lines. Nearest items, none the same bug: LUCENE-8477 / #9523 / lucene-solr#620 (2019, introduced the rewrite and the minExtent()==1 grouping, before repeated sources existed); LUCENE-9099 / #10141 / lucene-solr#1097 / 7c1ba1aebee (2020, introduced the repeated source; discusses matching and maxgaps of repeats only); LUCENE-8633 / #9679 (added minExtent for scoring); LUCENE-10140 / #11177, LUCENE-10406 / #11442 (dup of #11443), #13819, #10244, #9243, #9872 and the interleaved-terms items #10458, #12213 and elastic/elasticsearch#58576 (other code or other symptoms); open PRs #16851, #16 and #16847 touch other intervals code
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27 and 28) through gradle in a scratch worktree, stock and with the fix, assertions on and off, and through the standalone JUnit runner; the 10.5.2 (assertions on and off) and 9.12.3 release jars. 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 main (2026-10-27). The fix was validated in the scratch worktree: the new test, a new TestDisjunctionRewrites test, the whole lucene/queries module (52 suites, 518 tests) with assertions on and off, the interval tests of lucene/queryparser and lucene/highlighter, and :lucene:queries:check -x test. After the fact checks (2026-10-28), a probe ran each point they raised on stock main, on 10.5.2, with the fix, and with the fix plus the LFV-810 candidate fix; the fix with reworded comments was re-run in a new scratch worktree.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:93-96 (minExtent returns in.minExtent()); Disjunctions.java:74-95 (splitDisjunctions and its comment); IntervalsSource.java:63-64 (minExtent javadoc); Intervals.java:47-54 and 118-132 (rewrite note, or() and or(false, ...) javadoc); IntervalScorer.java:66 (sloppy frequency 1/max(length - minExtent + 1, 1))
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/ContainedByIntervalsSource.java:28-30; ContainingIntervalsSource.java:31-33; NotContainedByIntervalsSource.java:27-30; NotContainingIntervalsSource.java:27-30; RepeatingIntervalsSource.java:34
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/RepeatingIntervalsSource.java:93-96; OrderedIntervalsSource.java:29-57, 69-75; UnorderedIntervalsSource.java:33-57; Disjunctions.java:75-95; DisjunctionIntervalsSource.java:146-152, 171-175, 222-247; IntervalScorer.java:60-69; IntervalsSource.java:63-64; Intervals.java:47-54, 118-136
Who is affected
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:123-125; package-info.java:21; FilteredIntervalsSource.java:128-130
Why it looks unintended
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalsSource.java:63-64; Intervals.java:47-54, 118-136; Disjunctions.java:75-78
  • lucene/CHANGES.txt:5996-6001, 6502-6506
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:380-402, 407, 425, 729, 739 (20 minExtent asserts, none on a repeat); TestDisjunctionRewrites.java
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:347-349, 381-383
Suggested fix
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/ExtendedIntervalsSource.java:126-132; ExtendedIntervalIterator.java:53-76; IntervalIterator.java:62-71; Disjunctions.java:33-62; OrderedIntervalsSource.java:69-75; UnorderedIntervalsSource.java:69-75; BlockIntervalsSource.java:59-66; IntervalQuery.java:72, 189-196; IntervalScorer.java:66; Intervals.java:47-54

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 the code at main a9779ef3c193 and an executed probe (stock, assertions on and off, and with the fix shadowed in); the PNG is the summary still. 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.