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.
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.- 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 negativepostis documented to allow some overlap. - You expect both "a" spans, [1,2) and [2,3), exactly what
post = 0returns: the only "b" is at 0 and touches neither "a". - 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 >= 3is false, so that "a" is rejected. - 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. 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.
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.
- 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 withinpretokens before orposttokens after. A negativepost(ordist, 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, aSpansiterator'sstartPosition()andendPosition()return it (Spans.java:34-51).
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):
- 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). - 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 readingNO_MORE_POSITIONS. - Otherwise it checks the next exclude span's start:
if (excludeSpans.startPosition() - post >= candidate.endPosition()) return YES; else return NO;(:210-214). - For the next include span of the same document, step 2 does nothing (
2147483647 <= candidate.startPosition() - preis false for any ordinarypre) and step 3 computes2147483647 - post. Withpost = 0that is 2147483647 and the span is kept; withpost = -1it is 2^31, which wraps to -2147483648, and-2147483648 >= 3is false: rejected. Every negativepostwraps, because indexed positions are at mostInteger.MAX_VALUE - 128(IndexWriter.java:211). FilterSpansthen 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.
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 < 0or the 3-argument constructor withdist < 0, both documented as allowed overlap (SpanNotQuery.java:51-66);rewritekeeps the values (:230). Default codec,IndexWriterConfigand searcher (the executedmain()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,
Matchespositions, 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;Matchesreports 1 position instead of 2. - Nested query. In
spanNearorspanPositionRange, whole documents stop matching (executed). - Under a negation. A
MUST_NOToverspanNear([spanNot(a, b, 0, -1), c], 0, true)keeps "b a a c" ([0, 1] instead of [1]); the same withdist = -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.
WeightedSpanTermExtractoriterates the same spans, so the span highlighter would mark fewer occurrences (read, not executed). - Not reachable through Lucene's query parsers: the XML
SpanNotBuilderandComplexPhraseQueryParseruse 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
TestSpanSearchEquivalenceuses the 2-argument constructor).
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).
/*
* 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);
}
}
}
}
# 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-eaand-da.
More files, environment, full output35 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- Read as a window, as LUCENE-7431's tests do, the javadoc's allowed overlap means
post = -1keeps every spanpost = 0keeps. - 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 positivepost.
The full argument252 words
- The contract.
SpanNotQuery.java:51-56(3-argument): "matching spans fromincludewhich have no overlap with spans fromexcludewithindisttokens ofinclude. Inversely, a negativedistvalue may be used to specify a certain amount of allowable overlap.":61-66(4-argument): "... withinpretokens before orposttokens ofinclude. Inversely, negative values forpreand/orpostallow 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 thatpost = -1keeps the 16 hits thattestSpanWithMultipleNotSingle(:244) asserts forpost = 0, and thatpost = -2adds two more;testSpanNotWindowNegPre(:354-375) does the same forpre(-2 keeps the 16, -3 adds the two). - History. Until 7.1.0 the check was
candidate.endPosition() + post <= excludeSpans.startPosition(), correct for anypost <= 0; 7.1.0 returns both spans (executed). LUCENE-8034 (#9082) turned it into the subtraction only to fix an overflow for a huge positivepost; its discussion never mentions negativepost, and its test usespre = 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.
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
postonce the exclude spans of the document are used up, includingpost = Integer.MAX_VALUE; thelongcomparison keeps LUCENE-8034's protection for a huge positivepostand also covers a huge negativeposton real positions. - Not covered and not examined: the loop condition at
:202still computescandidate.startPosition() - preinint, so an extreme negativepre(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/queriespasses (53 classes, 514 tests, 5 skipped), including the 22 upstream span classes (232 tests;TestBasics29 of 29 withtestSpanNotNoOverflowOnLargeSpansandtestSpanNotWindowNegPost), and all oflucene/highlighter(34 classes, 722 tests);ecjLintandcheckGoogleJavaFormatpass for queries. - Through the classpath runner the repro passes with the fix shadowed, and the probe passes 24 of 24 with
-eaand-da. - Not run: the full Lucene test suite; no benchmark.
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 = 0on "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, includingtestSpanNotNoOverflowOnLargeSpansandtestSpanNotWindowNegPost(that run also shadowed six interval fixes from the same hunt unit; its one failure in 273 tests is the interval testTestDisjunctionRewrites#testNotContainedBy, which asserts a rewrite form one of those fixes changes).
(long) candidate.endPosition() + post <= excludeSpans.startPosition().
Trade-offs
Equivalent to the one-line long option, with the same sentinel corner.
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).
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
SpanNotQuerypreandpostin -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:210and 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; the JIRA archive; the web | 2026-10-26 | the 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 queries | no 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 OpenSearch | 2026-10-26 | 18 REST and 15 GraphQL queries, including code fragments ("startPosition() - post", "excludeSpans", "NO_MORE_POSITIONS" overflow) and the issue keys | only the related items below |
| JIRA REST; solr-user@, Solr users@ and dev@, general@; Stack Overflow | 2026-10-26 | 6 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_not | no report |
| open PRs and upstream history | 2026-10-26 | the changed files of all 413 open apache/lucene PRs; the history of SpanNotQuery.java on main, branch_10x and branch_10_6 | no open PR touches SpanNotQuery.java; last upstream change 2022-10-19, line 210 unchanged |
| related items (read) | 2026-10-26 | the 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 run | 2026-10-26 | 22 JIRA-archive and mailing-list queries | no report |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the compact JUnit fails through gradle, assertions on and off, and through the classpath runner with -ea and -da |
main ea57360912ed | affected | the 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 | affected | the 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 first | the 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 form | the main() port passes: both spans, nested count 1 (the 6.x line never got LUCENE-8034) |
| 6.3.0 | negative values clamped | passes vacuously: negative post was clamped to 0 before LUCENE-7431 |
fix (scratch worktree at a9779ef3c193) | not affected | recommended 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 |
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).
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | The 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, high | Own 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 weak | Reading 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, high | Traced 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, high | Re-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 predicted | 19 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 reproduces | 4 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 validated | The 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. |
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.
- PNG 468 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,349 words
- 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 wholelucene/queriesandlucene/highlightertest 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 mainea57360912ed(sameSpanNotQuery.java), a version-neutralmain()port on releases 6.3.0 to 10.5.2, and the main refresh toa9779ef3c193, which reproduces with identical messages.
- 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-96lucene/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, 230lucene/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, 125lucene/CHANGES.txt:7780-7781, 8710-8712lucene/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
- TestSpanNotNegativePost.java (JUnit test):
repro/LFV-279/src/org/apache/lucene/queries/spans/TestSpanNotNegativePost.java(60 lines)
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.
assets/LFV-279/bug.png(468 KB)
![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).](../assets/LFV-279/bug.png)