All findings · Lucene finding · LFV-279 · spannot-negative-post-overflow-after-exclude-exhausted

SpanNotQuery with a negative post (or dist) drops every include span after the one that uses up a document's exclude spans, because NO_MORE_POSITIONS - post overflows int

SpanNotQuery with a negative post or dist (documented as allowed overlap) drops every include span after the one that uses up a document's exclude spans: on "b a a c", spanNot(a, b, 0, -1) returns 1 of its 2 spans, and nested in spanNear the document is missed.

Summary

Priority
Medium, rank 251Since 7.2, the documented negative post of a SpanNotQuery drops spans and, once nested, documents.
Impact
silent wrong results
Since
7.2.0 (LUCENE-8034, 2017)introduced 2017-11-22 · about 8 years
Affected
7.2.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
bounded-exhaustive differential check of span filters against the javadoc

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

SpanNotQuery walks a document's include spans in order, with a cursor on its exclude spans. When the cursor runs past the last exclude span it stays on the document reading NO_MORE_POSITIONS (Integer.MAX_VALUE), and the next check, excludeSpans.startPosition() - post >= candidate.endPosition(), overflows for any negative post.

Summary diagram. The example: tokens b, a, a, c at positions 0 to 3, a dark bar marking the exclude span b [0,1); new SpanNotQuery(a, b, 0, -1) on "b a a c"; expected [1,2) and [2,3), what post = 0 returns; actual [1,2), with [2,3) dropped; spanNear([spanNot(a, b, 0, -1), c], 0, true): expected 1 hit, actual 0, the document is missed. Inside SpanNotQuery.java, the filter's accept(): for a [1,2) the loop moves past b [0,1) and, with no more b, returns YES; excludeSpans stays at NO_MORE_POSITIONS = Integer.MAX_VALUE; for a [2,3) the loop does not run, line 210 computes 2147483647 - (-1), which wraps to -2147483648, and -2147483648 >= 3 is false, so line 213 returns NO and [2,3) is dropped; with a long subtraction the value is 2147483648 >= 3 and the span is kept. When it happens: a negative post (or dist) and a document with an exclude span before two or more include spans and none after them; not affected: ordinary post >= 0 (only a post near Integer.MAX_VALUE reaches the same sentinel), the 2-argument constructor and Lucene's bundled query parsers; top level: the same hits but fewer spans and matches and lower scores; nested in spanNear or spanPositionRange: documents missed; since 7.2.0 (LUCENE-8034).
The example "b a a c" with the expected and actual spans and the nested hit count, the accept() steps for both "a" candidates with the wrap at line 210, and when it happens. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index "b a a c" (b at 0, a at 1 and 2, c at 3) and run new SpanNotQuery(a, b, 0, -1): keep each "a" that no "b" overlaps, where the negative post is documented to allow some overlap.
  2. You expect both "a" spans, [1,2) and [2,3), exactly what post = 0 returns: the only "b" is at 0 and touches neither "a".
  3. Checking the first "a" moves the exclude cursor past the last "b", where it reads NO_MORE_POSITIONS = 2147483647. For the second "a" the code computes 2147483647 - (-1), which wraps to -2147483648; -2147483648 >= 3 is false, so that "a" is rejected.
  4. You get only [1,2). Nested as spanNear([spanNot(a, b, 0, -1), c], 0, true) ("a" right before "c"), the document no longer matches: 0 hits instead of 1.
  5. new SpanNotQuery(a, b, -1) (dist) fails the same way, and adding another "b" at the end ("b a a c b") brings the dropped span back.

Who is affected

Java code that builds SpanNotQuery with a negative post or dist, whenever a document has an exclude match before two or more include matches and none after. With ordinary negative values a top-level query keeps its hits but loses spans and score; nested in spanNear or spanPositionRange it loses documents. Lucene's parsers pass 0.

Mechanism, terms and numbers757 words

SpanNotQuery(include, exclude, pre, post) keeps the include spans that no exclude span overlaps within pre tokens before or post tokens after, and its javadoc says negative values "allow a certain amount of overlap to occur". Read as a window, a negative post can only keep more spans than post = 0. In fact it keeps fewer: once checking one include span has moved the exclude cursor past the document's last exclude span, the cursor reads NO_MORE_POSITIONS (Integer.MAX_VALUE), and for every later include span SpanNotQuery.java:210 computes excludeSpans.startPosition() - post, which wraps to a negative int for any post < 0. On "b a a c", spanNot(a, b, 0, -1) returns [1,2) instead of [1,2), [2,3), and spanNear([spanNot(a, b, 0, -1), c], 0, true) no longer matches. With ordinary negative values a top-level SpanNotQuery keeps its document set but loses spans and score; nested in a positional query it loses documents. The line came from LUCENE-8034 (7.2.0), which fixed the opposite overflow for a huge positive post; every release from 7.2.0 to 10.6.0 (10.6.0 by reading) and main is affected. Lucene's query parsers always pass 0 and cannot reach it.

