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.
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.- You index a text field through a whitespace tokenizer,
StopFilter("the")andEdgeNGramTokenFilter(1, 3)orNGramTokenFilter(3, 3), a common autocomplete chain, and analyzeabc the. StopFilterremovestheand 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.- 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. Forabc x the, where it also drops the shortx, you get 1 instead of 2. IndexWriteradds that number to the field position before the next value. With the valuesabc theanddefand the default gap 0,deflands at position 1, not 2: the phraseabc deffinds the document (1 hit; 0 without the n-gram filter), and the phrase that keeps the hole (abcat 0,defat 2) misses.
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.
- Position increment: how many positions a token moves on from the previous one. A removed word leaves a hole:
StopFiltergives 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).StopFilterreports the removed last word ofabc theas 1. - Multi-valued field: several values of one field in one document.
IndexWriteradds 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).
- Both filters count, in
curPosIncr, the increments of input tokens they drop themselves (shorter thanminGram, withoutpreserveOriginal), 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). end()callssuper.end(), which runs the input'send()and leaves the input's final increment in the attribute, and thenposIncrAtt.setPositionIncrement(curPosIncr)(NGramTokenFilter.java:157-161,EdgeNGramTokenFilter.java:146-150). That replaces the input's hole with the filter's own drops.- The two counts are disjoint (
curPosIncronly sees tokens the filter received), so the right value is their sum, as inFilteringTokenFilter.end()(FilteringTokenFilter.java:72-75). - 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.
input, whitespace tokenizer and StopFilter("the") | expected final increment | actual |
|---|---|---|
abc the, StopFilter alone (control) | 1 | 1 |
abc the, + NGramTokenFilter(3,3) or EdgeNGramTokenFilter(1,3) | 1 | 0 |
abc x the, + NGramTokenFilter(3,3) or EdgeNGramTokenFilter(2,3) (the filter drops x) | 2 | 1 |
field f = ["abc the", "def"], gap 0 | def at | phrase abc def | phrase abc@0 def@2 |
|---|---|---|---|
| StopFilter only (control) | 2 | 0 hits | 1 hit |
| + either n-gram filter | 1 | 1 hit | 0 hits |
| + either n-gram filter, with the fix | 2 | 0 hits | 1 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 anotherFilteringTokenFiltersuch asLengthFilter,CodepointCountFilter,TypeTokenFilterorKeepWordFilter) beforeNGramTokenFilterorEdgeNGramTokenFilter, built with the constructors, thenGram/edgeNGramfactories orCustomAnalyzer, 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);
CheckIndexis clean. - The gap. Lucene's
Analyzerdefault 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).defis 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.
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.
/*
* 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();
}
}
# 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)onabc the, expected a final position increment of 1 afterend(); actual 0, with-eaand-da.
More files, environment, full output38 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> 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)- The analysis package docs say every component "must" put accurate values into the attributes at the end of the field, and
TokenStream#endnames stop-filter holes as what goes there. - Sibling filters such as
FilteringTokenFilterandFixedShingleFilteradd to the input's value inend()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 addedend()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) andFixedShingleFilter.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 otherend()override in core and analysis-common that callssetPositionIncrementkeeps or adds to it. - The same filters keep mid-stream holes:
abc the defkeepsdeftwo positions afterabc. 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.
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.*andTestConditionalTokenFiltertests: 91 of 91, assertions on and off (stock: the 3 new tests fail). - All of
:lucene:analysis:common:testwith assertions on: 340 classes, 1,912 tests, 18 skipped, 0 failures (the upstreamtestEndPositionIncrementtests, the LUCENE-8360 case, included). :lucene:analysis:common:check -x testpasses (google-java-format, ecjLint, forbiddenApis, licenses).- Through the repro harness: the new test 3 of 3 with the fix shadowed on
a9779ef3c193; earlier onea57360912edthe 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 andConditionalTokenFiltertests; the LUCENE-8360 control still gives 4.
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.
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.
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.
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#endcontract were compared token by token, and on the end-of-stream state, with the realNGramTokenFilterandEdgeNGramTokenFilter. - 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
curPosIncrreset (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").
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.
| Where | When | How | Found |
|---|---|---|---|
| scripted prior-report search (four runs) | 2026-10-26 | 51, 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 web | no report; the high hits (#9407, #9408, #4922, #9418, #16577 and others) were read |
| GitHub (GraphQL and code search) | 2026-10-26 | about 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 PRs | no report; no indexed fork or downstream copy with the fix was found; no open PR changes either end() |
| Mailing lists and JIRA | 2026-10-26 | lists.apache.org (Lucene dev@, java-user@, issues@, solr-user@; Solr users@ and dev@, full range); LUCENE and SOLR JIRA | no report; LUCENE-8360 notifications and a release thread only |
| Closest items (read) | 2026-10-26 | full 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) |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | new test 3 of 3 fail through gradle and the repro harness (assertions on and off) |
| 10.5.2, 9.12.3 | affected | new 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 | affected | release-portable main(): final increment 0 instead of 1 for both filters |
| 7.3.1 | not affected | both filters keep the hole (EdgeNGramTokenFilter has the opposite end() defect there) |
7.4.0 jars + filters at b03b527a312 vs its parent | bisect | the 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 affected | 91 of 91 n-gram tests and all module tests pass; the LUCENE-8360 case still gives 4 |
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.
| Check | Verdict | What it found |
|---|---|---|
| Cross-model refute review | not real (low), procedural | An 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 | real | Own 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 | real | Own 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 | keep | Re-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 passes | Gradle 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). |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 397 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,531 words
- 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 upstreamngram.*suites andTestConditionalTokenFilter, assertions on and off (stock: 3 of 91 fail; with the fix: 0), the fix with all of:lucene:analysis:common:testandcheck -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;-dain the fact check); the hunt's release-portablemain()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 mainea57360912ed, whose analysis code is identical (IndexingChain.javachanged, +61/-6, and the multi-valued effect was executed on both commits). OpenJDK 25.0.3, Linux aarch64.
- 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 @ a9779ef3c193lucene/analysis/common/src/java/org/apache/lucene/analysis/ngram/EdgeNGramTokenFilter.java:44, :100, :104-105, :117-118, :143, :146-150 @ a9779ef3c193lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:142-156; lucene/core/src/java/org/apache/lucene/analysis/FilteringTokenFilter.java:72-75 @ a9779ef3c193lucene/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 @ a9779ef3c193lucene/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) @ a9779ef3c193lucene/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 @ a9779ef3c193lucene/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 @ a9779ef3c193lucene/CHANGES.txt:851 (GITHUB#16577), :7498-7499 (LUCENE-8360), :13337-13340 (LUCENE-3849) @ a9779ef3c193lucene/analysis/common/src/test/org/apache/lucene/analysis/ngram/TestNGramTokenFilter.java:214-222; TestEdgeNGramTokenFilter.java:281-289 @ a9779ef3c193lucene/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
- TestNGramTokenFilterFinalPositionIncrement.java (JUnit test):
repro/LFV-532/src/org/apache/lucene/analysis/ngram/TestNGramTokenFilterFinalPositionIncrement.java(39 lines)
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.
assets/LFV-532/bug.png(397 KB)
