All findings · Lucene finding · LFV-828 · intervals-overlap-filters-skip-disjunction-rewrite

Intervals.overlapping, nonOverlapping and notWithin skip the disjunction rewrite, so a word can be both within and not within n positions of or(york, "new york")

With the default Intervals.or(), containedBy and within check every alternative of an or() reference, but overlapping, nonOverlapping and notWithin see only its minimal matches. So on "visit new york", within(visit, 1, or(york, "new york")) and the same notWithin both match the same word, silently, since 8.1.0.

Summary

Priority
Low, rank 265Needs an overlap filter with an or() reference whose alternatives contain one another, a rare query shape.
Impact
silent wrong results
Since
8.1.0 (LUCENE-8477, 2019)introduced 2019-03-27 · about 7 years
Affected
8.1.0 to 10.5.2, 10.6.0 (branch_10_6 4ae946e5a9b intervals sources) and main
Status
documented
Found by
relation-law checking of the real jar (semi-formal)

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

The finding in 30 seconds

An interval query can filter a source by a reference such as or(york, "new york"). Because or() keeps only minimal matches, LUCENE-8477 (8.1.0) made several operators split such a reference into one filter per alternative. containedBy and within split it that way; overlapping, nonOverlapping and notWithin do not.

The example on the document "visit new york" with R = or(york, "new york"): within(visit, 1, R) tests each alternative, and "new york" widened by 1 to positions 0-3 contains visit at position 0, so visit matches; notWithin(visit, 1, R) reads the or() as one source, which keeps only york at position 2 ("new york" contains it and is dropped), widened to positions 1-3, which misses visit, so visit matches again: both return visit [0,0].
The "visit new york" example: within splits R and finds "new york" right next to visit; notWithin reads R as one source, which keeps only york, two positions away, so it keeps visit too. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index one document, "visit new york" (visit = position 0, new = 1, york = 2), and use the reference R = or(york, "new york"), built with the default Intervals.or.
  2. within(visit, 1, R) finds 1 hit: visit is right before "new york". You expect notWithin(visit, 1, R) to find 0, since the two are documented as opposites; with R = "new york" alone they give 1 and 0.
  3. Instead notWithin(visit, 1, R) also finds 1 hit, the same word [0,0]. Read as one source, the default or() keeps only minimal matches: "new york" [1,2] contains york [2,2] and is dropped, so notWithin sees only york, 2 positions away. within splits R and checks each alternative.
  4. The filters also disagree on "new york": containedBy(new, R) = 1 but overlapping(new, R) = 0 and nonOverlapping(new, R) = 1, while maxgaps(0, overlapping(new, R)), which can only remove intervals, gives 1. With or(false, ...) every relation agrees.

Who is affected

Anyone calling overlapping, nonOverlapping or notWithin, or the fn: query-text forms, with a default or() reference whose alternatives contain one another, such as a word and a phrase ending in it. Default settings, every release since 8.1.0. overlapping misses hits; the two exclusion filters keep hits they should drop.

Mechanism, terms and numbers661 words

Intervals queries can filter a source by a reference that is an or() of alternatives. The default Intervals.or() keeps only minimal intervals, so an alternative such as "new york" [1,2] is dropped when it contains another one, york [2,2]. Since LUCENE-8477 (8.1.0), containedBy and therefore within pull such a reference apart and test every alternative; overlapping, nonOverlapping and notWithin were built without that rewrite and read the minimized or(). For one and the same reference the relations then contradict each other: on "visit new york", within(visit, 1, or(york, "new york")) and notWithin(visit, 1, or(york, "new york")) both return visit; on "new york", containedBy(new, R) matches but overlapping(new, R) does not, nonOverlapping(new, R) keeps new, and maxgaps(0, overlapping(new, R)) matches again because the nested form does split the reference. The same happens from fn: query text. With or(false, ...) every relation agrees. Every release from 8.1.0 to 10.5.2, 10.6.0 (its intervals sources at 4ae946e5a9b) and main is affected; 8.0.0 is consistent. Rewriting the disjunctions in the three factories (for overlapping on both operands, as its nested form already does) fixes it and passes the queries and queryparser test suites.

Terms

  • Interval: the positions a source matches, [start,end]. In "visit new york", york is [2,2] and the phrase "new york" is [1,2].
  • Minimal intervals: the default Intervals.or() drops an interval that contains another interval of the same or(). So or(york, "new york") yields only york [2,2] on "visit new york".
  • Disjunction rewrite (LUCENE-8477, 8.1.0): for operators where that loses matches, Lucene builds one copy of the operator per alternative and ORs the copies (Disjunctions.pullUp). Alternatives with minExtent() 1 are grouped into one or(); extend raises the extent, so within / notWithin with n >= 1 split even an or() of single words. or(false, ...) turns the rewrite off; the javadoc offers it for when you "care more about speed than about accuracy".
  • The relations: within(x, n, r) = containedBy(x, extend(r, n, n)); notWithin(x, n, r) = nonOverlapping(x, extend(r, n, n)); overlapping(x, r) keeps x intervals that overlap an r interval.

What happens (main a9779ef3c193)

// ContainedByIntervalsSource.java:28-30: the reference is split, one filter per alternative
return Intervals.or(Disjunctions.pullUp(big, s -> new ContainedByIntervalsSource(small, s)));

// Intervals.java:508-511
within(source, n, reference)    -> containedBy(source, Intervals.extend(reference, n, n))
// Intervals.java:493-497, 467-470, 478-480: plain constructors, the reference is read as one or()
notWithin(minuend, n, subtrahend) -> new NonOverlappingIntervalsSource(minuend, Intervals.extend(subtrahend, n, n))
nonOverlapping(minuend, subtrahend) -> new NonOverlappingIntervalsSource(minuend, subtrahend)
overlapping(source, reference)    -> new OverlappingIntervalsSource(source, reference)

