All findings · Lucene finding · LFV-830 · intervals-within-notwithin-not-complements-doc

Intervals.within and notWithin are documented as opposites, but a phrase only partly inside the window matches neither

within(X, n, Y) needs all of X inside Y widened by n positions, notWithin(X, n, Y) needs none of X there. So in "the new york times", "new york" is neither within 1 of "times" nor not within it, and the docs, which describe them as opposites, never say so.

Summary

Priority
Low, rank 1230A documentation and API-contract defect where within and notWithin are documented as opposites but are not exact complements for multi-position sources in this little-used pair.
Impact
minor
Since
8.0.0 (LUCENE-8629, 2019-01-07)introduced 2019-01-07 · about 7 years
Affected
8.0.0 to 10.6.0 and main; documentation / API contract
Status
documented
Found by
semi-formal: complement-law checking of the real jar

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

Both factories widen the reference by n positions on each side. within is containedBy that window (Intervals.java:510): the whole source interval must lie inside. notWithin is non-overlap with it (Intervals.java:495-496): no position may touch it. A phrase that overlaps the window without lying inside fails both tests; a single term never can.

Background: interval queries

  1. An interval is a run of token positions in a field. A term is one position; in "the new york times" the phrase "new york" is the interval 1-2.
  2. Intervals.within(source, n, reference) and Intervals.notWithin(source, n, reference) keep or drop the source's intervals depending on how near they are to the reference's intervals; an IntervalQuery matches the documents where at least one interval is left.
  3. The flexible StandardQueryParser offers the same two functions as fn:within(source n reference) and fn:notWithin(source n reference).
Diagram of the bug: the positions 0 to 3 of "the new york times". The reference "times" at position 3, widened by 1, covers positions 2 to 4. The phrase "new york" covers positions 1 and 2: it overlaps the window but is not inside it, so Intervals.within (whole interval inside) and Intervals.notWithin (no overlap) both reject it, and the two queries count 0 + 0.
On "the new york times", "times" widened by 1 covers positions 2-4; "new york" (1-2) touches that window but is not inside it, so within (containment) and notWithin (no overlap) both drop it. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You index "the new york times" (positions: the 0, new 1, york 2, times 3) and run within(phrase(new, york), 1, times) and notWithin(phrase(new, york), 1, times).
  2. The docs describe the two as opposites ("appear within" / "do not appear within a set number of positions"), so you expect exactly one to match, most likely within: "new york" ends right before "times".
  3. Instead both count 0. Both widen "times" by 1 to positions 2-4; within wants all of "new york" (1-2) inside that window, notWithin wants none of it there.
  4. within needs n = 2 for this phrase, although the documented single-term examples match a term right next to the reference at n = 1. The query syntax fn:within / fn:notWithin behaves the same. A single term cannot touch the window without lying inside it, so it never falls in this gap.

Who is affected

Users of within / notWithin (Java API, or fn:within / fn:notWithin in StandardQueryParser since 9.1.0) with a multi-position source such as a phrase. A phrase right next to the reference is missed by within(..., 1, ...), and notWithin(X, n, Y) and MUST(X) MUST_NOT(within(X, n, Y)) return different documents. Default settings; opt-in experimental package.

Mechanism, terms and numbers757 words

Intervals.within(source, n, reference) and Intervals.notWithin(source, n, reference) are documented as opposites: one returns source intervals that "appear within a set number of positions" of the reference, the other those that "do not appear within" it, and the fn:within / fn:notWithin query-parser docs give both the same "maximum positions" parameter. Both widen each reference interval by n positions on both sides, but within keeps a source interval only if it lies entirely inside that window (containedBy), while notWithin drops it if it touches the window at all (non-overlap).

