All findings · Lucene finding · LFV-291 · not-contained-by-or-rewrite-negates-wrongly

Intervals.notContainedBy over an OR keeps matches that lie inside one of the OR's clauses but not another

Intervals.notContainedBy(york, or("new york", "york city")) should keep only a york in neither phrase, but Lucene rewrites it into "not in new york, or not in york city", so a york inside one phrase still matches: 3 hits instead of 1. Silent, default settings, 8.1.0 to 10.6.0 (10.6.0 by reading) and main.

Summary

Priority
Medium, rank 41Since 8.1, notContainedBy over an OR containing a phrase silently over-matches on ordinary text, and Elasticsearch and OpenSearch filters expose it, so it heads the intervals rows.
Impact
silent wrong results
Since
8.1.0 (LUCENE-8477, 2019)introduced 2019-03-27 · about 7 years
Affected
8.1.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
bounded-exhaustive check against a reference semantics

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

The finding in 30 seconds

An interval is the range of positions a source matches. notContainedBy(small, big) keeps the intervals of small that lie inside no interval of big. By default Lucene pulls an OR out of such operators: one copy per clause, ORed. That is right for "contained by", but "not inside (B1 or B2)" means not inside B1 and not inside B2.

Summary card titled "Lucene: Intervals.notContainedBy(small, or(B1, B2)) keeps matches that lie inside only one of B1 and B2". Panel 1: the query notContainedBy(term(york), or(phrase(new, york), phrase(york, city))) on docs 0 "i love new york", 1 "york city is big", 2 "york is here"; expected hits [2] (outside both phrases), actual hits [0, 1, 2]. Panel 2: the built query or(NOT_CONTAINED_BY(york,BLOCK(new,york)),NOT_CONTAINED_BY(york,BLOCK(york,city))) as a table per document: branch 1 (not in "new york") drops york in doc 0 and keeps it in docs 1 and 2; branch 2 (not in "york city") keeps york in docs 0 and 2 and drops it in doc 1; the OR matches all three, while only doc 2 should match. Fix sketch: chain the filters, giving hits [2]. The history line: introduced by LUCENE-8477 (2019-03-27), first released in 8.1.0 (8.0.0 gives hits [2] here); executed wrong on 8.1.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main.
The york example: the OR is pulled out of the excluded side into one filter per phrase, each filter checks york against one phrase only, and their OR brings docs 0 and 1 back; chaining the filters keeps only doc 2. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index three documents: 0 "i love new york", 1 "york city is big", 2 "york is here". Search notContainedBy(york, or(phrase(new, york), phrase(york, city))), meaning "york, but not as part of new york or york city".
  2. You expect 1 hit, doc 2: doc 0's york (position 3) lies inside "new york" (positions 2-3), and doc 1's york (position 0) inside "york city" (positions 0-1). Chaining two filters, or using or(false, ...), returns exactly doc 2.
  3. Instead you get 3 hits. By default Lucene splits the OR and builds or(notContainedBy(york, "new york"), notContainedBy(york, "york city")). The first filter keeps docs 1 and 2, the second keeps docs 0 and 2, and their OR keeps all three.
  4. So a york inside one phrase survives the other phrase's filter, and adding a phrase adds hits: notContainedBy(a, "c a") on "c a" gives 0 hits, notContainedBy(a, or("c a", b)) gives 1. No error, with assertions on or off.

Who is affected

Anyone who excludes with notContainedBy over an OR of two or more distinct clauses, one longer than one word: a phrase, ordered, extend, a multi-word synonym that keeps the original token, or a parsed fn:or(...). No configuration needed; two or three documents suffice; 8.1.0 to 10.6.0 (10.6.0 by reading) and main. Silent over-matching, never beyond the small side's hits.

Mechanism, terms and numbers678 words

Intervals.notContainedBy(small, big) returns the intervals of small that do not appear within intervals of big. When big is an Intervals.or(...) that splits into two or more groups (in practice at least two distinct clauses, one of them longer than one position: a phrase, ordered, extend, a multi-word synonym that keeps the original token), the default disjunction rewrite of LUCENE-8477 pulls the OR out of the excluded side and ORs one filter per clause. That computes "not inside B1, or not inside B2" where the operator means "not inside B1 and not inside B2", so an interval inside one clause is still returned by the other clause's filter. "york, but not in new york or york city" returns 3 documents instead of 1, and adding a clause to the exclusion adds hits. Chained filters, or or(false, ...), give the documented answer. It is silent, needs no configuration, is reachable from the flexible query parser and through a multi-word synonym that keeps the original token, with no user-written OR. It is in every release from 8.1.0 to 10.6.0 and on main (8 of them run, the rest by git ancestry, 10.6.0 by reading); 8.0.0 gives the documented answer.

