All findings · Lucene finding · LFV-583 · suggeststopfilter-end-drops-trailing-stopword-positions

SuggestStopFilter forgets the positions of the stop words it removed at the end of the input, so suggesters treat 'one of the ' like 'one '

Typing one of the into a suggester whose query analyzer uses SuggestStopFilter also suggests one direction: the filter removes finished stop words like StopFilter, but its end() reports 0 removed positions, not 2, so the lookup acts like one . Visible since 4.6.0 (FreeTextSuggester) and 6.5.0 (analyzing and document suggesters).

Summary

Priority
Low, rank 440SuggestStopFilter's wrong end() broadens as-you-type suggestions in its recommended setup on everyday input, but it affects suggestion quality only and clears at the next typed word.
Impact
silent wrong results
Since
4.6.0 in FreeTextSuggester, 6.5.0 in the analyzing and document suggesters; wrong end() since 4.5.0 (LUCENE-3849, 2013)introduced 2013-08-20 · over 13 years
Affected
4.5.0 to 10.5.2 and main; 10 releases run
Status
documented
Found by
bounded-exhaustive comparison of end() states

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

A stop filter removes words but keeps their positions as holes, so one of the best is one, two holes, best. At the end of the input those holes are reported in the final position increment. SuggestStopFilter drops them there, and the suggester's query then has too few holes.

The example: the typed text 'one of the ' goes through StopFilter and through SuggestStopFilter. Both keep only 'one'. StopFilter reports final position increment 2 (two holes), SuggestStopFilter reports 0. The suggester's query becomes 'one' plus two separators with StopFilter, matching only 'one of the best', and 'one' plus one separator with SuggestStopFilter, matching 'one direction' and 'one of the best'.
The one of the example step by step: each removed word should leave a hole; StopFilter reports two at the end, SuggestStopFilter reports none, so the query one plus one separator also matches one direction. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You build an AnalyzingSuggester over two entries, one direction (weight 10) and one of the best (weight 5). The index analyzer removes the stop words to a the of with StopFilter, the query analyzer with SuggestStopFilter, as the class javadoc recommends.
  2. The user types one of the with a space after the, so both stop words are finished. Both filters keep only one, and each removed word should leave a hole: StopFilter reports 2 removed positions at the end of the input (final position increment 2). SuggestStopFilter reports 0.
  3. You expect [one of the best], which the same suggester returns with a StopFilter query analyzer: the query means one, then two skipped words, then whatever comes next.
  4. Instead you get [one direction, one of the best], with no error. With 0 removed positions the suggester sees only one and a space, so every entry that starts with one matches. go to a the reports 0 instead of 3 the same way.
  5. With one finished stop word (one the ) the AnalyzingSuggester adds one hole by itself, because the final offset lies past the end of the last kept word (finalOffsetGapAsHole), which happens to be right; the document suggester (PrefixCompletionQuery) does not, so there one the already matches like one.

Who is affected

Applications with SuggestStopFilter in a suggester's query analyzer (directly or as the suggestStop factory), as its javadoc recommends. Lookups ending in finished stop words can get extra suggestions: with two or more such words in AnalyzingSuggester and FuzzySuggester, with one in the document suggester and FreeTextSuggester. No error; it clears once a non-stop word is typed.

Mechanism, terms and numbers905 words

SuggestStopFilter is the query-side stop filter for autocomplete. Its javadoc defines it as "Like StopFilter except it will not remove the last token if that token was not followed by some token separator" and recommends StopFilter in the index analyzer with this class in the query analyzer of the analyzing suggesters. When the typed text ends in stop words followed by a separator, such as one of the , it removes them like StopFilter, but its end() reports the input's own final position increment, 0, instead of adding the positions it removed; StopFilter reports 2. Terms, position increments, offsets and the final offset are identical. The suggesters turn that final increment into trailing holes of the lookup automaton, so with too few holes the lookup matches more entries: AnalyzingSuggester.lookup("one of the ") returns [one direction, one of the best] where a StopFilter query analyzer returns [one of the best]. AnalyzingSuggester and FuzzySuggester show it with two or more removed trailing stop words, the document suggester and FreeTextSuggester with one. In a bounded probe the result was always the result for the same text without its trailing stop words; in exactFirst mode (AnalyzingSuggester, FuzzySuggester) one entry whose text equals the typed text is dropped (with assertions on, the lookup throws an AssertionError). The end value has been wrong since 4.5.0 (LUCENE-3849); it has changed FreeTextSuggester results since that suggester's first release, 4.6.0, and the other suggesters' since 6.5.0 (LUCENE-7619). With a small fix (+6/-2 lines in SuggestStopFilter) the whole suggest test task has 0 failures.

Terms

  • Position increment: each token says how many positions it moved on from the previous one. A removed stop word leaves a hole: in go to school, school has increment 2.
  • Final position increment: after the last token, TokenStream#end() reports the positions skipped at the end of the input: "any skipped positions (such as those removed by a stopfilter) can be applied to the position increment" (TokenStream.java:152-154). StopFilter does this (FilteringTokenFilter.java:72-75): go to a the ends with final increment 3.
  • SuggestStopFilter: "Like StopFilter except it will not remove the last token if that token was not followed by some token separator" (SuggestStopFilter.java:29-37). It keeps an unfinished last word (find the), so that a query a can still suggest apple (LUCENE-5165 CHANGES entry).
  • Lookup automaton: AnalyzingSuggester turns the analyzed query into an automaton and matches it as a prefix of the analyzed entries. Each hole becomes an extra separator (AnalyzingSuggester.java:268-307); trailing holes come from the final position increment (TokenStreamToAutomaton.java:212-241).

