All findings · Lucene finding · LFV-858 · simplespanfragmenter-protects-only-first-span-at-start

SimpleSpanFragmenter cuts a phrase match when phrases overlap, share a start, or sit on stacked synonyms

SimpleSpanFragmenter promises not to split a phrase match, but it tracks one phrase at a time, so overlapping or same-start phrases get cut. For "new york" OR "new york city hall" the best snippet is We flew to <B>New</B> <B>York</B> <B>City</B>, without Hall; swap the clauses and it stays whole.

Summary

Priority
Low, rank 529This is the rarer sibling of LFV-859, with wrong fragment boundaries only for overlapping phrases, phrases that share a start, or stacked synonyms in the opt-in SimpleSpanFragmenter, so it falls below the default-config UnifiedHighlighter display rows LFV-498 and LFV-494 and above the dirty-data row LFV-599.
Impact
minor
Since
2.4.0 (2008), when the class was addedintroduced 2008-04-29 · over 18 years
Affected
2.4.0 to 10.6.0 and main; 20 releases and 10.6.0 executed
Status
documented
Found by
a model of the Highlighter's fragment loop, checked against the real classes on 66 million bounded cases (semi-formal)

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-03.

The finding in 30 seconds

isNewFragment remembers one position to wait for and sets it only at a span's start, from the first span listed there, then break (SimpleSpanFragmenter.java:76-80). While waiting it returns before any span lookup (:65-69), and the Highlighter shows it only the first token of each token group (Highlighter.java:209, :224).

Background: fragments, spans and how the fragmenter is asked

  1. The classic Highlighter cuts a text into fragments and returns the best-scoring ones; a Fragmenter decides where a cut may go. SimpleSpanFragmenter cuts about every N characters (default 100) and promises not to cut through a phrase match.
  2. QueryScorer finds each phrase match as a span of token positions: in We flew to New York City Hall ..., "new york" covers positions 3-4 and "new york city hall" 3-6. Every word of a phrase carries the spans of every phrase it belongs to, in clause order.
  3. The Highlighter asks the fragmenter once per word: "may a new fragment start here?". Tokens that cover the same text, such as a synonym and its original word, form one group, and it asks only for the group's first token.
