All findings · Lucene finding · LFV-266 · spanwithin-matches-offsets-include-big-span

SpanWithinQuery's Matches report the contained span's position but the enclosing span's offsets and terms, so the default UnifiedHighlighter marks the enclosing terms

For ac inside ab … bc, a SpanWithinQuery match says position 1 but offsets 0-8 and the terms ab, ac, bc, so the default UnifiedHighlighter returns <b>ab</b> <b>ac</b> <b>bc</b> instead of ab <b>ac</b> bc. The query's own hits and scores are right; every release since 7.5.0 has it.

Summary

Priority
Low, rank 358Highlighting-only (hits and scores are correct) in the little-used legacy SpanWithinQuery.
Impact
silent wrong results
Since
7.5.0 (LUCENE-8306 / LUCENE-8404, 2018)introduced 2018-07-22 · about 8 years
Affected
7.5.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
bounded-exhaustive comparison of highlighter offset sources on the real jars

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

SpanWithinQuery(big, little) matches the little spans inside a big span. Weight#matches, which highlighters read, takes a match's positions from the little span but its offsets and term list from a collector that, for this query, also walks the big span.

Summary diagram titled 'SpanWithinQuery matches mix the little span's positions with the big span's offsets'. Example: one document, field fo = "ab ac bc" with offsets; q = SpanWithinQuery(big = SpanNearQuery([ab, bc], slop 1, in order), little = ac). Left: the text on an offset ruler 0-8 with positions 0, 1, 2; the match's positions [1,1] box sits on ac (correct), its offsets [0,8) bar covers ab ac bc (wrong), expected [3,5) on ac. Right, expected vs actual: Weight#matches [3,5) ac@1 vs [0,8) ab@0 ac@1 bc@2; the UnifiedHighlighter with default flags, on field fo and on the positions-only field fp, ab <b>ac</b> bc vs <b>ab</b> <b>ac</b> <b>bc</b>; MatchRegionRetriever [3,5) vs fo [0,8) (wrong) and fp [3,5) (right); the control Intervals.containedBy(ac, ordered(ab, bc)) gives [1,1] [3,5) ac@1 and ab <b>ac</b> bc. A Why box: SpanWeight#matches takes positions from the spans, which for SpanWithinQuery are the little spans, but offsets and sub-matches from Spans#collect, and ContainSpans#collect also collects the big spans; intervals had the same defect and were fixed in 9.1.0 (LUCENE-10229). A line on the minimal position-filter fix (misses stacked tokens and some nested queries). Affected: every release from 7.5.0 to 10.5.2 and main, also when nested; hits and scores are not affected.
One document, "ab ac bc": the match's position box sits on ac, but its offsets and terms cover ab ac bc, so the default UnifiedHighlighter marks all three words and MatchRegionRetriever reports 0-8 with offsets but 3-5 without. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index "ab ac bc" (ab at offsets 0-2, ac 3-5, bc 6-8) and search SpanWithinQuery(big = SpanNear([ab, bc], slop 1), little = ac): "ac inside ab … bc". The document is a hit, correctly.
  2. You ask Weight#matches where it matched, as highlighters do. You expect ac only: position 1, offsets 3-5, one term. The intervals equivalent, Intervals.containedBy(ac, ordered(ab, bc)), reports exactly that.
  3. Instead you get position 1 but offsets 0-8 and the terms ab, ac, bc. The position comes from the contained span; the offsets and terms come from a collector that, for this query, also walks the enclosing span.
  4. So the default UnifiedHighlighter returns <b>ab</b> <b>ac</b> <b>bc</b>, on a field with offsets and on a plain positions-only field alike, and MatchRegionRetriever returns 0-8 with offsets but 3-5 for the same text without them.
  5. Nested queries are hit too: SpanNear([that query, zz]) on "ab ac bc zz" reports offsets 0-11 instead of 3-11. Which documents match and the query's own scores never change.

Who is affected

Applications that highlight SpanWithinQuery hits, directly or nested in another span query, with the default UnifiedHighlighter, MatchHighlighter or MatchRegionRetriever, or that read its Matches. It happens on every such match, with default settings and at any index size. The query's own hits and scores are unaffected; the query is little used and the recorded searches found no report since 2018.

Mechanism, terms and numbers517 words

SpanWithinQuery(big, little) matches the little spans that lie inside a big span. For each match, SpanWeight#matches takes the positions from the spans, which for this query are the little spans, but the offsets and the sub-matches from Spans#collect, and ContainSpans#collect collects the big span's terms as well. So one MatchesIterator reports the contained term's position with the enclosing span's offsets and terms: for ac inside ab … bc, positions [1,1] with offsets [0,8) and the sub-matches ab@0, ac@1, bc@2. The default UnifiedHighlighter (weight matches on since 9.0.0) therefore marks the enclosing terms, on fields with and without offsets, and MatchRegionRetriever returns [0,8) on a field with offsets but [3,5) on the same text indexed with positions only. Nested span queries are affected the same way. Which documents the query matches and its own scores are unaffected. The interval counterpart had the same inconsistency and was fixed in 9.1.0 (LUCENE-10229); spans were never changed. Present since span queries got Matches in 7.5.0.