Terms

  • Span: a match of a span query inside one document, written [start,end) in token positions. In "b a a c" the term "a" has the spans [1,2) and [2,3).
  • SpanNotQuery(include, exclude, pre, post): keeps the include spans that no exclude span overlaps within pre tokens before or post tokens after. A negative post (or dist, which sets both) allows that much overlap (SpanNotQuery.java:51-66), so, read as a window, it can only keep more.
  • NO_MORE_POSITIONS: Integer.MAX_VALUE (Spans.java:32). After the last span of the current document, a Spans iterator's startPosition() and endPosition() return it (Spans.java:34-51).

The bug

SpanNotWeight#getSpans wraps the include spans in a FilterSpans whose accept(candidate) runs once per include span, in position order (SpanNotQuery.java:166-216 on main a9779ef3c193):

  1. It catches the exclude cursor up to the candidate's document and, if the cursor has not yet started in this document, moves it to its first span (:175-200).
  2. It skips exclude spans that end before the candidate starts (:202-207). If that runs out of exclude spans, it accepts the candidate: return AcceptStatus.YES; // no more exclude at current doc. (:205). The cursor stays on the document, now reading NO_MORE_POSITIONS.
  3. Otherwise it checks the next exclude span's start: if (excludeSpans.startPosition() - post >= candidate.endPosition()) return YES; else return NO; (:210-214).
  4. For the next include span of the same document, step 2 does nothing (2147483647 <= candidate.startPosition() - pre is false for any ordinary pre) and step 3 computes 2147483647 - post. With post = 0 that is 2147483647 and the span is kept; with post = -1 it is 2^31, which wraps to -2147483648, and -2147483648 >= 3 is false: rejected. Every negative post wraps, because indexed positions are at most Integer.MAX_VALUE - 128 (IndexWriter.java:211).
  5. FilterSpans then asks for the next include span (FilterSpans.java:83-96), which is rejected the same way: every later include span of the document is gone. The next document starts fresh.

The wrap can only reject, never accept, and the include span that used up the exclude spans is itself accepted, so with ordinary negative values a top-level SpanNotQuery keeps its document set. The trigger is a negative post: in a bounded enumeration with pre and post in -2..2, a negative pre with post >= 0 gave 0 mismatches. Windows of about 2^31 tokens hit related corners: post = Integer.MAX_VALUE compares against the sentinel without wrapping and drops the same span, and post = Integer.MIN_VALUE also wraps on real positions, where even a top-level document is lost.

The numbers

On "b a a c": spanNot(a, b, 0, 0) gives [1,2), [2,3); spanNot(a, b, 0, -1) and new SpanNotQuery(a, b, -1) give [1,2); spanNear([spanNot(a, b, 0, -1), c], 0, true) counts 0 instead of 1. On "b a a c b" the same query gives both spans. On "x y y y y y", spanNot(y, x, 0, post) keeps 1 of 5 "y" for post -1, -2 and -3 and all 5 for 0 and 1. A two-phase exclude (spanNear([b, x], 0, true)) is affected too. Every document over {a, b} of 1 to 5 tokens, pre and post in -2..2: 260 mismatches in 6,450 include-span checks, all with post < 0 after the exclude spans were used up.

Exposure and likelihood299 words
  • Who. Java code that calls the 4-argument constructor with post < 0 or the 3-argument constructor with dist < 0, both documented as allowed overlap (SpanNotQuery.java:51-66); rewrite keeps the values (:230). Default codec, IndexWriterConfig and searcher (the executed main() port) and randomized test codecs (the JUnits), assertions on or off, any index size.
  • When. A document has an exclude match before two or more include matches and no exclude match after the include match that uses them up. Ordinary text does this.
  • Top-level query (ordinary negative values). The same documents match, but spans, Matches positions, span frequency and scores are wrong: in one probe 11 of 57 documents score lower with 57 = 57 hits; in another "b a a c" scores 0.053059246 instead of 0.070576414 with 4 = 4 hits; Matches reports 1 position instead of 2.
  • Nested query. In spanNear or spanPositionRange, whole documents stop matching (executed).
  • Under a negation. A MUST_NOT over spanNear([spanNot(a, b, 0, -1), c], 0, true) keeps "b a a c" ([0, 1] instead of [1]); the same with dist = -1; spanNot(a, spanNot(a, b, 0, -1)) returns [0] instead of [] (executed on main, 10.5.2 and 9.12.3; all pass with the fix).
  • Highlighting. WeightedSpanTermExtractor iterates the same spans, so the span highlighter would mark fewer occurrences (read, not executed).
  • Not reachable through Lucene's query parsers: the XML SpanNotBuilder and ComplexPhraseQueryParser use the 2-argument constructor (SpanNotBuilder.java:45, ComplexPhraseQueryParser.java:359, 417).
  • Cost. Deterministic, per document; no hang or resource blow-up was observed: the filter stays linear and emits fewer spans (one "b" then 50,000 "a": 1 span, milliseconds).
  • Why the tests miss it. No deterministic upstream negative-window test has an include span after the exclude spans are used up (the randomized TestSpanSearchEquivalence uses the 2-argument constructor).

