All findings · Lucene finding · LFV-532 · ngram-filters-end-overwrites-final-position-increment

NGramTokenFilter and EdgeNGramTokenFilter end() throw away the input's final position increment, so a trailing stop-word hole is lost

NGramTokenFilter and EdgeNGramTokenFilter lose the hole StopFilter leaves for a removed last word: abc the ends with a final position increment of 0, not 1. The next value of a multi-valued field then starts one position early, so the phrase abc def matches across the values abc the and def.

Summary

Priority
Low, rank 486NGram and EdgeNGram filters lose a trailing position hole, a cross-value-boundary loss that rarely changes results but in widely used filters.
Impact
silent wrong results
Since
7.4.0 (LUCENE-8360, 2018)introduced 2018-06-17 · about 8 years
Affected
7.4.0 to 10.5.2 and main
Status
documented
Found by
bounded-exhaustive check of the filters against their javadoc

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 token stream reports positions skipped after its last token in end(), as the final position increment; IndexWriter adds it before the next value of the field. The two n-gram filters replace their input's value instead of adding to it.

Summary card. Left: the chain WhitespaceTokenizer, a StopFilter for "the", NGramTokenFilter(ts, 3, 3, false) on a field f with the values "abc the" and "def"; PhraseQuery("f", "abc", "def"): expected 0 hits, actual on main 1 hit, a false positive; EdgeNGramTokenFilter(ts, 1, 3, false) the same. Middle: with StopFilter only, abc is at position 0, the removed the leaves a hole at 1 and def is at 2; behind NGramTokenFilter def is at 1, moved into the hole, so abc and def are adjacent. Right: NGramTokenFilter.end() calls super.end(), where the input's end() gives 1, then setPositionIncrement(curPosIncr), and curPosIncr = 0 overwrites the 1; for "abc x the" end() gives 1, should be 2. Bottom: not affected are term queries, scores, single-valued fields and the first value, and with a gap of 100 this phrase gives the same hits; affected are 7.4.0 to 10.5.2 and main; fix direction: add instead of assign, like FilteringTokenFilter.end(); with it def is at 2 and the phrase gives 0 hits.
The positions of the two values abc the and def in one field, with StopFilter only and behind NGramTokenFilter, the end() line that overwrites the hole, and the fix direction. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index a text field through a whitespace tokenizer, StopFilter("the") and EdgeNGramTokenFilter(1, 3) or NGramTokenFilter(3, 3), a common autocomplete chain, and analyze abc the.
  2. StopFilter removes the and reports the skipped position at the end of the stream: a final position increment of 1. The n-gram filter drops nothing here, so you expect it to report 1 too.
  3. You get 0. The filter's end() first fetches the input's 1, then overwrites it with the positions of the tokens it dropped itself, 0. For abc x the, where it also drops the short x, you get 1 instead of 2.
  4. IndexWriter adds that number to the field position before the next value. With the values abc the and def and the default gap 0, def lands at position 1, not 2: the phrase abc def finds the document (1 hit; 0 without the n-gram filter), and the phrase that keeps the hole (abc at 0, def at 2) misses.

Who is affected