A single term cannot touch the window without lying inside it, so it never falls in this gap (against a single-term reference the pair splits it exactly; with a nested disjunctive reference the hunt's separate LFV-828 can make it match both). For a phrase or near query, an interval that overlaps the window without lying inside it fails both: on "the new york times", within(phrase(new, york), 1, times) and notWithin(...) both count 0, and within needs n = 2. The code has done this since within was added in 8.0.0 and looks deliberate, so this is a documentation / API-contract defect: the proposed fix states the two relations and the gap in the javadoc and the fn: docs, with a one-line behaviour change only as an option.

The mechanism (main 645d4fe14a20)

  1. Both factories widen the reference the same way. within returns containedBy(source, Intervals.extend(reference, positions, positions)) (Intervals.java:508-511); notWithin returns new NonOverlappingIntervalsSource(minuend, Intervals.extend(subtrahend, positions, positions)) (Intervals.java:493-497). extend moves each reference interval's start back by n (clamped at 0, ExtendedIntervalIterator.java:61) and its end forward by n. For "times" at position 3 and n = 1 the window is 2-4.
  2. within tests whole containment. ContainedByIntervalsSource returns a source interval a only when the window b it finds has b.end() >= a.end() (ContainedByIntervalsSource.java:53) and b.start() <= a.start() (:59). "new york" is 1-2 and the window starts at 2, so it is dropped.
  3. notWithin tests no contact. NonOverlappingIterator returns a only when the next window with b.end() >= a.start() (NonOverlappingIntervalsSource.java:72) has b.start() > a.end() (:78). The window starts at 2 and "new york" ends at 2, so it is dropped too.
  4. Partly inside means neither. A single position is either inside a window or apart from it, so a single-term source never falls in this gap; against "times" the pair splits it exactly (executed: the, new, york and times, n = 0 to 3, within + notWithin = 1 every time). Over the same window of a single-term reference, overlapping / nonOverlapping and containedBy / notContainedBy are each exact complements; on the repro document overlapping = 1, nonOverlapping = 0, containedBy = 0, notContainedBy = 1. Only the mixed pair that within / notWithin use leaves a gap. With a disjunctive reference, separate defects change this: with nesting alternatives (LFV-828) notWithin reads fewer reference intervals than within, so a single term can match both (executed: on "visit new york", within(visit, 1, or(york, phrase(new, york))) = 1 and notWithin(...) = 1), and for some or() references LFV-291 breaks containedBy / notContainedBy.

Two shapes

  • A source that straddles the window edge, as above: a phrase directly before or after the reference at n = 1. within(phrase(new, york), 2, times) = 1, so within needs n = 2 where the documented single-term examples use n = 1 for a term right next to the reference ("dog" after "lazy", intervalfn/package-info.java:533-535).
  • A source that contains the reference and sticks out more than n positions past it. On "new york", within(phrase(new, york), 0, york) and notWithin(phrase(new, york), 0, york) both count 0, and MUST(phrase) MUST_NOT(within(...)) counts 1 while notWithin counts 0, so two common ways of writing "X but not near Y" disagree. A containing source that sticks out by at most n is within (within(phrase(new york), 0, phrase(new york)) = 1). So, for one occurrence of the reference, a source that contains it falls in the gap exactly when it starts more than n positions before the reference interval or ends more than n positions after it; any source wider than 2n + 1 around a single-position reference is one such case.

Through the query parser

fn:within and fn:notWithin call the same factories (Within.java:38, NotWithin.java:38). On the docs' own example sentence, "The quick brown fox jumps over the lazy dog", fn:within(fn:phrase(brown fox) 1 jumps) and fn:notWithin(fn:phrase(brown fox) 1 jumps) both count 0; at n = 0 only fn:notWithin matches and at n = 2 only fn:within.

Exposure and likelihood243 words
  • Who. Callers of Intervals.within / Intervals.notWithin with IntervalQuery, and users of fn:within / fn:notWithin in the flexible StandardQueryParser (since 9.1.0), who pass a multi-position source: a phrase, or an ordered or unordered near. A single-term source never falls in this gap; the upstream tests use single-term sources and the fn: doc examples a disjunction of single terms, so none has a multi-position source.
  • Downstream. The Solr 10.1 intervals query parser exposes within / not_within with the same opposite-pair wording; the Elasticsearch and OpenSearch intervals queries do not offer these two filters.
  • Default vs opt-in. Default analyzer, codec and searcher; the relation is decided per source interval inside one document, so index size plays no part (the repro uses one document). The intervals package is opt-in (the queries module) and labelled "experimental classes" (intervals/package-info.java:21).
  • What goes wrong. Fewer hits than the docs describe; splitting a source's matches into "near Y" and "not near Y" silently loses the partly-near ones; an exclusion written two common ways returns different documents. No error, deterministic, nothing written to the index.
  • Age and likelihood. The gap is present from 8.0.0 (2019) through 10.6.0 and on main; the recorded searches found no public report, which suggests that accidental hits are rare (a judgement, not measured).
  • Scope of the evidence. The repro uses a single-term reference with one occurrence; disjunctive, nested or repeated references are not part of this claim (for nesting or() alternatives see the separate LFV-828).

Minimal repro

One JUnit test, one method, one document, with a fixed document and fixed queries: "the new york times" with the default analyzer and codec. It asserts what the docs suggest, that exactly one of within(phrase(new, york), 1, times) and notWithin(...) matches. Public API only, in the intervals test package.

TestWithinNotWithinPartlyNear.java52 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;
import org.apache.lucene.tests.util.TestUtil;

public class TestWithinNotWithinPartlyNear extends LuceneTestCase {

  public void testPhraseNextToReferenceIsWithinOrNotWithin() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
      Document doc = new Document();
      doc.add(new TextField("f", "the new york times", Field.Store.NO));
      w.addDocument(doc);
      try (DirectoryReader r = DirectoryReader.open(w)) {
        IndexSearcher searcher = new IndexSearcher(r);
        IntervalsSource newYork = Intervals.phrase("new", "york");
        IntervalsSource times = Intervals.term("times");
        int within = searcher.count(new IntervalQuery("f", Intervals.within(newYork, 1, times)));
        int notWithin =
            searcher.count(new IntervalQuery("f", Intervals.notWithin(newYork, 1, times)));
        // expected: exactly one of within / notWithin matches the doc; actual: neither (0 + 0)
        assertEquals("within + notWithin", 1, within + notWithin);
      }
    }
  }
}
Command
cp $REPRO/TestWithinNotWithinPartlyNear.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/intervals/
cd $LUCENE
./gradlew :lucene:queries:test --tests org.apache.lucene.queries.intervals.TestWithinNotWithinPartlyNear -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)
Expected
Exactly one of within(phrase(new, york), 1, times) and notWithin(phrase(new, york), 1, times) matches "the new york times".
Observed
Neither matches: within + notWithin expected:<1> but was:<0>, with assertions on and off.

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