What happens (main a9779ef3c193, SuggestStopFilter.java)

// incrementToken(): the last token is a stop word and the input has no more tokens
clearAttributes();
input.end();
endState = captureState();                 // :96  the input's end state: final posInc 0
int finalEndOffset = offsetAtt.endOffset();
if (finalEndOffset > endOffset) {
  // OK there was a token separator after the stopword, so it was a stopword
  return false;                            // :102 skippedPositions and this word's posInc are lost
}

// end()                                      :61-71
if (endState == null) { super.end(); } else { restoreState(endState); }

// StopFilter, through FilteringTokenFilter.end()   :72-75
super.end();
posIncrAtt.setPositionIncrement(posIncrAtt.getPositionIncrement() + skippedPositions);
  1. For one of the , one is returned. of is skipped (skippedPositions = 1). the is a stop word and the last token, so the filter calls input.end() and captures that state: final increment 0, final offset 11.
  2. The final offset is past the end of the (10), so the was finished and is removed. The filter returns false without adding skippedPositions (1) or the's own increment (1). end() restores the captured state: final increment 0. StopFilter gives 2.
  3. The index side stores one of the best as one, three separators, best (one separator per position step), and one direction as one, one separator, direction. The StopFilter query one of the is one plus two separators: a prefix of the first entry only. The SuggestStopFilter query is one plus one separator: a prefix of both.
  4. AnalyzingSuggester and FuzzySuggester call setFinalOffsetGapAsHole(true) (AnalyzingSuggester.java:317-322): a final increment of 0 becomes 1 hole when the final offset lies past the end of the last token (TokenStreamToAutomaton.java:214-216; here 11 against 3, the gap of the ). That is right for one removed word and too few for two or more. The document suggester builds its automaton without that setting (ConcatenateGraphFilter.java:233-244), so there one removed word already matters.

Mid-stream the class does the bookkeeping: go to a the school gives school increment 4, which the upstream TestSuggestStopFilter.testMultipleStopWords asserts. Only the end-of-input branch drops it.

The numbers

input (whitespace tokenizer, stop words to a the of)StopFilter final posIncSuggestStopFilter final posInc
go the 10
one of the 20
go to a the 30
go to school (control)00
AnalyzingSuggester, index analyzer StopFilter, entries one direction (10), one of the best (5)lookup("one of the ")
query analyzer StopFilter (control)[one of the best]
query analyzer SuggestStopFilter (main)[one direction, one of the best]
query analyzer SuggestStopFilter with the fix[one of the best]

An exhaustive comparison with StopFilter over 11,718 inputs (every sequence of 0-5 words over go to a the school, 3 endings) found 4,686 differences, all in the final position increment only; 0 with the fix (compared: terms, position increments, offsets, final offset and final position increment).

Exposure and likelihood546 words
  • Who. Users of SuggestStopFilter in the query analyzer of AnalyzingSuggester, FuzzySuggester, the document suggester (PrefixCompletionQuery, FuzzyCompletionQuery) or FreeTextSuggester, directly or through SuggestStopFilterFactory (SPI name suggestStop). It is opt-in: no Lucene analyzer uses the class by default. The suggester defaults (exactFirst, preserved separators and position increments) are the configuration shown. preservePositionIncrements=false does not hide it but reverses it: the buggy lookup then misses single-token entries that a StopFilter query analyzer matches (AnalyzingSuggester with entries one direction, one of the best, oneplus: [one direction, one of the best] instead of [one direction, one of the best, oneplus]). AnalyzingInfixSuggester is not affected.
  • Trigger. Ordinary typing: text that ends in removed stop words followed by a space, such as one of the . AnalyzingSuggester and FuzzySuggester need two or more such words (with one, their own trailing-space hole happens to be right); the document suggester needs one (one the matches like one), and so does FreeTextSuggester, which reads the end increment itself (FreeTextSuggester.java:474-541): one of returns the next-word predictions after one where a StopFilter query analyzer returns none.
  • Effect. Extra suggestions. In a bounded probe (6 words, 3 of them stop words, texts of 1 to 4 words: 1,434 entries, 3,108 queries, 777 ending in removed stop words) the result with the bug always equals the result for the same text without its trailing stop words, 0 mismatches. Not every such query changes: 270 of the 777 differ from the fixed filter in AnalyzingSuggester and in FuzzySuggester, 528 (prefix) and 513 (fuzzy) in the document suggester; in FreeTextSuggester 657 of 2,868 queries differ. Contexts and document filters still apply to every hit.
  • One entry can be lost. In exactFirst mode, an entry whose text equals the typed text, trailing space included (entry one of the , query one of the ), is dropped with assertions off: the probe's AnalyzingSuggester returns [one of the best, one direction, one the end], where StopFilter and the fix return [one of the , one of the best, one the end]. With assertions on the lookup throws an AssertionError at AnalyzingSuggester.java:808. FuzzySuggester, which inherits that code, does the same.
  • Index time (not the documented setup). In an index analyzer the lost increment moves the next value of a multi-valued field one position earlier: foo the | bar with gap 0 puts bar at position 1 instead of 2, so the phrase "foo bar" matches.
  • How likely. Plausible: in that setup such a lookup can be broadened, and the extra entries are the suggestions for the shorter text; they go away once a word that is not a stop word is typed. No report was found in the searches performed. No scale is needed.
  • Workaround. None that keeps the class's purpose: preservePositionIncrements=false does not help (above) and ignores every hole; a StopFilter query analyzer is right for one of the but removes an unfinished last word (find the), the case SuggestStopFilter exists for. A filter after it cannot repair the value (the count of removed trailing words stays inside incrementToken()), and the class is final (SuggestStopFilter.java:38); an application would need its own copy of the class with the fix.