Terms

  • SpanWithinQuery(big, little): "Keep matches that are contained within another Spans"; it matches "spans from little that are inside of big" (SpanWithinQuery.java:28, :31-35). SpanContainingQuery(big, little) is the mirror image: it keeps the big spans that contain a little one.
  • Weight#matches(context, doc) returns Matches; getMatches(field) gives a MatchesIterator with, per match, its start and end position, its start and end offset, and getSubMatches(), "the positions and offsets of individual terms within the current match" (MatchesIterator.java:47-84). Highlighters use it: the UnifiedHighlighter by default since 9.0.0, MatchRegionRetriever and MatchHighlighter since 9.0.0.
  • Spans#collect(SpanCollector) visits the leaf terms (postings) of the current span; payload scoring and the UnifiedHighlighter's older phrase path use it too.

Mechanism (main a9779ef3c193)

  1. SpanWeight#matches (SpanWeight.java:209) returns spans.startPosition() and spans.endPosition() - 1 as the match positions (:252-260).
  2. Its startOffset() and endOffset() are the first and last entries of innerTerms (:262-276), and getSubMatches() iterates innerTerms (:278-323). collectInnerTerms() fills innerTerms with spans.collect(termCollector) and sorts them by position (:330-334).
  3. For SpanWithinQuery the spans are new ContainSpans(big, little, little) (SpanWithinQuery.java:90): positions come from the third argument, the little spans (ContainSpans.java:28-47).
  4. But ContainSpans#collect calls bigSpans.collect(collector); littleSpans.collect(collector); (ContainSpans.java:54-58). At a match the big spans sit on the enclosing span, so ab@0 and bc@2 join ac@1; after the sort the offsets run from ab's 0 to bc's 8.
  5. The UnifiedHighlighter marks every leaf sub-match (OffsetsEnum.java:171-240) and re-analyzes a positions-only field into a MemoryIndex that stores offsets (MemoryIndexOffsetStrategy.java:48), so it is wrong on both fields. MatchRegionRetriever reads the match offsets on a field with offsets (OffsetsFromMatchIterator.java:53-54) but maps the match positions to offsets on a field without them (OffsetsFromPositions), so the two fields disagree.

SpanContainingQuery uses new ContainSpans(big, little, big) (SpanContainingQuery.java:91): its positions are the big span's, so its offsets and terms agree with them. The interval counterpart was fixed by keeping only the small source: "the only sub source we care is the \"small\" source" (ContainedByIntervalsSource.java:69-75, #521).