More files, environment, full output340 words
  • Seed-independent. One fixed document and fixed queries, a plain IndexSearcher, the default codec pinned, so the result does not depend on the seed: the test framework still randomizes locale, time zone and similarity, and the tie-break's seeds 7EB4EA11 and 0DA0DA01 give the same failure as DEADBEEF. Assertions on and off fail the same way.
  • It transcribes the complement reading of the docs. No Java API text promises that complement in so many words, so the test demonstrates the documentation ambiguity, not a code defect. Under the recommended javadoc fix it becomes obsolete, because the documented contract becomes "a partly-near interval matches neither". A test is unusual for a javadoc-only change; if upstream wants one, keep this same test as a characterization test with the expectation the clarified docs give, assertEquals("within + notWithin", 0, within + notWithin), and the comment "new york [1,2] overlaps the widened times [2,4] but is not inside it, so it matches neither". It passes with the javadoc fix, with assertions on and off. Under the behaviour change (option B) the test as written passes unchanged.
  • Releases. The same file fails unchanged on the 9.1.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 release jars; with only the package line and the test-framework imports adapted it fails on 8.0.0, 8.1.1, 8.2.0, 8.11.4 and 9.0.0. 7.7.3 has no within.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on the 9.1.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 release jars, and (package and imports adapted) on 8.0.0 to 9.0.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestWithinNotWithinPartlyNear.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/intervals/
cd $LUCENE
./gradlew :lucene:queries:test --tests org.apache.lucene.queries.intervals.TestWithinNotWithinPartlyNear -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false (same failure)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestWithinNotWithinPartlyNear > testPhraseNextToReferenceIsWithinOrNotWithin FAILED
    java.lang.AssertionError: within + notWithin expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:3A90972F4189603B]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
        at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:647)
        at org.apache.lucene.queries.intervals.TestWithinNotWithinPartlyNear.testPhraseNextToReferenceIsWithinOrNotWithin(TestWithinNotWithinPartlyNear.java:48)
:lucene:queries:test (FAILURE): 1 test, 1  failure
# -Ptests.asserts=false: the same failure
# documentation fix (option A): changes no code; the same test expecting 0 passes
# behaviour change (option B): BUILD SUCCESSFUL, 1 test

Why it looks unintended

  • The docs present the pair as opposites: "appear within" / "do not appear within a set number of positions", and fn:within / fn:notWithin share one parameter text.
  • #9675, which added within, calls it the "inverse of the already available NOT_WITHIN".
  • No doc or test mentions the partly-near case; the code looks deliberate, so the fix is the docs.
The full argument316 words
  • The summaries use the file's own complement vocabulary. In Intervals.java, "appear within" / "do not appear within" describe containedBy (:554) and notContainedBy (:541-542), an exact complement pair for a non-disjunctive reference (the separate LFV-291 breaks it for some or() references); within and notWithin use the same words (:500-501, :485-486), and so does the package overview (intervals/package-info.java:68-70).
  • The query-parser docs differ only by an emphasized not. fn:within: "Matches intervals of the source that appear within the provided number of positions from the intervals of the reference"; fn:notWithin: the same with "do <em>not</em> appear within" (intervalfn/package-info.java:515-516, :544-545). Both take "an integer number of maximum positions between source and reference" (:526, :555), and the source is a "term or other function" (:524, :553), so a phrase is a documented input.
  • Intent at introduction. #9675 / LUCENE-8629 lists "a WITHIN b - inverse of the already available NOT_WITHIN" and, in the same list, "a OVERLAPPING b - inverse of the already available NOT_OVERLAPPING" (as @romseygeek wrote there). Over a non-disjunctive reference, overlapping and nonOverlapping are exact complements.
  • Nothing documents the gap. No javadoc, overview, fn: doc, CHANGES entry or test says that within needs the whole source inside the widened window, that notWithin rejects any contact, or that a partly-near interval matches neither. The upstream tests use single-term sources only (TestIntervals.java:915-922, TestIntervalQuery.java:182-186), and the fn: doc examples a disjunction of single terms (fn:or(fox dog), intervalfn/package-info.java:533-538), so every exercised source is single-position.
  • The parameter text is ambiguous for a phrase. By the docs' single-term convention ("dog" right after "lazy" matches at 1), "brown fox" right before "jumps" would match at 1; it needs 2. That follows only if the distance is measured to the farthest position of the source, which no doc says.
The devil's-advocate check338 words

Strongest case for "intended" (the study's devil's advocate and a cross-model reviewer, OpenAI GPT-5.6): read pointwise, the Java @param texts describe the code: within's "the maximum distance that intervals of the source may occur from intervals of the reference" (Intervals.java:504-505) means every position at most n away, i.e. containment; notWithin's "the minimum distance ..." (:489-490) is a bound on every position, i.e. no contact (the code keeps a position only if it is more than n away). LUCENE-8629 changed notWithin's @param from "maximum" to "minimum" in the same commit that added within. notWithin equals SpanNotQuery(include, exclude, n, n) (executed, n = 0 to 3). The package is experimental, the tests are single-term, and the recorded searches found no complaint since 2019. The reviewer: "within and notWithin are deliberately different endpoint-distance filters, not Boolean negations."