// DisjunctionIntervalsSource.java:230-236: the or() iterator drops queued intervals that contain the current one
while (intervalQueue.size() > 0 && contains(intervalQueue.top(), start, end)) { ... pop ... }

// OverlappingIntervalsSource.java:78-83: the split exists, but only a parent (maxgaps, phrase, ...) calls it
public Collection<IntervalsSource> pullUpDisjunctions() {
  return Disjunctions.pullUp(Arrays.asList(source, reference), ss -> new OverlappingIntervalsSource(ss.get(0), ss.get(1)));
}

On "visit new york" with R = or(york, "new york"):

  1. within(visit, 1, R) tests visit [0,0] against extend(york) = [1,3] and extend("new york") = [0,3]. The second contains it: 1 hit.
  2. notWithin(visit, 1, R) reads extend(R) as one source. The minimized or() yields only york, widened to [1,3], which does not touch visit [0,0]: visit is kept, 1 hit.
  3. On "new york", overlapping(new, R) compares new [0,0] with york [1,1] only: 0 hits, although new overlaps the alternative "new york" [0,1], which containedBy(new, R) finds. nonOverlapping(new, R) and notWithin(new, 0, R) keep new.
  4. maxgaps(0, overlapping(new, R)) asks its source for its alternatives (FilteredIntervalsSource.java:31-36), which splits the reference: 1 hit. A filter that can only remove intervals adds one.
  5. With or(false, ...) nothing is split anywhere, every relation sees york only, and they agree: within 0, notWithin 1, containedBy 0, overlapping 0.

The same holds without a single-word alternative: with or("new york", "new york city") on "new york city hall", within(hall, 1, ...) and notWithin(hall, 1, ...) both match, because "new york city" [0,2] contains "new york" [0,1] and is dropped for notWithin.

Exposure and likelihood391 words
  • Who. Callers of Intervals.overlapping, Intervals.nonOverlapping and Intervals.notWithin, and of fn:overlapping, fn:nonOverlapping and fn:notWithin in the flexible StandardQueryParser, whose reference is a default Intervals.or() with alternatives that contain one another: a word and a phrase that starts or ends with it (york / "new york"), or a phrase and a longer phrase ("new york" / "new york city"). Synonym or place-name lists can have this shape.
  • Settings. Default IndexWriterConfig settings and codec and a plain IndexSearcher, with StandardAnalyzer or the test framework's MockAnalyzer; assertions on and off. Every run used one-document indexes; the mechanism works per document, so index size should not matter (reasoning, not run). The rewrite is on by default (Intervals.or(...) = or(true, ...)).
  • Effect. Silent wrong match sets in both directions: overlapping misses documents (0 instead of 1 on "new york"); nonOverlapping and notWithin used as exclusion filters keep documents they should drop (1 instead of 0 on "visit new york" and on "new york city hall"). Adding an alternative to the reference removes overlapping hits and adds nonOverlapping / notWithin hits, and wrapping the filter in maxgaps or phrase adds hits back.
  • Not affected. References with one alternative, or()s of single words (two different single-position intervals never contain one another, so the minimized or() drops nothing: 0 violations in 1,452 relation checks over all 363 documents of up to 5 words, executed), or(false, ...), and within / containedBy themselves.
  • How likely. Rated rare: it needs one of these three filters, a reference whose alternatives contain one another, and text where only the longer alternative decides. No report was found since 8.1.0 (2019). For such a query and document the wrong answer comes every time. The intervals package is marked experimental.
  • Workaround on stock Lucene. Spell the alternatives out with the public factories, the structure the fix builds: or(overlapping(x, york), overlapping(x, "new york")) (1 on "new york"), nonOverlapping(nonOverlapping(x, york), "new york") (0) and notWithin(notWithin(visit, 1, york), 1, "new york") (0 on "visit new york"), all executed on main. Or use or(false, ...) everywhere for consistent minimized answers.
  • Downstream (code and documentation reading only, not executed). Elasticsearch's intervals query builds any_of with Intervals.or and its overlapping / not_overlapping filters with Intervals.overlapping / Intervals.nonOverlapping; Solr's intervals query parser exposes within, not_within, overlapping, not_overlapping and any_of.

Minimal repro

The test shows that Intervals.within(visit, 1, ref) and Intervals.notWithin(visit, 1, ref) both match 'visit new york' when ref = Intervals.or(term(york), phrase(new, york)): within tests every alternative of the or(), notWithin reads the minimized or() that drops 'new york'.

TestWithinNotWithinDisjunction.java (JUnit test)54 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;
import org.apache.lucene.tests.util.TestUtil;

public class TestWithinNotWithinDisjunction extends LuceneTestCase {

