SpanContainingQuery(big, little) keeps the big spans that contain a little span; SpanWithinQuery(big, little) keeps the little spans that lie inside a big span. Both walk the document's big and little spans once, forward only.
- You search inside one sentence (
bos ... eos) forcake, orappleup to 5 words beforepie:SpanContainingQuery(big = spanNear([bos, eos], 100), little = spanOr(spanNear([apple, pie], 5), cake)).SpanWithinQuerywith the same pair asks the same question. - On
bos cake eos bos pie eosboth queries find the document. Add one word to the first sentence:bos apple cake eos bos pie eos. That sentence still containscake, so you expect 1 hit. - You get 0 hits from both queries, and
IndexSearcher.countsays 0. No error. The Intervals equivalents (Intervals.containing,Intervals.containedBy) return 1. - Why: spans are sorted by start, then by end. The little spans are
apple ... pie[1,6), which runs into the second sentence, thencake[2,3): the end goes down. Containing tests sentence [0,4) only against [1,6) and moves on; within skips sentence [0,4) while placing [1,6) and never goes back forcake. - It only loses matches. An exhaustive check over 5,460 short documents and 648 queries found 10,762 missed document hits, 0 extra, and no miss when the little ends rise.
Applications that run SpanContainingQuery or SpanWithinQuery with a little query whose spans can end out of order, typically an OR of a longer phrase or near clause and a shorter clause that can occur inside it. Default codec and settings. Fixed-width little queries (terms, exact phrases, ORs of single terms) are safe; no Lucene query parser builds these queries.
Mechanism, terms and numbers762 words
SpanContainingQuery(big, little) should return every big span that contains at least one little span, and SpanWithinQuery(big, little) every little span inside a big span. Spans are enumerated by start and then by end, so when the little query is a SpanOrQuery of a longer clause and a shorter one that starts inside it, the little spans' ends go down: [1,6), then [2,3). Both queries merge big and little in one forward pass that assumes the ends go up: containing tests each big span against only the first little span that starts at or after it, and within advances the big iterator past big spans that a later, shorter little span would fit in, and never goes back. Whole documents and individual spans are missed, IndexSearcher.count is wrong too, SpanWithinQuery(A, B) and SpanContainingQuery(A, B) can disagree, and adding a word to a document or an OR alternative to the little query can remove a hit; the queries never return an extra match. It happens with the default codec and settings; the merge is per document, so index size does not matter; and every release since the classes were added (5.2.0) is affected. A local fix buffers each document's spans instead of skipping them and keeps collect() working.
- Span: a match of a span query in one document, written
[start, end)in token positions. Inbos apple cake eos bos pie eosthe tokens are at positions 0 to 6, andcakeis the span [2,3). - Order of spans: a
Spansiterator returns a document's spans "by increasing start position and finally by increasing end position" (Spans.java:25-28). The ends may go down across starts. - Big and little:
SpanContainingQuery"matching spans frombigthat contain at least one spans fromlittle" (SpanContainingQuery.java:33-35);SpanWithinQuery"matching spans fromlittlethat are inside ofbig" (SpanWithinQuery.java:32-34). - Out-of-order ends: a
SpanOrQuery("the union of its clauses") of a longer clause and a shorter one that starts inside it.spanOr(spanNear([apple, pie], 5), cake)on the document above gives [1,6), then [2,3).
- Containing (
SpanContainingQuery.java:94-132): for each big span, advance little whilelittle.start < big.start, then test only that one little span (big.end >= little.end). Sentence [0,4) is tested against [1,6) only (6 > 4); for sentence [4,7) the little iterator runs past [2,3) and is exhausted.cakeis never tested against the sentence that contains it. - Within (
SpanWithinQuery.java:93-131): for each little span, advance big whilebig.end < little.end, then testbig.start <= little.start. Little [1,6) pushes the big iterator past sentence [0,4) to [4,7); little [2,3) is then tested only against [4,7) (4 > 2). ASpanscannot be rewound, so [0,4) is gone. - Only out-of-order ends trigger it. If the little ends rise, skipping is safe: a big span that ends before the current little span ends before every later one. A TLA+ model of both loops (TLC, every pair of span lists of up to 3 spans over 4 and 5 positions) shows them sound and complete whenever the little ends rise, whatever the big spans are.
- Trigger detail. For containing, the long little span that hides the match must be the first little span that starts at or after
big.start. Within fails whenever an earlier, longer little span pushes the big iterator past the big span that contains a later, shorter one.
| document | big | little (spans) | expected containing / within | main |
|---|---|---|---|---|
new york city | phrase new york [0,2) | york [1,2) | 1 / 1 | 1 / 1 |
new york city | phrase new york [0,2) | spanOr(phrase new york city, york) ([0,3), [1,2)) | 1 / 1 | 0 / 0 |
new york city | york [1,2) | the same OR | 1 / 1 | 1 / 0 |
a b a | spanNear([a, b], 0) [0,2) | spanOr(spanNear([a, a], 5), b) ([0,3), [1,2)) | 1 / 1 | 0 / 0 |
The last row is the 6-state minimal counterexample TLC produced. Adding an OR alternative removes a hit, and within disagrees with containing on the same pair.
Exhaustive check of the stock jar (every document of 1 to 6 tokens over 4 letters, 5,460 documents; 18 span queries as big and as little; both query types: 648 queries; expected results built from the spans Lucene itself returns for big and little): 68 queries miss documents, 10,762 missed document hits and 20,016 missed spans, 0 extra hits or spans, IndexSearcher.count wrong on the same 68 queries, 28 big/little pairs where within and containing disagree, and no failure when the little ends rise.
Exposure and likelihood265 words
- Who is exposed. Code that builds
SpanContainingQueryorSpanWithinQuery(public API in lucene-queries) with such a little clause: a sentence- or paragraph-scoped "X OR (Y NEAR Z)" search, or a nested containment query as the little clause. The registry also lists downstream query languages with span_containing / span_within queries as a route (not checked here). - What happens. Documents are missed, so counts, top-k and facets built on the query are too low. Documents that still match can lose spans and so score lower (sloppy frequency): 76 queries in the exhaustive check return a wrong span set against 68 with a wrong hit set; for example
SpanWithin(b, spanOr(spanNear([a, b], 3, unordered), b))onb b areturns [0,1) but not [1,2). - Inside an exclusion (
MUST_NOT), a missed match lets a document through:+*:* -SpanContaining(sentence, little)returnsbos apple cake eos bos pie eos(1 hit, expected 0); with the fix, 0. - Within and containing disagree.
SpanWithinQuery(A, B)andSpanContainingQuery(A, B)match different documents for 28 big/little pairs, which an upstream test asserts cannot happen. - Scale. Deterministic and per document (seen from one-document indexes to 100,000 documents); the default codec,
IndexWriterConfigandIndexSearcherin the plainmain()probes, randomized codecs in the JUnits; assertions on and off. On 100,000 random 40-word documents,SpanContainingQuery(near(w1, w2, 10), spanOr(near(w3, w4, 5), w5))returns 1,122 hits on main and 1,124 with the fix: rare on random text; real phrasing patterns decide. - How likely. Rare: span queries are a legacy API (intervals are the replacement), the shape is specific, and nobody has reported it since 2015. Not a 10.6.0 regression.
The test shows that SpanContainingQuery and SpanWithinQuery miss a match when the little query's spans end out of order: on 'a b a', little = spanOr(spanNear([a, a], 5), b) yields [0,3) then [1,2), and the shorter [1,2) inside big spanNear([a, b], 0) = [0,2) is never tested.
/*
* 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 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.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestSpanContainUnorderedLittleEnds extends LuceneTestCase {
public void testShortLittleSpanAfterLongerOne() throws Exception {
IndexWriterConfig iwc =
new IndexWriterConfig(new StandardAnalyzer()).setCodec(TestUtil.getDefaultCodec());
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
Document doc = new Document();
doc.add(new TextField("f", "a b a", Field.Store.NO));
w.addDocument(doc);
w.commit();
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IndexSearcher searcher = new IndexSearcher(reader);
SpanQuery a = new SpanTermQuery(new Term("f", "a"));
SpanQuery b = new SpanTermQuery(new Term("f", "b"));
SpanQuery big = new SpanNearQuery(new SpanQuery[] {a, b}, 0, true);
SpanQuery little = new SpanOrQuery(new SpanNearQuery(new SpanQuery[] {a, a}, 5, true), b);
int containing = searcher.count(new SpanContainingQuery(big, little));
int within = searcher.count(new SpanWithinQuery(big, little));
// expected: big 'a b' [0,2) contains little 'b' [1,2), so both match; actual: neither does
assertEquals("containing=1 within=1", "containing=" + containing + " within=" + within);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-909/, 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.TestSpanContainUnorderedLittleEnds
- Expected vs actual
- Expected both queries to match the document (
containing=1 within=1); actual: neither does (containing=0 within=0), with-eaand-da.
More files, environment, full output34 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
=> org.junit.ComparisonFailure: expected:<containing=[1 within=1]> but was:<containing=[0 within=0]>
org.junit.Assert.assertEquals(Assert.java:117)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.queries.spans.TestSpanContainUnorderedLittleEnds.testShortLittleSpanAfterLongerOne(TestSpanContainUnorderedLittleEnds.java:53)- The javadoc promises exact containment: "matching spans from
bigthat contain at least one spans fromlittle". - Falling ends are legal; an OR "matches the union of its clauses", yet adding a clause removes a hit.
- An upstream test asserts SpanWithinQuery(A, B) and SpanContainingQuery(A, B) match the same documents; on
new york citywith bigyork: 0 and 1.
The full argument185 words
- Exact containment is documented (
SpanContainingQuery.java:33-35,:76-79;SpanWithinQuery.java:32-34,:75-78;package-info.java:35-38). No javadoc, package doc, CHANGES or MIGRATE entry says the result is approximate or lazy. The only "minimum slop matches" caveat in the package is onNearSpansOrdered(NearSpansOrdered.java:26), which is not involved: the expected results are built from the spans Lucene itself returns for big and little. - The input is legal.
Spans.java:25-28allows falling ends; the module's test wrapperAssertingSpanschecks that starts never go backwards and thatstart <= end, not the order of ends (every query of the union-law test was wrapped inAssertingSpanQuery; no assertion fired). - Upstream asserts the broken property.
TestSpanSearchEquivalence.testSpanWithinVsContaining(:452-465): "SpanWithinQuery(A, B) = SpanContainingQuery(A, B)", with a single-term little clause. - Tests never covered it. Every upstream little clause in
TestSpanContainQuery,TestSpanSearchEquivalence,TestSpanWithinQueryand the nightly payload test is a term, an OR of single terms or a two-term near: all have rising ends. - History. The loops came with the classes (LUCENE-6083, 2015) and are unchanged; the discussion covered containment semantics, two-phase iteration and naming, not completeness.
The devil's-advocate check168 words
The strongest case for "works as designed": span queries are forward-only and lazy, and upstream accepts the consequences. In #8451 the author of ContainSpans defined lazy as "all subspans are only moved forward" and wrote "To allow collecting/payloads easily, I'd rather accept the limitations/bugs of the current lazy implementation"; #10247 stopped QueryBuilder from building span graph queries because of #8451; #16820 says that span queries "have some basically unfixable correctness bugs".
Why it fails here: #8451's statements are about SpanNearQuery and #10247 about the span graph queries QueryBuilder used to build, where a complete answer needs backtracking over child spans; #16820's remark names no class and no specific bug. Here the child spans are correct, the miss is in the two containment loops, the fix needs no rewinding of any child Spans, and the javadocs of these two classes promise exact containment. Every verifier judged it real, and the two that built the works-as-designed case rejected it; expect upstream to raise it anyway and to suggest intervals.
Recommended A. Buffer the document's spans, replay the partner's leaves with their real freq
Buffer per document instead of skipping: within keeps the largest end of the big spans starting at or before the little span; containing keeps the smallest end of the little spans starting at or after the big span. Record the buffered spans' leaves, with their real freq(), for collect(). Validated: every repro, the exhaustive check, the queries and highlighter modules.
private boolean littleInsideBig() throws IOException {
final int littleStart = littleSpans.startPosition();
while (bigSpans.startPosition() <= littleStart) { // NO_MORE_POSITIONS ends the loop
if (bigSpans.startPosition() != -1) {
bigEnds = ArrayUtil.grow(bigEnds, numBig + 1);
bigEnds[numBig++] = bigSpans.endPosition();
bigLeaves.record(bigSpans);
maxBigEnd = Math.max(maxBigEnd, bigSpans.endPosition());
}
bigSpans.nextStartPosition();
}
return maxBigEnd >= littleSpans.endPosition();
}All options and trade-offs4 options ยท 974 words
Option A was applied in a scratch worktree of main a9779ef3c193 and run through gradle with assertions on and off; it was validated on main only, not ported to or run on any release branch. Options B to D were not run. A user-side workaround is the Intervals API: Intervals.containing / Intervals.containedBy return the expected documents on the inputs checked.
A. Buffer the document's spans, replay the partner's leaves with their real freq (validated by the checks) (recommended)
- SpanWithinQuery: keep the big spans of the current document that start at or before the current little span, with the maximum of their ends; the little span matches iff that maximum is
>= little.end. Nothing is rewound. - SpanContainingQuery: buffer the document's little spans with the suffix minima of their ends; a big span matches iff the minimum end over the little spans that start at or after
big.startis<= big.end. collect()still reports a partner (ContainSpans.java:55-58collects big and little, and payload scoring relies on it). A new package-privateSpanLeafRecorderrecords the buffered spans' leaves (term, position, doc, freq, offsets, payload copy) and replays the partner's: for containing the first buffered little span inside the big span, for within the first buffered big span that contains the little span.- What this doc changed in the hunt's sketch: the replayed
PostingsEnumreturns the recordedfreq()anddocID()of the original postings. The sketch returnedfreq() == 1, and the UnifiedHighlighter (which sizes its offset arrays bypostings.freq(),PhraseHelper.java:355,:377-378) and a freq-sizedSpanCollectorthrewArrayIndexOutOfBoundsException, also on queries that work today.
3 files, +261/-38 (google-java-format applied). The within loop:
private boolean littleInsideBig() throws IOException {
final int littleStart = littleSpans.startPosition();
while (bigSpans.startPosition() <= littleStart) { // NO_MORE_POSITIONS ends the loop
if (bigSpans.startPosition() != -1) {
bigEnds = ArrayUtil.grow(bigEnds, numBig + 1);
bigEnds[numBig++] = bigSpans.endPosition();
bigLeaves.record(bigSpans);
maxBigEnd = Math.max(maxBigEnd, bigSpans.endPosition());
}
bigSpans.nextStartPosition();
}
return maxBigEnd >= littleSpans.endPosition();
}Trade-offs
- Cost. Each candidate document's little spans (containing) or big spans (within) are drained and their leaves recorded, with a payload copy when payloads exist; memory grows with the spans and the recorded leaves (term, position, doc, freq, offsets, payload copy) per document, and high-frequency leaves, deep span trees and large payloads were not measured. A rough probe on a shared, loaded machine (100,000 random documents,
count(), 7 alternating runs each; not a benchmark), median main vs fix: containing with an OR little clause 9.1 vs 10.7 ms, within with the OR 7.2 vs 7.9 ms, with a term little clause 5.5 vs 5.7 ms and 4.3 vs 4.7 ms. The run-to-run spread is about as large: probably a modest slowdown; benchmark properly before quoting numbers. - Partner reported to
collect(). Where main already matches, containing reports the same little span as today. Within can report another big span when ends are out of order: ona b c d e, bigspanOr(phrase a b c, phrase a b c d e), littlespanOr(phrase a b c d, b), the little span [1,2) is collected with big [0,5) on main and with big [0,3) with the fix (both contain it). Payload scores and highlights of such spans can change; keep today's partner if the PR must not change them. - Review nits. Containing's
collect()ends withthrow new AssertionError(...); upstream is replacing such errors with proper exceptions (#16919), so useIllegalStateException. The recorder allocates one smallPostingsEnumper replayed leaf. - Scope. Local to the two classes plus one package-private helper; no API change.
What the checks validated
- Gradle in a scratch worktree of main
a9779ef3c193(removed afterwards), seed DEADBEEF, assertions on and off: the finder's, the independent reproducer's and the devil's advocate's JUnits and the freq-sized collector test (4 classes, 14 tests) pass, where main fails 9 of them with assertions on and 9 off; the two UnifiedHighlighter tests pass on main and with the fix. - This doc's JUnit: 3 of 3 pass with the fix and 3 of 3 fail on main, assertions on and off.
- All span and payload tests with
-Ptests.nightly=true: 32 classes, 280 tests, 0 failures (the copied tests included). - All of
:lucene:queries:test: 55 classes, 526 tests, 0 failures; all of:lucene:highlighter:test: 36 classes, 724 tests, 0 failures.:lucene:queries:check -x test(google-java-format, ecjLint, forbiddenApis, license headers) passes with the patch and this doc's JUnit. - Through the repro harness with the three files shadowed (assertions on and off): the finder's JUnit 4 of 4, the reproducer's 6 of 6, the root-cause test, the two highlighter tests and the freq-sized collector test pass; the exhaustive check gives 0 mismatches over 839,056 checks.
- UnifiedHighlighter snippets are identical to main's on every case main already matches, with offsets from postings and from weight matches; the missed cases now highlight, for example
<b>new</b> <b>york</b> city. - Earlier, for the position logic: TLC shows the fixed loops sound and complete within its bounds (mutants of the fix are caught), and 264 upstream span and payload tests pass with
-Dtests.nightly=true.
Keep today's fast path (test the current span first) and buffer only the spans the loop advances past while it looks further, with a sliding minimum (containing) or maximum (within).
Trade-offs
Avoids draining whole documents when the first candidate matches, at the price of more complex state; needs its own model check or exhaustive run.
SpanContaining(big, spanOr(a, b)) becomes spanOr(SpanContaining(big, a), SpanContaining(big, b)), and the same for within, as #9523 did for intervals.
Trade-offs
Partial: fixes only a top-level OR whose clauses each have rising ends, changes scoring and span duplication, and leaves nested shapes broken.
State in both javadocs that the little query's spans must end in start order.
Trade-offs
Cheapest; fixes nothing.
Used inside an exclusion (a MUST_NOT clause), a missed match lets a document through, so an application that filters with these queries can show documents it meant to hide.
A bounded-exhaustive check of the stock jars against the javadoc, with expected results built from the spans Lucene itself returns for big and little, flagged the misses. A TLA+ model of the two loops then gave the 6-state minimal counterexample and showed, within its bounds, both loops complete whenever the little ends rise.
The method in full150 words
- Method (semi-formal): bounded-exhaustive checking of the stock jars against the set semantics in the javadoc. An earlier hunt unit's spans checker met the
SpanWithinleg and set it aside as the #8451 class; a later planning probe (5,460 documents x 392 queries) found both legs and isolated the containment step. - Then TLA+ model checking (TLC; the logs say "TLC2 Version 2026.10.20.183001") of the per-document loops, transcribed line by line: every pair of sorted span lists of up to 3 spans over 4 and 5 positions. TLC found the 6-state counterexample big [0,2), little [0,3), [1,2) and, within those bounds, showed both loops sound and complete whenever the little ends rise, and showed the fix's position logic sound and complete (its mutants are caught).
- The exhaustive check of the jar was then widened to 857,111 checks, and the finder reduced it to a public-API JUnit; independent checkers followed (see "Independent checks").
No prior report found (searched 2026-10-28: 225 queries over GitHub issues, PRs and commits, the lists, JIRA, downstream trackers and the web). Closest: #8451, the same forward-only family in SpanNearQuery; #16820 proposes deprecating span queries.
Searches3 searches
#8451, #9523 and #16820 are related, but this one is separate: the child spans are correct, the Intervals siblings return the right result, and the fix is local to the two classes. If #8451 is read broadly enough to cover this, the novelty becomes "known". The hunt's own earlier sighting is the hunt's, not outside prior art. Gaps: GitHub search returns at most 50 hits per query; private reports cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search (three runs) | 2026-10-28 | apache/lucene issues, PRs and commits; the dev, java-user, issues, commits and solr-user lists; the JIRA archive; downstream trackers; the web | no report |
| GitHub (manual) | 2026-10-28 | 51 searches on apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr and all of GitHub; the changed files of all 412 open apache/lucene PRs and the 100 most recently updated closed PRs; a refresh | no report; no open PR touches the four files |
| Related items (read) | 2026-10-28 | 23 hits read in full | #8451 (SpanNearQuery, open), #9523 and #9243 (the Intervals sibling, fixed in 8.1), #16820 (deprecation proposal), #7145 (introduced the classes), #14405 (tests), #10247 (QueryBuilder stopped building span graphs) |
Introduced with the classes by LUCENE-6083 (4483ff20895, 2015-04-29) and unchanged since; first released in 5.2.0. Every version run, from 5.2.0 to 10.5.2, the 10.6.0 RC and main, fails. Not fixed upstream.
Versions tested6 rows
Introduced by LUCENE-6083 "Span containing/contained queries" (GitHub #7145), patch by Paul Elschot with renames and tests by Robert Muir, who committed it on 2015-04-29: trunk 4483ff20895, branch_5x 2e2ae5435e7. No PR (svn era). The commit created the classes, so there is nothing to bisect. Normalized digests of both loop methods are identical from that commit to main, and git log -S on the four loop conditions finds only the two introducing commits plus aad44bc6e36 (2021, the commit that emptied the old branch_8x).
Not fixed upstream: the last commits on the four files are #13957 (2024), #11840 (2022) and #152 (2021), none touching the loops; no open PR touches them (checked 2026-10-28, main head c5ab3e57b9b).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's JUnit fails 3 of 3 (assertions on and off, gradle and harness); the finder's 3 of 4, the independent reproduction's 4 of 6, the intended-behaviour check's 2 of 3 |
branch_10_6 head 4ae946e5a9b (the 10.6.0 RC1 revision, released 2026-10-29) | affected | the finder's JUnit 3 of 4, the independent reproduction's 4 of 6 |
| 10.5.2, 10.0.0, 9.12.3 | affected | the finder's JUnit 3 of 4; this doc's JUnit 3 of 3 on 10.5.2 and 9.12.3 |
| 5.2.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0 | affected | main() variants: 3 of 5 checks fail, controls pass (assertions off) |
| 5.1.0 | API absent | the classes do not exist; the repro does not compile |
fix A on main a9779ef3c193 | not affected | every repro passes; the queries and highlighter modules pass |
An independent reproducer, cross-model reviews, a root-cause adversary, a devil's advocate, an adjudication and a credibility audit all judged it real and re-ran it on main; this doc redid the fix's replay and validated it through gradle.
Checks and verdicts372 words
Every check ran on main a9779ef3c193, except the cross-model reviews, which ran on main ea57360912ed (2026-10-24; the span classes are identical); the reproducer also ran on the 10.6.0 RC, 10.5.2 and 9.12.3.
| Check | Verdict | What it found |
|---|---|---|
| Cross-model reviews (refute and intended lenses) | real (high) | An independent reviewer from another model family (OpenAI GPT-5.6), run by the finder before submitting the candidate. Each lens wrote and ran its own public-API test: the refute lens on main with several seeds and with assertions off, the intended lens on main, 10.5.2 and 9.12.3. The intended lens rated the works-as-designed argument moderate (the refute lens: weak) and rejected it. |
| Independent reproducer | real | Own JUnit with expected results from Lucene's own spans: 4 of 6 fail on main (assertions on and off, three seeds), the 10.6.0 RC, 10.5.2 and 9.12.3; own exhaustive run: 1,134 wrong results, 0 extra, 0 with rising little ends. |
| Root-cause adversary | real (high); fix sketch not ready | The same GPT-5.6 reviewer: an instrumented trace places the loss in the two loops; a randomized check finds no mismatch with rising little ends; it found that the sketch's replayed postings report freq() == 1 and break the highlighter (fixed in this doc). |
| Devil's advocate | real (high) | Built the strongest works-as-designed case (#8451, #10247, #16820) and rejected it; its union-law test with upstream AssertingSpanQuery wrappers fails 2 of 3 on main and 10.5.2. Medium confidence on how upstream will classify it. |
| Adjudication | confirmed (high), Medium | Re-ran the independent reproduction's JUnit with a new seed (4 of 6 fail, assertions on and off) and the finder's (3 of 4); the fix's position logic validated, its collect/replay part to be redone. |
| Credibility audit | keep | Own JUnit with StandardAnalyzer and the default IndexWriterConfig: 2 of 3 fail with assertions on and off (new york city gives 0 and 0); code and prior art re-checked. |
| Fix validation (this doc) | fix A passes | Redid the replay (real freq() and docID()), then gradle in a scratch worktree, stock and fixed: every repro and the cross-model review's three regression tests pass, and so do the queries and highlighter modules; the exhaustive check gives 0 mismatches. |
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 561 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,558 words
- Registry title
- SpanContainingQuery and SpanWithinQuery miss documents and spans when the little query's spans end out of order (for example a SpanOrQuery of a longer near clause and a shorter clause that can occur inside it): their single forward pass tests only the current little span (containing) or never goes back to a big span it skipped (within), so a later, shorter little span inside a big span is never tried
- Area
- queries / span queries (per-document lockstep of the ContainSpans subclasses in SpanContainingQuery and SpanWithinQuery)
- Priority
- Low, rank 266: Needs a mixed-length SpanOrQuery as the little query of the legacy SpanContainingQuery or SpanWithinQuery.
- Impact class
- silent wrong results
- Impact note
- Applications that run SpanContainingQuery or SpanWithinQuery (directly or through a downstream span_containing / span_within query) with a little query whose spans can end out of order, typically a SpanOrQuery that mixes a longer near or phrase clause with a shorter clause that can occur inside it, silently lose whole documents and individual spans (IndexSearcher.count is wrong too, documents that still match can get fewer spans and so lower sloppy-frequency scores, and SpanWithin(A, B) and SpanContaining(A, B) can match different documents), with the default codec, IndexWriterConfig and searcher, -ea and -da, at any index size, in every release from 5.2.0 to 10.5.2, 10.6.0 (released 2026-10-29) and main; nothing extra is ever returned, and little queries made of terms, phrases or ORs of single terms are not affected.
- Summary
- You index 'a b a' and search with SpanContainingQuery(big = SpanNear([a, b], slop 0), little = SpanOr(SpanNear([a, a], slop 5), b)): the big span 'a b' at [0,2) contains the little span 'b' at [1,2), so you expect a hit, and SpanWithinQuery with the same pair should match too. Instead both return nothing, because the little spans arrive sorted by start as [0,3) and then [1,2) (their ends go down, which the Spans contract allows), and each query's single forward pass gives up after testing only the longer little span [0,3): containing moves on to the next big span, within moves the big iterator past [0,2) and never comes back. The same happens in a sentence-scoped search for '(apple NEAR/5 pie) OR cake' on 'bos apple cake eos bos pie eos' (the 'cake' sentence is missed only because 'apple ... pie' crosses into the next sentence), so adding a word to a document or an OR alternative to the query can remove a hit; nothing extra is ever returned.
- Introduced
- 4483ff208956f74eeef32cfd6ee724ec0ea5d549 (trunk, svn r1676716, 2015-04-29, committed by Robert Muir) and its branch_5x backport 2e2ae5435e7 (svn r1676723, the commit 5.2.0 contains): LUCENE-6083 'Span containing/contained queries' (GitHub #7145 after the JIRA migration), patch by Paul Elschot with renames and tests by Robert Muir; no pull request (svn era); CHANGES 5.2.0 New Features [read: git blame -w -M on main's lockstep lines and git log --all -S on each of the four lockstep conditions point only to these two commits (plus the 2021 commit that emptied the old branch_8x); whitespace- and comment-normalized digests of both lockstep methods are identical from 4483ff20895 to main a9779ef3c193; exec: 5.1.0 lacks the classes, 5.2.0 fails like main]. Not a regression: the lockstep has been incomplete since the queries were added; no commit-level bisection is possible because the introducing commit creates the classes (2015-04-29)
- First affected version
- 5.2.0 [exec] (the first release of SpanContainingQuery and SpanWithinQuery; 5.1.0 and earlier lack the classes [exec: the repro does not compile against 5.1.0, whose core jar has none of the four classes])
- Affected versions
- every release from 5.2.0 through 10.5.2, 10.6.0 (released 2026-10-29; branch_10_6 head 4ae946e5a9b) and main a9779ef3c193; not fixed upstream (the last commits on the four files are #13957 (2024-10-26), #11840 (2022-10-19) and #152 (2021-05-26), none of which touches the lockstep; main head c5ab3e57b9ba and branch_10_6 head 4ae946e5a9b9 checked 2026-10-28). [exec] the finder's JUnit fails 3 of 4 (its Intervals control passes) on main (-ea, -da), the branch_10_6 head, 10.5.2, 10.0.0 and 9.12.3 (-ea); its main() variants fail 3 of 5 (controls pass) on 5.2.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0 and main (-da); the independent reproduction's JUnit fails 4 of 6 on main (-ea, -da), the branch_10_6 head, 10.5.2 (-ea, -da) and 9.12.3; the intended-behaviour check's fails 2 of 3 on main (-ea, -da) and 10.5.2. [read] identical lockstep digests at 5.2.0, 5.5.5, 6.6.6, 7.7.3, 8.8.1, 9.0.0, 9.12.3, 10.0.0, 10.5.2, 4ae946e5a9b and main; 8.8.2 to 8.11.3 are not tagged in the history clone and are listed because 8.8.1 [read] and 8.11.4 [exec] are. Not a 10.6.0 regression. Caveats: all runs used JDK 25 on aarch64 Linux, and the releases before 9.12.3 ran with -da only
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check; also reached by TLA+/TLC
- Upstream and prior reports
- none found (2026-10-28, the prior-report record, verdict new:, reusing the finder's run; 225 recorded read-only queries: three scripted prior-report search runs over apache/lucene issues, PRs and commits, the dev@, java-user@, issues@, commits@ and solr-user@ lists, the JIRA archive, downstream trackers and the web, 51 manual GitHub searches (apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr, all of GitHub), the changed files of all 412 open apache/lucene PRs and of the 100 most recently updated closed or merged PRs; 23 hits read, none the same bug; refresh 2026-10-28: no issue or PR updated since 2026-10-27 mentions spans, none of the 37 PRs updated since then touches spans files). Not fixed upstream (no commit after 2024-10-26 on the four files, by author date, on main or branch_10_6; no open PR touches them). Closest items, not reports of this bug: #8451 (LUCENE-7398, open, 'Nested Span Queries are buggy': SpanNearQuery's NearSpansOrdered / NearSpansUnordered miss matches when SpanOr subclauses differ in length; same family of forward-only span merging, but all 37 comments are about SpanNear, the containment queries are never named, and its fix would change different code); #9523 (LUCENE-8477, fixed in 8.1: the Intervals containing / containedBy sibling missed matches when the big interval was minimized, fixed by pulling disjunctions up; spans untouched); #9243 (LUCENE-8196: a 2018 outside comment shows Intervals containment losing a match when an OR clause is added, with a general aside on span-query ordering and laziness, about Intervals); #16820 (open, proposes deprecating span queries in Lucene 11 for 'basically unfixable correctness bugs', naming none); #7145 (the introducing issue, no completeness discussion); #14405 (tests only); #10247 (QueryBuilder stopped building span graph queries because of #8451)
- Lucene commit tested
- main
a9779ef3c193(2026-10-26). For this doc: a new 3-test JUnit through the repro harness on main (assertions on and off; 10 iterations with another seed), 10.5.2 and 9.12.3, and with the fix shadowed; gradle in a scratch worktree (removed afterwards), seed DEADBEEF, stock and with the fix: the finder's, the independent reproduction's, the intended-behaviour check's and separate tests with assertions on and off, all span and payload tests withtests.nightly, all of:lucene:queries:testand:lucene:highlighter:test, and:lucene:queries:check -x test; through the harness, the exhaustive check, highlighter snippets, an exclusion probe, acollect()partner probe and a rough cost probe. Earlier checks ran the tests on the branch_10_6 head4ae946e5a9b, 10.5.2, 10.0.0 and 9.12.3, andmain()variants on 5.2.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4 and 9.0.0. OpenJDK 25.0.3, Linux aarch64.
- Finding record
lucene/queries/src/java/org/apache/lucene/queries/spans/SpanContainingQuery.java:33-35 (at a9779ef3c193) (javadoc: 'matching spans from big that contain at least one spans from little'), :94-132 (twoPhaseCurrentDocMatches / nextStartPosition: little advanced only while little.start < big.start, then only the current little span is tested with big.end >= little.end)lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWithinQuery.java:32-34 (at a9779ef3c193) (javadoc: 'matching spans from little that are inside of big'), :93-131 (big advanced while big.end < little.end, never rewound; then big.start <= little.start tested)lucene/queries/src/java/org/apache/lucene/queries/spans/Spans.java:25-28 (at a9779ef3c193) (spans enumerated by increasing start position and then by increasing end position, so ends may fall across starts), SpanOrQuery.java:37 ('Matches the union of its clauses')lucene/queries/src/java/org/apache/lucene/queries/spans/ContainSpans.java:23-30 (at a9779ef3c193) (bigSpans, littleSpans, the shared base of both anonymous subclasses), :55-58 (collect visits big and little; the LFV-266 site, not this one)lucene/queries/src/test/org/apache/lucene/queries/spans/TestSpanSearchEquivalence.java:452-465 (at a9779ef3c193) (testSpanWithinVsContaining asserts SpanWithinQuery(A, B) = SpanContainingQuery(A, B) at document level, with a single-term little clause)Lucene main a9779ef3c193 SECURITY.md sections 'Untrusted query and analysis input' and 'In-scope reports' (line 36 on)
- The finding in 30 seconds
lucene/queries/src/java/org/apache/lucene/queries/spans/Spans.java:25-28 (order of spans) @ a9779ef3c193lucene/queries/src/java/org/apache/lucene/queries/spans/SpanContainingQuery.java:33-35 (javadoc), :94-132 (lockstep) @ a9779ef3c193lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWithinQuery.java:32-34 (javadoc), :93-131 (lockstep) @ a9779ef3c193lucene/queries/src/java/org/apache/lucene/queries/spans/SpanOrQuery.java:37 ("Matches the union of its clauses.") @ a9779ef3c193
- Why it looks unintended
lucene/queries/src/java/org/apache/lucene/queries/spans/SpanContainingQuery.java:33-35, :76-79; lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWithinQuery.java:32-34, :75-78 @ a9779ef3c193lucene/queries/src/java/org/apache/lucene/queries/spans/package-info.java:35-38; lucene/queries/src/java/org/apache/lucene/queries/spans/NearSpansOrdered.java:26 @ a9779ef3c193lucene/queries/src/test/org/apache/lucene/queries/spans/TestSpanSearchEquivalence.java:452-465 @ a9779ef3c193lucene/queries/src/test/org/apache/lucene/queries/spans/AssertingSpans.java:76-77, :103-104 @ a9779ef3c193
- Suggested fix
lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/PhraseHelper.java:355, :377-378, :408 @ a9779ef3c193lucene/queries/src/java/org/apache/lucene/queries/spans/ContainSpans.java:55-58 (collect) @ a9779ef3c193
- TestSpanContainUnorderedLittleEnds.java (JUnit test):
repro/LFV-909/src/org/apache/lucene/queries/spans/TestSpanContainUnorderedLittleEnds.java(57 lines)
Every token, span list, count and code line on screen is read from a probe executed on main a9779ef3c193 and from the sources at that commit; the fix results come from the same probe with the recommended fix compiled in and from the fix validation.
assets/LFV-909/bug.png(561 KB)
![Summary card in four panels. 1, what you do: the document 'bos apple cake eos bos pie eos', big = spanNear([bos, eos], 100, true) (one sentence), little = spanOr([spanNear([apple, pie], 5, true), cake]); both counts are expected to be 1. 2, what Lucene sees: big spans [0,4) sentence 1 and [4,7) sentence 2; little spans [1,6) apple ... pie, then [2,3) cake, which sentence 1 contains. 3, where it goes wrong: SpanContainingQuery tests big [0,4) only against the current little span [1,6) (4 >= 6 fails) and then moves little past cake; SpanWithinQuery moves big past [0,4) for little [1,6) and never goes back, so cake [2,3) fails 4 <= 2. 4, result on main with assertions on and off: both counts 0 instead of 1; the document is found without the word apple, and by Intervals.containing / containedBy.](../assets/LFV-909/bug.png)