Why it fails: it shows that the code is intended, which is why this is classed as documentation and not as wrong results; it does not show that the documentation describes the behaviour. The gap follows from one reading of the Java @params only, and even under that reading the notWithin text is off by one: "dog" right after "lazy" is 1 position away, yet fn:notWithin(dog 1 fn:or(quick lazy)) drops it (executed in the tie-break; the fn:notWithin doc example shows the same), so the smallest distance notWithin keeps is n + 1, not the stated minimum n. The fn: docs, which document the query-string route, have no maximum / minimum distinction. Before LUCENE-8629, notWithin's @param said "the maximum distance" (LUCENE-8196, 2018); read literally, that describes what within keeps, not what notWithin keeps. The reviewer itself wrote that the parser docs' "symmetric 'maximum positions' wording could reasonably be clarified to state whole containment for within, no contact for notWithin, and that partial intersections match neither": that clarification is the proposed fix. Confidence: high on the behaviour and the documentation gap, medium on how upstream will want to resolve it (a javadoc clarification is the most likely outcome).

Suggested fix

Recommended A. Document the two relations and the gap (recommended)

Document what the code does: the reference is widened by n positions on both sides, within needs the whole source interval inside, notWithin needs no overlap, and an interval only partly inside matches neither. No behaviour change. Validated with gradle: the format and javadoc checks pass, and the intervals and flexible-parser suites pass.

All options and trade-offs2 options ยท 1,180 words

Two options; both were applied in a scratch worktree of main 645d4fe14a20 and run with gradle. The documentation fix is the proposal, because the code looks deliberate and has behaved this way since 8.0.0.

A. Document the two relations and the gap (recommended) (validated by the checks)

In the javadoc of Intervals.within / notWithin and in the fn:within / fn:notWithin docs, say that the reference is widened by n positions on both sides, that within needs the whole source interval inside, that notWithin needs no overlap, and that an interval only partly inside matches neither. Replace the "distance" / "maximum positions between" parameter text.