  public void testWithinAndNotWithinAreComplements() throws Exception {
    IndexWriterConfig iwc =
        new IndexWriterConfig(new StandardAnalyzer()).setCodec(TestUtil.getDefaultCodec());
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      Document doc = new Document();
      doc.add(new TextField("f", "visit new york", Field.Store.NO));
      w.addDocument(doc);
      w.commit();
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        IntervalsSource visit = Intervals.term("visit");
        IntervalsSource ref = Intervals.or(Intervals.term("york"), Intervals.phrase("new", "york"));
        int within = searcher.count(new IntervalQuery("f", Intervals.within(visit, 1, ref)));
        int notWithin = searcher.count(new IntervalQuery("f", Intervals.notWithin(visit, 1, ref)));
        // expected: 'visit' is either within 1 position of ref or not (1 hit); actual: both (2)
        assertEquals("within=" + within + ", notWithin=" + notWithin, 1, within + notWithin);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-828/, 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.TestWithinNotWithinDisjunction
Expected vs actual
Expected the one 'visit' interval to be within 1 position of ref or not (1 hit in total); actual: both queries match (within=1, notWithin=1 expected:<1> but was:<2>), with -ea and -da.

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

More files, environment, full output35 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: within=1, notWithin=1 expected:<1> but was:<2>
       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.TestWithinNotWithinDisjunction.testWithinAndNotWithinAreComplements(TestWithinNotWithinDisjunction.java:50)

Why it looks unintended

  • The javadoc: within returns intervals that "appear within" n positions of the reference, notWithin those that "do not appear within" them; LUCENE-8629 added within as the "inverse" of notWithin.
  • The default or() is the mode the javadoc presents as accurate, yet only or(false, ...) answers consistently.
  • Under maxgaps, which rewrites its child, overlapping(new, R) finds the match it misses alone.
The full argument429 words
  • Documented as opposites. Intervals.java:500: within "Returns intervals of the source that appear within a set number of positions of intervals from the reference"; :485: notWithin "Returns intervals of the minuend that do not appear within a set number of positions of intervals from the subtrahend query". fn:notWithin: "Matches intervals of the source that do not appear within the provided number of positions from the intervals of the reference". LUCENE-8629 (#9675) proposed within and overlapping as the "inverse of the already available NOT_WITHIN" and "NOT_OVERLAPPING". The repro uses single-position sources, so the separate question of a partly-near multi-position interval (LFV-830) does not arise.
  • Set logic. containedBy ("appear within intervals of the big query", :554) implies overlapping ("overlap with intervals from another source", :473; fn:overlapping: "overlap with at least one interval of the reference") and excludes nonOverlapping ("do not overlap with intervals from the subtrahend", :462).
  • The default is the mode the javadoc presents as accurate. The class note, written about gap-sensitive sources ("e.g. PHRASE and MAXGAPS"), says the rewrite ensures documents "are matched" and "if you care more about speed than about accuracy you can use" or(false, ...) (:47-54); or(false, ...) "can miss valid hits due to minimization" (:131). LUCENE-8477's CHANGES entry: "Interval disjunctions could miss valid hits if some of the clauses of the disjunction are minimized away". Here the default is the inconsistent path. The note does not promise in so many words that every top-level relation expands its operands; a maintainer may read it that narrowly.
  • The same filter answers differently under a parent. maxgaps(0, overlapping(new, R)) and phrase(overlapping(new, R), york) match "new york" although overlapping(new, R) alone has no interval there. maxgaps and phrase rewrite their child at construction, as documented for gap-sensitive sources, so the difference itself is expected from the design; what it shows is that the module's own expansion finds a hit that the top-level filter misses.
  • Maintainers on the rewrite (#10244): romseygeek, "Intervals automatically rewrite disjunctions (and have done since LUCENE-8477) so that queries behave as your or I would expect them to"; jimczi, "If it's not correct, it's a bug, not a feature".
  • Tests. No upstream test uses these filters with a reference whose alternatives contain one another: TestDisjunctionRewrites has no case for them, and the other uses (in TestIntervals, TestIntervalQuery, the match-highlighter tests and TestStandardQPEnhancements) have a single word, a phrase, an ordered / unordered pair, an extend, or an or() of single words (single words never contain one another, so minimization drops nothing there).
The devil's-advocate check334 words

Intervals use minimal-interval semantics ("These sources implement minimum-interval algorithms", Intervals.java:43), under which or(york, "new york") is york. The rewrite is documented for gap-sensitive sources ("e.g. PHRASE and MAXGAPS"); LUCENE-8477's commit message lists PHRASE, MAXGAPS and the four containment filters, not the overlap filters. In its review jimczi wrote "I am not sure if the rewriting should be the default option since minimum interval semantic is the basis of the paper", and earlier in #9523 "IMO we should document this unintuitive behavior rather than rewriting all queries in a non-optimal form". The fn: docs say "the algorithm is not required to return or highlight all matching interval alternatives", and the package is experimental. Two narrower readings follow: the nested-versus-top-level overlapping difference is the documented construction-time rewrite of gap-sensitive parents at work, and standalone overlapping / nonOverlapping are complementary over the minimized reference.

Why it fails: that explains overlapping or nonOverlapping taken alone, not the contradiction. within itself uses the non-minimized alternatives, so under either reading one of within and notWithin is wrong, and with or(false, ...) everything agrees, so the inconsistency lives only on the default path. romseygeek answered jimczi in the same thread ("I think we still need to make it possible to get accurate hits", "My suggestion is that we automatically rewrite things to match accurately"), and the rewrite was committed that way; #10244 later defends it. The fn: caveat is about which intervals a matching document returns or highlights; this bug changes which documents match, and notWithin returns an extra interval rather than missing one. The earlier cross-model reviews rated the argument moderate and rejected it, and the intended-behaviour checker built it and rejected it. The cross-model fact check (reading only) rated the intended reading weak for within / notWithin and strong for the two narrower cases taken alone. A maintainer could still choose either consistent fix (rewrite the overlap filters, or minimize in within / containedBy too, which would undo LUCENE-8477), which is why the adjudication's confidence is medium.

Suggested fix

Recommended A2. Rewrite the reference in overlapping, nonOverlapping and notWithin, and the source too in overlapping

Rewrite the disjunctions in the three factories: overlapping is built as its nested form already is (one filter per pair of source and reference alternatives, ORed); nonOverlapping / notWithin chain one exclusion per reference alternative (+20/-4 in Intervals.java). The repro, 1,093 upstream tests and a nested-case guard test pass.

All options and trade-offs4 options ยท 1,172 words

Option A2 is recommended: validated with gradle on a9779ef3c193 (tests with assertions on, static checks) and with the repro runner (assertions off). The finder's sketch, option A, passes the same tests but loses a nested match that stock Lucene finds, which a cross-model fact check found and a later run confirmed. Options B and C were not run.

A2. Rewrite the reference in overlapping, nonOverlapping and notWithin, and the source too in overlapping (validated by the checks) (recommended)

In Intervals.java (+20/-4): overlapping is built exactly as its nested form already is (OverlappingIntervalsSource.pullUpDisjunctions): one OverlappingIntervalsSource per combination of a source alternative and a reference alternative, ORed. So top-level and nested overlapping agree on both operands. nonOverlapping and notWithin become a chain of one NonOverlappingIntervalsSource per reference alternative ("overlaps no alternative"), the AND that the LFV-291 fix also uses for notContainedBy. A reference with nothing to split gives one filter, as before, and or(false, ...) is never split. The guard test (1 test, public API) passes on stock main and with A2 and fails with option A.

   public static IntervalsSource nonOverlapping(
       IntervalsSource minuend, IntervalsSource subtrahend) {
-    return new NonOverlappingIntervalsSource(minuend, subtrahend);
+    return nonOverlappingAll(minuend, subtrahend);
+  }
+
+  // A minuend interval must overlap no alternative of a disjunctive subtrahend. Like
+  // containedBy/containing (LUCENE-8477), look at every alternative instead of the minimized
+  // disjunction, and combine the exclusions as a conjunction.
+  private static IntervalsSource nonOverlappingAll(
+      IntervalsSource minuend, IntervalsSource subtrahend) {
+    IntervalsSource result = minuend;
+    for (IntervalsSource s : Disjunctions.pullUp(subtrahend, s -> s)) {
+      result = new NonOverlappingIntervalsSource(result, s);
+    }
+    return result;
   }
 ...
   public static IntervalsSource overlapping(IntervalsSource source, IntervalsSource reference) {
-    return new OverlappingIntervalsSource(source, reference);
+    // overlap with any alternative of a disjunctive source or reference, as the nested form
+    // (OverlappingIntervalsSource.pullUpDisjunctions) already does
+    return Intervals.or(
+        Disjunctions.pullUp(
+            Arrays.asList(source, reference),
+            ss -> new OverlappingIntervalsSource(ss.get(0), ss.get(1))));
   }
 ...
   public static IntervalsSource notWithin(
       IntervalsSource minuend, int positions, IntervalsSource subtrahend) {
-    return new NonOverlappingIntervalsSource(
-        minuend, Intervals.extend(subtrahend, positions, positions));
+    return nonOverlappingAll(minuend, Intervals.extend(subtrahend, positions, positions));
   }

Trade-offs

  • Like every LUCENE-8477 rewrite it trades speed for accuracy (not benchmarked): overlapping builds one filter per combination of source and reference alternatives, each with its own iterators; the nonOverlapping / notWithin chain builds the minuend iterator once, plus one iterator per reference alternative.
  • Expansion limit: A2's overlapping uses the list pull-up, which throws IllegalArgumentException("Too many disjunctions to expand") above IndexSearcher.getMaxClauseCount() combinations, as the nested form and phrase already do. Top-level overlapping over an or() of 1,025 phrases now throws where stock main builds it (executed; maxgaps(0, ...) of it throws on stock main too). The chain uses the single-source pull-up, which has no limit, like containedBy and notContainedBy today.
  • The chain's order is a hash set's iteration order, so toString, equals and hashCode of the chain depend on it, though results do not (NON_OVERLAPPING(NON_OVERLAPPING(xx,EXTEND(bb,1,1)),EXTEND(aa,1,1)), executed); a PR could sort the alternatives (reasoning, not run).
  • It changes results for existing queries (that is the fix): top-level overlapping with a disjunctive source or reference, nonOverlapping / notWithin with a disjunctive reference. The source side of nonOverlapping / notWithin needs no split at the document level: if a longer source interval overlaps nothing, the shorter one inside it overlaps nothing either (reasoning, not run).

What the checks validated

  • Gradle on a9779ef3c193 in a scratch worktree, seed DEADBEEF, assertions on: the finder's 4 tests, the nested-filter probe and the 2 query-text tests pass, and so do the whole :lucene:queries (517 tests in 53 classes) and :lucene:queryparser (535 tests in 31 classes) test tasks and the 48 match-highlighter tests: 1,100 tests, 0 failures (1,093 upstream).
  • Gradle static checks of :lucene:queries with A2 and the guard test: checkGoogleJavaFormat, forbiddenApisMain, forbiddenApisTest, ecjLintMain and ecjLintTest pass, and the guard test passes.
  • The repro runner, assertions off: the 4 + 2 repro tests, 117 upstream interval tests and 23 upstream query-parser tests pass. The guard test passes with assertions on and off.
  • The probe with A2: every count in this doc consistent; the source-side case (overlapping(or(new, "new york"), york) on "new york") is 1 at top level and 1 nested.
  • Bounded differential (10 x 10 operand pairs, 120 documents, top level and under maxgaps(0, ...), maxgaps(1, ...) and phrase(..., cc)): A2 leaves all 300 nested overlapping expressions as on stock main, while option A loses documents in 15 of them.

A. Rewrite only the reference (the finder's sketch; not recommended) (validated by the checks)

In Intervals.java (+17/-4): the same nonOverlapping / notWithin chain as A2, but overlapping = Intervals.or(Disjunctions.pullUp(reference, r -> new OverlappingIntervalsSource(source, r))), so only the reference is split. That loses a nested match that stock Lucene finds: maxgaps(0, overlapping(or(new, "new york"), or(york, "zzz qqq"))) on "new york" is 1 on stock main and with A2, but 0 with A, because the parent now maps over A's outer or() and no longer splits the source (executed, assertions on and off). In the bounded differential, A loses documents in 15 of the 300 nested overlapping expressions. It also leaves the source side of top-level overlapping alone.

   public static IntervalsSource overlapping(IntervalsSource source, IntervalsSource reference) {
-    return new OverlappingIntervalsSource(source, reference);
+    // overlap with any alternative of a disjunctive reference, as containedBy does
+    return Intervals.or(
+        Disjunctions.pullUp(reference, r -> new OverlappingIntervalsSource(source, r)));
   }
 (nonOverlapping and notWithin: as in A2)

Trade-offs

Smaller than A2, but it regresses the nested case above, which none of the 1,100 tests covers (the guard test fails with A). It moves the clause limit: maxgaps(0, overlapping(new, or(1,025 phrases))) builds with A, where stock main throws (executed).

What the checks validated

  • Gradle on a9779ef3c193 in a scratch worktree, seed DEADBEEF: the finder's 4 tests, the nested-filter probe and the 2 query-text tests pass, and so do the whole :lucene:queries (517 tests in 53 classes) and :lucene:queryparser (535 tests in 31 classes) test tasks and the 48 match-highlighter tests: 1,100 tests, 0 failures (1,093 upstream).
  • Assertions off: the intervals tests (122 in 9 classes) and the flexible standard query-parser tests (127 in 8 classes) pass.
  • Static checks of :lucene:queries with A: forbiddenApisMain and ecjLintMain pass; checkGoogleJavaFormat does not flag Intervals.java (only line wraps in the copied test files, now applied to the repro test, which still fails 4 of 4 on main and passes with A).
  • Earlier, with the standalone runner: the finder's, the independent reproducer's and both reviewers' own tests pass; in the law-checking harness the within / notWithin "both" class and the inclusion-law failures disappear.

B. Minimize in within and containedBy as well (not recommended)

Make every relation read the minimized or(), as in 8.0.0.

Trade-offs

Consistent, but it undoes LUCENE-8477 for the containment filters and brings back the missed containedBy hits that LUCENE-8477 fixed. Not run.

C. Document the difference (not a fix)

State that the overlap filters read a disjunctive reference as minimized.

Trade-offs

Leaves within and notWithin both matching, and the same filter answering differently at top level and when nested.

Downstream note

Downstream: the Elasticsearch intervals query builds any_of and its overlapping / not_overlapping filters on these factories, and Solr's intervals query parser documents the same filters, so an exclusion rule written that way can let documents through (code and documentation reading only, not executed).

How it was found

Semi-formal. An AI tool ran Lucene's real interval sources against relation laws that need no reference implementation (each interval is either within or not within a reference; containedBy implies overlapping) over every document of up to 5 positions, with the known interval fixes shadowed. The remaining violations were reduced by hand to a public-API JUnit.

The method in full186 words

Method: reference-free law checking (translation validation) of the real jar, the hunt's semi-formal tier: no model checker, solver or prover produced the signal.

  • The harness ran real interval sources on an index of all 1,364 documents of up to 5 positions over a 4-letter alphabet and checked laws that need no reference semantics: each minimal interval of X is returned by exactly one of a relation and its negation; containedBy(X, Y) is a subset of overlapping(X, Y); or() is commutative and idempotent.
  • Bounded-exhaustive over a 116-shape pool (116 x 116 pairs), plus 30,000 random depth-3 pairs per random leg, with the known interval fixes shadowed (the LFV-287 and LFV-291 sketches and seven earlier switches), so only new violation classes remained.
  • The residual class "within and notWithin both return an alternative's interval" and the inclusion-law failures were reduced by hand to the public-API JUnit; with the fix added, both classes disappear from every leg.
  • An earlier unit of the hunt had noted the nonOverlapping half alone and judged it a design question; the complement and inclusion laws turn it into a contradiction between documented relations.

Prior reports, history and checks

Prior reports

No prior report found: 224 read-only queries on 2026-10-28 over GitHub issues, PRs and commits, the Lucene lists, JIRA, Elasticsearch, OpenSearch and Solr, open-PR file lists and the web, re-checked later that day. Nearest: LUCENE-8477 itself, whose discussion never mentions the overlap filters.

Searches6 searches

No report, issue, PR, review comment, commit, list thread or downstream report of this bug was found. The closest item is the change that introduced it.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub apache/lucene and apache/lucene-solr issues, PRs and commits2026-10-28three scripted prior-report searches (identifiers; symptoms and the fn: functions; downstream) and 27 GraphQL searches (class and method names, pullUpDisjunctions, "new york")no report; nearest #9523 / LUCENE-8477 and lucene-solr#620 (the rewrite; overlap filters never mentioned), #9243, #9675, #10244, #11442 / #11443
Open and recent PRs2026-10-28the changed files of all 411 open apache/lucene PRs; the 73 issues and PRs updated since 2026-10-26; intervals commits on main, branch_10x and branch_10_6no PR touches the three factories; newest intervals commit #16899 (2026-10-26, unrelated)
Downstream (Elasticsearch, OpenSearch, Solr)2026-10-28scripted prior-report search downstream leg and own searches for not_overlapping, overlapping and any_ofno report; a generic Elasticsearch minimization caveat (its example is contained_by over a repeated phrase)
Mailing lists and JIRA2026-10-28dev@, java-user@, issues@ and commits@ (60 list queries over the three runs); 19 JIRA queries (12 by the tool, 7 manual)no report
Web2026-10-2811 queries (2 throttled)no report
Re-check for this doc2026-10-28upstream main head and intervals commits; issues created since 2026-10-27; notWithin, nonOverlapping, Intervals.overlapping and pullUpDisjunctions searchesnothing new; upstream main 20ced71c1e6 has no later intervals commit

History

Introduced by LUCENE-8477 (2019-03-27, first released in 8.1.0), which added the split to the containment filters but built the overlap filters without it. 8.0.0 is consistent; all 59 releases from 8.1.0 to 10.5.2, 10.6.0 (branch_10_6 4ae946e5a9b) and main are affected (executed), and a commit-level A/B pins it. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-8477 "Automatically rewrite disjunctions when internal gaps matter" (#9523, apache/lucene-solr#620): f1782d0dd11 on master (released from 9.0.0) and 2571bf355f8 on branch_8x (released in 8.1.0), both 2019-03-27. It gave containedBy (and so within) the reference pull-up and extend() a pullUpDisjunctions(), but built overlapping and nonOverlapping as plain constructors and notWithin as NonOverlapping over extend. Before it, every relation read the same minimized or() and they agreed.

API history: nonOverlapping, notWithin and containedBy since 7.4.0, within and overlapping since 8.0.0, the fn: functions since 9.1.0. 8.1.0 and 8.1.1 shipped the package in lucene-sandbox; it is in lucene-queries from 8.2.0.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected4 of 4 fail (gradle and runner, assertions on and off); query-text test 2 of 2
branch_10_6 4ae946e5a9b (10.6.0, released 2026-10-29) affected4 of 4 fail (its intervals sources over the 10.5.2 core)
8.1.0 to 10.5.2 (59 releases) affectedall four contradictions on each (history probe); the JUnit fails 4 of 4 on 10.5.2, 10.0.0 and 9.12.3
2571bf355f8 vs its parent b08566cacc7 bisectthe commit's intervals sources fail 4 of 4 over the 8.1.0 jars; the parent's are consistent
8.0.0 not affectedconsistent: every relation reads the minimized or()
7.4.0, 7.7.3 not affectedconsistent on the one check that could run there, containedBy versus nonOverlapping (within and overlapping did not exist yet; notWithin did, but without within to compare against)

Independent checks

The finder, an independent reproducer, an intended-behaviour devil's advocate and reviews from another model family (OpenAI GPT-5.6) said real; an adjudication confirmed it (medium confidence) and an audit kept it. Fact checks kept it and corrected the wording and the fix choice; this doc's gradle runs reproduced it and validated the fix.

Checks and verdicts535 words

Each check had its own lens.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit 4 of 4 fail on main (-ea, -da), 10.5.2, 9.12.3 and the branch_10_6 sources, still 4 of 4 with every other known interval fix shadowed; query-text test 2 of 2; the fix sketch passes them and 117 interval and 23 query-parser tests.
refute review (OpenAI GPT-5.6) real, highOwn JUnit (within and notWithin over or(beta, phrase(alpha, beta)) both match) fails on main with two seeds and with -da; phrase-only control passes; passes with the fix.
independent reproducer realOwn 7-test JUnit outside the intervals package: the 4 relation checks fail, the 3 controls pass, on main (-ea, -da, two seeds), 10.5.2 and 9.12.3; all pass with the fix.
works-as-designed review (OpenAI GPT-5.6, reading only) real, mediumIntended argument rated moderate and rejected; a separate JUnit shows maxgaps(0, overlapping(...)) adding a match on main, fixed by the sketch.
intended-behaviour devil's advocate realBuilt the strongest minimal-interval case and rejected it; probes show maxgaps(0, overlapping(...)) and phrase(overlapping(...), york) matching where overlapping(...) has no interval (main -ea/-da, 10.5.2).
adjudication confirmed, mediumFresh seed 5EED1234: the reproducer's JUnit 4 of 7 fail with -ea and -da, controls pass. Not a duplicate (LFV-291 is the counterpart at another fix site); security none.
credibility audit keepOwn StandardAnalyzer / plain IndexSearcher probe: within and notWithin both 1, containedBy 1 vs overlapping 0, maxgaps(0, overlapping) 1; or(false, ...) consistent; finder JUnit 4 of 4 with a fresh seed.
doc step (gradle) reproduced; fix validatedGradle in a scratch worktree at a9779ef3c193: stock 7 of 7 repro tests fail with -ea and -da; option A passes them and 1,093 upstream tests (queries, queryparser, match highlighter), also with -da for the interval and flexible-parser suites; option A2 the same with -ea. A plain-Java probe with default settings gives every count in this doc.
fact check holds; 2 wording errors fixedRe-ran the gradle repro (4 of 4 fail, -ea and -da), the runner on main, 10.5.2 and 9.12.3 with a fresh seed, and its own probe; bug, mechanism, history and security hold. Two wording errors (the repro's controls; why an or() of single words is unaffected) are fixed.
cross-model fact check (OpenAI GPT-5.6, reading only) holds; 7 errors fixedConfirmed the central observation and the history. Found by reading that option A loses a nested overlapping match; a later run confirmed it, so A2 is now recommended. The other six errors (no clause guard on A's expansion, the controls claim, the single-word reason, the analyzer, iterator reuse, the 7.x API) are fixed.
diagram check passEvery on-screen value, code line and trace matches the code at a9779ef3c193 and the executed probe; sizes, contrast and vendor scrub pass.
resolution runs A regresses, A2 does notProbe on stock main, A and A2 (assertions on and off): the nested case is 1 / 0 / 1; a bounded differential of 1,200 expressions finds 15 nested overlapping losses with A and none with A2; the guard test fails only with A; A2 passes the static checks and the interval and query-parser tests with assertions off.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,734 words

Finding record

LFV-828, snapshot of 2026-11-06

Registry title
Intervals.overlapping, nonOverlapping and notWithin read a disjunctive reference through the minimized or(), while containedBy and within test every alternative, so for the reference or(york, "new york") the same interval is reported both within and not within n positions of it, and containedBy(new, ref) matches 'new york' where overlapping(new, ref) does not
Area
queries / intervals (overlap filters Intervals.overlapping / nonOverlapping / notWithin vs the LUCENE-8477 disjunction rewrite that containedBy and within apply to their reference)
Priority
Low, rank 265: Needs an overlap filter with an or() reference whose alternatives contain one another, a rare query shape.
Impact class
silent wrong results
Impact note
Anyone who calls Intervals.overlapping, Intervals.nonOverlapping or Intervals.notWithin (or writes fn:overlapping / fn:nonOverlapping / fn:notWithin for the flexible StandardQueryParser) with a default Intervals.or() reference whose alternatives contain one another, such as or(york, "new york") or or("new york", "new york city"), with default writer, codec, analyzer and searcher settings, at any index size, with -ea and -da, in every release from 8.1.0 to 10.6.0 and on main, silently gets match sets that contradict the containment filters over the same reference: overlapping misses hits that containedBy and within find, and nonOverlapping / notWithin keep intervals that lie inside or next to the longer alternative, so an exclusion filter lets through documents it should drop; single-alternative references and or(false, ...) give consistent answers [exec: the controls]; downstream intervals queries that build any_of with Intervals.or and offer overlapping / not_overlapping filters reach the same factories [read by the finder in Elasticsearch's IntervalsSourceProvider; not executed].
Summary
You index 'visit new york' and run within(visit, 1, R) and notWithin(visit, 1, R) with R = or(york, "new york"), and you expect exactly one of them to return 'visit', because the javadoc defines notWithin as the intervals that do not appear within n positions of the reference. Instead both return the same interval [0,0]: within is built on containedBy, which pulls the or() apart (LUCENE-8477) and sees 'new york' right next to 'visit', while notWithin, overlapping and nonOverlapping read the default or() as one minimized source, which drops 'new york' because that interval contains 'york'. In the same way containedBy(new, R) matches 'new york' but overlapping(new, R) does not, and maxgaps(0, overlapping(new, R)) does match, because the nested form does pull the reference apart.
Introduced
f1782d0dd1195c823c79aab87529ebd7e8217b95 (master, released from 9.0.0) and its branch_8x backport 2571bf355f82e193d50ec2509f83ba698b262562 (the first released form, in 8.1.0), both 2019-03-27: 'LUCENE-8477: Automatically rewrite disjunctions when internal gaps matter' (apache/lucene-solr#620 by romseygeek; the JIRA issue is now apache/lucene#9523). It gave containedBy (ContainedByIntervalsSource.build) the reference pull-up and extend() a pullUpDisjunctions(), so within = containedBy(source, extend(reference)) tests every alternative, but rewrote overlapping and nonOverlapping as plain new OverlappingIntervalsSource / NonOverlappingIntervalsSource constructors (and notWithin as NonOverlapping over extend) without a top-level pull-up; OverlappingIntervalsSource.pullUpDisjunctions runs only when the filter is nested [exec: commit-level A/B with the intervals sources of the branch_8x parent b08566cacc7 and of 2571bf355f8 over the 8.1.0 jars: parent consistent, commit affected 4 of 4] [read: git blame on main; the nonOverlapping and overlapping bodies are f1782d0dd11, the notWithin body is that line reformatted by LUCENE-9570 (8ef6a0da568), within is unchanged since LUCENE-8629 (7d34bfdaad3)]. A regression of the relations' mutual consistency: 8.0.0 is consistent because every relation read the same minimized or() (2019-03-27 (author date of f1782d0dd11 and of the branch_8x backport 2571bf355f8))
First affected version
8.1.0 [exec] (8.1.0 and 8.1.1 shipped the intervals package in lucene-sandbox as org.apache.lucene.search.intervals; LUCENE-8856 moved it to lucene-queries in 8.2.0). Last unaffected release 8.0.0 [exec]: there within, containedBy and the overlap filters all read the minimized or() and agree (all miss the 'new york' alternative). API age: nonOverlapping, notWithin and containedBy since 7.4.0 (LUCENE-8196), within and overlapping since 8.0.0 (LUCENE-8629), the fn: query-text functions since 9.1.0 (LUCENE-10223) [read]
Affected versions
every release from 8.1.0 through 10.5.2 and 10.6.0 (its intervals sources at branch_10_6 RC1 4ae946e5a9b [exec], below) [exec: the follow-up's history probe shows all four contradictions (within and notWithin both on 'visit new york' and on 'new york city hall', containedBy without overlapping, containedBy and nonOverlapping on 'new york'), with single-alternative controls passing, on each of the 59 releases 8.1.0, 8.1.1, 8.2.0-8.11.4, 9.0.0-9.12.3 and 10.0.0-10.5.2; the finder JUnit fails 4 of 4 on 10.5.2, 10.0.0 and 9.12.3 (it does not compile against the 9.0.0 test framework, so 9.0.0 is probe-only); the independent reproduction's own JUnit fails 4 of 7, its 3 controls passing, on 10.5.2 and 9.12.3; the intended-behaviour check's probes fail on 10.5.2]; branch_10_6 head 4ae946e5a9b [exec: its intervals sources over the 10.5.2 core, 4 of 4], so the released 10.6.0 has it; main a9779ef3c193 [exec, -ea and -da]; upstream main 20ced71c1e6 [read: no intervals commit after f4cc6ac2097 (#16899), which a9779ef3c193 contains]. Not affected: 8.0.0 [exec]; 7.4.0 and 7.7.3 for the only pair that existed then, containedBy / nonOverlapping [exec]. All release runs on JDK 25
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, 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 but not certain, searched 2026-10-28 plus the finder's scripted prior-report searches and web leg, read-only; 224 recorded queries): three scripted prior-report searches (identifiers; symptoms and the flexible query parser; downstream) over apache/lucene issues and PRs, commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 27 GitHub GraphQL searches including apache/lucene-solr; 7 manual JIRA and 14 web queries (2 throttled); the changed files of all 411 open apache/lucene PRs; the 73 issues and PRs updated since 2026-10-26; the intervals commit history on main, branch_10x and branch_10_6 by author date. Not fixed upstream: no open PR touches Intervals.java, OverlappingIntervalsSource.java or NonOverlappingIntervalsSource.java, and the newest intervals commit on main is f4cc6ac2097 (#16899, author date 2026-10-26, contained in a9779ef3c193). Nearest items, not reports: #9523 / LUCENE-8477 and apache/lucene-solr#620 (the rewrite for phrase, maxgaps and the four containment operators; the overlap filters are never mentioned), #9243 / LUCENE-8196 (2018 comment: containedBy over an OR loses a match, the case LUCENE-8477 fixed), #9675 / LUCENE-8629 (added within and overlapping as inverses of notWithin and nonOverlapping), #10244 / LUCENE-9204 (maintainers on the rewrite as needed for correctness), #11442 / #11443 (a different, fixed or/extend/containing bug). Re-check (2026-10-28, gh read-only): no apache/lucene issue or PR created or updated since 2026-10-26 concerns intervals except the merged #16899; upstream main head 20ced71c1e6 and branch_10_6 have no later intervals commit
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree, assertions on and off, and the standalone JUnit runner with several seeds); 59 release jars from 8.1.0 to 10.5.2 and the clean 8.0.0, 7.4.0 and 7.7.3 (history probe; the JUnit also on 10.5.2, 10.0.0 and 9.12.3); branch_10_6 4ae946e5a9b (its intervals sources over 10.5.2); the LUCENE-8477 branch_8x commit and its parent over the 8.1.0 jars; options A and A2 on a9779ef3c193. JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:459-470 (at a9779ef3c193) (nonOverlapping javadoc 'Returns intervals of the minuend that do not overlap with intervals from the subtrahend'; body new NonOverlappingIntervalsSource), :472-480 (overlapping: new OverlappingIntervalsSource, no pull-up), :482-497 (notWithin javadoc :485 'do not appear within a set number of positions of intervals from the subtrahend query'; body NonOverlapping over extend), :499-511 (within javadoc :500 'appear within a set number of positions of intervals from the reference'; body containedBy(source, extend(reference))), :559 (containedBy), :47 (class note on the rewrite), :121 and :130-131 (or() 'Automatically rewrites if wrapped by an interval source that is sensitive to internal gaps'; rewrite=false 'can miss valid hits due to minimization')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/ContainedByIntervalsSource.java:28-29 (at a9779ef3c193) (build: Intervals.or(Disjunctions.pullUp(big, ...)))
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/OverlappingIntervalsSource.java:79-83 (at a9779ef3c193) (pullUpDisjunctions splits source and reference, used only when the filter is nested)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/DisjunctionIntervalsSource.java:231 (at a9779ef3c193) (the iterator drops a queued interval that contains the current one)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21 (at a9779ef3c193) ('experimental classes')
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:542-566 (at a9779ef3c193) (fn:notWithin) and :673-690 (fn:overlapping)
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:467-470 (at a9779ef3c193) (nonOverlapping), :478-480 (overlapping), :493-497 (notWithin), :508-511 (within), :551-561 (containedBy)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/ContainedByIntervalsSource.java:28-30 (at a9779ef3c193) (build: pull-up of big)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/DisjunctionIntervalsSource.java:147-152 (at a9779ef3c193) (pullUpDisjunctions only when rewrite is on), :222-247 (nextInterval drops containing intervals, contains())
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Disjunctions.java:66-95 (at a9779ef3c193) (pullUp of one source; splitDisjunctions groups alternatives with minExtent 1)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/ExtendedIntervalsSource.java:126-143 (at a9779ef3c193) (minExtent; pullUpDisjunctions passes the split through)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/OverlappingIntervalsSource.java:78-83 (at a9779ef3c193) (pullUpDisjunctions splits both operands); NonOverlappingIntervalsSource.java:37-39 (returns Collections.singletonList(this))
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteredIntervalsSource.java:31-36 (at a9779ef3c193) (maxGaps maps over in.pullUpDisjunctions())
Who is affected
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:513-566 (at a9779ef3c193) (fn:within, fn:notWithin), :673-710 (fn:overlapping, fn:nonOverlapping)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:121 (at a9779ef3c193) (or() rewrites by default), :131 (or(false, ...) 'can miss valid hits due to minimization')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21 (at a9779ef3c193) ('experimental classes')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/ExtendedIntervalsSource.java:126-131 (at a9779ef3c193) (extend raises minExtent, so within / notWithin split an or() of single words)
Why it looks unintended
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:43, (at a9779ef3c193) 47-54, 131, 462, 473, 485, 500, 554 (javadoc quotes)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:542-566 (at a9779ef3c193) (fn:notWithin), :673-690 (fn:overlapping)
  • lucene/CHANGES.txt:6502-6506 (at a9779ef3c193) (LUCENE-8477 entry)
  • GitHub apache/lucene#10244 comments, read 2026-10-28 (romseygeek and jimczi quotes)
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:925-933, (at a9779ef3c193) TestIntervalQuery.java:150-158, 182-187; lucene/highlighter/src/test/org/apache/lucene/search/matchhighlight/TestMatchRegionRetriever.java:382-384, TestMatchHighlighter.java:423-455; lucene/queryparser/src/test/org/apache/lucene/queryparser/flexible/standard/TestStandardQPEnhancements.java:137-145
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:347-349, (at a9779ef3c193) 381-383 ('not required to return or highlight all matching interval alternatives')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteredIntervalsSource.java:31-36, (at a9779ef3c193) BlockIntervalsSource.java:30-35 (maxgaps and phrase rewrite their child at construction)
Suggested fix
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Disjunctions.java:33-62 (at a9779ef3c193) (list pullUp with the max-clause guard at :43-44), :66-73 (single-source pullUp, no guard); DisjunctionIntervalsSource.java:46-48 (or of one source returns it), :58-67 (HashSet), :147-152 (or(false) never split); ExtendedIntervalsSource.java:140-142 (Set)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/OverlappingIntervalsSource.java:78-83 (at a9779ef3c193) (nested pull-up of both operands); FilteredIntervalsSource.java:31-36 (maxGaps maps over in.pullUpDisjunctions())
History
  • a Lucene clone: git tag --contains 2571bf355f8 (branch_8_1 onwards)
  • GitHub apache/lucene commits API, read 2026-10-28 (newest intervals commit f4cc6ac2097; main head 20ced71c1e6)

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

Repro files

About the diagrams

A step-by-step picture of the one-document example: how within and notWithin are built, how the or() read as one source drops "new york", and where both keep visit. The PNG is the summary still. Made in parallel with this doc from the code at a9779ef3c193 and the executed probe. The card's last line ("Fix sketch") describes the finder's sketch, option A; the recommended option A2 builds the same chain for nonOverlapping / notWithin and, for overlapping, also splits the source (see Suggested fix).

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.