Exposure and likelihood359 words
  • Highlighting. The UnifiedHighlighter uses Weight#matches by default since 9.0.0 (LUCENE-9431; opt-in from 7.5.0, LUCENE-8286): it marks <b>ab</b> <b>ac</b> <b>bc</b> on a field with offsets and on a plain positions-only TextField (executed). MatchRegionRetriever and MatchHighlighter (9.0.0, LUCENE-9463) return [0,8) with offsets and [3,5) without (executed). Custom code that reads MatchesIterator offsets or sub-matches gets the wide values (executed).
  • Nested queries. SpanNear([SpanWithin(...), zz]) reports offsets [0,11) instead of [3,11) and the terms ab, bc besides ac, zz (executed). SpanOrQuery, FilterSpans and the other composite spans forward collect the same way (read).
  • No special settings. Nothing beyond using SpanWithinQuery and highlighting: the mechanism depends only on the span tree and the indexed positions and offsets, not on codec or IndexWriterConfig settings (read). The JUnits ran under the test framework's randomized codec, IndexWriterConfig and similarity, and the highlighter with all its defaults (executed); no field with offsets is needed for the UnifiedHighlighter.
  • How often. On every SpanWithinQuery match whose enclosing span has leaves outside the contained one, those leaves become sub-matches, and with ordinary (non-stacked) tokens the match offsets widen to them; that is typical when the big clause is wider than the little one, though no usage data was gathered. Deterministic.
  • How visible. The extra marks are query terms, so they look plausible; SpanWithinQuery is a little-used legacy query (intervals are the modern replacement, and #16820 proposes deprecating spans). Registry likelihood: rare; priority: medium (both qualitative; maintainers could rate it lower).
  • Downstream. Elasticsearch exposes the query as span_within; a maintainer closed elastic/elasticsearch#42715 (2024) as fixed with output that highlights the enclosing terms, shown as evidence that highlighting works; the extra marks were not discussed (read, not executed).
  • What stays right. Which documents a SpanWithinQuery matches, counts and its own scores: span scoring never calls Spans#collect, whose only callers are Matches, the payload queries, the UnifiedHighlighter's PhraseHelper, the sandbox PayloadSpanUtil and the composite spans that forward it (read). Executed: identical hits and bit-identical scores for top-level SpanWithinQuerys with and without fix option B; fix option A changes payload-function scores over a SpanWithinQuery (see the fixes). The SpanContainingQuery and Intervals.containedBy controls on the same input pass (executed).

Minimal repro

The test shows that Weight#matches of SpanWithinQuery(SpanNear([ab, bc], 1, ordered), ac) on "ab ac bc" reports the contained span's position (ac@1) but the enclosing span's offsets.

TestSpanWithinMatchesOffsets.java (JUnit test)67 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 org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexOptions;
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.MatchesIterator;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreMode;
import org.apache.lucene.search.Weight;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSpanWithinMatchesOffsets extends LuceneTestCase {

  public void testMatchOffsetsAreThoseOfTheContainedSpan() throws Exception {
    FieldType withOffsets = new FieldType(TextField.TYPE_NOT_STORED);
    withOffsets.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new Field("f", "ab ac bc", withOffsets));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        SpanQuery big =
            new SpanNearQuery(
                new SpanQuery[] {
                  new SpanTermQuery(new Term("f", "ab")), new SpanTermQuery(new Term("f", "bc"))
                },
                1,
                true);
        Query query = new SpanWithinQuery(big, new SpanTermQuery(new Term("f", "ac")));
        Weight weight =
            searcher.createWeight(searcher.rewrite(query), ScoreMode.COMPLETE_NO_SCORES, 1f);
        MatchesIterator it = weight.matches(reader.leaves().get(0), 0).getMatches("f");
        assertTrue(it.next());
        // the match is "ac" at position 1, offsets [3,5): expected startOffset 3, actual 0 (that of the enclosing "ab")
        assertEquals(3, it.startOffset());
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-266/, 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.TestSpanWithinMatchesOffsets
Expected vs actual
Expected startOffset 3 (the match is "ac", offsets [3,5)); actual 0, the start of the enclosing "ab" (expected:<3> but was:<0>).

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

More files, environment, full output34 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:<3> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.queries.spans.TestSpanWithinMatchesOffsets.testMatchOffsetsAreThoseOfTheContainedSpan(TestSpanWithinMatchesOffsets.java:63)

Why it looks unintended

  • MatchesIterator: positions and offsets are "of the current match", sub-matches are "terms within the current match"; positions [1,1] with offsets [0,8) fit no reading.
  • Lucene fixed the same split for intervals in 9.1.0 (LUCENE-10229): containedBy reports only the contained term.
  • Lucene disagrees with itself: [0,8) with offsets, [3,5) without; the classic Highlighter marks only ac.
The full argument307 words
  • The contract. MatchesIterator.java:47-52 "The start position of the current match"; :61-66 "The starting offset of the current match"; :68-73 "The ending offset of the current match"; :75-84 getSubMatches "iterates over the positions and offsets of individual terms within the current match". If ab and bc were part of the match, its positions would be [0,2]; if they were not, its offsets would be [3,5) and they would not be sub-matches.
  • What the query says it matches. SpanWithinQuery.java:28 "Keep matches that are contained within another Spans"; :31-35 "matching spans from little that are inside of big"; :75-78 "Return spans from little ... The payload is from the spans of little".
  • Precedent. LUCENE-10229 (#11265, by @dweiss) "Match offsets should be consistent for fields with positions and fields with offsets" quotes @romseygeek, who led the Matches API work, from #11259: "It's because it returns the position of the inner match, but the offsets of the outer ... the current behaviour is kind of the worst of both worlds". #521 (@zhaih, 9.1.0, CHANGES.txt:4469-4470) made containedBy report only the small source; #803 (9.2.0) followed up. Spans were not mentioned or changed.
  • Design intent. LUCENE-8306 described getSubMatches() as "another MatchesIterator over the individual term positions within the current match"; LUCENE-8286's CHANGES entry says weight-matches highlighting "will highlight more accurately than any other highlighter" (CHANGES.txt:7161-7162).
  • Internal disagreement (executed): MatchRegionRetriever [0,8) with offsets vs [3,5) without; the classic Highlighter gives ab <B>ac</B> bc; Intervals.containedBy gives [3,5) and ac only.
  • No test or discussion endorses it. No upstream test checks the positions, offsets, sub-matches or highlights of SpanWithinQuery or SpanContainingQuery Matches: TestSpanWithinQuery and TestSpanContainQuery reach the test framework's CheckHits.checkMatches, which asserts only that Matches is non-null for every hit (CheckHits.java:353-359). The LUCENE-6083, -6706, -8306, -8404 and -10229 threads say nothing in favour of reporting the enclosing span.
The devil's-advocate check226 words

The strongest works-as-designed case (the intended-behaviour verifier and a reading-only review by a model from another family): a containment match depends on both clauses, and ContainSpans#collect has visited both on purpose since LUCENE-6706 (2015, payload scoring), so "every leaf that caused the match" is one consistent reading of the sub-matches, and the Matches class javadoc says it reports "all matching terms in a query" (Matches.java:24-25); Matches and MatchesIterator are @lucene.experimental and SpanWeight is "Expert-only"; the UnifiedHighlighter's older phrase path (PhraseHelper, its own Spans#collect call) has highlighted the enclosing terms for years; and an Elasticsearch maintainer closed #42715 as fixed with such output. The #11259 comment quoted above also says "It's not obvious to me what the correct response is here" and goes on: "perhaps we should change it so that you get offsets of the inner match as standard, and then the outer match is returned as part of the sub matches"; the expectation that the sub-matches are ac only follows what #521 implemented, not that comment.

Why it fails: it explains the collector, not one MatchesIterator whose positions and offsets name different regions (even the #11259 suggestion gives the match the inner offsets, [3,5) here); "experimental" lets the API change, not contradict its own javadoc; and Lucene already ruled on the identical inconsistency for intervals. The verifiers rejected it (moderate strength).

Suggested fix

Recommended A SpanWithinQuery match collects only the contained span's leaves

In SpanWithinQuery, let the match's collect visit only the contained span (+6 lines), so offsets and sub-matches describe ac alone, nested cases included. Both JUnits and the whole queries and highlighter suites pass. Trade-off: payload scoring and payload checks over a SpanWithinQuery then see only the contained span's payloads, as its javadoc already says.

        @Override
        public void collect(SpanCollector collector) throws IOException {
          // the match is the little span: collect its leaves only, not the big span's
          littleSpans.collect(collector);
        }
All options and trade-offs3 options · 794 words

Option A was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF); option B (the finder's sketch) was run on the same JUnits; option C was not built.

A SpanWithinQuery match collects only the contained span's leaves (validated by the checks) (recommended)

Override collect in the anonymous ContainSpans that SpanWithinWeight#getSpans returns (+6 lines in SpanWithinQuery.java), so that Spans#collect visits only littleSpans. This fixes the top-level case, nested span queries (composite spans forward collect), big-span terms stacked on the little span's position, and also the UnifiedHighlighter's non-Matches phrase path (PhraseHelper calls Spans#collect itself). It gives the same small-source-only result as #521, which made containedBy report only the small source, but unlike #521 (an intervals Matches change only) it also changes the other Spans#collect consumers. SpanContainingQuery is unchanged.

        @Override
        public void collect(SpanCollector collector) throws IOException {
          // the match is the little span: collect its leaves only, not the big span's
          littleSpans.collect(collector);
        }

Trade-offs

  • Payloads change for SpanWithinQuery. Every Spans#collect consumer sees only the contained span: PayloadScoreQuery over a SpanWithinQuery scores only its payloads (probe, payloads ab=1, ac=2, bc=4: min/avg/max 1.0/2.33/4.0 before, 2.0/2.0/2.0 after), SpanPayloadCheckQuery checks only those, so it can accept or reject different spans and therefore match different documents (SpanPayloadCheckQuery.java:164-169), and the sandbox PayloadSpanUtil returns only those (read).
  • That matches the existing SpanWithinWeight#getSpans javadoc ("The payload is from the spans of little", SpanWithinQuery.java:76-77, LUCENE-6083) but reverses what LUCENE-6706 (5.3.0) made collect do; that change targeted payload scoring in general, its thread never discusses SpanWithinQuery, and no upstream test covers SpanWithinQuery payloads.
  • A direct SpanContainingQuery is unchanged, but a SpanContainingQuery or other composite with a SpanWithinQuery clause changes the same way.
  • It would need a CHANGES entry that names the payload change.
  • The adjudication and the registry notes describe the complete fix as a Matches-only leaf collection that keeps payload behaviour (option C). A is recommended here because it is the only option built and validated that fixes the nested and stacked cases and the PhraseHelper path, and because it matches the getSpans javadoc; if payload behaviour must not change, C is the choice, and maintainers may prefer it.

What the checks validated

  • Applied in a clean worktree at a9779ef3c193, gradle, seed DEADBEEF: both JUnits pass, 4 of 4 and 4 of 4, with assertions on and off.
  • The whole lucene/queries test task (53 suites, 518 tests, 5 skipped) and lucene/highlighter (36 suites, 727 tests) pass; that includes this doc's 2 JUnits and 2 probes, so 85 upstream suites with 1,234 tests.
  • With -Ptests.nightly=true: TestPayloadScoreQuery (including the @Nightly testSpanContainingQuery), TestPayloadCheckQuery and TestPayloadSpans pass, 22 tests.
  • Probe: the stacked case becomes [1,1] [3,5) with the sub-match ac only; the UnifiedHighlighter gives ab <b>ac</b> bc with weight matches on and off.
  • checkGoogleJavaFormat passes for both modules with the fix and the two JUnits.
  • Not run: the rest of ./gradlew check (forbidden APIs, ecj, javadoc lint), other modules (for example lucene/sandbox, whose PayloadSpanUtil calls Spans#collect, and lucene/monitor), other seeds (all runs used DEADBEEF), benchmarks.

Filter the collected terms to the match positions (Matches path only) (validated by the checks)

In SpanWeight#matches collectInnerTerms(), keep only collected terms whose position lies in [startPosition, endPosition) (+14 lines; the finder's sketch). No change to payloads or scoring.

Trade-offs

  • Leaves nested queries wrong: big-span terms inside the outer match positions survive (bc@2 in SpanNear([SpanWithin, zz])), so the default UnifiedHighlighter still marks bc there: ab <b>ac</b> <b>bc</b> <b>zz</b> on "ab ac bc zz", where option A gives ab <b>ac</b> bc <b>zz</b> (executed by the fact check).
  • Leaves stacked tokens wrong: a big-span term at the little span's position survives (xy@1 in the probe).
  • The UnifiedHighlighter's non-Matches phrase path is unchanged.

What the checks validated

  • Same worktree and seed, assertions on: TestSpanWithinHighlighting passes 4 of 4, but TestSpanWithinMatches still fails 1 of 4: the nested test, "Unexpected term match: 2[6->8]" (bc@2).
  • Probe: the stacked case keeps the sub-match xy@1 [3,8) (offsets become [3,5)); the UnifiedHighlighter without weight matches still marks all three words; payload scores unchanged.
  • In the hunt, the same diff passed the finder's and the independent reproduction's tests and changed no other result in a run of 437 highlighter and span tests (435 upstream tests plus the finder's 2-test repro; only the repro changed).

A separate, Matches-only leaf collection (not built)

Give Spans a second collection path used only by SpanWeight#matches (for example a method that defaults to collect, overridden by the within-ContainSpans and forwarded by every composite Spans). It would fix nested and stacked cases without touching payloads.

Trade-offs

Touches every composite Spans class and adds API surface to a module that may be deprecated (#16820). Not built or run.

Downstream note

Downstream servers expose this query (Elasticsearch's span_within), so their highlights of it can mark the enclosing terms too; only highlight extents and Matches offsets change, never which documents the query matches, its scores or any filtering decision.

How it was found

Bounded-exhaustive translation validation of the highlighter offset sources on the real jars: every document of a small enumerated language and 121 queries. The check "same text, same highlight with and without offsets" failed on the multi-valued tiers only for this SpanWithinQuery; code reading found the cause.

The method in full155 words

Method: bounded-exhaustive translation validation on the stock jars (the hunt's semi-formal tier), hunt unit "highlighter offset sources".

  • The harness ran the real UnifiedHighlighter offset sources, MatchRegionRetriever and the Matches API of the stock main jars over every document of a small enumerated document language (holes, stacked tokens, multi-valued fields with position gaps 0 and 1) and a 121-query catalogue, and compared them with an exact reference semantics and with each other.
  • Relation R7b (MatchRegionRetriever on a field with offsets equals the same on a positions-only field) failed on the multi-valued tiers, which have no stacked tokens with distinct offsets, only for SpanWithinQuery(SpanNear([ab, bc], 1, ordered), ac): 44 of 1,815 span checks at gap 1 and 56 at gap 0 locally, 3,280 in the larger gap-0 batch job.
  • The root cause was then found by reading SpanWeight#matches and ContainSpans#collect, and reduced to a public-API JUnit.
  • No model checker, SMT solver or prover was involved.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26, 239 recorded queries over GitHub, the mailing lists, JIRA, downstream repositories and the web, plus all 413 open PRs; re-checked 2026-10-27). Closest: LUCENE-10229 (#11265), the same defect for intervals, fixed in 9.1.0 without touching spans.

Searches5 searches

Verdict new: 19 hits classified, none the same bug; you have filed nothing on spans or Matches. Borderline call: if #11265's general title is read as covering every Matches implementation, the overlap is partial and the bug would count as known. Keyword search cannot prove absence; GitHub's search rate limit cut some automated queries, and private security reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs; dev@, java-user@, issues@, commits@; LUCENE JIRA archive; the web2026-10-26the hunt's prior-report tool in two passes (87 queries) plus 5 JIRA REST queriesno report; related only: #11265, #11259, #9468, #9353, #9451, #7764, #7145
GitHub (apache/lucene and three downstream search-server repositories)2026-10-26121 GraphQL searches: class, method and field names (SpanWithinQuery, ContainSpans, bigSpans/littleSpans, SpanWeight matches), symptoms, related JIRA keysno report; elastic/elasticsearch#42715 was closed as fixed by a maintainer with output that shows the symptom; the extra marks were not discussed
Items read in full2026-10-2638 apache/lucene items (body, all comments, review comments) and 4 Elasticsearch itemsLUCENE-10229 is the precedent for intervals; nothing on spans
Open PRs2026-10-26changed files of all 413 open apache/lucene PRsonly #15819 touches SpanWeight.java, and only its scorer supplier
apache/lucene issues and PRs created since 2026-10-262026-10-27read-only GitHub search, repeated by the fact checkno report; #16918 (deprecations slated for removal in 11.0) lists no span classes

History

Introduced in 7.5.0 by 028c86b1fa7 (LUCENE-8306 / LUCENE-8404, 2018), which gave span queries Matches with offsets and terms from Spans#collect; it relies on a 2015 change that made the within-collector visit both spans. Executed: fails on nine releases from 7.5.0 to 10.5.2 and on main; not fixed upstream.

Versions tested8 rows

Introduced by 028c86b1fa7 (master) and its branch_7x copy 2376d7dded3, 2018-07-22, "LUCENE-8306: Allow iteration over submatches. Also includes LUCENE-8404, adding match iteration to SpanQuery" (@romseygeek; JIRA patch workflow, issues #9353 and #9451, no CHANGES entry). It added SpanWeight#matches with offsets and sub-matches from Spans#collect. First release 7.5.0 (git tag --contains); first main-line release 8.0.0.

Precondition, not the cause: 8914ec5f750 (trunk, first release tag 6.0.0) and its branch_5x copy eeae87518e3 (first release tag 5.3.0), LUCENE-6706, #7764, 2015-08-03, changed ContainSpans#collect from sourceSpans.collect to big and little, for payload scoring. Reverting only that method body makes the repro pass on 7.5.0 and on main (a causal experiment, not a fix).

Exposure grew: UnifiedHighlighter weight matches opt-in from 7.5.0 (LUCENE-8286) and default from 9.0.0 (LUCENE-9431); MatchRegionRetriever from 9.0.0 (LUCENE-9463). Every release tag from 7.5.0 to 10.5.2 in the history clone has the same three ingredients (tag scan; 8.8.2 and 8.9.0 to 8.11.4 are not tagged there, 8.11.4 was executed); branch_10x and branch_10_6 have the same relevant code (their SpanWeight.java and highlighter files differ only outside the Matches path).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's JUnits: 2 of 4 and 3 of 4 fail through gradle, assertions on and off; controls pass
main ea57360912ed affectedthe finder's test 1 of 2 and the independent reproduction's test 2 of 5 fail, -ea and -da
10.5.2, 10.0.0, 9.12.3, 9.1.0 affectedthe finder's test fails 1 of 2 (startOffset 0 instead of 3); the intervals control passes
9.0.0 intervals also wrongthe span test fails and so does the intervals control, because LUCENE-10229 landed in 9.1.0
8.11.4, 8.0.0, 7.7.3, 7.5.0 affectedthe portable test fails 1 of 2; the fingerprint pos=[1,1] off=[0,8) with sub-matches at 0, 1, 2 (run on 7.5.0, 7.7.3 and 8.11.4) is identical to main
7.4.0 no span MatchesWeight#matches exists, but span queries report no positional matches at all
7.5.0 and main with the 2015 collect change reverted (shadow) causal checkthe portable test passes 2 of 2
main a9779ef3c193 with fix option A not affectedboth JUnits pass; the whole queries and highlighter suites pass

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing. Reading-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause; an adjudication re-ran it and confirmed it, and this doc's gradle runs reproduce it on a9779ef3c193 and validate the fix.

Checks and verdicts305 words

Every executing check wrote or re-ran its own JUnit on stock main; every control passed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateTranslation-validation harness plus a public-API JUnit: fails 1 of 2 on main (-ea, -da), 10.5.2 and 9.12.3; passes with the position filter, which changes no other result in a run of 437 highlighter and span tests (the repro included).
cross-model review, refute lens not real, low (procedural)OpenAI GPT-5.6, reading only: it could not run code, and the refute review voted not real on procedure only; it agreed with the root cause and found the inputs in contract.
independent reproducer realOwn JUnit on "the quick brown fox" fails 2 of 5 on main (-ea, -da), 10.5.2 and 9.12.3; SpanContaining, intervals and TermQuery controls pass; found the LUCENE-10229 precedent.
adversary, intended lens real, mediumOpenAI GPT-5.6, reading only: the works-as-designed argument is moderate and fails; noted that the position filter misses stacked tokens.
intended-behaviour devil's advocate real, moderate to highOwn probe fails 3 of 7 on main and 10.5.2, including the nested case; read the LUCENE-6083, -6706, -8306, -8404 and -10229 threads.
adjudication confirmed, highRe-ran the independent reproduction's test with the independent reproduction's seed (C0FFEE01), -ea and -da: fails 2 of 5.
this doc reproduced; fix A validatedNew JUnits through gradle on a9779ef3c193, stock and with fix options A and B.
fact checks of this doc bug holds; wording correctedAn independent fact check re-ran this doc's gradle commands on a fresh copy of a9779ef3c193 (same output, assertions on and off), option A over the queries and highlighter modules, TestSpanWithinMatches on 10.5.2 and the option B nested highlight; a reading-only cross-model fact check (OpenAI GPT-5.6) found the core bug real. Their wording findings are resolved.

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

Finding record

LFV-266, snapshot of 2026-11-06

Registry title
SpanWithinQuery's Weight#matches reports the contained (little) span's positions but the enclosing (big) span's offsets and sub-matches, so Matches-based highlighting (the default UnifiedHighlighter, MatchRegionRetriever) marks the enclosing terms and a field with offsets highlights differently from the same text indexed with positions only
Area
queries / span queries Matches API (SpanWeight#matches takes offsets and sub-matches from ContainSpans#collect for SpanWithinQuery; seen through the UnifiedHighlighter and MatchRegionRetriever)
Priority
Low, rank 358: Highlighting-only (hits and scores are correct) in the little-used legacy SpanWithinQuery.
Impact class
silent wrong results
Impact note
Applications that read Weight#matches of a SpanWithinQuery, alone or nested inside another span query, get the little span's positions together with the enclosing big span's offsets and leaf terms on every such match, with the default codec, IndexWriterConfig and searcher, -ea and -da, at any index size, in every release from 7.5.0 through 10.6.0 (10.6.0 by reading) and on main: the UnifiedHighlighter with weight matches (on by default since 9.0.0) marks the enclosing span's terms, also on a default positions-only field through its re-analysis offset source, and MatchRegionRetriever returns the whole big span on a field with offsets but only the contained match on a positions-only field; which documents match and their scores are unaffected (executed: identical hits and bit-identical scores with and without the fix), and SpanContainingQuery and Intervals.containedBy report consistent matches.
Summary
You index 'ab ac bc' in a field with offsets and ask Weight#matches for SpanWithinQuery(big = SpanNear([ab, bc], slop 1, in order), little = ac); you expect one match, 'ac' at position 1 with offsets [3,5) and sub-matches only inside it, which is what the equivalent Intervals.containedBy(ac, ordered(ab, bc)) reports. Instead the match says positions [1,1] but offsets [0,8) and lists ab@0 and bc@2 as sub-matches, because the offsets and sub-matches come from Spans#collect, which for SpanWithinQuery also collects the enclosing span. So the default UnifiedHighlighter marks '<b>ab</b> <b>ac</b> <b>bc</b>' instead of 'ab <b>ac</b> bc', and MatchRegionRetriever returns [0,8) on a field with offsets but [3,5) on the same text indexed with positions only; hits and scores are unaffected.
Introduced
028c86b1fa70faeec4f7705698a59557f58bcbb0 (master, first main-line release 8.0.0) and its branch_7x copy 2376d7dded3c0c03fa3bb9c75cc7d8c58bfcb319 (the released one, 7.5.0), 'LUCENE-8306: Allow iteration over submatches. Also includes LUCENE-8404, adding match iteration to SpanQuery' by Alan Woodward (@romseygeek); JIRA patch workflow, no GitHub PR, issues migrated as apache/lucene#9353 and #9451, no CHANGES.txt entry [read: git blame of the startOffset/endOffset/getSubMatches/collectInnerTerms lines of SpanWeight.java in the full-history clone; exec: 7.4.0 has no positional span Matches at all, 7.5.0 fails like main]. That commit added SpanWeight#matches, which takes positions from the spans but offsets and sub-matches from Spans#collect. Contributing precondition, not the introducing commit: 8914ec5f75079d07233e4dbb4f93df7ac5c6f403 (LUCENE-6706, apache/lucene#7764, @romseygeek, 2015-08-03, first released in 5.3.0) changed ContainSpans#collect from sourceSpans.collect to collecting both bigSpans and littleSpans, for payload scoring; reverting only that method body makes the repro pass on 7.5.0 and on main [exec: shadow isolation], so the bug is the 2018 Matches code consuming the 2015 collect behaviour. Not a regression: wrong since span queries have had Matches (2018-07-22)
First affected version
7.5.0 [exec] (through the branch_7x copy; first main-line release 8.0.0 [exec]). 7.4.0 is not affected: Weight#matches exists there (LUCENE-8229) but span queries inherit the default, which reports no positions or offsets, so the repro and its control both fail for that unrelated reason [exec]; Weight#matches does not exist before 7.4.0 [read]
Affected versions
every release from 7.5.0 through 10.5.2, 10.6.0 (by reading), branch_10x and branch_10_6 [read: identical SpanWeight#matches and ContainSpans via read-only GitHub GETs, 2026-10-26] and main ea57360912ed; not fixed upstream. [exec] the finder's repro or its release-portable variant fails 1 of 2 (startOffset 0 instead of 3; the SpanContainingQuery or Intervals.containedBy control passes) on 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea), and on main also with -da; a behaviour fingerprint (pos=[1,1] off=[0,8), sub-matches at positions 0, 1 and 2) is identical on 7.5.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main; the independent reproduction's own test fails 2 of 5 on main (-ea, -da), 10.5.2 and 9.12.3. On 9.0.0 the intervals control fails the same way, because the interval counterpart was fixed only in 9.1.0 (LUCENE-10229) [exec]. [read] a tag scan finds the three ingredients (ContainSpans#collect collects big and little, SpanWeight#matches takes offsets and sub-matches from collect, SpanWithin reports the little spans) in every release tag 7.5.0 to 10.5.2 of the history clone; 8.8.2 and 8.9.0 to 8.11.3 are not tagged there and are listed because 8.8.1 [read] and 8.11.4 [exec] are. Highlighter exposure [read]: UnifiedHighlighter WEIGHT_MATCHES since 7.5.0 (LUCENE-8286), on by default since 9.0.0 (LUCENE-9431); MatchRegionRetriever/MatchHighlighter since 9.0.0 (LUCENE-9463). Caveats: the history runs used -ea only, JDK 25 and seed DEADBEEF; 7.x ran with a JUnit 4 runner
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: 239 recorded read-only queries, namely the scripted prior-report search in two passes over apache/lucene issues and PRs, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and the web, 121 GitHub GraphQL searches of apache/lucene and of the Elasticsearch, OpenSearch and Solr repositories, 5 JIRA REST queries, forum and Q&A searches, the changed files of all 413 open apache/lucene PRs, and local git history; 38 apache/lucene items and 4 Elasticsearch items read in full; 19 hits classified, none the same bug); not fixed upstream (no open PR fixes it: #15819, the only open PR touching SpanWeight.java, changes only the scorer supplier; no upstream commit after ea57360912ed touches the code). Closest items, not reports: precedent LUCENE-10229 / #11265 ('Match offsets should be consistent for fields with positions and fields with offsets', fixed for interval queries in 9.1.0 by #521 / 53099e01de2 and in 9.2.0 by #803; the issue, both PRs, both CHANGES entries and every comment are about intervals, spans are never mentioned and no spans file changed), with its trigger #11259 (LUCENE-10223) and the earlier interval design note #9468 (LUCENE-8422, 2018); elastic/elasticsearch#42715 (closed 2024-03-12: a maintainer's closing output for span_within shows the enclosing terms highlighted, accepted as correct, not discussed as a defect; not executed); the introducing issues #9353 / #9451 and the collect change #7764; #16820 (open, proposes deprecating span queries in Lucene 11)
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the two JUnits of this doc through gradle in a clean worktree, stock (assertions on and off) and with fix options A and B; with option A the whole lucene/queries and lucene/highlighter test suites and the nightly payload tests (2026-10-27). Hunt runs on main ea57360912ed and on the release jars 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2; the main refresh to a9779ef3c193 reproduces with identical messages.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWeight.java:252-283 (at ea57360912ed) (startPosition/endPosition return the spans' positions; startOffset/endOffset return innerTerms[0].startOffset and innerTerms[innerTermCount - 1].endOffset; getSubMatches iterates innerTerms), :330-334 (collectInnerTerms: termCollector.reset(); spans.collect(termCollector); sort by position)
  • lucene/queries/src/java/org/apache/lucene/queries/spans/ContainSpans.java:55-58 (at ea57360912ed) (collect: bigSpans.collect(collector); littleSpans.collect(collector))
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWithinQuery.java:28 (at ea57360912ed) ('Keep matches that are contained within another Spans'), :77 ('The payload is from the spans of little'), :90 (new ContainSpans(big, little, little): positions come from little)
  • lucene/core/src/java/org/apache/lucene/search/MatchesIterator.java:31, (at ea57360912ed) :36 (@lucene.experimental), :48-77 ('The start position of the current match', 'The starting offset of the current match', getSubMatches: 'individual terms within the current match')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/ContainedByIntervalsSource.java:73 (at ea57360912ed) (the LUCENE-10229 fix: 'the only sub source we care is the "small" source')
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/PhraseHelper.java:276 (at ea57360912ed) (spans.collect in the UnifiedHighlighter's non-weight-matches path, not changed by the minimal fix)
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWeight.java:209, 252-260, 262-276, 278-323, 330-334
  • lucene/queries/src/java/org/apache/lucene/queries/spans/ContainSpans.java:28-47, 54-58; SpanWithinQuery.java:28, 31-35, 90; SpanContainingQuery.java:91
  • lucene/core/src/java/org/apache/lucene/search/MatchesIterator.java:47-84
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/OffsetsEnum.java:171-240; MemoryIndexOffsetStrategy.java:48; UnifiedHighlighter.java:110, 518-527
  • lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/OffsetsFromMatchIterator.java:53-54; OffsetsFromPositions.java:28-35
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/ContainedByIntervalsSource.java:69-75
Who is affected
  • lucene/CHANGES.txt:4770-4771 (LUCENE-9431, 9.0.0), 4816 (LUCENE-9463, 9.0.0), 7159-7164 (LUCENE-8286, 7.5.0)
  • lucene/queries/src/java/org/apache/lucene/queries/spans/NearSpansOrdered.java:151; SpanOrQuery.java:371; FilterSpans.java:118
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWeight.java:332; payloads/PayloadScoreQuery.java:244; payloads/SpanPayloadCheckQuery.java:167; lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/PhraseHelper.java:276; lucene/sandbox/src/java/org/apache/lucene/payloads/PayloadSpanUtil.java:173 (every Spans#collect caller besides the composite spans; SpanScorer has none)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/MatchesIterator.java:36, 47-84; Matches.java:24-31
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWithinQuery.java:28, 31-35, 75-78; SpanWeight.java:44
  • lucene/CHANGES.txt:4197-4198, 4469-4470, 7159-7164
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:106, 199, 353-359; QueryUtils.java:148
Suggested fix
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWithinQuery.java:75-78, 90-132; lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadScoreQuery.java:244; SpanPayloadCheckQuery.java:164-169; lucene/sandbox/src/java/org/apache/lucene/payloads/PayloadSpanUtil.java:173; lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/PhraseHelper.java:276

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 (assertions on and off, 10.5.2, and with the fix shadowed); every value on screen comes from the probe log. The PNG is the summary frame. Its "Minimal fix" line describes the position filter, option B here, which the diagram's probe validated; the doc recommends option A, which also covers the nested and stacked cases.

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.