Minimal repro

The test shows that SpanNotQuery(a, b, 0, -1) over the text "b a a c" drops the second "a" span, because once the exclude spans are used up Integer.MAX_VALUE - post overflows (SpanNotQuery.java:210).

TestSpanNotNegativePost.java (JUnit test)60 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.spans;

import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.ScoreMode;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSpanNotNegativePost extends LuceneTestCase {

  public void testNegativePostKeepsSpansAfterLastExclude() throws Exception {
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(new StandardAnalyzer()))) {
      Document doc = new Document();
      doc.add(new TextField("f", "b a a c", Field.Store.NO));
      w.addDocument(doc);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        SpanQuery q =
            new SpanNotQuery(
                new SpanTermQuery(new Term("f", "a")), new SpanTermQuery(new Term("f", "b")), 0, -1);
        Spans spans =
            q.createWeight(new IndexSearcher(reader), ScoreMode.COMPLETE_NO_SCORES, 1f)
                .getSpans(reader.leaves().get(0), SpanWeight.Postings.POSITIONS);
        List<String> actual = new ArrayList<>();
        spans.nextDoc();
        while (spans.nextStartPosition() != Spans.NO_MORE_POSITIONS) {
          actual.add("[" + spans.startPosition() + "," + spans.endPosition() + ")");
        }
        // "b" at 0 overlaps neither "a": expected [1,2) and [2,3) (as with post 0), actual [1,2)
        assertEquals(List.of("[1,2)", "[2,3)"), actual);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-279/, 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.spans.TestSpanNotNegativePost
Expected vs actual
Expected spans [1,2), [2,3) (as with post = 0, since "b" overlaps neither "a"); actual only [1,2), with -ea and -da.

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

More files, environment, full output35 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<[[1,2), [2,3)]> but was:<[[1,2)]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.queries.spans.TestSpanNotNegativePost.testNegativePostKeepsSpansAfterLastExclude(TestSpanNotNegativePost.java:56)

Why it looks unintended

  • Read as a window, as LUCENE-7431's tests do, the javadoc's allowed overlap means post = -1 keeps every span post = 0 keeps.
  • An extra "b" after both "a"s ("b a a c b") brings the dropped span back.
  • Until 7.1.0 the check was an addition that handles negative post; LUCENE-8034 (7.2.0) rewrote it for a huge positive post.
The full argument252 words
  • The contract. SpanNotQuery.java:51-56 (3-argument): "matching spans from include which have no overlap with spans from exclude within dist tokens of include. Inversely, a negative dist value may be used to specify a certain amount of allowable overlap." :61-66 (4-argument): "... within pre tokens before or post tokens of include. Inversely, negative values for pre and/or post allow a certain amount of overlap to occur."
  • A deliberate feature. lucene/CHANGES.txt:8710-8712 (6.4.0): "LUCENE-7431: Allow a certain amount of overlap to be specified between the include and exclude arguments of SpanNotQuery via negative pre and/or post arguments." That change removed the earlier clamping of negative values to 0.
  • Lucene's own tests use it. TestBasics#testSpanNotWindowNegPost (TestBasics.java:331-352) asserts that post = -1 keeps the 16 hits that testSpanWithMultipleNotSingle (:244) asserts for post = 0, and that post = -2 adds two more; testSpanNotWindowNegPre (:354-375) does the same for pre (-2 keeps the 16, -3 adds the two).
  • History. Until 7.1.0 the check was candidate.endPosition() + post <= excludeSpans.startPosition(), correct for any post <= 0; 7.1.0 returns both spans (executed). LUCENE-8034 (#9082) turned it into the subtraction only to fix an overflow for a huge positive post; its discussion never mentions negative post, and its test uses pre = post = Integer.MAX_VALUE (TestBasics.java:232).
  • Order dependence. The same include span is kept or dropped depending on whether an earlier include span used up the exclude spans, and an extra exclude match after it restores it.
The devil's-advocate check188 words

The strongest works-as-designed case, from the intended-behaviour verifier, a reviewer from another model family (OpenAI GPT-5.6) and the adjudication: testSpanNotWindowNegPost carries the comment "test handling of invalid window < 0" (TestBasics.java:332), so negative windows might be invalid input; SpanNotWeight is @lucene.internal (SpanNotQuery.java:125); no javadoc says what happens after the exclude spans run out; and no bundled parser can produce the trigger.

Why it fails: the comment predates LUCENE-7431, when the test was testSpanNotWindowNeg and the constructor clamped negative values to 0; LUCENE-7431 renamed the test and made it assert that negative windows widen the hits, but kept the comment. The constructors are public and documented; only the weight is internal. Under the window reading that LUCENE-7431's tests assert, the javadoc is monotone (a more negative post never removes spans), and Spans documents the sentinel that accept itself reads. A documented input that only Java callers can pass is still a legitimate trigger. Every verifier and the adjudication rejected the intended argument; the cross-model reviews rated it weak. Open issue #16820 proposes deprecating span queries, which could argue for won't-fix but not for intended behaviour.

Suggested fix

Recommended Accept once the exclude spans are used up, and compare in long

Accept the candidate as soon as the document's exclude spans are used up (excludeSpans.startPosition() == NO_MORE_POSITIONS) and compare line 210 in long: +5/-1 lines in SpanNotQuery.java. The repro, a 24-case window probe and all lucene/queries and lucene/highlighter tests pass. The hunt's one-line long cast fixes the repro too but misses post = Integer.MAX_VALUE.

           if (excludeSpans.startPosition() == -1) { // init exclude start position if needed
             excludeSpans.nextStartPosition();
           }
 
+          if (excludeSpans.startPosition() == NO_MORE_POSITIONS) {
+            return AcceptStatus.YES; // an earlier candidate used up the exclude spans of this doc
+          }
+
           while (excludeSpans.endPosition() <= candidate.startPosition() - pre) {
@@
-          if (excludeSpans.startPosition() - post >= candidate.endPosition()) {
+          if ((long) excludeSpans.startPosition() - post >= candidate.endPosition()) {
All options and trade-offs3 options · 646 words

Two options were applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF), with the compact repro and a 24-case window probe that compares spanNot(a, b, pre, post) with the javadoc rule on "b a a c", "a b" and "b a a c b" (stock main: 7 mismatches); the third option was not run. A diff with the recommended fix and the repro as a new test is attached (fix-with-test.diff).

Accept once the exclude spans are used up, and compare in long (validated by the checks) (recommended)

In accept, right after the exclude cursor is initialised (SpanNotQuery.java:198-200), return YES when excludeSpans.startPosition() == NO_MORE_POSITIONS: an earlier candidate used up the exclude spans of this document, so nothing can exclude this one. Also make line 210 overflow-safe with a long subtraction. +5/-1 lines.

           if (excludeSpans.startPosition() == -1) { // init exclude start position if needed
             excludeSpans.nextStartPosition();
           }
 
+          if (excludeSpans.startPosition() == NO_MORE_POSITIONS) {
+            return AcceptStatus.YES; // an earlier candidate used up the exclude spans of this doc
+          }
+
           while (excludeSpans.endPosition() <= candidate.startPosition() - pre) {
@@
-          if (excludeSpans.startPosition() - post >= candidate.endPosition()) {
+          if ((long) excludeSpans.startPosition() - post >= candidate.endPosition()) {

Trade-offs

  • One int comparison for each include candidate that gets past the document check at :194-196, whether it is then accepted or rejected; no benchmark run.
  • The check makes the result independent of post once the exclude spans of the document are used up, including post = Integer.MAX_VALUE; the long comparison keeps LUCENE-8034's protection for a huge positive post and also covers a huge negative post on real positions.
  • Not covered and not examined: the loop condition at :202 still computes candidate.startPosition() - pre in int, so an extreme negative pre (about -2^31) is not made safe.

What the checks validated

  • Stock main a9779ef3c193: the repro fails through gradle with assertions on and off.
  • With the fix: the repro and the window probe pass with assertions on and off (24 of 24 cases).
  • With the fix, all of lucene/queries passes (53 classes, 514 tests, 5 skipped), including the 22 upstream span classes (232 tests; TestBasics 29 of 29 with testSpanNotNoOverflowOnLargeSpans and testSpanNotWindowNegPost), and all of lucene/highlighter (34 classes, 722 tests); ecjLint and checkGoogleJavaFormat pass for queries.
  • Through the classpath runner the repro passes with the fix shadowed, and the probe passes 24 of 24 with -ea and -da.
  • Not run: the full Lucene test suite; no benchmark.

Only compare in long (one line, the hunt's fix) (validated by the checks)

if ((long) excludeSpans.startPosition() - post >= candidate.endPosition()) at SpanNotQuery.java:210.

-          if (excludeSpans.startPosition() - post >= candidate.endPosition()) {
+          if ((long) excludeSpans.startPosition() - post >= candidate.endPosition()) {

Trade-offs

It still compares against the sentinel as if it were a real position, so post = Integer.MAX_VALUE with pre = 0 still drops the later span once the exclude spans are used up (1 of the probe's 24 cases). That needs a window of 2^31 - 1 tokens.

What the checks validated

  • This doc, gradle at a9779ef3c193: the repro passes; the window probe fails 1 of 24 cases (post = Integer.MAX_VALUE, pre = 0 on "b a a c": [1,2) instead of both).
  • The hunt (classpath runner, main ea57360912ed): the finder's, the independent reproducer's (0 of 6,450 enumeration mismatches) and the intended-lens reviewer's tests and the negated-shape probe pass, as do 156 upstream span tests in 9 classes, including testSpanNotNoOverflowOnLargeSpans and testSpanNotWindowNegPost (that run also shadowed six interval fixes from the same hunt unit; its one failure in 273 tests is the interval test TestDisjunctionRewrites#testNotContainedBy, which asserts a rewrite form one of those fixes changes).

Restore the pre-7.2 addition form in long (not run)

(long) candidate.endPosition() + post <= excludeSpans.startPosition().

Trade-offs

Equivalent to the one-line long option, with the same sentinel corner.

Downstream note

Downstream, the Elasticsearch and OpenSearch span_not builders clamp negative pre and post to 0 (read in their source, not run), so only an application's own Java code can build the trigger; one that nests such a query under MUST_NOT removes fewer documents than the rule says (executed probe).

How it was found

Bounded-exhaustive translation validation on the stock jars: every document of up to 3 positions over {a, b, c}, with pre and post in -1..1 over 30 child queries, against an exact reference built from the children's own spans and the javadoc. 636 of 510,300 SpanNot checks failed, all with post = -1; reading line 210 then gave the cause.

The method in full168 words

Method: bounded-exhaustive translation validation on the stock jar (semi-formal tier), in a hunt unit on span and interval queries.

  • The harness evaluates the span filters of the real main jars on every document of up to 3 positions over {a, b, c}, with SpanNotQuery pre and post in -1..1 over 30 child queries, and compares each result with an exact reference built from the children's actual Lucene spans and the javadoc semantics.
  • The SpanNot relation failed in 636 of 510,300 checks, all with post = -1, and in 0 of 12,425,400 checks with the unit's fix sketches shadowed onto the jar (larger scopes, run on separate build machines).
  • The root cause was then read from SpanNotQuery.java:210 and reduced to a public-API JUnit. No model checker, SMT solver or prover was involved.
  • Found 2026-10-26 on main ea57360912ed; then checked by an independent reproducer, reviews by a model from another family (OpenAI GPT-5.6), an intended-behaviour devil's advocate, an adjudication, a history and prior-report follow-up, and a credibility audit.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26) in about 140 recorded queries over GitHub, JIRA, the mailing lists, Elasticsearch, OpenSearch, Solr, Stack Overflow and the web, plus the changed files of all 413 open PRs. Closest: #9082 (LUCENE-8034), the 2017 fix of the opposite overflow, which introduced this one.

Searches6 searches

Verdict new: no report by anyone. If #9082's title ("SpanNotWeight returns wrong results due to integer overflow") is read as covering every overflow in that comparison, the overlap is partial; its report, fix and discussion are about a huge positive post only. Keyword search cannot prove absence; GitHub search does not index review threads or diffs, and private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; the JIRA archive; the web2026-10-26the hunt's prior-report tool in two passes: 55 queries (16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list, 4 JIRA-archive, 10 web), then 24 list and JIRA queriesno report; 18 hits read, none the same bug
GitHub REST and GraphQL search across apache/lucene, the old lucene-solr and solr repositories, Elasticsearch and OpenSearch2026-10-2618 REST and 15 GraphQL queries, including code fragments ("startPosition() - post", "excludeSpans", "NO_MORE_POSITIONS" overflow) and the issue keysonly the related items below
JIRA REST; solr-user@, Solr users@ and dev@, general@; Stack Overflow2026-10-266 JIRA queries (LUCENE-8034, LUCENE-7431, LUCENE-7931, LUCENE-9426 and LUCENE-7580 read in full); list and Stack Overflow searches for SpanNotQuery, spanNot and span_notno report
open PRs and upstream history2026-10-26the changed files of all 413 open apache/lucene PRs; the history of SpanNotQuery.java on main, branch_10x and branch_10_6no open PR touches SpanNotQuery.java; last upstream change 2022-10-19, line 210 unchanged
related items (read)2026-10-26the threads and comments#9082 (LUCENE-8034, introduced it while fixing a huge positive post); #8484 (LUCENE-7431, made negative pre/post a feature); elastic/elasticsearch#27134 (2017, the old positive overflow); #16820 (open, proposes deprecating span queries; context only)
the finder's run2026-10-2622 JIRA-archive and mailing-list queriesno report

History

Introduced by LUCENE-8034 (726ee05240a, 2017-11-22, first release 7.2.0), which turned the correct addition into the subtraction. Executed: 6.4.0 to 7.1.0 pass; 7.2.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main fail. Not fixed upstream.

Versions tested7 rows

Introduced by 726ee05240a65badf6c3499dd1e29daf490a3e48 on master, "LUCENE-8034: use subtraction instead of addition to sidestep int overflow in SpanNotQuery" (a patch attached to the JIRA issue, now #9082, committed by Mike McCandless on 2017-11-22; CHANGES: "(Hari Menon via Mike McCandless)"), backported to branch_7x the same day as 6b328dcd4f9. It replaced candidate.endPosition() + post <= excludeSpans.startPosition(), which is correct for any post <= 0, with excludeSpans.startPosition() - post >= candidate.endPosition(), to fix an overflow for a huge positive post.

Precondition, not the cause: LUCENE-7431 (750da7c5f7a on master, 55ed23168f6 on branch_6x, 2016-11-08, first in 6.4.0 and 7.0.0) removed the clamping of negative pre/post to 0 and documented them as allowed overlap; the addition form was still correct then. The spans moved to the queries module in 9.0.0 without touching the line; SpanNotQuery.java last changed upstream on 2022-10-19 (6cde41c9fd8c), and the line is unchanged on main, branch_10x and branch_10_6. A source scan finds the subtraction in every release tag from 7.2.0 to 10.5.2 (the history clone has no 8.x tags after 8.8.1, so 8.9.0 to 8.11.4 were not scanned; 8.11.4 was executed).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe compact JUnit fails through gradle, assertions on and off, and through the classpath runner with -ea and -da
main ea57360912ed affectedthe finder's, the independent reproducer's, the devil's advocate's and the negated-shape probe's tests fail, -ea and -da
9.12.3, 10.0.0, 10.5.2 affectedthe finder's JUnit and the main() port fail; the compact JUnit fails on 9.12.3 and 10.5.2
7.2.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0 7.2.0 firstthe main() port: spanNot(a, b, 0, -1) = [1,2) only, nested count 0 (post 0 controls: both spans, 1)
6.4.0, 6.6.6, 7.0.0, 7.1.0 addition formthe main() port passes: both spans, nested count 1 (the 6.x line never got LUCENE-8034)
6.3.0 negative values clampedpasses vacuously: negative post was clamped to 0 before LUCENE-7431
fix (scratch worktree at a9779ef3c193) not affectedrecommended fix: the repro and the 24-case probe pass, assertions on and off; all lucene/queries (514 tests) and lucene/highlighter (722 tests) tests pass; the one-line long fix passes the repro and misses 1 probe case

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests; a reviewer from another model family (OpenAI GPT-5.6) rated it real with a weak intended argument; an adjudication re-ran it with a new seed; this doc's gradle runs reproduce it on a9779ef3c193 and validate the fix.

Checks and verdicts473 words

Every executing check wrote or re-ran its own JUnit on stock main; every post = 0 control passed, and every fix check of the repros passed (the one-line long fix misses 1 of the 24 window-probe cases).

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateThe translation-validation harness, then a public-API JUnit: fails 1 of 1 on main (-ea, -da), 10.5.2 and 9.12.3; passes with the long fix.
cross-model review, refute lens (OpenAI GPT-5.6, reading only) not real, low (procedural)The reviewer could not run code, and it voted not real on procedure only; its own notes say the static evidence strongly favours a real defect, the root cause agrees and the intended argument is weak. A re-run of the repro failed.
independent reproducer real, highOwn test on "stop go go end": fails 3 of 3 on main (-ea, -da), 10.5.2 and 9.12.3; enumeration: 260 of 6,450 include-span checks mismatch; pre-9.0 variant fails on 7.2.0 and 8.11.4, passes on 7.1.0; top-level hits 57 = 57 but 11 documents score lower; passes with the fix.
cross-model review, intended lens (OpenAI GPT-5.6, the cross-model review) real, medium; intended argument weakReading only; a separate sweep on "x y y y y y" keeps 1 of 5 spans for post -1, -2 and -3 and all 5 for 0 and 1; fails on main (-ea, -da) and 10.5.2, passes with the fix.
intended-behaviour devil's advocate real, highTraced the "invalid window < 0" comment to before LUCENE-7431; 4 of 4 probes fail on main: the extra exclude restores the span, dist = -1, a two-phase exclude, and a lower top-level score with equal hits.
adjudication confirmed, highRe-ran the independent reproduction's test with seed 1DE5EED5 (3 of 3 fail, -ea and -da; 3 of 3 pass with the fix) and the finder's repro (fails).
Follow-up (history, prior reports, negated shapes) new; history as predicted19 history runs as predicted (7.1.0 passes, 7.2.0 and later fail, the fix passes); about 140 recorded prior-report queries; a probe of negated shapes (a MUST_NOT rule over the nested query removes fewer documents; all pass with the fix); fresh-seed re-runs reproduce. The strict security re-evaluation of 2026-10-27 found it out of scope of SECURITY.md (security: none).
main refresh to a9779ef3c193 still reproduces4 of 4 replays (the finder's and the independent reproduction's tests, -ea and -da) fail exactly as recorded on ea57360912ed.
this doc's runs reproduces; fix validatedThe compact JUnit fails on stock main through gradle (assertions on and off) and through the classpath runner on main, 10.5.2 and 9.12.3. With the recommended fix it passes, as do a 24-case window probe, all of lucene/queries and lucene/highlighter, lint and format; the one-line long fix passes the repro and misses 1 probe case.

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

Finding record

LFV-279, snapshot of 2026-11-06

Registry title
SpanNotQuery with a negative post (or dist) drops every include span that follows the last exclude span in a document, because NO_MORE_POSITIONS - post overflows int
Area
queries / span queries (SpanNotQuery: the exclusion filter in SpanNotWeight#getSpans)
Priority
Medium, rank 251: Since 7.2, the documented negative post of a SpanNotQuery drops spans and, once nested, documents.
Impact class
silent wrong results
Impact note
Applications that build a SpanNotQuery in Java with the documented negative post (4-argument constructor) or negative dist (3-argument constructor) get too few spans in every document that has an exclude span before two or more include spans and none after them, with the default codec, IndexWriterConfig and searcher, -ea and -da, at any index size, in every release from 7.2.0 through 10.6.0 (10.6.0 by reading) and on main: a top-level SpanNotQuery still matches the same documents (the include span that uses up the exclude spans is kept) but reports fewer spans and Matches positions and lower scores (the independent reproduction probe: 11 of 57 documents score lower), and once it is nested in a positional parent such as spanNear or spanPositionRange whole documents stop matching (or, under a MUST_NOT or as the exclude side of another SpanNotQuery, wrongly match); Lucene's bundled query parsers always pass pre = post = 0 and cannot reach it.
Summary
You index the text 'b a a c' and run new SpanNotQuery(a, b, 0, -1), whose javadoc says it keeps the 'a' spans that do not overlap a 'b' and that a negative post only allows some overlap; since 'b' at position 0 overlaps neither 'a', you expect both spans [1,2) and [2,3), exactly as with post = 0. Instead you get only [1,2): once the first 'a' has used up the document's exclude spans, the exclude iterator sits at NO_MORE_POSITIONS (Integer.MAX_VALUE) and SpanNotQuery.java:210 computes Integer.MAX_VALUE - post, which wraps to a negative int for any post < 0, so every later 'a' in the document is rejected. Nested as spanNear([spanNot(a, b, 0, -1), c], 0, true), the document no longer matches at all (count 0 instead of 1).
Introduced
726ee05240a65badf6c3499dd1e29daf490a3e48 (master) and its branch_7x backport 6b328dcd4f90f4b61961b1d3a16629b4ae0c3ece, 'LUCENE-8034: use subtraction instead of addition to sidestep int overflow in SpanNotQuery' (issue migrated as apache/lucene#9082; a patch attached to the JIRA issue by JIRA user hshankar, committed by mikemccand; no GitHub PR) [read: git blame and git log -L of SpanNotQuery.java:210 in the full-history clone, no later commit changed the line; exec: 7.1.0 passes, 7.2.0 fails]. It replaced 'candidate.endPosition() + post <= excludeSpans.startPosition()', which is correct for any post <= 0, with 'excludeSpans.startPosition() - post >= candidate.endPosition()', to avoid overflow for a huge positive post. Precondition, not the introducing commit: LUCENE-7431 (750da7c5f7af on master, 55ed23168f6 on branch_6x, 2016-11-08, first released in 6.4.0 and 7.0.0) removed the clamping of negative pre/post to 0 and documented negative values as allowed overlap, which made the trigger legal. A regression since 7.2.0 (2017-11-22)
First affected version
7.2.0 [exec] (7.1.0, 7.0.0, 6.6.6 and 6.4.0 pass [exec]; the 6.x line never received LUCENE-8034; before LUCENE-7431 the constructor clamped negative pre/post to 0, so the trigger did not exist: 6.3.0 passes vacuously [exec])
Affected versions
every release from 7.2.0 through 10.6.0 (7.2.0, 7.2.1, 7.3.0-7.7.3, 8.0.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2, and 10.6.0 by reading), main ea57360912ed, the unreleased branch_10x, and branch_10_6 (head 2763db11b0a; 10.6.0 was released from this branch on 2026-10-29) [read: SpanNotQuery.java line 210 unchanged there, read-only GitHub GETs 2026-10-26]; not fixed upstream. [exec] the finder's JUnit fails on 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da on main); a version-neutral main() port with the same document and queries fails on 7.2.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main and passes on 6.4.0, 6.6.6, 7.0.0 and 7.1.0 (6.3.0 passes vacuously: negative post clamped); the independent reproduction's own JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3, and its pre-9.0 variant fails on 7.2.0 and 8.11.4 and passes on 7.1.0. [read] a scan of every release tag 4.0.0-10.5.2 in the history clone finds the subtraction form in every tag from 7.2.0 to 10.5.2 (8.9.0-8.11.3 are not tagged there; 8.11.4 was executed). History runs used JDK 25, seed DEADBEEF, -ea
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record, read-only: the scripted prior-report search in two passes, 55 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and the web, then 24 list and JIRA queries; 18 manual GitHub REST and 15 GraphQL searches of apache/lucene and the Elasticsearch, OpenSearch and Solr repositories; 6 JIRA REST queries; the solr-user@, Solr users@ and dev@ and general@ lists; Stack Overflow; the changed files of all 413 open apache/lucene PRs; the upstream commit history of SpanNotQuery.java on main, branch_10x and branch_10_6; plus the finder's earlier 22 JIRA and list queries; 18 hits classified, none the same bug); not fixed upstream (SpanNotQuery.java last changed upstream in 6cde41c9fd8c, 2022-10-19, line 210 unchanged on main, branch_10x and branch_10_6; no open PR touches SpanNotQuery.java). Closest items, not reports: #9082 (LUCENE-8034, fixed 7.2), the change that introduced this bug while fixing the opposite overflow (candidate.endPosition() + post for a huge positive post; its 10 JIRA comments never mention negative post); #8484 (LUCENE-7431, fixed 6.4), which made negative pre/post a documented feature; elastic/elasticsearch#27134 (closed 2017-10-30, docs), a downstream report of the old positive-post overflow
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the compact JUnit through gradle in a clean worktree, stock and with the recommended fix, assertions on and off; with the fix also a 24-case window probe and the whole lucene/queries and lucene/highlighter test suites (2026-10-28). Through the hunt's classpath runner: the compact JUnit on main (-ea, -da, each fix option shadowed), 10.5.2 and 9.12.3. Hunt runs on main ea57360912ed (same SpanNotQuery.java), a version-neutral main() port on releases 6.3.0 to 10.5.2, and the main refresh to a9779ef3c193, which reproduces with identical messages.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanNotQuery.java:51-56 (at ea57360912ed) (3-argument constructor javadoc: 'a negative dist value may be used to specify a certain amount of allowable overlap'), :61-66 (4-argument constructor javadoc: 'negative values for pre and/or post allow a certain amount of overlap to occur'), :173-215 (FilterSpans#accept), :175-196 (exclude caught up to the candidate's document), :202-206 (exhausted exclude returns YES once), :210 ('excludeSpans.startPosition() - post >= candidate.endPosition()'), :230 (rewrite copies pre and post)
  • lucene/queries/src/java/org/apache/lucene/queries/spans/Spans.java:32 (at ea57360912ed) (NO_MORE_POSITIONS = Integer.MAX_VALUE), :36-51 (startPosition/endPosition return NO_MORE_POSITIONS after the last position)
  • lucene/queries/src/java/org/apache/lucene/queries/spans/FilterSpans.java:83-94 (at ea57360912ed) (NO advances the include iterator)
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:211 (at ea57360912ed) (MAX_POSITION = Integer.MAX_VALUE - 128)
  • lucene/queries/src/test/org/apache/lucene/queries/spans/TestBasics.java:232 (at ea57360912ed) (testSpanNotNoOverflowOnLargeSpans), :331-347 (testSpanNotWindowNegPost: comment 'test handling of invalid window < 0', asserts post -1 and -2 hit sets)
  • lucene/CHANGES.txt:8694-8696 (at ea57360912ed) (LUCENE-7431: 'Allow a certain amount of overlap to be specified between the include and exclude arguments of SpanNotQuery via negative pre and/or post arguments')
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/SpanNotBuilder.java:45 (at ea57360912ed) and lucene/queryparser/src/java/org/apache/lucene/queryparser/complexPhrase/ComplexPhraseQueryParser.java:359, :417 (2-argument constructor only)
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanNotQuery.java:51-66, 150-217 (accept 173-215: 175-200, 202-207, 205, 210-214)
  • lucene/queries/src/java/org/apache/lucene/queries/spans/Spans.java:32, 34-51; FilterSpans.java:83-96
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:211
Who is affected
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanNotQuery.java:51-66, 230
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/SpanNotBuilder.java:45; complexPhrase/ComplexPhraseQueryParser.java:359, 417
Why it looks unintended
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanNotQuery.java:51-66, 125
  • lucene/CHANGES.txt:7780-7781, 8710-8712
  • lucene/queries/src/test/org/apache/lucene/queries/spans/TestBasics.java:232, 244, 331-352, 354-375
Suggested fix
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanNotQuery.java:198-214

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

Repro files

About the diagrams

Made in parallel with this doc from a probe run on main a9779ef3c193 (stock and with the long fix); the values on screen come from the code and that probe. The fix line on the diagram is the long comparison, which both fix options contain.

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.