Users who put StopFilter (or any filter that can drop the last tokens of a value) before NGramTokenFilter or EdgeNGramTokenFilter on a multi-valued field with positions. Phrase, span and interval queries across a value boundary then match or miss wrongly with a small gap (Lucene's default is 0). Terms, scores and single-valued fields are unaffected.

Mechanism, terms and numbers653 words

TokenStream#end is where a token stream reports the positions it skipped after its last token. StopFilter reports the removed last word of abc the as a final position increment of 1, and IndexWriter adds that number to the field position before the next value of a multi-valued field. NGramTokenFilter and EdgeNGramTokenFilter call super.end(), which leaves the input's value in the attribute, and then replace it with the increments of only the tokens they dropped themselves (shorter than minGram). So behind either filter abc the ends with 0 instead of 1, and abc x the with 1 instead of 2. With Lucene's default position increment gap of 0, the second value def of ["abc the", "def"] is indexed at position 1 instead of 2: the phrase abc def matches (1 hit, against 0 for the same chain without the n-gram filter) and the phrase that keeps the hole misses. Terms, scores, single-valued fields and the first value of the field are unchanged; a gap of 100 hides it from ordinary exact phrases. It came with LUCENE-8360 in 7.4.0, which fixed the opposite case; adding instead of replacing, as FilteringTokenFilter.end() and FixedShingleFilter.end() do, fixes it.

Terms

  • Position increment: how many positions a token moves on from the previous one. A removed word leaves a hole: StopFilter gives the next token an increment of 2 instead of 1.
  • Final position increment: holes after the last token have no next token to carry them, so the stream reports them in end() ("any skipped positions (such as those removed by a stopfilter) can be applied to the position increment", TokenStream.java:152-154). StopFilter reports the removed last word of abc the as 1.
  • Multi-valued field: several values of one field in one document. IndexWriter adds each value's final increment, then the analyzer's position increment gap (0 by default), before the next value (IndexingChain.java:2105-2109, :2121-2122; Analyzer.java:300-311).

The bug

  1. Both filters count, in curPosIncr, the increments of input tokens they drop themselves (shorter than minGram, without preserveOriginal), hand the sum to the next emitted token (a gram, or the preserved original) and reset it (NGramTokenFilter.java:108, :113-114, :132-133; EdgeNGramTokenFilter.java:100, :104-105, :117-118).
  2. end() calls super.end(), which runs the input's end() and leaves the input's final increment in the attribute, and then posIncrAtt.setPositionIncrement(curPosIncr) (NGramTokenFilter.java:157-161, EdgeNGramTokenFilter.java:146-150). That replaces the input's hole with the filter's own drops.
  3. The two counts are disjoint (curPosIncr only sees tokens the filter received), so the right value is their sum, as in FilteringTokenFilter.end() (FilteringTokenFilter.java:72-75).
  4. Only end() is wrong: grams, their order, offsets, per-token increments, position lengths, types and keyword flags match a reference on all 24,106,264 generated inputs of the hunt's exhaustive harness, and a hole in the middle of the stream (abc the def) is kept.

What the chain returns (main a9779ef3c193)

input, whitespace tokenizer and StopFilter("the")expected final incrementactual
abc the, StopFilter alone (control)11
abc the, + NGramTokenFilter(3,3) or EdgeNGramTokenFilter(1,3)10
abc x the, + NGramTokenFilter(3,3) or EdgeNGramTokenFilter(2,3) (the filter drops x)21
field f = ["abc the", "def"], gap 0def atphrase abc defphrase abc@0 def@2
StopFilter only (control)20 hits1 hit
+ either n-gram filter11 hit0 hits
+ either n-gram filter, with the fix20 hits1 hit

Other readers of the final increment, by code reading only: TokenStreamToAutomaton turns it into trailing-hole states (TokenStreamToAutomaton.java:212-239), as used by ConcatenateGraphFilter for completion fields (with its default preservePositionIncrements=true); AnalyzingSuggester sets setFinalOffsetGapAsHole(true) (AnalyzingSuggester.java:320), which restores one lost trailing hole from the offset gap. Queries built by QueryBuilder (the query parsers' path) do not read the final increment, so those are unchanged; a suggester's lookup runs TokenStreamToAutomaton on the query text too (AnalyzingSuggester.java:885-890).

Exposure and likelihood344 words
  • Who is exposed. Chains with a filter that can remove the last tokens of a value (StopFilter, or another FilteringTokenFilter such as LengthFilter, CodepointCountFilter, TypeTokenFilter or KeepWordFilter) before NGramTokenFilter or EdgeNGramTokenFilter, built with the constructors, the nGram / edgeNGram factories or CustomAnalyzer, on a multi-valued field that indexes positions.
  • What changes. Each later value of the field in the same document starts early by the trailing holes lost in the earlier values: normally one position per removed trailing word (precisely, the summed increments of the removed tokens, FilteringTokenFilter.java:58), and the shift adds up over successive values. Phrase, span and interval queries across that boundary match or miss differently from the same chain without the n-gram filter, in both directions (executed for phrases). At gap 0, phrases across a value boundary match by design (Analyzer.java:300-303), so the change is which cross-value phrases match, not whether any do.
  • What does not change (executed on 300 random multi-valued documents, both filters, gaps 0 and 100): terms, frequencies, norms and BM25 term-query scores; single-valued fields and the first value of a multi-valued field (only later values move; a hole lost at the end of the last value has no effect); CheckIndex is clean.
  • The gap. Lucene's Analyzer default is 0. The factories' own javadoc example and common search servers use 100 for text fields, which hides it from ordinary exact phrases (executed at gap 100 for the adjacent and the one-hole phrase). def is still at 101 instead of 102, so a phrase with explicit positions across the gap, or a sloppy, span or interval query that reaches that far, could still differ (not executed).
  • Default vs opt-in. The n-gram filters are optional analysis-common components; once configured, ordinary text that ends in a stop word triggers it.
  • Scale and versions. Deterministic, any index size, no error. Every release from 7.4.0 to 10.5.2 and main.
  • How likely. Rare in visible results: n-gram fields are usually term-matched for autocomplete, and no report since 2018 was found in the recorded searches.

Minimal repro

The test shows that NGramTokenFilter.end() overwrites the final position increment of its input, so the trailing hole StopFilter leaves for a removed last word is lost.

TestNGramTokenFilterFinalPositionIncrement.java (JUnit test)39 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.analysis.ngram;

import org.apache.lucene.analysis.StopFilter;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.PositionIncrementAttribute;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;

public class TestNGramTokenFilterFinalPositionIncrement extends BaseTokenStreamTestCase {

  public void testEndKeepsInputFinalPositionIncrement() throws Exception {
    TokenStream ts =
        new NGramTokenFilter(
            new StopFilter(whitespaceMockTokenizer("abc the"), StopFilter.makeStopSet("the")),
            3, 3, false);
    PositionIncrementAttribute posInc = ts.addAttribute(PositionIncrementAttribute.class);
    ts.reset();
    while (ts.incrementToken()) {}
    ts.end();
    // expected 1 (StopFilter's hole for the removed trailing "the"; the n-gram filter drops nothing), actual 0
    assertEquals("final position increment after end()", 1, posInc.getPositionIncrement());
    ts.close();
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-532/, 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: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.analysis.ngram.TestNGramTokenFilterFinalPositionIncrement
Expected vs actual
For whitespace -> StopFilter("the") -> NGramTokenFilter(3, 3) on abc the, expected a final position increment of 1 after end(); actual 0, with -ea and -da.

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

More files, environment, full output38 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: final position increment after end() expected:<1> 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.apache.lucene.analysis.ngram.TestNGramTokenFilterFinalPositionIncrement.testEndKeepsInputFinalPositionIncrement(TestNGramTokenFilterFinalPositionIncrement.java:36)

Why it looks unintended

  • The analysis package docs say every component "must" put accurate values into the attributes at the end of the field, and TokenStream#end names stop-filter holes as what goes there.
  • Sibling filters such as FilteringTokenFilter and FixedShingleFilter add to the input's value in end() instead of replacing it.
  • No test pins the overwrite: with the fix all module tests pass.
The full argument247 words
  • The docs. "End of Input Cleanup": the components "must put accurate values into the token attributes to reflect the situation at the end of the field" (package-info.java:274-284). TokenStream#end: "any skipped positions (such as those removed by a stopfilter) can be applied to the position increment" (TokenStream.java:152-154). LUCENE-3849 added end() increments for exactly this: "TokenStreams now set the position increment in end(), so we can handle trailing holes" (CHANGES.txt:13337-13340).
  • The class javadoc. NGramTokenFilter "emits all n-grams for the same token at the same position" (NGramTokenFilter.java:30). With 3-character tokens and grams of 3 it emits exactly its input, yet it changes indexed positions.
  • Sibling filters add. FilteringTokenFilter.end() (:74) and FixedShingleFilter.end() (FixedShingleFilter.java:176-179, fixed this way by #16577, merged 2026-08) add to the input's value; per a verifier's grep, every other end() override in core and analysis-common that calls setPositionIncrement keeps or adds to it.
  • The same filters keep mid-stream holes: abc the def keeps def two positions after abc. Only the trailing hole is lost.
  • History and tests. LUCENE-8360 ("did not correctly set position increments in end()", CHANGES.txt:7498-7499) wrote the assignment to fix the opposite case, the filter's own dropped trailing tokens. Its tests (TestNGramTokenFilter.java:214-222, TestEdgeNGramTokenFilter.java:281-289) feed a whitespace tokenizer whose final increment is 0, so they cannot tell "set" from "add"; the issue's comments never mention the input's increment. With the fix all module tests pass (1,912, 18 skipped).
The devil's-advocate check197 words

The checkers built the strongest case for "intended": (a) TokenStream#end only says skipped positions "can be" applied, which is permissive, and no javadoc states that the sum of increments must be preserved; (b) LUCENE-8360 wrote the assignment on purpose and its CHANGES entry calls the result correct; (c) in its motivating caller, ConditionalTokenFilter, the n-gram filter's input is a wrapper whose end() calls endAttributes(), so the input's increment there is always 0 and overwrite equals add; (d) n-gram fields are mostly single-valued and term-matched.

Why it fails: (a) the package docs say "must", and LUCENE-3849 exists to carry trailing holes; (b) the 8360 tests cannot distinguish the two behaviours and nothing in that issue discusses discarding the input's value; (c) explains why the change looked right there, not why a stop-word hole from a plain chain should vanish; the fix behaves identically in that caller; (d) is about severity, not intent. The open LUCENE-8361 position-preservation check never names the n-gram filters: its saved patch checks every chain, and only its discussion raises exceptions, for filters that summarise the whole stream and for truncating filters. Every checker rejected the case; the cross-model reviews rated it weak.

Suggested fix

Recommended A. Add the filter's own drops to the input's final increment (NGramTokenFilter and EdgeNGramTokenFilter)

Add instead of replace: in both end() methods, posIncrAtt.setPositionIncrement(posIncrAtt.getPositionIncrement() + curPosIncr), the same shape as FilteringTokenFilter.end() and FixedShingleFilter.end(). It cannot double count, and an input whose final increment is 0 gives today's result. Validated: the new tests pass, as do all module tests and check.

   @Override
   public void end() throws IOException {
     super.end();
-    posIncrAtt.setPositionIncrement(curPosIncr);
+    // keep the input's final increment (e.g. a trailing stop-word hole) and add the positions of
+    // the tokens this filter dropped after the last emitted token
+    posIncrAtt.setPositionIncrement(posIncrAtt.getPositionIncrement() + curPosIncr);
   }
All options and trade-offs3 options · 503 words

Option A was applied in a scratch worktree of main a9779ef3c193 and run through gradle with assertions on and off; option B is complementary and was not run.

A. Add the filter's own drops to the input's final increment (NGramTokenFilter and EdgeNGramTokenFilter) (validated by the checks) (recommended)

In NGramTokenFilter.end() (NGramTokenFilter.java:160) and EdgeNGramTokenFilter.end() (EdgeNGramTokenFilter.java:149), replace setPositionIncrement(curPosIncr) by adding curPosIncr to the value super.end() left in the attribute.

Why it cannot double count. curPosIncr only accumulates increments of tokens that input.incrementToken() returned; the input's final increment exists only after input.end(), inside super.end().

What it changes. Only the final increment, and only when the input reports one. With an input whose final increment is 0 (a plain tokenizer, or ConditionalTokenFilter's wrapper) the result is the same as today; the LUCENE-8360 case (seventeen one two three four, grams 8..8) still gives 4.

   @Override
   public void end() throws IOException {
     super.end();
-    posIncrAtt.setPositionIncrement(curPosIncr);
+    // keep the input's final increment (e.g. a trailing stop-word hole) and add the positions of
+    // the tokens this filter dropped after the last emitted token
+    posIncrAtt.setPositionIncrement(posIncrAtt.getPositionIncrement() + curPosIncr);
   }

Trade-offs

  • Indexes built before the fix keep the old positions for later values until reindexed, so phrase results across value boundaries change after an upgrade and reindex; that is the fix.
  • No API change; negligible cost: one attribute read and one addition per end() call.

What the checks validated

  • The 3 new tests pass, with the 88 upstream ngram.* and TestConditionalTokenFilter tests: 91 of 91, assertions on and off (stock: the 3 new tests fail).
  • All of :lucene:analysis:common:test with assertions on: 340 classes, 1,912 tests, 18 skipped, 0 failures (the upstream testEndPositionIncrement tests, the LUCENE-8360 case, included).
  • :lucene:analysis:common:check -x test passes (google-java-format, ecjLint, forbiddenApis, licenses).
  • Through the repro harness: the new test 3 of 3 with the fix shadowed on a9779ef3c193; earlier on ea57360912ed the finder's repro 4 of 4, the reproducer's suite 6 of 6, the devil's advocate's probe 3 of 3, and 59 upstream n-gram and ConditionalTokenFilter tests; the LUCENE-8360 control still gives 4.

B. Also check end increments generically in the test framework (complementary, not run)

LUCENE-8361 (#9408, open since 2018) proposes that TestRandomChains check that the sum of position increments, the final one included, is preserved through a chain. Its saved patch checks every chain, with no exemption list; its discussion raises exceptions for filters that summarise the whole stream and for truncating filters. By reading its patch, it would probably flag this bug; whether it also flags the sibling bugs in ConditionalTokenFilter, ConcatenatingTokenStream and WordDelimiterGraphFilter was not checked.

Trade-offs

Larger scope: any exemptions must be agreed, and it finds bugs rather than fixing them; not a substitute for option A.

C. Document the overwrite as intended (not recommended)

Say in the class javadocs that the filters reset the final increment to their own drops.

Trade-offs

Contradicts the package docs' "must", TokenStream#end and the sibling filters that add, and keeps the wrong positions in multi-valued fields.

Downstream note

Search servers that expose these filters inherit the shifted positions; their usual gap of 100 for text fields hides it from ordinary exact phrase queries.

How it was found

A bounded-exhaustive check of the real filters against references written from their javadocs and the TokenStream#end contract, on 24,106,264 generated inputs. The only mismatch was the final position increment, in exactly the inputs whose source reported a trailing hole; reading end() then gave the cause.

The method in full212 words
  • Method (semi-formal): bounded-exhaustive translation validation of the stock main jar, in the hunt's unit on analysis filters against their javadoc. References written from the class javadocs and the TokenStream#end contract were compared token by token, and on the end-of-stream state, with the real NGramTokenFilter and EdgeNGramTokenFilter.
  • Inputs. 12,053,132 generated inputs per filter: every term of up to 4 code points over 5 code points (including a supplementary character and a combining mark), and every sequence of up to 3 tokens over 7 terms with position increments 0/1/2, position lengths 1/2, the keyword flag, a source final increment of 0 or 1 and several gram parameter sets. These are synthetic streams; some, such as an empty term or a first token with increment 0, no real chain produces.
  • Signal. 6,026,566 mismatches per filter, all in the final increment and exactly the inputs whose source reported 1; grams, order, offsets, increments, position lengths, types and keyword flags all matched. The harness killed three planted n-gram mutants: two that change the generated grams (by its exhaustive suite) and one that skips the curPosIncr reset (by its separate reuse check).
  • Root cause by reading the two end() overrides; the finder then wrote the public-API JUnit and the fix, and independent checkers followed (see "Independent checks").

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26: about 175 tool queries and 40 direct searches over GitHub, JIRA, the Lucene and Solr lists and the web, plus all 409 open PRs). Closest: LUCENE-8360, which introduced the overwrite, and the open LUCENE-8361 test idea that would catch it.

Searches4 searches

27 plausible hits were read; none describes this bug. Keyword search only: a report that only describes the symptom could be missed, GitHub's search rate limit capped two tool runs (covered by GraphQL searches), one 2018 Q&A page could not be fetched, and private lists cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (four runs)2026-10-2651, 60, 32 and 32 queries (the second run had 51 until a rerun ingested its saved web searches): identifiers and the code fragment, the trigger and symptom, and the bug family, over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the webno report; the high hits (#9407, #9408, #4922, #9418, #16577 and others) were read
GitHub (GraphQL and code search)2026-10-26about 40 exact-phrase searches on apache/lucene, apache/solr and two downstream search servers (curPosIncr, the end() statement, each n-gram filter with StopFilter, "final position increment", "trailing hole", every ngram issue or PR since 2018-06-18); code search for the add fix (two spellings); the changed files of all 409 open apache/lucene PRsno report; no indexed fork or downstream copy with the fix was found; no open PR changes either end()
Mailing lists and JIRA2026-10-26lists.apache.org (Lucene dev@, java-user@, issues@, solr-user@; Solr users@ and dev@, full range); LUCENE and SOLR JIRAno report; LUCENE-8360 notifications and a release thread only
Closest items (read)2026-10-26full threads and attached patches#9407 / LUCENE-8360 (introduced the overwrite while fixing the opposite case), #9408 / LUCENE-8361 (open: generic position-preservation check, never names these filters), #4922 / LUCENE-3849 (the trailing-hole contract), #9418 / #9423 and #16577 (the same symptom fixed in other filters)

History

Introduced by LUCENE-8360 (d1dc874d90bc, 2018-06-17), first released in 7.4.0; 7.3.1 keeps the hole. Fails on every executed release from 7.4.0 to 10.5.2 and on main. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-8360 (now #9407, "NGramTokenFilter doesn't set posIncAttribute correctly in end()"; commit subject "NGram filters should set posIncAtt in end()"), by @romseygeek with a +1 from @jpountz: master d1dc874d90bc (2018-06-17), branch_7_4 b03b527a312 (what 7.4.0 ships) and branch_7x a045344b254, committed from a JIRA patch (no PR). It fixed the opposite case (the filter's own dropped trailing tokens were lost) by overwriting the increment.

Precondition. LUCENE-7960 (master 2c1ab31b4e5, first released in 8.0.0, and its 7.4.0 backport 71e2a681235, both 2018-06-04; both commit messages mistype the issue as "LUCENE-7690"; never released without LUCENE-8360) replaced NGramTokenFilter's CodepointCountFilter wrapper, whose end() added its skipped positions, with the filter's own counting. So for NGramTokenFilter 7.4.0 is a plain regression; for EdgeNGramTokenFilter it traded one end() defect for another.

Not fixed upstream. Upstream main ffa9d816eeb4 (2026-10-26) and c5ab3e57b9ba (2026-10-28) still have the overwrite at NGramTokenFilter.java:160; later commits on the files only reformat, fix javadoc or remove final modifiers. The overwrite is present at every release tag from 7.4.0 to 8.8.1 and 9.0.0 to 10.5.2 in the history clone; 8.8.2 and 8.9.0 to 8.11.3 have no tag in the clone and are inferred (8.11.4 executed).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectednew test 3 of 3 fail through gradle and the repro harness (assertions on and off)
10.5.2, 9.12.3 affectednew test 3 of 3 fail (10.5.2 -ea; 9.12.3 -ea and -da); the finder's and the reproducer's JUnit fail
7.4.0, 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 10.0.0 affectedrelease-portable main(): final increment 0 instead of 1 for both filters
7.3.1 not affectedboth filters keep the hole (EdgeNGramTokenFilter has the opposite end() defect there)
7.4.0 jars + filters at b03b527a312 vs its parent bisectthe commit loses the hole, its parent keeps it; the two shadowed filter-source sets differ only by the end() override
fix on main a9779ef3c193 not affected91 of 91 n-gram tests and all module tests pass; the LUCENE-8360 case still gives 4

Independent checks

An independent reproducer (own 6-test suite), a works-as-designed review by a reviewer from another model family, an intended-behaviour devil's advocate, an adjudication and a credibility audit all judged it real; the reproducer, the devil's advocate, the adjudication and the audit re-ran it. A read-only refute review said "not real" only because it could not run code.

Checks and verdicts392 words

Every verdict below that re-ran a repro did so on main ea57360912ed, whose analysis code is identical to a9779ef3c193; the reading-only cross-model reviews were followed by a separate re-run of the repro (fails). This doc's own fix validation ran on a9779ef3c193.

Each independent check and its verdict
CheckVerdictWhat it found
Cross-model refute review not real (low), proceduralAn independent reviewer from another model family (OpenAI GPT-5.6), reading only: it could run no command, so it could not vote real. It agreed with the root cause, found the inputs within the contract, rated the intended-behaviour case weak and pointed to FixedShingleFilter.end() as the model for the fix. A separate run re-ran the repro: fails 3 of 4.
Independent reproducer realOwn public-API suite written from the claim only: 4 of 6 fail on main with assertions on and off, on 10.5.2 and on 9.12.3; both controls (StopFilter alone; a mid-stream hole) pass. Added the abc x the case and the phrase false positive; 6 of 6 pass with the fix.
Works-as-designed review real (medium)The same OpenAI GPT-5.6 reviewer with an intended-behaviour lens, reading only: the "can be applied" and "LUCENE-8360 on purpose" case is weak. Separate re-runs fail on main with assertions on and off and on 10.5.2, and pass with the fix.
Intended-behaviour devil's advocate realOwn probe through CustomAnalyzer factory names: 3 of 3 fail on main with assertions on and off and on 10.5.2, 3 of 3 pass with the fix. Every other end() override in core and analysis-common adds to or passes through the input's increment.
Adjudication confirmed (high)Re-ran the reproducer's suite on main with a new seed: 6 run, 4 failed; 6 of 6 pass with the fix.
Credibility audit keepRe-ran the finder's repro with assertions off (fails 3 of 4) and an own CustomAnalyzer probe (2 of 4 fail with assertions on and off, 0 with the fix); impact kept as a narrow cross-value-boundary effect: which cross-value phrases match changes, term queries and scores do not.
Fix validation (this doc) fix A passesGradle in a scratch worktree of a9779ef3c193: stock 3 of 91 n-gram tests fail (the new ones); with fix A 0 of 91, the whole module 1,912 tests, 0 failed, check -x test passes (google-java-format, ecjLint, forbiddenApis, licenses).

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

Finding record

LFV-532, snapshot of 2026-11-06

Registry title
NGramTokenFilter and EdgeNGramTokenFilter end() overwrite the input's final position increment instead of adding to it, so a trailing hole (such as a removed trailing stop word) is lost and the next value of a multi-valued field starts too early (released since 7.4.0)
Area
analysis / common n-gram token filters (NGramTokenFilter, EdgeNGramTokenFilter: final position increment in end())
Priority
Low, rank 486: NGram and EdgeNGram filters lose a trailing position hole, a cross-value-boundary loss that rarely changes results but in widely used filters.
Impact class
silent wrong results
Impact note
Applications that index a multi-valued text field through StopFilter (or another filter that removes the last tokens of a value, such as LengthFilter or KeepWordFilter) followed by NGramTokenFilter or EdgeNGramTokenFilter, a common autocomplete chain, with a small position increment gap (Lucene's Analyzer default is 0): every value that ends in removed tokens makes the next value start one position early per removed trailing token, so phrase, span and interval queries across the value boundary match or miss differently from the same chain without the n-gram filter (executed: a phrase false positive and, for the phrase that keeps the stop-word hole, a false negative at gap 0); term queries, scores, single-valued fields and the last value are unchanged (executed); consumers of the trailing hole such as TokenStreamToAutomaton (suggesters) see 0 (code reading); deterministic, ordinary scale, every release from 7.4.0 to 10.5.2 and main; with the gap of 100 that common search servers use for text fields, exact phrases are unaffected (executed).
Summary
You analyse 'abc the' with a whitespace tokenizer, StopFilter and then NGramTokenFilter(3,3) or EdgeNGramTokenFilter(1,3); StopFilter reports the removed trailing 'the' as a final position increment of 1 in end(), and because the n-gram filter drops nothing here you expect end() to still report 1. Instead both filters call super.end() and then replace that value with only the increments of tokens they dropped themselves, so end() reports 0 ('abc x the' gives 1 instead of 2) and the trailing hole is lost. In a multi-valued field with Lucene's default gap 0, the second value 'def' of ['abc the', 'def'] is then indexed at position 1 instead of 2, and PhraseQuery('abc','def') matches the document (1 hit) where the same chain without the n-gram filter gives 0 hits (executed).
Introduced
d1dc874d90bc (LUCENE-8360 'NGram filters should set posIncAtt in end()', by romseygeek with a +1 from jpountz, master; the branch_7_4 backport b03b527a312 is what 7.4.0 ships, branch_7x backport a045344b254; committed from a JIRA patch, no GitHub PR; the issue is now #9407) [exec: on the 7.4.0 jars, both filters shadowed with their source at b03b527a312^ keep the trailing hole and with their source at b03b527a312 lose it, the two trees differing only by the 6-line end() override; read: git blame gives the end() lines to d1dc874d90bc and git log -S finds no later change]; precondition [read]: LUCENE-7960 (2c1ab31b4e5, 2018-06-04, not released on its own) replaced NGramTokenFilter's CodepointCountFilter wrapper, whose end() adds its skipped positions, with the filter's own curPosIncr counting (2018-06-17 (author date; committed 2018-06-18))
First affected version
7.4.0 [exec] (released 2018-06-27; the last unaffected release 7.3.1 keeps the trailing hole in both filters [exec])
Affected versions
every release from 7.4.0 through 10.5.2, plus main ea57360912ed and the newer main a9779ef3c193 (not fixed; upstream main ffa9d816eeb4 of 2026-10-26 still has the overwrite at NGramTokenFilter.java:160 [read]). Executed (fail, final increment 0 instead of 1 for both filters): 7.4.0, 7.5.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main with a release-portable public-API main() (main also with -da) [exec]; the finder JUnit fails on main (-ea, -da), 10.5.2, 9.12.3 and a9779ef3c193, the independent reproduction's suite on main (-ea, -da), 10.5.2 and 9.12.3, the intended-behaviour check's probe on main (-ea, -da) and 10.5.2 [exec]; 7.3.1 passes [exec]; the overwrite is in both filters at every release tag 7.4.0..8.8.1 and 9.0.0..10.5.2 of the history clone [read]; 8.9.0 to 8.11.3 inferred (8.11.4 executed). For EdgeNGramTokenFilter, 7.3.1 had the opposite end() defect that LUCENE-8360 fixed (the filter's own dropped trailing tokens were lost); for NGramTokenFilter 7.4.0 is a plain regression [exec]
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; the prior-report record, verdict new; plus the finder's scripted prior-report search of 51 queries and the intended-behaviour check's searches): a scripted prior-report search in three runs (51 + 32 + 32 queries over apache/lucene GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, and 12 web queries ingested), 24 lines of manual GitHub GraphQL searches (curPosIncr, the exact end() statement, the class names with end, 'position increment', 'trailing hole', 'trailing stop word', TestRandomChains and more), GitHub code search for the fixed statement (0 hits: no fork or downstream copy carries the add fix), the changed files of all 409 open apache/lucene PRs, downstream repository searches, manual list and JIRA searches, and the local full history; 27 hits read, all same_bug false. Closest items, none this bug: LUCENE-8360 (#9407, fixed 7.4.0) introduced the overwrite while fixing the opposite defect (its test input has a final increment of 0, so it cannot tell overwrite from add; its 4 comments never mention the input's final increment); LUCENE-8361 (#9408, open since 2018) proposes a generic TestRandomChains check that position increments, the end() one included, are preserved, which would probably catch this (code reading), but it was never committed and never names the n-gram filters; LUCENE-3849 (#4922, 2012) set the end() trailing-hole contract; LUCENE-8371/8376 (#9418/#9423) and #16577 (merged 2026-08-28, backports #16590 and #16630) fixed the same symptom in FixedShingleFilter and ConditionalTokenFilter, #16577 with the same add pattern; no open PR changes either end() (#13390, #13814 and #16911 touch the ngram package for other reasons). Update 2026-11-02: #16911 (open when searched) merged 2026-10-29 as 48cf3f515d6, which emptied branch_10x to a README tombstone (10.6 is the last 10.x minor; 10.x fixes go to branch_10_6); it fixes nothing here.
Lucene commit tested
main a9779ef3c193 (2026-10-26) through gradle in a scratch worktree (removed afterwards): the new 3-test class with the upstream ngram.* suites and TestConditionalTokenFilter, assertions on and off (stock: 3 of 91 fail; with the fix: 0), the fix with all of :lucene:analysis:common:test and check -x test. The new test through the repro harness on main (-ea, -da, and with the fix shadowed), 10.5.2 (-ea) and 9.12.3 (-ea; -da in the fact check); the hunt's release-portable main() on 7.3.1 (passes) and 7.4.0 through 10.5.2 and main (fail); a commit-level A/B of LUCENE-8360 on the 7.4.0 jars. The hunt itself ran on main ea57360912ed, whose analysis code is identical (IndexingChain.java changed, +61/-6, and the multi-valued effect was executed on both commits). OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ngram/NGramTokenFilter.java:26-34 (at ea57360912ed) (class javadoc: 'emits all n-grams for the same token at the same position'), :53 (curPosIncr), :108 (adds the increments of the filter's own dropped tokens), :113-114 and :132-133 (applied to the next emitted token, then reset), :154 (reset), :157-161 (end(): super.end() then setPositionIncrement(curPosIncr) at :160)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ngram/EdgeNGramTokenFilter.java:44 (at ea57360912ed) (curPosIncr), :100 (accumulation), :104-105 and :117-118 (applied and reset), :143 (reset), :146-150 (end(): setPositionIncrement(curPosIncr) at :149)
  • lucene/core/src/java/org/apache/lucene/analysis/FilteringTokenFilter.java:72-75 (at ea57360912ed) (end() adds skippedPositions to the input's increment); lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/FixedShingleFilter.java:176-178 (end() adds pendingPosInc, GITHUB#16577)
  • lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:152-154 (at ea57360912ed) ('any skipped positions (such as those removed by a stopfilter) can be applied to the position increment'), :156 (always call super.end()); lucene/core/src/java/org/apache/lucene/analysis/package-info.java:274-284 ('End of Input Cleanup': components 'must' put accurate values into the attributes at the end of the field)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2050-2054 (at ea57360912ed) (end() increment added to the field position), :2066-2068 (position increment gap before the next value); lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:309-311 (default gap 0)
  • lucene/CHANGES.txt:7482 (at ea57360912ed) (LUCENE-8360), :13321 (LUCENE-3849 'TokenStreams now set the position increment in end()'), :7182 (LUCENE-8376/8371), :835 (GITHUB#16577)
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ngram/NGramTokenFilter.java:26-34, :53, :108, :113-114, :132-133, :154, :157-161 @ a9779ef3c193
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ngram/EdgeNGramTokenFilter.java:44, :100, :104-105, :117-118, :143, :146-150 @ a9779ef3c193
  • lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:142-156; lucene/core/src/java/org/apache/lucene/analysis/FilteringTokenFilter.java:72-75 @ a9779ef3c193
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2105-2109, :2121-2122; lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:300-311 @ a9779ef3c193
  • lucene/core/src/java/org/apache/lucene/analysis/TokenStreamToAutomaton.java:212-239; lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:316-322; lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConcatenateGraphFilter.java:233-242 @ a9779ef3c193 (code reading)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConcatenateGraphFilter.java:64 (DEFAULT_PRESERVE_POSITION_INCREMENTS = true); lucene/core/src/java/org/apache/lucene/analysis/TokenStreamToAutomaton.java:215-218; lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:885-890 (lookup) @ a9779ef3c193 (code reading)
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ngram/NGramFilterFactory.java:27-41 (example positionIncrementGap="100"; NAME nGram); EdgeNGramFilterFactory.java:41 (NAME edgeNGram) @ a9779ef3c193
  • lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:300-311 @ a9779ef3c193
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/analysis/package-info.java:274-284; lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:152-156 @ a9779ef3c193
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ngram/NGramTokenFilter.java:30 @ a9779ef3c193; lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/FixedShingleFilter.java:176-179 @ a9779ef3c193
  • lucene/CHANGES.txt:851 (GITHUB#16577), :7498-7499 (LUCENE-8360), :13337-13340 (LUCENE-3849) @ a9779ef3c193
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/ngram/TestNGramTokenFilter.java:214-222; TestEdgeNGramTokenFilter.java:281-289 @ a9779ef3c193
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConditionalTokenFilter.java:84-95 (OneTimeWrapper.end, endAttributes) @ a9779ef3c193
Suggested fix
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/ngram/NGramTokenFilter.java:108, :157-161; lucene/analysis/common/src/java/org/apache/lucene/analysis/ngram/EdgeNGramTokenFilter.java:100, :146-150 @ a9779ef3c193

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

Repro files

About the diagrams

Every on-screen element was checked against the code at a9779ef3c193 and an executed probe made for the diagrams; its notes, palette and frame-by-frame list sit with the files.

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.