Terms

  • Interval: the range of positions [start, end] where an IntervalsSource matches in a document. york in "i love new york" is [3,3]; phrase(new, york) is [2,3].
  • notContainedBy(small, big): keeps each interval of small that does not lie inside any interval of big. Its javadoc: "Returns intervals from the small IntervalsSource that do not appear within intervals from the big IntervalsSource" (Intervals.java:539-540).
  • Disjunction rewrite (LUCENE-8477, on by default): operators that are sensitive to internal gaps pull an OR among their sources to the top, FUNC(a, or(x, y)) => or(FUNC(a, x), FUNC(a, y)) (Disjunctions.java:28-31, class note Intervals.java:47-54), so that a longer clause is not "minimized away". It is exact where OR distributes: containedBy(small, or(B1, B2)) is containedBy(small, B1) or containedBy(small, B2). Intervals.or(false, ...) turns it off.

The bug

  1. Intervals.notContainedBy calls NotContainedByIntervalsSource.build (Intervals.java:545-547), which returns Intervals.or(Disjunctions.pullUp(subtrahend, s -> new NotContainedByIntervalsSource(minuend, s))) (NotContainedByIntervalsSource.java:27-30).
  2. Disjunctions.pullUp (Disjunctions.java:66-73) splits the excluded OR with splitDisjunctions (:79-95): all clauses with minExtent() == 1 go into one group, every other clause (in practice a longer one) into its own group. An OR with a single distinct clause has already been collapsed to it (DisjunctionIntervalsSource.java:45-49). With two or more groups, build makes one filter per group and ORs them.
  3. An OR returns an interval when any branch returns it. A branch filters against one group only. When that group does not occur in the document, RelativeIterator.reset marks it absent (RelativeIterator.java:58-61) and the filter returns the small source's intervals unchanged (NotContainedByIntervalsSource.java:73-75); when it has no iterator in the segment at all, DifferenceIntervalsSource.java:44-47 does the same. So doc 0's york, removed by "not inside new york", comes back through "not inside york city".
  4. The operator needs the intersection of the filters (for clauses that do not nest, De Morgan: not (inside B1 or inside B2) = not inside B1 and not inside B2); the built query computes their union.
  5. The other containment operators pull an OR up only from positive positions, where OR distributes: containedBy and containing from their big side, notContaining from its minuend, the side it keeps (ContainedByIntervalsSource.java:28-30, ContainingIntervalsSource.java:31-33, NotContainingIntervalsSource.java:27-30). notContainedBy alone pulls one out of the side it subtracts.

The numbers

query, on docs 0 "i love new york", 1 "york city is big", 2 "york is here"hits
notContainedBy(york, phrase(new, york))[1, 2]
notContainedBy(york, phrase(york, city))[0, 2]
notContainedBy(york, or(phrase(new, york), phrase(york, city))), built as or(NOT_CONTAINED_BY(york,BLOCK(new,york)),NOT_CONTAINED_BY(york,BLOCK(york,city)))[0, 1, 2] (the union)
chained notContainedBy(notContainedBy(york, phrase(new, york)), phrase(york, city))[2] (the intersection)
notContainedBy(york, or(false, phrase(new, york), phrase(york, city)))[2]

A york inside both phrases ("new york city") stays excluded. An OR of single terms, or(term(new), term(city)), is built as one filter, NOT_CONTAINED_BY(york,or(city,new)), and is correct. Main with -ea and -da, and 10.5.2.

Exposure and likelihood393 words
  • The natural use: "term X, but not as part of phrase P1 or P2". A Stack Overflow question asks for exactly this with span queries.
  • Executed trigger paths (main, also 10.5.2): a user-written Intervals.or of phrases; the flexible StandardQueryParser strings fn:notContainedBy(b fn:or("a b" "b c")), parsed to or(NOT_CONTAINED_BY(b,BLOCK(a,b)),NOT_CONTAINED_BY(b,BLOCK(b,c))), and fn:notContainedBy(york fn:or("new york" "york city")); with no user-written OR, Intervals.analyzedText("nyc", ...) with a query-time multi-word synonym that keeps the original token (nyc => nyc, new york city), which builds or(BLOCK(new,york,city), nyc); an OR of single terms wrapped in extend(..., 0, 1). A replace-only synonym (nyc => new york city) builds BLOCK(new,york,city) with no OR and is correct.
  • Not affected: an OR of single terms only (one group); Intervals.notWithin (by code reading: no pull-up, Intervals.java:491-495); containedBy, containing and notContaining.
  • Downstream, read only (nothing run): Elasticsearch and OpenSearch map the intervals query's not_contained_by filter to Intervals.notContainedBy and any_of to the rewriting Intervals.or; the Solr 10.1 reference guide documents an intervals parser with any_of and not_contained_by.
  • Default vs opt-in: no configuration needed. The plain-Java diagram probe uses the default codec, IndexWriterConfig and IndexSearcher; the JUnit fails under the test framework's randomized codecs; assertions on or off. The intervals API itself is opt-in and less used than boolean and phrase queries, and its package is marked experimental (package-info.java:21).
  • Scale: no minimum; two or three short documents suffice (every run used one segment; multi-segment indexes were not run). A document is wrongly returned when every occurrence of the small term lies inside at least one group and at least one occurrence lies outside at least one group. With two phrases, a document is excluded only where every occurrence lies inside both phrases at once; how much that changes real result sets was not measured.
  • Damage: silent over-matching and inflated counts; adding a clause to the exclusion adds hits. Every branch returns only intervals of the small source, so the extra hits stay within the small source's hits. The same source drives matches(), so highlighting sees the same extra intervals (by code reading, not tested). No exception, crash or index damage.
  • How likely: plausible (a judgement, not measured): ordinary text triggers it, but notContainedBy over an OR of phrases is a niche query shape.