Minimal repro

The test shows that SuggestStopFilter removes the separator-terminated trailing stop words of "one of the " but its end() leaves their positions out of the final position increment.

TestSuggestStopFilterTrailingHoles.java (JUnit test)41 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.suggest.analyzing;

import java.io.StringReader;
import org.apache.lucene.analysis.StopFilter;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.tokenattributes.PositionIncrementAttribute;
import org.apache.lucene.tests.analysis.MockTokenizer;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSuggestStopFilterTrailingHoles extends LuceneTestCase {

  public void testEndCountsRemovedTrailingStopWords() throws Exception {
    Tokenizer tokenizer = new MockTokenizer(MockTokenizer.WHITESPACE, true);
    tokenizer.setReader(new StringReader("one of the "));
    TokenStream ts = new SuggestStopFilter(tokenizer, StopFilter.makeStopSet("of", "the"));
    PositionIncrementAttribute posInc = ts.addAttribute(PositionIncrementAttribute.class);
    ts.reset();
    while (ts.incrementToken()) {}
    ts.end();
    // expected 2 (StopFilter counts the removed "of" and "the" in end()); actual 0
    assertEquals(2, posInc.getPositionIncrement());
    ts.close();
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-583/, 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/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/suggest/build/libs/lucene-suggest-$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.search.suggest.analyzing.TestSuggestStopFilterTrailingHoles
Expected vs actual
Expected: 2, as StopFilter reports on the same input (the class is documented as "Like StopFilter"). Actual: expected:<2> but was:<0> (-ea and -da).

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

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:<2> 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.search.suggest.analyzing.TestSuggestStopFilterTrailingHoles.testEndCountsRemovedTrailingStopWords(TestSuggestStopFilterTrailingHoles.java:38)

Why it looks unintended

  • Documented as StopFilter with one exception, an unfinished last word; StopFilter reports the removed trailing positions in end(), and a test asserts it.
  • LUCENE-3849 made end() carry trailing holes; its commit added the skipped positions there for StopFilter and for the plain TokenFilter MockTokenFilter, not here.
  • No test, CHANGES entry or recorded decision was found for the current value.
The full argument241 words
  • One documented exception. "Like StopFilter except it will not remove the last token if that token was not followed by some token separator" (SuggestStopFilter.java:29-34). The LUCENE-5165 CHANGES entry says the same: a stop word "at the very end of the lookup query, and without any trailing token characters, will be preserved" (CHANGES.txt:13275-13280).
  • StopFilter reports the positions, and a test pins it. FilteringTokenFilter.end() adds skippedPositions (:72-75); TestStopFilter.testEndStopword asserts final increment 1 for test of (TestStopFilter.java:172-189).
  • end() is the channel. TokenStream#end: skipped positions "can be applied to the position increment" (TokenStream.java:152-154). LUCENE-3849: "TokenStreams now set the position increment in end(), so we can handle trailing holes" (CHANGES.txt:13337-13340). Its issue text (#4922) says "subclasses of FilteringTokenFilter (e.g. stopfilter) should do: super.end(); posIncAtt += skippedPositions;"; this class is a plain TokenFilter, but the commit made the same change in the test framework's MockTokenFilter, also a plain TokenFilter. Before the commit the committer wrote "I agree this bug is bad. It can also affects suggesters".
  • The class already does it mid-stream (SuggestStopFilter.java:92, :114; testMultipleStopWords asserts +4), and the suggesters read the end increment: FreeTextSuggester since its first release, 4.6.0 (FreeTextSuggester.java:474-541), the analyzing suggesters since LUCENE-7619 (6.5.0).
  • No test covers it. testEndIsStopWord (go to ) and testMultipleStopWordsEnd2 (go to a the ) use the assertTokenStreamContents overload that passes finalPosInc = null (TestSuggestStopFilter.java:47-64, 123-140).
The devil's-advocate check223 words

Strongest case (an intended-behaviour checker and a reviewer from another model family, OpenAI GPT-5.6; rated moderate): TokenStream#end says skipped positions "can" be applied, not "must". The author of the class rewrote SuggestStopFilter.end() in the LUCENE-3849 commit itself, six days after adding the class, and did not add them. The upstream trailing-stop tests leave the final increment unchecked, and TestAnalyzingSuggester strips trailing holes from its oracle because "TokenStream.end() does not tell us any trailing holes, yet ... there is an issue open for this" (TestAnalyzingSuggester.java:900-902).

Why it fails: the class's only documented difference from StopFilter does not apply to text that ends in a separator. LUCENE-3849's issue text asks FilteringTokenFilter subclasses such as StopFilter to add skippedPositions in end(), and the same commit did so for FilteringTokenFilter and for the test framework's MockTokenFilter, a plain TokenFilter like this class. When it landed, no suggester read the end increment yet: FreeTextSuggester, which does, was added six weeks later (4.6.0), and TokenStreamToAutomaton looked only at the final offset until LUCENE-7619 (6.5.0). The indexer reads it too, but an index analyzer is not where the class is documented to go. No record of a decision about the value was found. The TestAnalyzingSuggester comment dates from 2012, before LUCENE-3849, and concerns that test's own filter: it names a known gap, not a design.

Suggested fix

Recommended A. Add the removed positions before capturing the end state (recommended)

On the branch that removes a finished last stop word, add skippedPositions + posInc to the final position increment before capturing the end state (+6/-2 in SuggestStopFilter.java). With two finalPosInc asserts added to TestSuggestStopFilter, the whole :lucene:suggest test task (240 tests, 7 upstream skips) has 0 failures, the repro tests included, with assertions on and off.

All options and trade-offs3 options · 759 words

Fix A and fix B were built and run in a scratch worktree at a9779ef3c193 with the whole :lucene:suggest:test task (the two repro classes and the new asserts included); both give StopFilter's end state. Either fix also makes FreeTextSuggester follow StopFilter for such text. Both are behaviour-changing bug fixes: suggestion lists for such text change. The sibling end() bugs in other classes (LFV-354, LFV-542, LFV-532, LFV-488) have their own fix sites.

A. Add the removed positions before capturing the end state (recommended) (validated by the checks)

On the branch where the last stop word is followed by a separator, add skippedPositions + posInc to the input's final increment and only then capture endState; the unfinished-last-word branch captures it unchanged. end() stays as it is. The patch below plus two finalPosInc asserts in the existing TestSuggestStopFilter trailing-stop tests is fix-with-test.diff.

           clearAttributes();
           input.end();
-          endState = captureState();
           int finalEndOffset = offsetAtt.endOffset();
           assert finalEndOffset >= endOffset;
           if (finalEndOffset > endOffset) {
             // OK there was a token separator after the
-            // stopword, so it was a stopword
+            // stopword, so it was a stopword; like StopFilter,
+            // report the removed trailing positions in end()
+            posIncAtt.setPositionIncrement(
+                posIncAtt.getPositionIncrement() + skippedPositions + posInc);
+            endState = captureState();
             return false;
           } else {
+            endState = captureState();
             // No token separator after final token that
             // looked like a stop-word; don't filter it:
             restoreState(sav);

Trade-offs

  • Smallest change: the stored end state itself is right, so any consumer that calls end() sees StopFilter's value.
  • Changes suggestions only for text that ends in removed stop words and a separator; those now match StopFilter. FreeTextSuggester results for such text change the same way (one of gives [] instead of the bigrams after one).
  • With preservePositionIncrements=false the change goes the other way: such a lookup then matches like a StopFilter query analyzer, which also includes single-token entries that start with the kept word (oneplus for one of the ).
  • For very long runs of trailing stop words the lookup automaton grows like StopFilter's (40,004 states for 20,000 stop words instead of 6; single runs, not a benchmark); bounding query length is the application's job (SECURITY.md line 38).

What the checks validated

  • Gradle on a9779ef3c193, seed DEADBEEF: fix-with-test.diff plus the two repro classes, all of :lucene:suggest:test: 33 classes, 240 tests, 0 failures, 7 skipped (upstream @Ignore and @Nightly), with assertions on and off (an independent fact check repeated both). That is 236 upstream tests (e.g. TestAnalyzingSuggester 23, TestFuzzySuggester 14, TestAnalyzingInfixSuggester 23, TestFreeTextSuggester 11, TestPrefixCompletionQuery 9) plus the 4 repro tests.
  • :lucene:suggest:ecjLintMain, ecjLintTest and checkGoogleJavaFormat pass with fix-with-test.diff.
  • With the standalone runner (fix as a shadow class): the finder's and the reproducer's tests pass, 46 tests of the four closest classes pass, and the exhaustive differential against StopFilter gives 11,718 of 11,718 equal.

B. Remember the removed positions and add them in end() (validated by the checks)

Keep the capture where it is; store skippedPositions + posInc in a field on the separator branch, reset it in reset(), and add it after restoreState(endState) in end() (+5 lines).

   private State endState;
+  // positions of the stop words removed at the end of the input, reported in end() like StopFilter
+  private int trailingSkippedPositions;
   ...
     endState = null;
+    trailingSkippedPositions = 0;
   ...
       restoreState(endState);
+      posIncAtt.setPositionIncrement(posIncAtt.getPositionIncrement() + trailingSkippedPositions);
   ...
             // stopword, so it was a stopword
+            trailingSkippedPositions = skippedPositions + posInc;
             return false;

Trade-offs

Same result as A and reads like FilteringTokenFilter.end(), which adds its count after super.end(); one more field to reset.

What the checks validated

  • Gradle on a9779ef3c193, seed DEADBEEF, with the same test asserts and repro classes: all of :lucene:suggest:test passes with assertions on (33 classes, 240 tests, 0 failures, 7 skipped); ecjLintMain, ecjLintTest and checkGoogleJavaFormat pass. With assertions off (run by an independent fact check): 33 classes, 240 tests, 0 failures, 7 skipped.

Regression test for either fix (validated by the checks)

Pass finalPosInc in the two existing trailing-stop tests of TestSuggestStopFilter: 1 for go to (testEndIsStopWord) and 3 for go to a the (testMultipleStopWordsEnd2), with the assertTokenStreamContents overload that takes it. The finder's JUnit adds the suggester-level case.

Trade-offs

Pins the value at the filter level; the suggester-visible effect needs the repro's lookup test.

What the checks validated

  • On stock main the two asserts fail (finalPosInc expected:<1> but was:<0>, expected:<3> but was:<0>; 2 of 6 TestSuggestStopFilter tests); with fix A or fix B they pass.

Downstream note

By code reading only, Elasticsearch and OpenSearch build a SuggestStopFilter for their stop token filter with remove_trailing: false, and Solr documents SuggestStopFilterFactory for suggester query analyzers; nothing was run downstream and no downstream report was found.

How it was found

Semi-formal: a bounded-exhaustive sweep compared the end() positions and offsets of 434 selected token-filter configurations with a reference built from their input plus the positions they removed (6.78 million analyses), and flagged SuggestStopFilter. A differential test against StopFilter over 11,718 inputs isolated the final position increment; it was reduced to a public-API JUnit.

The method in full190 words
  • Sweep (bounded-exhaustive translation validation of the stock jars). 434 selected component configurations (the token filters registered through SPI with their defaults, except 27 that need parameters or resources, plus parameter grids for the buffering and token-removing filters), behind 3 upstream chains, on 5,208 texts each (6.78 million analyses). The reference for each run is the end() state of the same chain without the component, plus the positions the component removed; it compares terms, position increments and lengths, offsets, and the final position increment and offset, not every attribute. Every suggestStop configuration with a matching stop word list showed final-position mismatches.
  • Differential against the documented equivalence. Every sequence of 0-5 words over go to a the school with 3 endings (11,718 inputs) through StopFilter and SuggestStopFilter: 4,686 mismatches, all of one shape (only the final position increment differs; terms, position increments, offsets and the final offset equal; other attributes not compared); 0 with the fix (non-vacuous).
  • Reduction. Code reading found the separator branch in incrementToken; the finder wrote the public-API JUnit with the AnalyzingSuggester lookup and ran it on main, 10.5.2 and 9.12.3, and a main() probe on 8.11.4.

Prior reports, history and checks

Prior reports

No prior report found (142 logged queries plus about 40 manual GitHub searches, the JIRA archive, the mailing lists and downstream trackers, 2026-10-26). Nearest: LUCENE-3849 (#4922), whose commit fixed StopFilter's end() and missed this class, and LUCENE-5165 (#6229), which added the class.

Searches5 searches

Registry verdict new. None of the items reports that SuggestStopFilter.end() omits the removed trailing positions or the extra suggestions. Same family in other classes, each filed and fixed separately: LUCENE-5180 (#6244, ShingleFilter), LUCENE-8360 (NGramTokenFilter), LUCENE-8371 (#9418, FixedShingleFilter), LUCENE-8650 (#9696, ConcatenatingTokenStream) and #16577 (FixedShingleFilter). Also read and different: #9391 (LUCENE-8344, TokenStreamToAutomaton with preservePositionIncrements=false), #4915 (LUCENE-3842, the analyzing suggester).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub apache/lucene issues, PRs and commits2026-10-26three scripted prior-report searches (class-focused and symptom-focused, 142 queries in total) and about 40 manual gh searches (SuggestStopFilter and its factory, trailing stop words or holes in suggesters, TokenStreamToAutomaton trailing, final position increment, skippedPositions, finalOffsetGapAsHole); the file's history on main, branch_10x, branch_10_5 and branch_9xno report; nearest #4922, #6229, #9391, #4915
Open PRs2026-10-26the changed files of all 406 open apache/lucene PRsnone touches SuggestStopFilter or its test
Downstream (Elasticsearch, OpenSearch, Solr, Lucene.NET)2026-10-26issue searches, remove_trailing, code search for reachno report; Elasticsearch and OpenSearch map remove_trailing=false to SuggestStopFilter (code reading)
Mailing lists and JIRA2026-10-26java-user@, dev@, issues@, commits@, general@, solr-user@, users@solr; JIRA text ~ SuggestStopFilter (5 results)no report; a 2017 java-user@ thread is usage advice
Web2026-10-267 web queriesno report

History

Introduced by LUCENE-3849 (2013-08-20: e005a580e9f on branch_4x, shipped in 4.5.0; trunk twin 2a5421ca685), which made end() report trailing holes and missed this class. Visible in FreeTextSuggester since 4.6.0 and in the analyzing and document suggesters since 6.5.0 (LUCENE-7619). Every release checked from 4.5.0 to 10.5.2 and main; not fixed upstream.

Versions tested7 rows

Introduced by LUCENE-3849 (apache/lucene#4922), committed by Michael McCandless on 2013-08-20 as "LUCENE-3849: end() now sets position increment, so any trailing holes are counted": e005a580e9f on branch_4x (shipped in 4.5.0) and 2a5421ca685 on trunk (first released in 6.0.0). The commit gave FilteringTokenFilter.end() the skipped positions and changed SuggestStopFilter to capture its end state right after input.end(), without them. Commit isolation on 4.4.0 jars with shadowed sources: before it both filters agree, after it they differ exactly as in 4.5.0. The class itself (LUCENE-5165, #6229) was added six days earlier.

Visible in FreeTextSuggester since 4.6.0: LUCENE-5214 added it (branch_4x 9494be7ec2a, 2013-10-02; first release 4.6.0), and from its first version its lookup reads the end increment. lookup("one of ") returns [] with a StopFilter query analyzer and [one_, onedirection, oneone] with SuggestStopFilter on 4.6.0, 4.10.4, 5.0.0, 5.5.5, 6.4.2, 6.5.0 and main.

Visible in AnalyzingSuggester, FuzzySuggester and the document suggester since 6.5.0: LUCENE-7619 (#8670; branch_6x 4e4ec082caa, 2017-01-17) made TokenStreamToAutomaton turn the end increment into trailing holes. On 6.4.2 even a StopFilter query analyzer returns [one direction, one of the best] from AnalyzingSuggester; from 6.5.0 on StopFilter narrows it and SuggestStopFilter does not. FuzzySuggester and the document suggester use the same TokenStreamToAutomaton; their effect was run on main only.

Not fixed upstream: since 2013 the file has had only non-functional commits (license header, imports, a 2021 reformat); in the full-history clone all 114 release tags from 4.5.0 to 10.5.2 contain one of the two LUCENE-3849 commits. The clone has no tag for 7.4.1, 7.5.1, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0 to 8.11.3 and 8.11.4; 8.11.4 was run from its release jars and is affected.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected4 of 4 repro tests fail (gradle and runner, assertions on and off)
6.5.0, 6.6.6, 7.7.3, 8.11.4, 9.12.3, 10.5.2 affectedend() 3/0, 1/0, 2/0 against StopFilter, and the extra AnalyzingSuggester suggestion (portable probes; FreeTextSuggester also on 6.5.0; the JUnit repro also fails on 9.12.3 and 10.5.2)
4.6.0, 4.10.4, 5.0.0, 5.5.5, 6.4.2 FreeTextSuggesterFreeTextSuggester lookup("one of "): [] with StopFilter, [one_, onedirection, oneone] with SuggestStopFilter (fact check); the wrong end() value (probed on 4.10.4, 5.5.5, 6.4.2); AnalyzingSuggester unchanged on 6.4.2 (same list for both filters; 4.x and 5.x analyzing lookup not run)
4.5.0 end() onlythe same wrong end() value; no FreeTextSuggester yet, analyzing lookup not run
4.4.0 + SuggestStopFilter before LUCENE-3849 vs + the LUCENE-3849 files bisectbefore: both filters agree (1/1); after: 3/0, 1/0, 2/0 as in 4.5.0
4.4.0 and earlier no such classthe release has no SuggestStopFilter
main + fix A or fix B not affectedall 240 tests of :lucene:suggest run with 0 failures (7 upstream skips), the 4 repro tests and the 2 new asserts included (fix A and fix B, assertions on and off)

Independent checks

The finder, an independent reproducer, an intended-behaviour checker and an adjudication each ran tests on main: all said real. Reviews from another model family (OpenAI GPT-5.6) read the code; one agreed, one returned a procedural 'not real' because it could not run code. An audit kept it; this doc's gradle runs reproduced it and validated two fixes.

Checks and verdicts522 words

Every check executed code on main except the cross-model reviews, which only read code; separate runs re-ran the finder's test (fails on main; the intended-lens stage also passed it with the fix). The intended-lens review read main ea57360912ed, where these files are identical.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateSweep and exhaustive differential (4,686 of 11,718 differ, 0 with the fix); JUnit fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3; main() probe fails on 8.11.4; with the fix 46 upstream suggest tests pass.
refute review (OpenAI GPT-5.6, reading only) procedural "not real", lowCould not run code; its own notes call the verdict 'not a substantive refutation'; root cause agrees, inputs in contract, intended argument moderate. A separate re-run of the finder's test failed 2 of 2.
independent reproducer realOwn JUnit written from the claim fails 2 of 2 on main (-ea, -da), 10.5.2 and 9.12.3; controls agree; the same fix passes it, and 46 upstream suggest tests pass with and without the fix. Noted that the raw end() difference already happens with one stop word.
intended-lens review (OpenAI GPT-5.6, reading only) real, high'Intended' argument moderate and rejected; root cause agrees, inputs in contract. Separate runs: the finder's test fails with -ea and -da and passes with the fix.
intended-behaviour checker real, medium-highBuilt the strongest intended case and refuted it (StopFilter equivalence, LUCENE-3849 issue text, the 2012 test comment); re-ran the repro on main (-ea, -da) and 10.5.2; TestAnalyzingInfixSuggester 23 of 23 pass with the fix; dated the suggester symptom to 6.5.0.
adjudication confirmed, highRe-ran the reproducer's JUnit at seed 5EED1234 (fails 2 of 2 with -ea and -da, passes with the fix); duplicate of nothing.
security screen none, highProbe over 3,108 queries on four suggester paths: the result with the bug equals the result for the text without its trailing stop words (0 mismatches); contexts and filters hold; found the exactFirst edge case and the one-word trigger on the document suggester.
credibility audit keepRe-ran the finder's test at seed C0FFEE42 (fails 2 of 2, -ea and -da); own probe confirms the one-word and preservePositionIncrements=false cases show no difference in AnalyzingSuggester; FuzzySuggester differs only on the larger probe dictionary.
this doc's gradle runs reproduced; fixes A and B pass the moduleScratch worktree at a9779ef3c193, seed DEADBEEF: stock main fails the two repro classes 4 of 4 with assertions on and off, and the two new TestSuggestStopFilter asserts 2 of 6; with fix A (assertions on and off) or fix B (on) all 240 tests of :lucene:suggest pass.
fact checks of this doc (one by a reviewer from another model family, OpenAI GPT-5.6, reading only) bug confirmed; text correctedBoth re-confirmed the bug, mechanism and fixes (the first re-ran gradle: 4 of 4 fail on main, fixes A and B pass the module with assertions on and off). Corrected here: FreeTextSuggester shows it since 4.6.0, the paste-ready command now sets -Ptests.asserts=true, preservePositionIncrements=false does not mask it, the release-tag coverage, and several wordings.

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

Finding record

LFV-583, snapshot of 2026-11-06

Registry title
SuggestStopFilter.end() leaves out the positions of the stop words it removes at the end of the input (StopFilter counts them), so a suggester with SuggestStopFilter in its query analyzer returns extra suggestions for text that ends in removed stop words and a space (two or more stop words for AnalyzingSuggester and FuzzySuggester, one for the document suggester; wrong end() since 4.5.0, visible in suggesters since 6.5.0)
Area
suggest / SuggestStopFilter (query analysis of the analyzing and document suggesters)
Priority
Low, rank 440: SuggestStopFilter's wrong end() broadens as-you-type suggestions in its recommended setup on everyday input, but it affects suggestion quality only and clears at the next typed word.
Impact class
silent wrong results
Impact note
Applications that put SuggestStopFilter in a suggester's query analyzer (directly, through the suggestStop factory, or, by code reading, through a downstream stop filter with remove_trailing=false), which is the setup its javadoc recommends next to a StopFilter index analyzer, get a broader suggestion list whenever the typed text ends in removed stop words followed by a separator: two or more stop words for AnalyzingSuggester and FuzzySuggester (one is hidden by finalOffsetGapAsHole), one for the document suggester (PrefixCompletionQuery and FuzzyCompletionQuery); the extra entries are exactly the suggestions for the text without those stop words, except that in exactFirst mode an entry whose text equals the typed text, trailing space included, is dropped (or, with -ea, the lookup throws an AssertionError); AnalyzingInfixSuggester is not affected, preservePositionIncrements=false masks it, and no scale is needed. Low severity: suggestion quality only.
Summary
You build an AnalyzingSuggester over 'one direction' and 'one of the best' with a StopFilter index analyzer and a SuggestStopFilter query analyzer, as the SuggestStopFilter javadoc recommends, and look up 'one of the ' (the user has typed 'the' and a space). You expect what a StopFilter query analyzer gives, [one of the best], because the class is documented as 'Like StopFilter' except that it keeps an unterminated last stop word, and StopFilter adds the positions of removed trailing stop words to the final position increment in end(). Instead SuggestStopFilter.end() reports the input's own final increment (0 instead of 2), so the lookup automaton has too few trailing holes and the lookup also returns 'one direction'; the tokens and the final offset are correct.
Introduced
e005a580e9f856e1cdebc3bf3230768911c7105c (branch_4x, svn r1515909, LUCENE-3849 'position increments should be implemented by TokenStream.end()', apache/lucene#4922, committed by mikemccand; the commit that shipped in 4.5.0) and its trunk twin 2a5421ca685b7341bbead24975cd218cbcaf34f7 (svn r1515887, first released in 6.0.0; git blame on main credits it with the SuggestStopFilter lines). Both capture endState right after input.end() and restore it in end() without adding skippedPositions or the removed last stop word's increment, while the same commit made FilteringTokenFilter.end() (StopFilter) add skippedPositions [exec: 4.4.0 jars plus SuggestStopFilter at d5e99b1b959 (just before) agree with StopFilter; 4.4.0 jars plus the four files at e005a580e9f differ like 4.5.0]. The suggester-visible symptom starts with LUCENE-7619 (branch_6x 4e4ec082caa, trunk 637915b890d), which made TokenStreamToAutomaton turn the end() position increment into trailing holes [exec: 6.4.2 gives the same suggestions as a StopFilter query analyzer, 6.5.0 differs] (2013-08-20 (wrong end() increment, LUCENE-3849); 2017-01-17 (visible in suggesters, LUCENE-7619))
First affected version
4.5.0 (wrong end() increment) [exec]; 6.5.0 for the suggester-visible extra suggestions [exec]
Affected versions
Every release from 4.5.0 to 10.5.2 (114 release tags) and main a9779ef3c193 (unreleased 11.0) [read: git tag --contains]. Executed: wrong end() increment on 4.5.0, 4.10.4, 5.5.5, 6.4.2, 6.5.0, 6.6.6, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main [exec]; extra suggestions on 6.5.0, 6.6.6, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main, not on 6.4.2 [exec]; JUnit fails on 9.12.3 and 10.5.2 (-ea) and on main (-ea and -da) [exec]. Not affected: 4.4.0 and earlier (no SuggestStopFilter) [exec, read]. Not fixed upstream: SuggestStopFilter.java has had only non-functional changes since 2013 (last a 2021 reformat) on main, branch_10x and branch_10_5 [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: exhaustive check
Upstream and prior reports
none found (2026-10-26, read-only; follow-up prior-report record verdict new): three scripted prior-report searches (142 queries over GitHub issues, PRs and commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and 7 web queries), about 40 manual gh searches on apache/lucene, the changed files of all 406 open PRs, the commit history of SuggestStopFilter.java on main, branch_10x, branch_10_5 and branch_9x, and lists.apache.org including solr-user@. Closest prior art, none of it this bug: #4922 LUCENE-3849 (defines the end() trailing-hole contract; its commit edited SuggestStopFilter.end() without the skipped positions; never mentions SuggestStopFilter), #6229 LUCENE-5165 (adds the class; discusses only the unterminated last token), #9391 LUCENE-8344 (TokenStreamToAutomaton with preservePositionIncrements=false, other fix site), #4915 LUCENE-3842 (design history). Same family in other classes, each filed and fixed separately: LUCENE-5180 #6244, LUCENE-8360, LUCENE-8371 #9418, LUCENE-8650 #9696, #16577. Caveat: the GitHub search API rate-limited twice; blocked queries were retried or duplicated completed ones
Lucene commit tested
main a9779ef3c193 (gradle in a scratch worktree, assertions on and off, and the standalone JUnit runner with several seeds); the release jars 4.5.0, 4.10.4, 5.5.5, 6.4.2, 6.5.0, 6.6.6, 7.7.3, 8.11.4, 9.12.3 and 10.5.2; 4.4.0 with the class shadowed just before and just after LUCENE-3849 (commit isolation); the FreeTextSuggester lookup on 4.6.0, 4.10.4, 5.0.0, 5.5.5, 6.4.2, 6.5.0 and main (fact check); fixes A and B with the whole :lucene:suggest:test task (assertions on and off). JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/SuggestStopFilter.java:29-37 (at a9779ef3c193) (class javadoc: 'Like StopFilter except it will not remove the last token if that token was not followed by some token separator'; StopFilter in the indexAnalyzer, this class in the queryAnalyzer of the analyzing suggesters), :61-71 (end() restores endState only), :73-118 (incrementToken: skippedPositions carried mid-stream; on the separator branch clearAttributes(); input.end(); endState = captureState(); return false, without the skipped positions)
  • lucene/core/src/java/org/apache/lucene/analysis/FilteringTokenFilter.java:70-75 (at a9779ef3c193) (StopFilter's end(): super.end() then posInc + skippedPositions)
  • lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:152-154 (at a9779ef3c193) ('any skipped positions (such as those removed by a stopfilter) can be applied to the position increment')
  • lucene/core/src/java/org/apache/lucene/analysis/TokenStreamToAutomaton.java:212-241 (at a9779ef3c193) (end() posInc becomes trailing HOLE arcs; finalOffsetGapAsHole maps 0 to 1 when the final offset passes the last token)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:317-322 (at a9779ef3c193) (setFinalOffsetGapAsHole(true)), :800-810 (exactFirst: assert results.size() == 1 on a same-surface-form path)
The finding in 30 seconds
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/SuggestStopFilter.java:29-37 (at a9779ef3c193) (class javadoc), :61-71 (end()), :73-118 (incrementToken; :83 skippedPositions, :92 mid-stream, :94-102 separator branch, :114)
  • lucene/core/src/java/org/apache/lucene/analysis/FilteringTokenFilter.java:72-75; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:152-154
  • lucene/core/src/java/org/apache/lucene/analysis/TokenStreamToAutomaton.java:212-241 (at a9779ef3c193) (trailing holes; :214-216 finalOffsetGapAsHole), :271-299 (addHoles)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:268-307 (at a9779ef3c193) (replaceSep: HOLE becomes epsilon, POS_SEP becomes SEP_LABEL), :317-322 (setFinalOffsetGapAsHole(true))
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConcatenateGraphFilter.java:233-244; (at a9779ef3c193) lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestSuggestStopFilter.java:85-102 (testMultipleStopWords, +4)
Who is affected
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/SuggestStopFilterFactory.java:73 (at a9779ef3c193) (NAME = suggestStop); AnalyzingSuggester.java:180-192 (default options), :799-811 (exactFirst, assert at :808)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/document/PrefixCompletionQuery.java:73-76; (at a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConcatenateGraphFilter.java:233-244
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FreeTextSuggester.java:474-541 (at a9779ef3c193) (end(), endPosInc, hole-only predictions skipped); SuggestStopFilter.java:38 (public final class), :83-102; lucene/core/src/java/org/apache/lucene/analysis/TokenStreamToAutomaton.java:214-218 (preservePositionIncrements=false drops a positive end increment, finalOffsetGapAsHole still adds 1)
Why it looks unintended
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/SuggestStopFilter.java:29-37, (at a9779ef3c193) :92, :114
  • lucene/core/src/java/org/apache/lucene/analysis/FilteringTokenFilter.java:72-75; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:152-154; lucene/core/src/test/org/apache/lucene/analysis/TestStopFilter.java:172-189
  • lucene/CHANGES.txt:13275-13280 (at a9779ef3c193) (LUCENE-5165), :13337-13340 (LUCENE-3849), :8407-8410 (LUCENE-7619)
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestSuggestStopFilter.java:47-64, (at a9779ef3c193) :85-102, :123-140; lucene/test-framework/src/java/org/apache/lucene/tests/analysis/BaseTokenStreamTestCase.java:474-528 (the overload these tests use delegates with finalPosInc null at :522); lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestAnalyzingSuggester.java:900-902
Suggested fix
  • SECURITY.md line 38

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

Repro files

About the diagrams

A step-by-step picture of the repro's example, made in parallel with this doc.

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.