Diagram of the bug: the text "We flew to New York City Hall last week for a conference" with the spans [3,4] for "new york" and [3,6] for "new york city hall", both starting on "New"; SimpleSpanFragmenter takes the first span listed, [3,4], and sets its wait from it; because its position counter runs one token behind, that wait still covers "City" and ends at "Hall", so at fragment size 20 the cut falls before "Hall" and getBestFragment returns "We flew to New York City"; with the longer phrase listed first the phrase stays whole. Two more triggers: overlapping phrases and stacked synonyms.
The text We flew to New York City Hall ... with the two spans that start on New; the fragmenter waits only for the first one listed, so the size-20 cut lands before Hall. Made in parallel with this doc. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You highlight We flew to New York City Hall last week for a conference with Highlighter, QueryScorer and new SimpleSpanFragmenter(scorer, 20) for "new york" OR "new york city hall": two SHOULD phrase clauses.
  2. The fragmenter "does not split up Spans" (its javadoc), and the 18-character match is shorter than the fragment size 20, so you expect the best fragment We flew to <B>New</B> <B>York</B> <B>City</B> <B>Hall</B> last. That is exactly what you get with the two clauses swapped.
  3. Instead the fragments are We flew to New York City | Hall last week | for a conference, and getBestFragment returns We flew to <B>New</B> <B>York</B> <B>City</B>. The same happens at the default size 100 in a 173-character sentence.
  4. Why: when the fragmenter reaches a phrase match it takes the first span listed for that word, here the 2-word new york, and stops looking. Once that wait is over (at Hall, one word late, because the fragmenter's position counter runs one word behind: a separate bug), the size check cuts inside the longer phrase, before Hall.
  5. Two more inputs fail the same way: a phrase that starts inside another ("new york" OR "york city hall", cut before Hall) and a phrase on stacked synonyms (big laptop bag with big->large, laptop->notebook, cut before bag). Hits, scores and marked words stay correct.

Who is affected

Applications using the classic Highlighter with QueryScorer and SimpleSpanFragmenter (opt-in in Lucene; Elasticsearch's plain highlighter uses it by default) whose queries combine overlapping or same-start phrases, or whose analyzer stacks synonyms on a phrase's first words. Only snippets change: hits, scores and marked words are correct.

Mechanism, terms and numbers863 words

The classic Highlighter cuts a text into fragments, and SimpleSpanFragmenter promises to cut about every N characters without splitting a phrase match. It keeps that promise for one phrase at a time: it remembers one position to wait for, sets it only at a span's start from the first span listed there, skips the lookup while it is waiting, and sees only the first token of each token group. So two phrases that share a start ("new york" OR "new york city hall"), a phrase that starts inside another ("new york" OR "york city hall") and a phrase whose first words carry stacked synonyms get cut in two, and getBestFragment returns part of the phrase. In the same-start case the outcome depends on clause order. Hits, scores and marked words are correct; only snippet boundaries are wrong. Present since the class was added (2.4.0, 2008); a 13-line bookkeeping fix passes the whole highlighter test suite.

Terms

  • Fragmenter: decides, token by token, whether a new fragment starts before the current token (Fragmenter.isNewFragment, "Test to see if this token from the stream should be held in a new TextFragment", Fragmenter.java:37-41). The Highlighter's default is SimpleFragmenter (Highlighter.java:44); SimpleSpanFragmenter is opt-in: "breaks text up into same-size fragments but does not split up {@link Spans}. This is a simple sample class." (SimpleSpanFragmenter.java:26-29).
  • Token group: tokens whose offsets overlap the group so far (TokenGroup.java:73-74). When a token starts a new group, the Highlighter encodes and appends the previous group and then calls isNewFragment() with the attributes of the new group's first token (Highlighter.java:209-224); true starts a new fragment before that token (:224-230).
  • Spans per term: QueryScorer extracts a BooleanQuery's clauses in order (WeightedSpanTermExtractor.java:133-138), and each phrase's span positions are appended to every one of its terms (:314-353; WeightedSpanTerm.java:62-64). For "new york" OR "new york city hall" the term york carries [3,4][3,6], in that order.

The bug

isNewFragment (SimpleSpanFragmenter.java:62-93) keeps one value, waitForPos, the position up to which it refuses to cut. While it is set, the method returns false before any span lookup (:65-69). Otherwise it looks up the current term's spans and takes the first one with start == position: waitForPos = positionSpan.end + 1; break; (:76-80). Then the size test decides (:84-86).

  1. Same start, first listed wins. york carries [3,4] before [3,6], so the loop takes [3,4] and breaks: waitForPos = 5. Because the counter lags one token (LFV-859, below), that wait still covers City and ends at Hall, where the size test cuts: We flew to New York City | Hall last week | for a conference (size 20). With exact positions the cut would come one word earlier, before City (executed with the counter fix alone); the phrase is split either way. With the longer clause listed first, [3,6] is taken and the phrase stays whole.
  2. Overlap, no lookup while waiting. For "new york" OR "york city hall" on The museum is near New York City Hall and the park the spans are new=[4,5], york=[4,5][5,7], city=[5,7], hall=[5,7]. The call at which [5,7] would be registered comes while the wait from [4,5] is pending, so it returns before the lookup; [5,7] is never registered and the cut lands before Hall (size 17).
  3. Stacked synonyms, first token of a group only. SynonymGraphFilter with the originals kept emits the synonym first and the original at increment 0 with the same offsets (large/inc1/10-13, big/inc0/10-13, notebook/inc1/14-20, laptop/inc0/14-20 for this text). The fragmenter is shown large and notebook, never big and laptop (an independent trace: left(+1) my(+1) large(+1) notebook(+1) bag(+1)|). Neither carries a span; at bag the span [3,5] does not start at the current position, so it is never registered and the cut lands before bag (size 21).

The numbers (stock main, getBestFragment)

casesizebest fragmentcontrol (passes)
"new york" OR "new york city hall"20We flew to <B>New</B> <B>York</B> <B>City</B>clauses swapped: ... <B>City</B> <B>Hall</B> last
the same query, 173-character sentence100 (default)We spent ... then took a cab to <B>New</B> <B>York</B> <B>City</B>clauses swapped: whole
"new york" OR "york city hall"17 near <B>New</B> <B>York</B> <B>City</B>"york city hall" alone: whole
"big laptop bag", big->large, laptop->notebook21I left my <B>big</B> <B>laptop</B>no synonyms: whole

The sibling that runs alongside

On stock code the position counter (:63) lags one token behind, because the Highlighter never shows the fragmenter the text's first token (LFV-859). The lookups above therefore happen one token after the span start (for case 1 at York), and that lag alone can cut a phrase right after its first word. Every failing case here cuts after the second or a later word, each has a passing control that changes one input (the clause order, the outer phrase or the synonyms), and a fix that keeps the stock counter removes them all. On stock, case 3 needs the phrase's first two words stacked; with only the first word stacked the lag hides it.

Exposure and likelihood312 words
  • Who is hit. The classic Highlighter with QueryScorer and SimpleSpanFragmenter. It is opt-in (the default fragmenter is SimpleFragmenter, Highlighter.java:44), but it is the classic phrase-aware fragmenter, and Elasticsearch's plain highlighter uses it as its default span fragmenter (code read; not tested).
  • Which queries. Two phrases in one query that share a start or overlap, for example phrase-variant expansion ("new york" OR "new york city hall"), a phrase plus a longer one typed by the user, or a query builder that adds sub-phrases; or a phrase highlighted with an analyzer that stacks synonyms on its first two words (SynonymGraphFilter with the originals kept; appending FlattenGraphFilter keeps the same token order and split pattern).
  • When it shows. Only when a size threshold falls inside the phrase, after its protected part. At the default size 100, the overlap case split in 8 of 51 text paddings of a longer sentence on stock, in 12 with the counter fix alone and in 0 with the hunt's cumulative fix (counter plus bookkeeping; fix A alone was not run on that probe). In the hunt's model of the fragment loop (it matched the real classes on every bounded case), with the counter of LFV-859 fixed, 121,152 of 7,873,440 short spans in the 2-3-span scope still split (100,800 of them overlap or share a start with another span); the bookkeeping fix leaves 0.
  • Effect. Silent and deterministic: the best snippet shows part of a phrase, the rest goes to a neighbouring fragment. getBestFragments(..., n >= 2) merges contiguous fragments (Highlighter.java:140, :299), so the split is hidden when both parts are selected. Not affected: hits, scores, which words are marked, the default SimpleFragmenter, the UnifiedHighlighter.
  • Why nobody noticed. The recorded searches found no report of overlapping, same-start or stacked-synonym splits; the three old single-phrase reports (2010, 2012, 2015) belong to LFV-859.

Minimal repro

The test shows that SimpleSpanFragmenter(scorer, 20) with the same-start phrases 'new york' OR 'new york city hall' splits the longer phrase match across fragments (TestSimpleSpanFragmenterSameStartPhrases#testSameStartPhrasesNotSplit).

TestSimpleSpanFragmenterSameStartPhrases.java (JUnit test)44 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.search.highlight;

import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.PhraseQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSimpleSpanFragmenterSameStartPhrases extends LuceneTestCase {

  public void testSameStartPhrasesNotSplit() throws Exception {
    Query query =
        new BooleanQuery.Builder()
            .add(new PhraseQuery("f", "new", "york"), Occur.SHOULD)
            .add(new PhraseQuery("f", "new", "york", "city", "hall"), Occur.SHOULD)
            .build();
    QueryScorer scorer = new QueryScorer(query, "f");
    Highlighter highlighter = new Highlighter(scorer);
    highlighter.setTextFragmenter(new SimpleSpanFragmenter(scorer, 20));
    String text = "We flew to New York City Hall last week for a conference";
    String best = highlighter.getBestFragment(new StandardAnalyzer(), "f", text);
    String phrase = "<B>New</B> <B>York</B> <B>City</B> <B>Hall</B>";
    // expected: the 4-word phrase match stays in one fragment; actual: it is cut before "Hall"
    assertTrue(
        "expected a fragment holding " + phrase + ", got [" + best + "]", best.contains(phrase));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-858/, 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/highlighter/build/libs/lucene-highlighter-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$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 5EED0858
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=5EED0858 \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.highlight.TestSimpleSpanFragmenterSameStartPhrases
Expected vs actual
Expected the best fragment to hold <B>New</B> <B>York</B> <B>City</B> <B>Hall</B>; actual [We flew to <B>New</B> <B>York</B> <B>City</B>] (cut before "Hall"), with -ea and -da.

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

More files, environment, full output44 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed 5EED0858: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected a fragment holding <B>New</B> <B>York</B> <B>City</B> <B>Hall</B>, got [We flew to <B>New</B> <B>York</B> <B>City</B>]
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.apache.lucene.search.highlight.TestSimpleSpanFragmenterSameStartPhrases.testSameStartPhrasesNotSplit(TestSimpleSpanFragmenterSameStartPhrases.java:41)

Why it looks unintended

  • The javadoc promises without exception that it "does not split up Spans" (SimpleSpanFragmenter.java:26-28).
  • The result depends on clause order: the same two phrases give a whole phrase in one order and a cut one in the other.
  • This method's span bookkeeping was fixed as a bug twice (LUCENE-1389 in 2008, LUCENE-2229 in 2015), and no test asserts today's behaviour.
The full argument182 words
  • The contract is unqualified. "breaks text up into same-size fragments but does not split up {@link Spans}" (SimpleSpanFragmenter.java:26-28). When the class was contributed (LUCENE-794, #1869), its author described it as one that "fragments based on size, but ensures that Spans are not broken up".
  • Clause order decides. "new york" OR "new york city hall" cuts the long phrase, "new york city hall" OR "new york" keeps it whole (executed at size 20 and at the default 100).
  • Fixed as a bug before. LUCENE-1389 (#2463, 2008) and LUCENE-2229 (#3305, 2015; CHANGES.txt:9891-9893 under 5.4.1: "Fix Highlighter's SimpleSpanFragmenter when multiple adjacent stop words following a span can unduly make the fragment way too long.").
  • No test asserts the current behaviour. TestHighlighter.testSimpleSpanFragmenter (:739-785) only prints when VERBOSE; the LUCENE-2229 test (:414-437) checks only that the result is under 60 characters. No test uses overlapping, same-start or stacked spans with this fragmenter.
  • Ordinary inputs. StandardTokenizer, a BooleanQuery of PhraseQuerys, SynonymGraphFilter and the public highlighter API; the class is public and not deprecated, and MIGRATE.md says nothing about it.
The devil's-advocate check246 words

The strongest case for "intended": the javadoc ends "This is a simple sample class" and the contributor wrote "This class might not be perfect yet" (#1869); the one-span-per-start rule looks deliberate, since the 2008 original also used only the first matching span (it returned true there, which also started a new fragment at the span's start), and Andrew Duffy, who supplied the LUCENE-1389 patch, called the break "a slight optimisation - the span loop is broken early when a span is found at the current position" (#2463); Fragmenter.isNewFragment does not promise a call per token (Fragmenter.java:37-41), so the stacked-synonym case can be called a Highlighter limitation; the effect is cosmetic; and @dsmiley wrote on PR #1069 (2022): "If only we renamed "Highlighter" to "OriginalHighlighter", maybe folks wouldn't continue to using this thing."

Why it fails: the guarantee is stated without exception, and "sample" has not meant "won't fix": this method's bookkeeping was fixed twice, once with a CHANGES entry. The "optimisation" has the effect of treating the first matching span at a position as enough, which fails as soon as two spans share a start, and the outcome then depends on clause order. The same-start and overlap cases need no Highlighter change. The remark on #1069 is about priority, not the contract: expect "low priority" or "use the UnifiedHighlighter", not "by design". Verdict: not intended (an independent reproducer, a devil's-advocate verifier, a cross-model review that read the code, an adjudication and a credibility audit).

Suggested fix

Recommended A: track every span that covers the position (SimpleSpanFragmenter only)

Fix A: on every call, check every span of the current term that covers the position, also while waiting; wait for the furthest end, drop the break, refuse a cut inside a span (+13/-5 lines in SimpleSpanFragmenter.java). The test passes 8 of 8, the highlighter test task 730 of 730. LFV-859's cuts after a first word remain; B fixes both.

All options and trade-offs4 options · 764 words

Two fixes were built in a scratch worktree at 645d4fe14a20 and validated through gradle; both diffs apply with git apply there. A fixes this bug alone; B adds the position-counter fix of the sibling LFV-859 and suits an issue that covers both. C and D are for the discussion with maintainers.

A: track every span that covers the position (SimpleSpanFragmenter only) (validated by the checks) (recommended)

On every call, for each span of the current term with start <= position <= end, set waitForPos = max(waitForPos, end + 1), with no early return while waiting and no break; return false while inside a span (strictly after its start), else run the unchanged size test. Written independently by the finder (inside its cumulative fix) and by the independent reproducer.

// isNewFragment(), after the position update (condensed; fix-a-span-bookkeeping.diff is formatted)
if (waitForPos <= position) {
  waitForPos = -1;
}
boolean insideSpan = waitForPos != -1; // no early return while waiting
WeightedSpanTerm wSpanTerm = queryScorer.getWeightedSpanTerm(termAtt.toString());
if (wSpanTerm != null) {
  for (PositionSpan positionSpan : wSpanTerm.getPositionSpans()) { // no break
    if (positionSpan.start <= position && position <= positionSpan.end) {
      waitForPos = Math.max(waitForPos, positionSpan.end + 1);
      if (positionSpan.start < position) insideSpan = true;
    }
  }
}
if (insideSpan) return false; // then the unchanged size test

Trade-offs

  • Leaves LFV-859: with the stock counter, cuts right after a phrase's first word remain (the reproducer's strict test: 46 cuts, all right after word 1; on stock the same 46 plus 5 after word 2; the finder's test 2 of 3).
  • Calls made inside a span now also scan the term's span list (the stock code skipped that scan while waiting; elsewhere both scan it). No benchmark was run.
  • Residual, the same as B's (code reading; not measured for A alone): it sees spans only through the tokens the fragmenter is shown, so a span none of whose shown tokens is a query term stays unprotected; that needs a Highlighter change.

What the checks validated

  • Scratch worktree at 645d4fe14a20, gradle: the paste-ready test fails 4 of 8 on stock (assertions on and off; 20 of 40 with 5 random seeds per method); with the patch the whole :lucene:highlighter:test task passes, 35 classes, 730 tests (722 existing plus the 8 new), 0 failures, assertions on and off.
  • ecjLintMain, ecjLintTest and checkGoogleJavaFormat for the module pass.
  • Shadowed on main through the standalone runner: the reproducer's cut-after-second-word sweep 7 of 7.

B: A plus the position-counter fix of LFV-859 (validated by the checks)

Fix A, plus position = queryScorer.getLastTokenPosition() + posIncAtt.getPositionIncrement(); with a new package-private accessor on QueryScorer that returns the position of the last scored token (the scorer sees every token; the fragmenter does not). +22/-6 lines in two files.

Trade-offs

  • Couples the fragmenter to the scorer's token position (same package, not public API); it changes LFV-859's behaviour too, so it belongs in an issue that covers both.
  • getLastTokenPosition() is right only if the fragmenter is given the same QueryScorer the Highlighter scores with: the constructor javadoc asks for the "QueryScorer that was used to score hits" (SimpleSpanFragmenter.java:43, :50), but nothing enforces it, and no test covers a fragmenter given another scorer.
  • Residual (finder): a span none of whose shown tokens is a query term (for example a stacked group whose first token is a synonym, with nothing later in the span shown) is still not protected; that needs a Highlighter change (1,152 of 20,382,180 short spans in the model's single-span scope).

What the checks validated

  • Gradle, same worktree: the paste-ready test 8 of 8; the whole highlighter test task 730 tests (722 existing plus the 8 new), 0 failures, assertions on and off; lint and format checks pass.
  • Shadowed: the reproducer's two classes 13 of 13 (including its strict test), the finder's 3 of 3, LFV-859's 3 of 3. The hunt's end-to-end sweep of the same logic (1,874,880 runs with real analysis and span extraction) found 0 splits except for a phrase with a hole, whose spans are already wrong (LFV-827).

C: let the fragmenter see every span up front (not built)

Compute in start() the positions strictly inside any span and refuse a cut there, or have the Highlighter consult the fragmenter for every token.

Trade-offs

Removes the per-call bookkeeping and the stacked-token residual, but needs a new way to enumerate the scorer's spans: QueryScorer exposes only a per-term lookup (QueryScorer.java:190-192). Untested.

D: document the limitation (not recommended)

Say in the javadoc that overlapping, same-start or stacked spans may be split.

Trade-offs

Cheapest, but leaves the clause-order dependence in place.

Downstream note

Search servers whose plain highlighter uses SimpleSpanFragmenter (Elasticsearch's default span fragmenter; read, not tested) show the same cut phrases for such queries.

How it was found

Semi-formal. A planning probe of 20,000 random texts traced phrase splits to overlapping and same-start phrases. A model of the Highlighter's fragment loop then matched the real classes on all 66 million bounded cases and, with the position counter fixed, still split 121,152 short spans, mostly overlapping or same-start ones; the span-bookkeeping fix removed them.

The method in full188 words
  • Signal. A planning probe swept 20,000 random texts with SHOULD clauses of 1-3 phrases and attributed the splits it found to causes: 455 to an overlapping occurrence and 46 to a shared start (415 and 15 still split with the position counter shadowed).
  • Model. The hunt unit built a transducer model of Highlighter.getBestTextFragments composed with SimpleSpanFragmenter and checked it against the real classes on every case of two bounded-exhaustive scopes: 56,100,240 single-span cases and 10,060,800 cases with two or three spans, overlapping and same-start ones included; 0 mismatches for stock and every fix variant.
  • Attribution by staged fixes. With the sibling's position fix, 121,152 of 7,873,440 short spans in the multi-span scope still split (100,800 of them spans that overlap or share a start with another span) and 20,288 in the single-span scope (spans that start on a token the fragmenter is not shown); adding the span-bookkeeping fix leaves 0 in the multi-span scope. The stacked-synonym repro came from the unit's realistic-analyzer scenarios (SynonymGraphFilter with the originals kept).
  • Witness. Public-API JUnits on the stock jars; an independent reproducer then wrote a test that the sibling's lag cannot trigger.

Prior reports, history and checks

Prior reports

No prior report in GitHub issues and PRs, the JIRA archive, four mailing lists, downstream trackers or the web (searched 2026-10-28, re-checked 2026-10-29). Related items are other defects of the same method (LUCENE-1389, LUCENE-2229, LUCENE-5019); three old single-phrase reports (2010, 2012, 2015) belong to LFV-859.

Searches4 searches

Verdict: new. It rests on absence of evidence; differently worded or private reports cannot be ruled out.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; the JIRA archive; dev@, java-user@, issues@, commits@; downstream repositories; the web2026-10-28a scripted prior-report search, 53 queries plus 8 web queries, and three earlier runs of 51 queries eachno report of this defect
GitHub (apache/lucene, apache/solr, apache/lucene-solr, apache/lucenenet, elastic/elasticsearch, opensearch-project/OpenSearch), Stack Overflow2026-10-28more than 30 manual searches; the Stack Exchange API; the file lists of all 410 open and the 200 newest closed apache/lucene PRs; the file's history on main, branch_10x and branch_10_6no report; no open PR touches SimpleSpanFragmenter
apache/lucene issues and PRs, commits API2026-10-29SimpleSpanFragmenter and four symptom phrasings; the file's last commits; open highlighter PRs7 known items for the class name (#6083, #15333, #5795, #2759, #1869, #3305, #2463), 0 for the rest; last change to the file 2022-09 (formatting)
Related items (read)2026-10-28the threads and patches#2463 / LUCENE-1389 (short fragments; origin of the break), #3305 / LUCENE-2229 (stop-word wait release), #6083 / LUCENE-5019 (open, long fragments), #1869 / LUCENE-794 (the contribution), #3661 and PR #1069 (fragment start offsets), #15333 and PR #15346 (zero-score merging); java-user 2010-05, elastic/elasticsearch#2095 (2012) and Stack Overflow 28167990 (2015) report single-phrase splits (LFV-859)

History

Present since the class was added in 35c7eb36dfb (2008-04-29, LUCENE-794); first release 2.4.0. Executed on 20 releases from 2.4.0 to 10.5.2, on 10.6.0 (branch_10_6 4ae946e5a9b) and main: all affected. Never fixed; the file's last change in behaviour was LUCENE-2229 (2015).

Versions tested7 rows

Introduced by 35c7eb36dfb (svn r652164, 2008-04-29), "Commit of LUCENE-794 patch - adding phrase/span query support to highlighter", committed by @markharwood from a patch series by @markrmiller (LUCENE-794, now #1869). It created both the skip-while-waiting rule and the first-matching-span rule (return true there; break since c2f7d554a3e, LUCENE-1389, 2008-11-13). The stacked-token case also relies on the Highlighter showing only a group's first token, which is older (14f0da2aa20, 2004). SimpleSpanFragmenter.java is byte-identical in 9.12.3, 10.x, branch_10_6 and main.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthis doc's test fails 4 of 8 (gradle and runner, assertions on and off)
10.5.2, 9.12.3 affectedthe same 4 of 8 (release jars, runner)
2.9.0 to 10.5.2 (18 releases), branch_10_6, main a9779ef3c193 affectedthe finder's three cases (not this doc's test inputs) split in main() ports; on stock two of them and both controls are cut right after the first word (LFV-859), the overlap case is this bug; with only the counter fixed (LFV-859; run on 8 of these releases, the branch and main) they still split and both controls stay whole
2.4.0, 2.4.1 this bug alonethe finder's three cases split, both controls whole (the counter was exact then)
2.4.0 + bookkeeping fix shadowall cases pass: the root cause
2.3.2 not affectedno SimpleSpanFragmenter
fix A / fix B on main 645d4fe14a20 not affected8 of 8; whole highlighter test task 730 of 730 (the 8 new included)

Independent checks

An independent reproducer, an intended-behaviour devil's advocate, an adjudication and a credibility audit each re-ran public-API tests and confirmed it; a reviewer from another model family (OpenAI GPT-5.6) agreed by reading the code only (its refute-lens run failed procedurally). This doc's test and both fixes were then run through gradle on main 645d4fe14a20.

Checks and verdicts248 words

No cross-model review executed Java; every executed result comes from the hunt's own runs.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidate3-test JUnit fails 3 of 3 on main, branch_10_6, 10.5.2 and 9.12.3; on stock 2 of its 3 cuts are LFV-859's, so it shows this bug cleanly only with the counter fixed.
independent reproducer realOwn sweep that the counter lag cannot trigger: 3 of 7 fail on main (on and off), 10.5.2 and 9.12.3, 4 controls pass; bookkeeping-only shadow 7 of 7, counter-only shadow leaves the 3.
cross-model review, refute lens (OpenAI GPT-5.6) inconclusiveInconclusive: this review could not run its checks, so it gave no technical verdict either way.
cross-model review, intended lens (OpenAI GPT-5.6) real (medium)Reading only: root cause agrees, inputs in contract, intended-behaviour argument weak.
intended-behaviour devil's advocate real (medium-high)Rejected the sample-class, optimisation and per-token readings; found the clause-order dependence; 8 of 51 splits at the default size 100.
adjudication confirmed (high)Re-ran the independent reproduction's test with a new seed, on and off: 3 of 7 fail; bookkeeping shadow 7 of 7.
credibility audit downgradeReproduced again (3 of 7, controls pass); impact reclassified to minor: snippet layout only.
this doc reproduced; both fixes validatedNew 8-test JUnit fails 4 of 8 on main 645d4fe14a20, 10.5.2 and 9.12.3; fixes A and B pass it and the whole highlighter test task (730 tests, the 8 new included).

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

Finding record

LFV-858, snapshot of 2026-11-03

Registry title
Classic Highlighter's SimpleSpanFragmenter splits a phrase match across fragments when two phrases overlap, when a longer phrase shares a shorter one's start (the result then depends on clause order), or when the phrase starts on stacked synonyms: it protects only the first span that starts at the current position, skips the span lookup while it waits for a span to end, and never sees a span that starts on a token the Highlighter does not show it
Area
highlighter / classic Highlighter fragmenting (SimpleSpanFragmenter.isNewFragment; Highlighter.getBestTextFragments)
Priority
Low, rank 529: This is the rarer sibling of LFV-859, with wrong fragment boundaries only for overlapping phrases, phrases that share a start, or stacked synonyms in the opt-in SimpleSpanFragmenter, so it falls below the default-config UnifiedHighlighter display rows LFV-498 and LFV-494 and above the dirty-data row LFV-599.
Impact class
minor
Impact note
Applications that highlight with the classic Highlighter, QueryScorer and SimpleSpanFragmenter (opt-in: the Highlighter's default fragmenter is SimpleFragmenter, but SimpleSpanFragmenter is the classic phrase-aware fragmenter and the default fragmenter of at least one downstream plain highlighter) and run a query with two overlapping phrases or two phrases that share a start, or a phrase over stacked synonyms, can get a phrase match shorter than the fragment size cut across two fragments, so getBestFragment returns only part of the phrase ('We flew to New York City' | ' Hall last week'); highlighting output only: hits, scores and which words are highlighted are correct, there is no error, no scale is needed, and it is in every release since 2.4.0.
Summary
You highlight a text with the classic Highlighter, a QueryScorer and SimpleSpanFragmenter, whose javadoc says it 'breaks text up into same-size fragments but does not split up Spans', for a query with two overlapping phrases ('new york' OR 'york city hall'), two phrases that share a start ('new york' OR 'new york city hall'), or a phrase whose words get synonyms stacked on them by SynonymGraphFilter, and you expect every phrase match shorter than the fragment size to stay inside one fragment. Instead the fragmenter cuts inside the longer phrase ('The museum is near New York City' | ' Hall and the park', 'We flew to New York City' | ' Hall last week', 'I left my big laptop' | ' bag on the train'), so getBestFragment can return only part of the phrase, and in the same-start case listing the longer phrase first in the BooleanQuery makes the phrase come out whole. Hits, scores and which words are highlighted are correct; only the fragment boundaries are wrong.
Introduced
35c7eb36dfb07ff919147d7570158433e48cd092 (svn r652164, 'Commit of LUCENE-794 patch - adding phrase/span query support to highlighter', committed by markharwood; the SimpleSpanFragmenter patch series is by markrmiller; JIRA LUCENE-794 = #1869) [read: git log --follow and git blame on SimpleSpanFragmenter.java; the commit creates the class with both the skip-while-waiting rule and the first-matching-span rule ('return true' there; 'break' since c2f7d554a3e, LUCENE-1389 / #2463, 2008-11-13, still the first match only)]. Not a regression: present since the class was added; checked at release granularity by execution (2.3.2 has no such class, 2.4.0 fails, and the 2.4.0 source with only the span-bookkeeping fix backported passes on the 2.4.0 jars). The stacked-token leg also relies on Highlighter calling the fragmenter only for the first token of each token group, which predates the fragmenter (14f0da2aa20, 2004-07-26, first release 1.9) [read] (2008-04-29 (35c7eb36dfb, svn r652164); the 'break' form of the first-match rule 2008-11-13 (c2f7d554a3e, LUCENE-1389))
First affected version
2.4.0 [exec] (released 2008-10-08; 2.3.2, the last release before the introducing commit, has no SimpleSpanFragmenter: the port fails to compile on the missing class)
Affected versions
Every release from 2.4.0 to 10.5.2, the branch_10_6 head 4ae946e5a9b (10.6.0, released 2026-10-29) and main a9779ef3c193 [exec: release-faithful main() ports fail all three cases (same start, overlap, stacked synonym) on 2.4.0, 2.4.1, 2.9.0, 2.9.4, 3.0.3, 3.1.0, 3.6.2, 4.0.0, 4.10.4, 5.4.0, 5.4.1, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2, branch_10_6 and main. In 2.4.x both controls pass on stock, so the port shows this bug alone. From 2.9.0 on, the stock controls also split because of the sibling position-counter lag (LUCENE-1389); there a counter-only shadow of each release's own SimpleSpanFragmenter.java, run at 2.9.0, 3.6.2, 4.10.4, 5.4.1, 5.5.5, 9.0.0, 9.12.3, 10.5.2, branch_10_6 and main, leaves the three cases split with both controls passing. The finder's JUnit fails 3 of 3 on 9.12.3, 10.5.2, branch_10_6 and main (-ea and -da); the independent reproduction's stock-attributable JUnit TestCutAfterSecondWord fails 3 of 7 on main (-ea and -da), 10.5.2 (-ea and -da) and 9.12.3 (-ea)]. Not fixed: SimpleSpanFragmenter.java is byte-identical (blob f41032e9) in 9.12.3, 10.0.0, 10.5.2, branch_10_6, branch_10x and main, and its last behavioural change is LUCENE-2229 (ec64ba00299, 2015-12-29) [read].
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
Upstream and prior reports
none found (2026-10-28, read-only; the prior-report record, verdict new: a scripted prior-report search over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive (53 queries) and the web (8 queries), plus the unit's three earlier runs of 51 queries each; more than 30 manual GitHub searches over apache/lucene, apache/solr, apache/lucene-solr, apache/lucenenet, elastic/elasticsearch and opensearch-project/OpenSearch; Stack Overflow through its API; the file lists of all 410 open apache/lucene PRs and of the 200 most recent closed ones; the upstream history of SimpleSpanFragmenter.java on main, branch_10x and branch_10_6. Re-check through the REST API (the search API was rate-limited): the 20 newest apache/lucene PRs and issues and all 76 issues and PRs updated since 2026-10-26 (no title mentions highlighting, fragments or spans), the file lists of the 11 open PRs created since 2026-10-23 (#16901 to #16926: no lucene/highlighter file), and the last upstream commit to the highlight package, bfe5c847d3c (author and commit date 2026-07-27, fp16 vectors, unrelated; main head c5ab3e57b9b). No issue, PR, review comment, JIRA entry, list thread or downstream report says that SimpleSpanFragmenter splits overlapping phrases, a longer phrase that shares a shorter one's start, or a phrase that starts on stacked tokens. Related, different: #2463 / LUCENE-1389 (2008, short fragments; origin of the 'break' on the first matching span, called 'a slight optimisation'), #3305 / LUCENE-2229 (fixed 2015, stop-word wait release), #6083 / LUCENE-5019 (open, long fragments after a stop word), #1869 / LUCENE-794 (the original contribution: 'This class might not be perfect yet'), #3661 / LUCENE-2587 and open PR #1069 (fragment start offsets), #15333 and open PR #15346 (merging of zero-score fragments), #4160 / LUCENE-3087, #2563 / LUCENE-1489, #5795 / LUCENE-4730, elastic/elasticsearch #29561, #7944 and #11820. Two old user reports of a single phrase split by this fragmenter (java-user 2010-05 'phrase query highlighter spans matching'; Stack Overflow question 28167990, 2015, a downstream plain highlighter) have one span with no overlap, shared start or stacked token: they match the sibling root cause simplespanfragmenter-position-counter-lags-token-positions, not this one.
Lucene commit tested
main 645d4fe14a20 (this doc's test through gradle in a scratch worktree, assertions on and off and 5 random seeds per method, and through the standalone JUnit runner); the 10.5.2 and 9.12.3 release jars (this doc's test); 2.4.0 to 10.5.2, branch_10_6 4ae946e5a9b and main a9779ef3c193 (the history runs: main() ports of the finder's cases and the finder's JUnit); both fixes on main 645d4fe14a20 (gradle: the whole highlighter test task, assertions on and off, lint and format checks; every earlier repro shadowed). The hunt ran on main a9779ef3c193, whose highlighter, span and synonym sources equal 645d4fe14a20.

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/SimpleSpanFragmenter.java:26-29 (at a9779ef3c193) (class javadoc: 'breaks text up into same-size fragments but does not split up {@link Spans}. This is a simple sample class.'), :63 (position counter), :65-69 (while waitForPos is set, return false before any span lookup), :71-82 (first PositionSpan with start == position sets waitForPos, then break), :84-86 (size test)
  • lucene/highlighter/src/java/org/apache/lucene/search/highlight/Highlighter.java:44 (at a9779ef3c193) (default fragmenter new SimpleFragmenter()), :209 (a token group is closed only when the next token is distinct), :224 (isNewFragment called once per closed token group, with the attributes of the next group's first token); WeightedSpanTermExtractor.java:314-353 (every term of a span query gets all span positions of that query, added in clause order)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/highlight/SimpleSpanFragmenter.java:26-29, :36, :62-93 (:63, :65-69, :76-80, :84-86)
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/highlight/Highlighter.java:44, :209-230; TokenGroup.java:73-74; Fragmenter.java:37-41
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/highlight/WeightedSpanTermExtractor.java:133-138, :314-353; WeightedSpanTerm.java:62-64
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/highlight/Highlighter.java:44, :140, :299
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/highlight/SimpleSpanFragmenter.java:26-28; Fragmenter.java:37-41
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:9859, :9891-9893
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/test/org/apache/lucene/search/highlight/TestHighlighter.java:414-437, :739-785
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/highlight/QueryScorer.java:142-143, :190-192

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 the code at main 645d4fe14a20 and executed probes; every value drawn should come from the paste-ready test's output and the source at that commit.

Built 2026-11-03 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.