Minimal repro

The test shows that Intervals.notContainedBy(a, or(phrase(c, a), b)) returns the "a" of the document "c a", although that "a" lies inside "c a", one clause of the excluded OR (the default rewrite ORs one filter per clause).

TestNotContainedByOrRewrite.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 org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestNotContainedByOrRewrite extends LuceneTestCase {

  public void testTermInsideOneClauseOfOrIsExcluded() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new TextField("f", "c a", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IntervalsSource source =
            Intervals.notContainedBy(
                Intervals.term("a"), Intervals.or(Intervals.phrase("c", "a"), Intervals.term("b")));
        IndexSearcher searcher = new IndexSearcher(reader);
        // expected: "a" lies inside "c a", so 0 hits; actual: 1 hit
        assertEquals("hits of " + source, 0, searcher.count(new IntervalQuery("f", source)));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-291/, 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.TestNotContainedByOrRewrite
Expected vs actual
Expected: 0 hits. Actual (-ea and -da): 1 hit, from the rewritten source or(NOT_CONTAINED_BY(a,BLOCK(c,a)),NOT_CONTAINED_BY(a,b)).

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

More files, environment, full output36 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: hits of or(NOT_CONTAINED_BY(a,BLOCK(c,a)),NOT_CONTAINED_BY(a,b)) expected:<0> but was:<1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.queries.intervals.TestNotContainedByOrRewrite.testTermInsideOneClauseOfOrIsExcluded(TestNotContainedByOrRewrite.java:45)

Why it looks unintended

  • The javadoc: notContainedBy "Returns intervals from the small IntervalsSource that do not appear within intervals from the big IntervalsSource"; no exception for an OR.
  • The docs call the rewrite the accurate mode, or(false, ...) the less accurate one; here or(false, ...) is right.
  • 8.0.0 gives the repro's documented answer; notContainedBy's only rewrite test checks the query's shape, not its matches.
The full argument264 words
  • Contracts. Intervals.notContainedBy (Intervals.java:539-540); the package doc, "Returns intervals that are not contained by another interval" (package-info.java:60-61); the flexible parser doc, "Matches intervals of the source that are not contained by intervals of the reference" (queryparser/.../nodes/intervalfn/package-info.java:596). None makes an exception for an OR.
  • The rewrite is the accuracy mode. "if you care more about speed than about accuracy you can use the or(boolean, IntervalsSource...) factory method to prevent rewriting" (Intervals.java:53-54); or(false, ...) "may run more efficiently, but can miss valid hits due to minimization" (Intervals.java:130-131); CHANGES for LUCENE-8477: "Interval disjunctions could miss valid hits ... This behaviour can be disabled if users are more interested in speed rather than accuracy of matching" (lucene/CHANGES.txt:6486-6490).
  • Wrong interval matches are bugs: LUCENE-10140, "Fix cases where minimizing interval iterators could return incorrect matches" (lucene/CHANGES.txt:4974-4975).
  • Tests. TestDisjunctionRewrites#testNotContainedBy (TestDisjunctionRewrites.java:201-213) asserts the OR-of-filters structure only, with the comment "the 'big' interval should not be minimized, the 'small' one should be". No upstream test checks the matches of notContainedBy with an OR as the big source (TestIntervalQuery#testNotContainedByQuery uses an unordered big source).
  • History. 7.4.0-8.0.0 filtered against the whole OR and give the documented answer. LUCENE-8477 aimed at matches missed through minimization (#9243 asked for "not losing matches by adding disjunctions"); its issue and PR never discuss the negated side. The rewrite's author later said intervals rewrite disjunctions "so that queries behave as your or I would expect them to" (#10244).
  • Monotonicity. Adding a clause to an exclusion can only remove hits; here it adds them.
The devil's-advocate check164 words

The strongest case for "intended" (rated moderate by the refute review and the "works as designed" review, weak by the devil's-advocate verifier): the pull-up is deliberate LUCENE-8477 code, TestDisjunctionRewrites#testNotContainedBy pins exactly this OR shape, the package is experimental, and users can opt out with or(false, ...).

Why it fails: the test checks structure, not matches, and its stated goal (do not minimize the big side) is also met by chaining the same groups. The "pull up the big side" rule is sound for notContaining, where the big side is the one kept, and wrong for notContainedBy, where it is subtracted. No javadoc, CHANGES, MIGRATE entry or discussion gives notContainedBy over an OR the meaning "outside any one clause". "Experimental" allows API changes, not wrong results. The opt-out is documented as the less accurate mode, and here it is the accurate one. The repro's clauses do not nest, so nothing is minimized away and 8.0.0 gave the expected answer. Verdict: a defect.

Suggested fix

Recommended Chain one NotContainedBy filter per pulled-up clause (recommended)

In NotContainedByIntervalsSource.build, chain one filter per pulled-up clause, notContainedBy(notContainedBy(small, B1), B2), instead of OR-ing them (+9/-2), and update the structural test. The repro, 195 tests in the interval, flexible-parser and highlighter suites, the whole queries module and the static checks pass. Dropping the pull-up instead breaks nested clauses.

static IntervalsSource build(IntervalsSource minuend, IntervalsSource subtrahend) {
  // An interval of the minuend is excluded if it lies inside any disjunct of the subtrahend, so
  // chain one filter per disjunct: OR-ing the filters would keep an interval that lies outside
  // just one of them.
  IntervalsSource result = minuend;
  for (IntervalsSource disjunct : Disjunctions.pullUp(subtrahend, Function.identity())) {
    result = new NotContainedByIntervalsSource(result, disjunct);
  }
  return result;
}
All options and trade-offs3 options · 532 words

One fix was built and validated in a scratch worktree at ea57360912ed; one alternative was run to show why it is not enough. Workaround in every affected release: nest notContainedBy once per clause.

Chain one NotContainedBy filter per pulled-up clause (recommended) (validated by the checks)

Keeps the pull-up groups, so the big side is still not minimized (the rewrite's stated goal), and requires an interval to pass every group's filter: outside B1 and outside B2. TestDisjunctionRewrites#testNotContainedBy changes to expect notContainedBy(notContainedBy(or(phrase("a", "b"), term("a")), term("d")), phrase("c", "d")). Diffs: chain-filters.diff, structural-test-update.diff, fix-with-test.diff (applies cleanly at ea57360912ed).

static IntervalsSource build(IntervalsSource minuend, IntervalsSource subtrahend) {
  // An interval of the minuend is excluded if it lies inside any disjunct of the subtrahend, so
  // chain one filter per disjunct: OR-ing the filters would keep an interval that lies outside
  // just one of them.
  IntervalsSource result = minuend;
  for (IntervalsSource disjunct : Disjunctions.pullUp(subtrahend, Function.identity())) {
    result = new NotContainedByIntervalsSource(result, disjunct);
  }
  return result;
}

Trade-offs

  • The built query's structure, toString and equals change. An OR of filters compared its branches as a set (DisjunctionIntervalsSource.java:108-118); a chain compares in order, and that order comes from a hash set, so two queries that differ only in clause order might not be equal. That matters to anything that relies on equals and hashCode (the query cache, deduplication, structural tests). Not tested.
  • Cost: one filter per group as before, but the small source is iterated once through nested filters instead of once per OR branch. No benchmark was run.
  • Zero groups (by code reading, not tested): notContainedBy(small, or()) with an empty rewriting OR gives no matches today and would return small unchanged with the fix.
  • Every validation run used a single segment.
  • Not reviewed by a committer.

What the checks validated

  • Stock: the doc's test fails 2 of 2 and the nested-clause probe 1 of 1; with the fix all pass.
  • org.apache.lucene.queries.intervals.* (with the updated structural test, the new test and the probe), TestStandardQP, TestStandardQPEnhancements and TestMatchHighlighter: 12 classes, 195 tests, 0 failures, assertions on and off.
  • The whole queries module: 53 classes, 515 tests, 0 failures, 5 skipped.
  • :lucene:queries:check -x test passes (google-java-format, ecjLint, forbiddenApis).
  • Every earlier hunt test with the patched class shadowed, fresh seeds: the finder's 2, the independent reproducer's 3 + 2 (parser string, extend), the devil's advocate's 4 (synonym, parser), the GPT-5.6 review stage's 2 (including the nested case) and the security probe's 4: all pass with -ea, and the security probe also with -da.

No pull-up on the excluded side (back to the 8.0.0 behaviour) (validated by the checks)

return new NotContainedByIntervalsSource(minuend, subtrahend); (option-no-pullup.diff).

Trade-offs

Minimizes the big side again: or("x y z", y) reduces to y, so x inside "x y z" is kept, the gap LUCENE-8477 set out to close. Simpler, but not enough.

What the checks validated

Passes the doc's 2 tests; fails the nested-clause probe: NOT_CONTAINED_BY(x,or(BLOCK(x,y,z),y)) expected:<1> but was:<2>.

Document the current meaning

Say in the javadoc that notContainedBy over a split OR means "outside at least one clause".

Trade-offs

Contradicts the existing javadoc, the package and parser docs, and makes exclusions non-monotonic. Not recommended.

Downstream note

Downstream: used as an allow or visibility rule, it shows a document whose term sits inside only one excluded phrase, and anyone who can write documents can place it so (4 visible instead of 2 in the probe). Bounded by the small side's hits; as a MUST_NOT deny rule it hides more instead. No exploit or such deployment is known.

How it was found

Semi-formal. While writing an independent reference semantics for interval operators, an AI tool suspected the rewrite. A bounded-exhaustive harness comparing Lucene's real operators with that reference on every tiny document then failed 300,120 of 1,436,292 negated-OR checks on main and 0 of 248,076 in a smaller run with the fix. A deterministic public-API JUnit followed.

The method in full177 words

Method: bounded-exhaustive translation validation (the hunt's semi-formal tier; no model checker, SMT solver or prover).

  • In a hunt unit (spans and intervals, 2026-10-26) the finder first suspected the defect by reading the callers of pullUpDisjunctions while writing IvRef, an exact reference semantics for interval operators.
  • The harness IvTV runs the real interval sources of the main jars against IvRef over every document of up to 6 positions over {a, b, c} (and up to 4 positions with stacked tokens), for every interval expression of 1-2 operator levels: 1,401,702,324 checks on separate build machines.
  • Its rewrite check for a negated filter over an OR (the reference takes the excluded side as the plain union of the clauses) failed 300,120 of 1,436,292 checks on stock main and 0 of 248,076 with the fix shadowed, in a smaller run (287,753,697 checks in all). That count also includes nesting shapes, so it is the signal, not a measure of the regression.
  • The finder then wrote a deterministic public-API JUnit; the independent checks, history sweep, prior-report search and security screen followed.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): 164 read-only queries over GitHub issues and PRs, commits, the dev@, java-user@, issues@ and commits@ lists, JIRA, Elasticsearch, OpenSearch and Solr issues, open-PR file lists and the web. Closest: LUCENE-8477 itself, which discusses only missed matches.

Searches5 searches

Verdict new. No issue, PR, commit, list thread, JIRA entry or downstream report describes notContainedBy over a split OR returning intervals that lie inside one of its clauses. No open PR touches NotContainedByIntervalsSource, Disjunctions, DifferenceIntervalsSource or TestDisjunctionRewrites (file scan of 2026-10-26 morning; a later check of recently updated open PRs found only an unrelated intervals change, #16899).

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene and apache/lucene-solr issues and PRs (migrated LUCENE-NNNN included)2026-10-263 runs of a scripted prior-report search plus 15 manual searches (prose phrasings too, since issue search does not index code)only #9523 (LUCENE-8477, the introducing change), #9243 (LUCENE-8196, the positive-position mirror) and #10244 (LUCENE-9204) touch the topic; none is this bug
Commits and per-file history2026-10-26scripted prior-report search; git log --follow, -S and CHANGES.txt for the four filesonly the LUCENE-8477 commits; no behavioural change since 2019-03
Open PRs2026-10-26all 413 open apache/lucene PRs listed; the file lists of the queries-module PRs readnone touches the four files; #16899 and #16851 touch nearby files without semantic change
Mailing lists and JIRA2026-10-26dev@, java-user@, issues@, commits@ (Lucene) and users@, dev@ (Solr); LUCENE and SOLR JIRA archiveno report; LUCENE-10406/10407 (a different containing() bug, fixed in 9.1) and SOLR-13764 (a Solr intervals parser, a reach path) read
Downstream and the web2026-10-26Elasticsearch, OpenSearch and Solr issues; 13 web queriesno report; Stack Overflow 76220733 asks for the same exclusion and solves it with spans

History

Introduced by LUCENE-8477 (merged 2019-03-27, first released in 8.1.0), which replaced one filter against the whole OR with an OR of per-clause filters. 8.0.0 gives the documented answer; 8.1.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main are wrong (executed), and git ancestry puts every release in between in the range. A regression; not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-8477 "Automatically rewrite disjunctions when internal gaps matter" (apache/lucene-solr#620, issue #9523, by @romseygeek, approved by @jimczi, merged 2019-03-27): f1782d0dd11 on the main line (first release 9.0.0) and the backport 2571bf355f8 (first release 8.1.0). Before it, notContainedBy built one difference filter against the whole big source. Later commits only moved the package to the queries module (8.2.0), reformatted it and made code cleanups. No commit-level bisection; the 8.0.0/8.1.0 jars bracket it and the built query's shape changes there. branch_10_6, from which 10.6.0 was released on 2026-10-29, and the unreleased branch_10x carry the same build(), so 10.6.0 is affected by reading and a fix should go to both branches too. Upstream main 49483e82b357 (2026-10-26) has no intervals change since ea57360912ed.

Scope: for an OR whose clauses nest, such as or("x y z", y), 8.0.0 and or(false, ...) were already wrong through minimization; the regression claim is for clauses that do not nest.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed affectedJUnit 2/2 fail (gradle and harness, -ea and -da); built as or(NOT_CONTAINED_BY(...),NOT_CONTAINED_BY(...))
10.0.0-10.5.2 affected10.0.0 and 10.5.2 executed: 3 hits instead of 1; JUnit 2/2 fail on 10.5.2
9.0.0-9.12.3 affected9.0.0 and 9.12.3 executed; JUnit 2/2 fail on 9.12.3
8.1.0-8.11.4 affected8.1.0 (-ea and -da), 8.1.1, 8.2.0 and 8.11.4 executed (portable main())
7.4.0, 7.7.3, 8.0.0 not affectedbuilt as NOT_CONTAINED_BY(a, or(BLOCK(c,a),b)): documented answer (7.4.0 and 7.7.3 with -ea, 8.0.0 with -ea and -da)
fix (chained filters) on main not affecteddoc's test, the nested probe, 195 suite tests and 515 queries-module tests pass

Independent checks

An independent reproducer, a devil's advocate and an adjudication, each running tests on main, and a "works as designed" review from another model family (OpenAI GPT-5.6, reading only) say real. A refute review that could not run code said "not real" only procedurally. This doc's test was re-run on main, 10.5.2, 9.12.3 and with the fix.

Checks and verdicts319 words

Every check ran on main ea57360912ed; the GPT-5.6 reviews could read code but not run it, and a separate run re-ran the repro.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit 2/2 fail on main (-ea, -da), 10.5.2 and 9.12.3; pass with the fix sketch.
refute review (GPT-5.6, reading only) not real, low (procedural)Could not run code, so it voted not real on procedure only; root cause and inputs agreed, and its notes say the static evidence strongly favours a real defect. A separate run re-ran the repro: 2/2 fail.
Independent reproducer realOwn JUnit from the claim alone: [0, 1, 2, 4] instead of [2, 4]; parser and extend reach; 8.0.0 correct, 8.1.0 and later wrong; passes with the fix.
"works as designed" review (GPT-5.6, reading only) real, mediumBreaks A minus (B1 or B2) = (A minus B1) and (A minus B2); intended argument moderate. A separate JUnit fails 2/2 on main, including the nested case.
Devil's advocate real (intended argument weak)No doc, test or discussion gives the "outside any one clause" meaning; own JUnit 3/4 fail on main and 10.5.2, including the synonym and parser paths.
adjudication confirmed, highRe-ran the independent reproduction's test with a new seed: 2 of 3 fail with -ea and -da; checked the root cause in the source.
doc re-runs (2026-10-26) reproduced; fix validatedThis doc's test fails 2/2 on main (gradle -ea, -da; harness), 10.5.2 and 9.12.3; the fix passes it and 195 + 515 module tests.
fact checks of this doc (2026-10-26) bug holds; wording fixedAn independent fact check re-ran the test on main (-ea, -da, a second seed), 10.5.2, 10.0.0, 9.12.3 and 9.1.0 and a reach probe; a reviewer from another model family (OpenAI GPT-5.6, reading only) checked every claim; a diagram check re-ran the probe. Their corrections are applied.

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

Finding record

LFV-291, snapshot of 2026-11-06

Registry title
Intervals.notContainedBy(small, or(B1, B2)) returns small intervals that lie inside B1 or B2, because the default disjunction rewrite turns it into an OR of per-clause filters
Area
queries / intervals (Intervals.notContainedBy: NotContainedByIntervalsSource disjunction rewrite, LUCENE-8477)
Priority
Medium, rank 41: Since 8.1, notContainedBy over an OR containing a phrase silently over-matches on ordinary text, and Elasticsearch and OpenSearch filters expose it, so it heads the intervals rows.
Impact class
silent wrong results
Impact note
Applications that call Intervals.notContainedBy with an OR as the big (excluded) source that the default rewrite splits into more than one group, which needs at least one clause of minExtent > 1 such as a phrase (written directly, through StandardQueryParser's fn:notContainedBy with fn:or [exec], through a multi-word query-time synonym in Intervals.analyzedText with no user-written OR [exec], or through the Elasticsearch and OpenSearch not_contained_by filter over any_of [read]), get silently over-matching results with the default codec, IndexWriterConfig and searcher, at any index size, with -ea and -da, in every release from 8.1.0 through 10.6.0 (10.6.0 by reading) and on main: a small interval is kept when it lies outside at least one clause instead of outside all of them, so the exclusion is weakened (3 hits instead of 1 in the repro), with no error, crash or index damage; an OR of single terms only is grouped into one clause and is correct.
Summary
You index 'i love new york', 'york city is big' and 'york is here' and search notContainedBy(york, or(phrase(new, york), phrase(york, city))), meaning 'york that is not part of new york or york city'; you expect only the third document, which is what or(false, ...) and the nested form notContainedBy(notContainedBy(york, 'new york'), 'york city') return. Instead all three documents match: the default disjunction rewrite (LUCENE-8477) pulls the OR out of the excluded operand and builds or(NOT_CONTAINED_BY(york, 'new york'), NOT_CONTAINED_BY(york, 'york city')), which computes 'not inside B1 or not inside B2' where the javadoc promises 'not inside (B1 or B2)'. So adding a clause to the exclusion adds hits: notContainedBy(a, or(phrase(c, a), b)) matches 'c a' although notContainedBy(a, phrase(c, a)) does not.
Introduced
f1782d0dd1195c823c79aab87529ebd7e8217b95 (main line, then master; first release 9.0.0) and its branch_8x backport 2571bf355f82e193d50ec2509f83ba698b262562 (the released one, 8.1.0), both 'LUCENE-8477: Automatically rewrite disjunctions when internal gaps matter' (apache/lucene-solr#620, issue migrated as apache/lucene#9523) by @romseygeek, approved by @jimczi [read: the commit replaced 'new DifferenceIntervalsSource(small, big, NOT_CONTAINED_BY)' in Intervals.notContainedBy with the new NotContainedByIntervalsSource.build; git log -S'Disjunctions.pullUp(subtrahend' shows only these two commits; blame of NotContainedByIntervalsSource.java:27-30 gives f1782d0dd11 plus the LUCENE-9570 reformat 8ef6a0da568; the other four branch_8x intervals commits between 8.0.0 and 8.1.0 change no notContainedBy line; exec: 8.0.0 correct, 8.1.0 wrong, with -ea and -da, and the built query changes from NOT_CONTAINED_BY(a, or(BLOCK(c,a),b)) to or(NOT_CONTAINED_BY(a,BLOCK(c,a)),NOT_CONTAINED_BY(a,b)) at that boundary]. A regression; no commit-level bisection was done (the parent of 2571bf355f8 was not built) (2019-03-27 (both commits; apache/lucene-solr#620 merged 2019-03-27T11:23:44Z))
First affected version
8.1.0 [exec] (last unaffected release 8.0.0 [exec], -ea and -da; intervals were in the sandbox module, package org.apache.lucene.search.intervals, until 8.1.x and in the queries module from 8.2.0). The notContainedBy API exists since 7.4.0 (LUCENE-8196); 7.4.0 and 7.7.3 are correct [exec]
Affected versions
every release from 8.1.0 through 10.6.0 (8.1.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2, and 10.6.0 by reading) and main ea57360912ed; not fixed upstream. [exec] wrong: the test-framework-free main() port NcbHistory fails on 8.1.0 (-ea, -da), 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main; the finder's JUnit fails 2 of 2 on 9.12.3, 10.5.2 and main (-ea, -da); the independent reproduction's own main() variant fails on 8.1.0, 8.2.0, 8.11.4 and main. [exec] correct: 7.4.0, 7.7.3 and 8.0.0 (-ea, -da). [read] f1782d0dd11 is an ancestor of every releases/lucene 9.x and 10.x tag, and 2571bf355f8 of every 8.x tag from 8.1.0 to 8.8.1 in the local clone (the 8.9.0-8.11.4 tags are not in the clone; 8.11.4 was executed); upstream main e1c4184cb983 (2026-10-26) adds two commits after ea57360912ed and neither touches intervals. Caveats: release jars were run on JDK 25 only; the regression claim covers ORs whose clauses do not nest; for nesting clauses such as or(phrase(x,y,z), y), over-matching against the literal javadoc already happens on 8.0.0 (minimal-interval semantics) and the rewrite leaves it in place
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: code reading; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record, verdict new: 164 recorded read-only queries, namely 3 runs of the scripted prior-report search with 98 queries over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the LUCENE JIRA archive, and 66 manual queries over apache/lucene and apache/lucene-solr issues and PRs, per-file history, the changed files of all 413 open apache/lucene PRs, Elasticsearch, OpenSearch and Solr issues, the Solr lists, LUCENE and SOLR JIRA and the web; no hit read is the same bug); not fixed upstream: no open PR touches NotContainedByIntervalsSource.java, Disjunctions.java, DifferenceIntervalsSource.java or TestDisjunctionRewrites.java, and upstream main e1c4184cb983 (re-checked 2026-10-26) adds two commits after ea57360912ed (#16895, #16898) that touch no intervals file. Closest items, not reports: the introducing change #9523 / LUCENE-8477 (apache/lucene-solr#620, commit f1782d0dd11), whose description, 17 comments and PR review discuss only matches missed through minimisation and never the negated (subtrahend) position; #9243 / LUCENE-8196, a 2018 comment showing containedBy losing a match when an OR clause is added (the positive-position mirror that motivated LUCENE-8477); #10244 / LUCENE-9204, where the rewrite's author says intervals rewrite disjunctions 'so that queries behave as your or I would expect them to'; #11442 / #11443 (LUCENE-10406/10407), a different containing() iterator bug fixed in 9.1; Stack Overflow 76220733, the same use case (exclude a term inside one of several phrases) solved with spanNot over spanOr, which does no pull-up
Lucene commit tested
tested 2026-10-26 on main ea57360912ed (commit of 2026-10-24): this doc's test through gradle with assertions on and off and through the standalone harness, also on the 10.5.2, 10.0.0, 9.12.3 and 9.1.0 release jars; earlier checks' tests on main, 10.5.2 and 9.12.3; a portable main() on 7.4.0, 7.7.3, 8.0.0, 8.1.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main; the recommended fix in a scratch worktree at ea57360912ed (removed afterwards): 195 tests in 12 interval, parser and highlighter suites with assertions on and off, the whole queries module (515 tests) and the static checks; every earlier hunt test with the fix shadowed.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/NotContainedByIntervalsSource.java:27-30 (at ea57360912ed) (build(): return Intervals.or(Disjunctions.pullUp(subtrahend, s -> new NotContainedByIntervalsSource(minuend, s))), one filter per OR group of the subtrahend, OR-ed together)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:536-547 (at ea57360912ed) (notContainedBy javadoc :539-540 'Returns intervals from the small IntervalsSource that do not appear within intervals from the big IntervalsSource'), :47-54 (class note: the rewrite is the default, or(boolean, ...) prevents it 'if you care more about speed than about accuracy'), :118-151 (or() rewrites by default; or(false) 'can miss valid hits due to minimization')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Disjunctions.java:64-73 (at ea57360912ed) (pullUp), :79-95 (splitDisjunctions groups clauses with minExtent() == 1 into one OR, so only an OR with a clause of minExtent > 1 is split)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/NotContainingIntervalsSource.java:27-30 (at ea57360912ed) (the sibling pulls up its minuend, a positive position)
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestDisjunctionRewrites.java:201-213 (at ea57360912ed) (testNotContainedBy asserts the OR-of-filters structure only; comment 'the big interval should not be minimized')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21 (at ea57360912ed) ('This package contains experimental classes')
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/NotContainedByIntervalsSource.java:27-30
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/Disjunctions.java:28-31, 66-73, 79-95
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/DisjunctionIntervalsSource.java:147-152 (an OR built with the rewrite exposes its clauses), 45-49 and 58-68 (a single distinct clause is collapsed; duplicates removed)
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/RelativeIterator.java:58-61 and NotContainedByIntervalsSource.java:73-75 (group absent from the document: the small source passes unchanged); DifferenceIntervalsSource.java:39-48 (group absent from the segment) and 58-60 (matches)
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:47-54, 539-547
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/ContainedByIntervalsSource.java:28-30, ContainingIntervalsSource.java:31-33, NotContainingIntervalsSource.java:27-30
Who is affected
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/Disjunctions.java:79-95; DifferenceIntervalsSource.java:39-65; package-info.java:21
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:491-495 (notWithin)
Why it looks unintended
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:53-54, 130-131, 146-147, 539-540; package-info.java:60-61
  • Lucene main ea57360912ed: lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:596
  • Lucene main ea57360912ed: lucene/CHANGES.txt:4974-4975, 6486-6490
  • Lucene main ea57360912ed: lucene/queries/src/test/org/apache/lucene/queries/intervals/TestDisjunctionRewrites.java:201-213; TestIntervalQuery.java:219-227
Suggested fix
  • Lucene main ea57360912ed: lucene/queries/src/java/org/apache/lucene/queries/intervals/DisjunctionIntervalsSource.java:58-66, 108-118

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

Repro files

About the diagrams

Made from an executed probe (public API, one segment, main with -ea and -da, and 10.5.2) that prints the intervals and hits of every source in the example. The PNG is the summary card.

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.