diff --git a/lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java b/lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java
index f79d8bf0e10..33fe265df05 100644
--- a/lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java
+++ b/lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java
@@ -485,9 +485,15 @@ public final class Intervals {
    * <p>Returns intervals of the minuend that do not appear within a set number of positions of
    * intervals from the subtrahend query
    *
+   * <p>Each subtrahend interval is widened by {@code positions} on both sides, and a minuend
+   * interval is returned only if it does not overlap any widened interval. A minuend interval that
+   * overlaps a widened interval but lies entirely inside none of them, for example a two-term
+   * phrase directly before the subtrahend when {@code positions} is 1, is returned neither by this
+   * source nor by {@link #within(IntervalsSource, int, IntervalsSource)}.
+   *
    * @param minuend the {@link IntervalsSource} to filter
-   * @param positions the minimum distance that intervals from the minuend may occur from intervals
-   *     of the subtrahend
+   * @param positions the number of positions by which each subtrahend interval is widened on both
+   *     sides
    * @param subtrahend the {@link IntervalsSource} to filter by
    */
   public static IntervalsSource notWithin(
@@ -500,9 +506,15 @@ public final class Intervals {
    * Returns intervals of the source that appear within a set number of positions of intervals from
    * the reference
    *
+   * <p>Each reference interval is widened by {@code positions} on both sides, and a source interval
+   * is returned only if it lies entirely inside a widened interval. A source interval that overlaps
+   * a widened interval but lies entirely inside none of them, for example a two-term phrase
+   * directly before the reference when {@code positions} is 1, is returned neither by this source
+   * nor by {@link #notWithin(IntervalsSource, int, IntervalsSource)}.
+   *
    * @param source the {@link IntervalsSource} to filter
-   * @param positions the maximum distance that intervals of the source may occur from intervals of
-   *     the reference
+   * @param positions the number of positions by which each reference interval is widened on both
+   *     sides
    * @param reference the {@link IntervalsSource} to filter by
    */
   public static IntervalsSource within(
diff --git a/lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java b/lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java
index e8abe61644b..038cc1c3d34 100644
--- a/lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java
+++ b/lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java
@@ -513,7 +513,10 @@
  * <h3>fn:within</h3>
  *
  * <p>Matches intervals of the source that appear within the provided number of positions from the
- * intervals of the reference.
+ * intervals of the reference. Each reference interval is widened by that number of positions on
+ * both sides, and a source interval matches only if it lies entirely inside a widened interval. A
+ * source interval that only partly overlaps a widened interval (with 1 position, the phrase "brown
+ * fox" directly before "jumps") matches neither fn:within nor fn:notWithin.
  *
  * <dl class="dl-horizontal narrow">
  *   <dt>Arguments
@@ -523,7 +526,8 @@
  *         <dt><code>source</code>
  *         <dd>source sub-interval (term or other function)
  *         <dt><code>positions</code>
- *         <dd>an integer number of maximum positions between source and reference
+ *         <dd>an integer number of positions by which each reference interval is widened on both
+ *             sides
  *         <dt><code>reference</code>
  *         <dd>reference sub-interval (term or other function)
  *       </dl>
@@ -542,7 +546,9 @@
  * <h3>fn:notWithin</h3>
  *
  * <p>Matches intervals of the source that do <em>not</em> appear within the provided number of
- * positions from the intervals of the reference.
+ * positions from the intervals of the reference. Each reference interval is widened by that number
+ * of positions on both sides, and a source interval matches only if it does not overlap any widened
+ * interval (see <code>fn:within</code> for a source that only partly overlaps one).
  *
  * <dl class="dl-horizontal narrow">
  *   <dt>Arguments
@@ -552,7 +558,8 @@
  *         <dt><code>source</code>
  *         <dd>source sub-interval (term or other function)
  *         <dt><code>positions</code>
- *         <dd>an integer number of maximum positions between source and reference
+ *         <dd>an integer number of positions by which each reference interval is widened on both
+ *             sides
  *         <dt><code>reference</code>
  *         <dd>reference sub-interval (term or other function)
  *       </dl>

Trade-offs

  • No behaviour change, so no existing query moves.
  • It replaces parameter wording that is ambiguous for a phrase source with what the code does, and states the partly-near case once per function.
  • Optional, not run: a sentence in the intervals package overview (intervals/package-info.java:68-70) and a CHANGES entry.
  • Limits of the wording: the examples assume one occurrence of the reference (a second occurrence whose widened window contains the phrase would make within return it), and with a disjunctive reference whose alternatives nest the separate LFV-828 currently makes notWithin read fewer reference intervals than within, so "does not overlap any widened interval" does not hold there today.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF: :lucene:queries:checkGoogleJavaFormat, :lucene:queryparser:checkGoogleJavaFormat, :lucene:queries:renderJavadoc and :lucene:queryparser:renderJavadoc pass, and the rendered Intervals.html and intervalfn/package-summary.html show the new text.
  • The intervals suite (org.apache.lucene.queries.intervals.*: the 117 upstream tests plus the minimal test in its documented form, expecting 0) passes, 118 tests, and the flexible-parser suite (org.apache.lucene.queryparser.flexible.standard.*) passes, 125 tests with 1 skipped as on stock main, assertions on; the documented-form test also passes with assertions off.
  • The minimal test as written was not re-run with it: the fix changes only comments, so it keeps failing as on stock main.

B. Make within the complement of notWithin (an option only) (validated by the checks)

One line: within tests overlap with the widened reference instead of containment, so within and notWithin become overlapping / nonOverlapping over the same window, an exact complement pair for a non-disjunctive reference.

diff --git a/lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java b/lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java
index f79d8bf0e10..f186518343a 100644
--- a/lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java
+++ b/lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java
@@ -507,7 +507,7 @@ public final class Intervals {
    */
   public static IntervalsSource within(
       IntervalsSource source, int positions, IntervalsSource reference) {
-    return containedBy(source, Intervals.extend(reference, positions, positions));
+    return overlapping(source, Intervals.extend(reference, positions, positions));
   }
 
   /**

Trade-offs

  • Changes results for every existing within query with a multi-position source: within(phrase(brown fox), 1, jumps) goes from 0 to 1, and a source that merely touches the window counts as within. It needs a CHANGES entry as a behaviour change, probably on main only.
  • The converse, notWithin = notContainedBy(minuend, extend(subtrahend, n, n)), is worse: today notWithin matches SpanNotQuery(include, exclude, n, n), and the open #16820 proposes moving span users to intervals. Not run.
  • With a disjunctive reference (or(...)), the hunt's separate overlap-filters finding (LFV-828, another root cause) also applies to overlapping.

What the checks validated

  • Gradle, the same worktree and seed: the minimal test passes with assertions on and off (1 test each); the intervals suite (117 tests) and the flexible-parser suite (125 tests, 1 skipped) pass, and :lucene:queries:checkGoogleJavaFormat passes.
  • The result change is real: with the change, within(phrase(brown fox), 1, jumps) on the docs' example sentence goes from 0 to 1 (tie-break probe). No upstream test pins either behaviour for multi-position sources.

Downstream note

Products that hand these functions to their own users, such as Solr's intervals query parser (within / not_within), repeat the opposite-pair wording, so their users inherit the gap.

How it was found

Semi-formal. A hunt unit checked whether interval filters documented as opposites really split every match, running the real jar over every pair of small interval sets (32,695,520 filter evaluations). Apart from a separately registered notContaining bug, only within / notWithin failed; the case was then reduced by hand to the one-document test.

The method in full210 words
  • Step 1, the documented relation. The unit first settled from the docs which pairs are presented as opposites: within / notWithin ("appear within" / "do not appear within"), containedBy / notContainedBy, overlapping / nonOverlapping.
  • Bounded-exhaustive leg. FilterLawsExhaustive fed every pair of minimal interval sets inside positions 0-6 (1,430 x 1,430 pairs, 32,695,520 filter evaluations) through the real filter classes via a public custom IntervalsSource. containedBy / notContainedBy and overlapping / nonOverlapping over a widened reference were exact complements; apart from a separately registered notContaining defect, the only violations were within / notWithin "neither" (2,530,558 / 645,023 / 106,025 intervals for n = 0 / 1 / 2). That put the gap in the two factories, not in the filter iterators.
  • Real-source harness. ComplementLawsTV (116 x 116 source shapes over all 1,364 documents of up to 5 positions over a 4-letter alphabet, plus 60,000 random pairs), with every other known interval fix shadowed, left the same class as the only residual besides extend() clamp artifacts.
  • Reduction and verification. Reduced by hand to the public-API JUnit; then an independent reproducer, cross-model reviews (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication, a credibility audit, a re-verification and a second adjudication. No model checker, SMT solver or prover produced the signal.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-28 and 2026-11-01): GitHub issues and PRs incl. the JIRA archive, commits, mailing lists, Solr, Elasticsearch and OpenSearch, the web and all open PRs. Closest: #9675, which added within as the "inverse" of notWithin.

Searches4 searches

Read, not the same bug: #9675 / LUCENE-8629 (added within; no discussion of multi-position sources); #9658 / LUCENE-8612 (built notWithin on extend()); #429 / LUCENE-10223 (wrote the fn: docs; no semantics discussion); #16820 (open, proposes deprecating span queries in favour of intervals; does not mention within); the Solr 10.1 intervals guide (repeats the opposite-pair wording; downstream exposure, not a report); elastic/elasticsearch#27134 (span_not docs, single-term, spans). Open PRs #16847, #16851 and #16 (LUCENE-9634, open since 2021) touch the intervals package but not within / notWithin. Limits: GitHub search reads at most 100 results per query and tokenizes fn:within loosely; one web query did not complete; private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, incl. migrated JIRA)2026-10-28 and 2026-11-01a scripted prior-report search (3 query sets: mechanism, doc wording, downstream) and 26 manual queries: notWithin, "Intervals.within", "fn:within", "fn:notWithin", within notWithin, intervals within inverse / complement, "maximum distance" intervals, NonOverlappingIntervalsSource, ContainedByIntervalsSource; the 2026-10-28 study search ran 44 more and a regex scan of 171 threadsno report
JIRA archive2026-10-28 and 2026-11-0115 queries; full reads of LUCENE-8629, LUCENE-8612, LUCENE-8196, LUCENE-8477, LUCENE-10223, LUCENE-10229no report
Open PRs and recent activity2026-11-01changed files of all 410 open PRs; every apache/lucene item updated since 2026-10-22 with its commentsnone changes within / notWithin or their docs
Mailing lists, downstream projects and the web2026-10-28 and 2026-11-01dev@, java-user@, issues@, commits@ and the Solr lists; Solr, Elasticsearch, OpenSearch issues and code; 20 web queriesno report; the Solr 10.1 guide repeats the opposite-pair wording

History

Introduced with within itself by LUCENE-8629 (#9675, 7d34bfdaad3 / branch_8x 3593af2ac55, 2019-01-07), first released in 8.0.0; the fn: docs followed in 9.1.0. Every release from 8.0.0 to 10.6.0 and main shows the gap; 7.4.0 to 7.7.3 have no within. Not fixed upstream.

Versions tested6 rows
  • Introduced: LUCENE-8629 "New interval functions: before, after, within, overlapping" (@romseygeek, 2019-01-07; a JIRA patch, the issue is now #9675): 7d34bfdaad3 on master and 3593af2ac55 on branch_8x, first released in 8.0.0. It added within = containedBy(source, extend(reference, n, n)) next to the existing notWithin, which LUCENE-8612 (2532a5d31c8, 2019-01-01) had built as no overlap with extend(subtrahend, n, n), and changed notWithin's @param from "maximum" to "minimum distance".
  • The fn: docs with the shared "maximum positions between source and reference" text came with LUCENE-10223 (#429, 2021-11-11): f725b27e12a on main (first released in 10.0.0) and its branch_9x twin ff9ee28c603 (first released in 9.1.0).
  • Not a regression: the gap is present from 8.0.0 through 10.6.0 and on main; within's body is unchanged in every revision since; notWithin moved to NonOverlappingIntervalsSource (LUCENE-8477, 8.1.0) with the same relation.
  • Upstream: main f815e679bf6 (2026-11-01) has the same Intervals.java, filter classes and package docs as 645d4fe14a20.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, gradle and standalone, assertions on and off; passes with option B
10.6.0, 10.5.2 affectedminimal test fails, assertions on and off
10.0.0, 9.12.3, 9.1.0 affectedminimal test fails
9.0.0, 8.11.4, 8.2.0, 8.1.1 affectedminimal test fails (package and test imports adapted)
8.0.0 first releaseminimal test fails, assertions on and off; an earlier sweep showed the gap on all 60 releases 8.0.0 to 10.5.2
7.4.0 to 7.7.3 no APIno within (compile error); notWithin already drops the phrase

Independent checks

Every check agrees on the behaviour and the root cause. The study's reproducer, devil's advocate, judge and audit, a re-verification and a second adjudication call it a documentation / API-contract defect; cross-model reviews (OpenAI GPT-5.6) said not real as a code-level complement law, while conceding that the docs' wording invites a complement reading and could be clarified.

Checks and verdicts343 words

The study ran on main a9779ef3c193, whose intervals and intervalfn sources equal those at 645d4fe14a20. The executed cross-model review ran on ea57360912ed, where within / notWithin, the filter classes and the intervalfn docs are the same as at 645d4fe14a20 (Intervals.java differs only in unrelated @throws javadoc, #16899); its test was later re-run on 645d4fe14a20 with the same result.

Each independent check and its verdict
CheckVerdictWhat it found
study finder reproduced, category docBounded-exhaustive complement-law leg and real-source harness; public-API JUnit fails 2 of 2 on main (assertions on and off), 10.5.2 and 9.12.3.
independent reproducer real, documentation defectOwn test fails 3 of 5 with both controls passing, on main, 10.5.2 and 9.12.3, including the fn: route.
cross-model adversary not real, medium (reading only)Agreed with the root cause and that the fn: prose invites a complement reading; its counterexample, run by a separate follow-up check, narrowed the claim (a containing source can be within a multi-position reference).
devil's advocate real, doc, LowBuilt the intended-behaviour case (pointwise @params, SpanNotQuery equality) and rejected it as a refutation of the documentation defect.
judge and audit confirmed, doc, minor; audit agreesRe-ran the tests; the audit expects a committer to call the code working as designed and to welcome a javadoc clarification.
re-verification (2026-11-01) real, docMinimal test fails on main and 10.5.2, assertions on and off; controls pass; passes with option B.
cross-model review, refute lens not real, high (executed on ea57360912ed)On main ea57360912ed, reproduced with its own test (3 run, 1 failed: the partition assertion; re-run by the hunt on 645d4fe14a20 with the same result, assertions on and off), root cause agrees, inputs in contract; rules out a code-level complement law, concedes the docs could state containment, no contact and the partial case.
Second adjudication real, docNew seeds on main and 10.6.0; the fn: route on the docs' example sentence gives 0 + 0; the 117 upstream intervals tests pass with and without option B.

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

Finding record

LFV-830, snapshot of 2026-11-06

Registry title
Intervals.within and notWithin are documented as opposites, but a multi-position source interval only partly inside the n-position window around the reference matches neither (documentation / API-contract defect)
Area
queries / intervals (Intervals.within / Intervals.notWithin and their javadoc; queryparser fn:within / fn:notWithin docs)
Priority
Low, rank 1230: A documentation and API-contract defect where within and notWithin are documented as opposites but are not exact complements for multi-position sources in this little-used pair.
Impact class
minor
Impact note
Anyone who calls Intervals.within / Intervals.notWithin (directly, through IntervalQuery, or as fn:within / fn:notWithin in the flexible StandardQueryParser since 9.1.0) with a multi-position source (a phrase, ordered or unordered near) and reads the docs, which present the pair as opposites, with default writer, codec, analyzer and searcher settings, at any index size, with -ea and -da, in every release from 8.0.0 to 10.6.0 and on main: a source interval that straddles the edge of the n-position window around the reference, or that contains a single-position reference and is wider than 2n+1 positions, is returned by neither query, so 'X but not within n of Y' written as notWithin(X, n, Y) and as MUST(X) MUST_NOT(within(X, n, Y)) return different documents; single-position sources (the shape of every upstream test and doc example) are exact complements [exec: controls]; the code does what each @param can be read to say, so the defect is classed as documentation, and nothing is written to the index.
Summary
You index 'the new york times' and run within(phrase(new, york), 1, times) and notWithin(phrase(new, york), 1, times), and you expect exactly one of them to match, because the javadoc says within returns source intervals that 'appear within a set number of positions' of the reference and notWithin those that 'do not appear within' it, and the fn:within / fn:notWithin docs give both the same 'maximum positions' parameter. Instead neither matches: within keeps an interval only if all of it lies inside the window [r.start-n, r.end+n] (here [2,4]), while notWithin drops it if any part touches that window, so 'new york' at [1,2], which touches the window without lying inside it, is in neither result (within needs n = 2 for a phrase directly next to the reference). In the same way, on 'new york' both within(phrase(new, york), 0, york) and notWithin(phrase(new, york), 0, york) return nothing, and no doc says that a partly-near interval matches neither.
Introduced
3593af2ac55f12a2ce2cfa76fd58407aa3fafa67 (branch_8x, first released in 8.0.0) and its master twin 7d34bfdaad37a25bfda5df2bf2c2bd1a31540bc2 (same Intervals.java, first released in 9.0.0), both 'LUCENE-8629: New interval functions: before, after, within, overlapping' (@romseygeek; a JIRA patch, no GitHub pull request; the issue is now apache/lucene#9675, whose description lists 'a WITHIN b - inverse of the already available NOT_WITHIN'). It added within = containedBy(source, extend(reference, n, n)) next to the existing notWithin = DifferenceIntervalsSource(minuend, extend(subtrahend, n, n), NON_OVERLAPPING) (LUCENE-8612, 2532a5d31c8, 2019-01-01, also first released in 8.0.0), and in the same commit changed notWithin's @param from 'the maximum distance' to 'the minimum distance' [read: git show 7d34bfdaad3; 3593af2ac55 has the same Intervals.java] [exec: 7.7.3 has no within; 8.0.0 shows the gap]. The gap needs both factories: notWithin's no-overlap rule predates within (7.4.0-7.7.3 already return no hit for notWithin on both repro documents [exec]). The fn:within / fn:notWithin docs with the shared text 'an integer number of maximum positions between source and reference' came later, with LUCENE-10223 (apache/lucene#429, @dweiss, f725b27e12a, first released in 9.1.0) [read]. Not a regression: within's body is identical in all 30 master revisions of Intervals.java since 7d34bfdaad3 and in main, and notWithin only moved to NonOverlappingIntervalsSource (LUCENE-8477), the same relation [read] (2019-01-07 (author date of 7d34bfdaad3 and of the branch_8x twin 3593af2ac55); the fn:within / fn:notWithin parser docs 2021-11-11 (f725b27e12a))
First affected version
8.0.0 [exec] (the first release with Intervals.within, shipped in the lucene-sandbox artifact as org.apache.lucene.search.intervals; the package moved to lucene-queries in 8.2.0). 7.4.0 to 7.7.3 have notWithin but no within [exec: probe reports the API absent, the JUnit does not compile]; 7.3.1 and earlier have no intervals package [exec 7.3.1]. The fn:within / fn:notWithin query-string route exists from 9.1.0 [read]
Affected versions
every release from 8.0.0 through 10.5.2, 10.6.0 (its intervals sources at branch_10_6 RC1 4ae946e5a9b [exec], below) and main a9779ef3c193 [exec: the follow-up history sweep ran a public-API probe (5 controls that hold on every version) and the finder JUnit (package-adapted for 8.0.0-9.0.0) on all 60 releases 8.0.0, 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 with -ea, and also with -da on 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2: 82 of 90 runs show the gap, 8 report the API absent (7.3.1-7.7.3), no control failed, nothing threw; the independent reproduction and the intended-behaviour check also on 10.5.2 and 9.12.3]; branch_10_6 head 4ae946e5a9b [exec: its intervals sources shadowed over the 10.5.2 core, 2 of 2 fail], so the released 10.6.0 has it; main a9779ef3c193 [exec, -ea and -da, also with every other known interval fix and the sibling overlap-filters fix shadowed]; upstream main 20ced71c1e6 [read: the 4 commits after a9779ef3c193 touch no intervals or query-parser file]. Not affected: 7.4.0-7.7.3 (no within) and 7.3.1 (no intervals package) [exec]. All 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 disputed it (the adjudication ruled it real), 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 (searched 2026-10-28, plus the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches and a re-check the same day): four scripted prior-report searches (mechanism; documentation wording; downstream; the finder's run) over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 44 manual GitHub searches, including apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch; 11 JIRA queries with full reads of LUCENE-8629, LUCENE-8612, LUCENE-8196, LUCENE-8477, LUCENE-10223, LUCENE-10229, SOLR-13764 and SOLR-18585; 8 web queries (three more did not complete); a regex scan of the bodies and comments of 171 issues and PRs; the changed files of all 411 open apache/lucene PRs. Not fixed upstream: no open PR touches within / notWithin or their docs; the newest Intervals.java commit is f4cc6ac2097 (#16899, author date 2026-10-26, contained in a9779ef3c193, unrelated); the intervals and intervalfn package-info files last changed in f725b27e12a (LUCENE-10223, author date 2021-11-11); the 4 upstream main commits after a9779ef3c193 touch neither. Re-check: no apache/lucene issue or PR created since 2026-10-26 mentions within / notWithin, and none of the 12 PRs opened since then touches intervals. Nearest items, not reports: #9675 / LUCENE-8629 (introduced within as the 'inverse of the already available NOT_WITHIN'), #9658 / LUCENE-8612 (notWithin on extend() and NON_OVERLAPPING), apache/lucene#429 / LUCENE-10223 (wrote the fn: docs; no semantics discussion), elastic/elasticsearch#64329 (a user asked for a span_not equivalent in intervals; closed as a question), the Solr 10.1 Intervals Query Parser guide (SOLR-13764, apache/solr#4582: exposes within / not_within with the same opposite-pair wording and single-term examples; downstream exposure, not a report)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test through gradle with assertions on and off, before and after the javadoc fix and the code alternative, with the intervals (117 tests) and flexible-parser (125 tests) suites, in a scratch worktree (2026-11-01); standalone on 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.1.0, 9.0.0, 8.11.4, 8.2.0, 8.1.1 and 8.0.0 (an earlier sweep covered all 60 releases from 8.0.0 to 10.5.2). Upstream main f815e679bf6 (2026-11-01) has the same code and docs.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:482-497 (at a9779ef3c193) (notWithin: summary :485-486 'Returns intervals of the minuend that do not appear within a set number of positions of intervals from the subtrahend query'; @param :489-490 'the minimum distance that intervals from the minuend may occur from intervals of the subtrahend'; body :495-496 new NonOverlappingIntervalsSource(minuend, Intervals.extend(subtrahend, positions, positions))), :499-511 (within: summary :500-501 'Returns intervals of the source that appear within a set number of positions of intervals from the reference'; @param :504-505 'the maximum distance that intervals of the source may occur from intervals of the reference'; body :510 containedBy(source, Intervals.extend(reference, positions, positions)))
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21 (at a9779ef3c193) ('experimental classes'), :68-69 (notWithin 'Returns intervals that do not appear within a set number of positions of another interval')
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:513-536 (at a9779ef3c193) (fn:within: 'appear within the provided number of positions', :526 positions 'an integer number of maximum positions between source and reference', single-term examples), :542-566 (fn:notWithin: 'do not appear within', :555 the same positions text)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/ContainedByIntervalsSource.java (at a9779ef3c193) and NonOverlappingIntervalsSource.java (the two relations the factories use)
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:482-511 (at 645d4fe14a20)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/ContainedByIntervalsSource.java:46-66; lucene/queries/src/java/org/apache/lucene/queries/intervals/NonOverlappingIntervalsSource.java:60-84; lucene/queries/src/java/org/apache/lucene/queries/intervals/ExtendedIntervalIterator.java:53-61 (at 645d4fe14a20)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:513-566; lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/Within.java:38; lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/NotWithin.java:38 (at 645d4fe14a20)
Who is affected
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/Within.java:38; lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/NotWithin.java:38; lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21 (at 645d4fe14a20)
Why it looks unintended
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:485-490, 500-505, 541-542, 554; lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:68-70 (at 645d4fe14a20)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/package-info.java:513-566 (at 645d4fe14a20)
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:915-922; TestIntervalQuery.java:182-186 (at 645d4fe14a20)
History
  • lucene/CHANGES.txt:6797-6798 (LUCENE-8629 under 8.0.0), :4415 (LUCENE-10223 under 9.1.0)

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

Repro files

About the diagrams

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.