All findings · Lucene finding · LFV-781 · fixedshinglefilter-route-gap-from-stream-position-increment

FixedShingleFilter drops the gap filler before stacked tokens: "coffee at the café" gives "at café"

In FixedShingleFilter, a token stacked right after a removed stop word gets no filler: "coffee at the café" with accent folding that keeps the original gives the shingle "at café", and "at _ café" is missing. Every release since 8.0.0, silently.

Summary

Priority
Medium, rank 134FixedShingleFilter silently indexes wrong shingles on ordinary text but only in an opt-in stop-word-plus-stacking chain.
Impact
silent wrong results
Since
8.0.0 (LUCENE-8564, 2018)introduced 2018-12-03 · about 7 years
Affected
8.0.0 to 10.5.2 (all 60 releases run) and main
Status
documented
Found by
bounded-exhaustive comparison with an all-paths reference, then a JUnit

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

FixedShingleFilter joins neighbouring tokens into shingles and writes a filler "_" for each empty position, such as a removed stop word. It walks every route through the token graph, but takes the gap before a token from that token's own position increment, which counts from the previous token in the stream, not from the previous token on the route.

Three panels. 1, the tokens and their positions for "coffee at the café": coffee at position 0, at at 1, an empty position 2 where "the" was removed, and cafe and café stacked at 3; in the stream, coffee +1, at +1, cafe +2, café +0. 2, what line 143 reads on each route: route at to cafe reads +2, one filler, "at _" (right); route at to café reads +0, no filler, "at café" (wrong), although its gap on the route is +2 too, because +0 counts from cafe, the previous token in the stream, not on the route. 3, word pairs at shingle size 2: expected "coffee at", "at _"; actual on main with assertions on and off "coffee at", "at _", "at café", and "at café" is not in the text; at size 3 "coffee at café" is extra and "at _ café" is missing. Footer: the same with StopFilter, KeywordRepeatFilter and PorterStemFilter; which stacked token is hit depends only on their stream order; after a token spanning two positions, "login _" replaces "login page"; fix idea: give each route token its gap on the route, then remove duplicates.
"coffee at the café" after stop removal and folding: "cafe" and "café" both sit two positions after "at", but the route to "café" reads its stream increment +0, gets no filler and yields "at café". Select the still to open it at full size. Generated with AI, checked against the code.
  1. Analyze "coffee at the café" with WhitespaceTokenizer, StopFilter {"the"}, ASCIIFoldingFilter(preserveOriginal = true) and FixedShingleFilter(2). "the" leaves an empty position; "cafe" and the original "café" are stacked at position 3, two positions after "at".
  2. You expect both forms to get the filler "_" that stands for "the": [coffee at, at _], and at size 3 [coffee at _, at _ cafe, at _ café].
  3. You get [coffee at, at _, at café], and at size 3 [coffee at _, coffee at café, at _ cafe]: "café" is joined to "at" as if "the" were not there, and "at _ café" is missing. No error, with assertions on or off.
  4. Why: on each route through the token graph, the filter takes the gap before a token from that token's own position increment. "café" has increment 0, counted from "cafe", not from "at". Swap the two stacked tokens and the other one loses its filler: a, gap, b/c gives [a _, a c]; a, gap, c/b gives [a _, a b].
  5. The same with KeywordRepeatFilter plus a stemmer ("walk the dogs" gives "walk dog"), and after a token that spans two positions ("login _" instead of "login page"). Every release from 8.0.0 to 10.5.2 has it.

Who is affected

Applications that run FixedShingleFilter (or the "fixedShingle" factory, default settings) after a chain that removes words and then stacks tokens: StopFilter before ASCIIFoldingFilter(preserveOriginal), KeywordRepeatFilter plus a stemmer, or single-word synonyms. Every stop word followed by a stacked word triggers it: the field silently gets shingles that skip the removed word and lacks the right ones.

Mechanism, terms and numbers656 words

FixedShingleFilter (lucene/analysis/common) writes a filler "_" for each empty position, such as a removed stop word, and promises stacked shingles for stacked tokens. Since 8.0 it builds one shingle per route through the token graph, but it takes the gap before each route token from that token's own position increment, which counts from the previous token in the stream, not from the previous token on the route. So a token stacked right after a gap (increment 0) gets no filler: "coffee at the café" through StopFilter and ASCIIFoldingFilter(preserveOriginal) gives "at café" and "coffee at café", and "at _ café" is missing; which stacked token keeps the filler depends only on the stream order of the stack, which Lucene documents as undefined. After a token that spans two positions, the next token can get a filler it should not have ("login _" instead of "login page"). Nothing throws. 7.4.0-7.7.3 produced the right stacked shingles; every release from 8.0.0 (LUCENE-8564, which rebuilt the filter on GraphTokenFilter) to 10.5.2 and main does not. A fix that measures the gap along the route, in GraphTokenFilter, was validated with the repro, the whole analysis-common module and a bounded-exhaustive model check.

Terms

  • Shingle and filler: FixedShingleFilter(input, shingleSize) emits every run of shingleSize consecutive tokens as one token. It "handles position increments > 1 by inserting filler tokens (tokens with termtext "_")" (FixedShingleFilter.java:40-41); the filler represents "gaps in the input stream (due to eg stopwords)" (:81). StopFilter removes a word and adds 1 to the next token's position increment.
  • Stacked tokens: tokens with position increment 0 sit at the same position as the token before them: a synonym, the original kept by ASCIIFoldingFilter(preserveOriginal = true), or the stem added by KeywordRepeatFilter plus a stemmer. The filter promises that "if the filter encounters stacked tokens (eg synonyms), then it will output stacked shingles" (:34-35).
  • Routes: since 8.0 the filter extends GraphTokenFilter, which walks one route through the token graph at a time (incrementGraphToken, GraphTokenFilter.java:97-112) and then switches a route token to the next token stacked behind it (incrementGraph, :119-139).

The bug

  1. Both route methods copy the cached token's attributes unchanged from the stream (GraphTokenFilter.java:100, :110).
  2. FixedShingleFilter reads the gap from them: int posInc = incAtt.getPositionIncrement(); (FixedShingleFilter.java:143) and writes posInc - 1 fillers (:144-161).
  3. That increment counts from the previous token in the stream. A token stacked behind another one has increment 0, so on its route the gap before its stack disappears.
  4. After a token that spans two positions, nextTokenInGraph (GraphTokenFilter.java:200-210) finds the right successor, but the successor's increment counts from a shorter token in the stream, so a filler can appear where there is no gap.

The example, step by step

Canned a(+1), b(+2), c(+0), shingle size 2: the route a -> b reads increment 2 and gives a _; the route a -> c reads increment 0 and gives a c, although c is two positions after a. At size 3 the route a -> c runs out of tokens and emits nothing, so a _ c is missing. With c first in the stream, a -> c gets the filler and a -> b does not: [a _, a b].

For login(+1, length 2), log(+0), page(+2) (a removed "in" under "login"), page directly follows login, but its increment 2 counts from log, so size 2 gives [login _, log _] instead of [login page, log _].

Not affected (in the bounded model): single-position chains with no stack right after a gap, and multi-position tokens whose last covered position starts a token, even with empty inner positions, when no stack follows a gap (0 differences in those domains of the exhaustive check). A gap under the last position of a multi-position token is the second shape.

Exposure and likelihood280 words
  • Who is exposed. Users of FixedShingleFilter (lucene/analysis/common, opt-in, @lucene.experimental), directly or through FixedShingleFilterFactory ("fixedShingle"), at default settings, after a chain that removes tokens and then stacks tokens:
    • StopFilter -> ASCIIFoldingFilter(preserveOriginal = true): "coffee at the café" gives "at café";
    • StopFilter -> KeywordRepeatFilter -> PorterStemFilter: "walk the dogs" gives "walk dog" (and at size 3 "walk _ dog" is missing);
    • single-word synonyms, then StopFilter: "coffee at the cafe" with cafe -> bistro gives "at cafe". With StopFilter before SynonymGraphFilter it does not show, because the synonym filter re-emits the match with increment 1.
  • How often. No crafted input: in such a chain, every stop word directly followed by a word with a stacked variant hits it, deterministically. The reach is narrower than that sounds: the filter is opt-in and experimental, preserveOriginal is not the default and stacks the original only when folding changes the word, and RemoveDuplicatesTokenFilter would drop only text-identical copies, not "cafe"/"café" or "dogs"/"dog" (reasoning). How common such chains are was not measured.
  • What goes wrong. Shingle fields usually speed up phrase or as-you-type matching. A lookup of "at café" can match text that says "at the café", and a lookup that expects "at _ café" finds nothing (reasoning; no search was run). Terms already indexed stay wrong until the field is reindexed.
  • Silent. No exception or warning, with assertions on and off; one 4-word string is enough.
  • The multi-position shape ("login _") needs a token-removing filter after a graph filter (demonstrated with SynonymGraphFilter with a multi-word rule, FlattenGraphFilter, StopFilter), which is rarer.
  • How likely. Plausible: the chain is opt-in, and extra or missing shingle terms are easy to miss, which fits that nobody has reported it since 8.0.0 (2019).

Minimal repro

FixedShingleFilter(2) on a(+1) b(+2) c(+0) joins a and c across the hole, because the stacked c keeps its stream increment 0 instead of the route gap.

TestFixedShingleFilterStackedAfterHole.java (JUnit test)46 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.shingle;

import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.analysis.CannedTokenStream;
import org.apache.lucene.tests.analysis.Token;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFixedShingleFilterStackedAfterHole extends LuceneTestCase {

  public void testStackedTokenAfterHole() throws Exception {
    TokenStream ts =
        new FixedShingleFilter(
            new CannedTokenStream(
                new Token("a", 1, 0, 1), new Token("b", 2, 6, 7), new Token("c", 0, 6, 7)),
            2);
    CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
    List<String> shingles = new ArrayList<>();
    ts.reset();
    while (ts.incrementToken()) {
      shingles.add(term.toString());
    }
    ts.end();
    ts.close();
    // a is at position 0 and b, c are stacked at position 2: no bigram may join a and c
    assertFalse("expected no shingle \"a c\", got " + shingles, shingles.contains("a c"));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-781/, 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.shingle.TestFixedShingleFilterStackedAfterHole
Expected vs actual
Expected no shingle "a c" (c is two positions after a), actual [a _, a c].

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

More files, environment, full output40 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 no shingle "a c", got [a _, a c]
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.junit.Assert.assertFalse(Assert.java:65)
       org.apache.lucene.analysis.shingle.TestFixedShingleFilterStackedAfterHole.testStackedTokenAfterHole(TestFixedShingleFilterStackedAfterHole.java:44)

Why it looks unintended

  • The javadoc promises a filler for each gap and stacked shingles for stacked tokens; "café" is two positions after "at", like "cafe".
  • Swapping two stacked tokens changes the output, and Lucene's analysis docs call their order undefined.
  • 7.4-7.7 produced "at _ café"; the 8.0 rewrite onto GraphTokenFilter lost it with no CHANGES note.
The full argument342 words
  • The class contract. "This filter handles position increments > 1 by inserting filler tokens" (FixedShingleFilter.java:40-41); stacked tokens give "stacked shingles" (:34-35); the filler represents "gaps in the input stream (due to eg stopwords)" (:81); the factory's fillerToken is "what should be added in place of stop words" (FixedShingleFilterFactory.java:32).
  • An undefined order decides. The analysis package docs: "the output order of the original token and the injected synonym is undefined as long as they both leave from the same position", and such tokens "would be considered to appear in exactly the same position" (analysis/package-info.java:341-345). Swapping two stacked tokens changes [a _, a c] into [a _, a b].
  • Lucene's own graph code gives stacked tokens the same gap. GraphTokenStreamFiniteStrings: "stacked token should have the same increment as original token at this position" (GraphTokenStreamFiniteStrings.java:237-240); fed path by path that way, FixedShingleFilter gives {a _} and {a _ b, a _ c}. TokenStreamToAutomaton accepts "a HOLE c" and rejects "a c".
  • Increments exist to stop phrases across removed words. PositionIncrementAttribute: values greater than one "inhibit exact phrase matches", so that "exact phrase queries will only match when the terms occur with no intervening stop words" (PositionIncrementAttribute.java:35-39). In review of LUCENE-8202 (#9249), @jpountz proposed the goal that the filter "should emit exactly all the exact phrases of length shingleSize that you could search if you indexed with the wrapped analyzer directly"; the author's next patch said it "should address all of Adrien's concerns".
  • It used to work. 7.4.0-7.7.3 emit [a _], [a _ b, a _ c] and "at _ café"; 8.0.0 onwards do not. LUCENE-8564's CHANGES entry only says it "also changes FixedShingleFilter to extend GraphTokenFilter", and the commit changed no shingle expectation in the tests (its test edit added a comment and removed testShingleCountLimits, an exception-message test for the old shingle-count limit).
  • No test pins the current output. Upstream TestFixedShingleFilter (15 tests) and TestGraphTokenFilter (4) never combine a gap with a stacked group, and they pass with the fix.
The devil's-advocate check295 words

The strongest case for "works as designed" (rated moderate by the devil's advocate and by a reviewer from another model family): PositionIncrementAttribute is defined "relative to the previous Token in a TokenStream" (PositionIncrementAttribute.java:22), GraphTokenFilter promises routes, not route-relative increments, and the javadoc's fillers are for "position increments > 1", which a stacked token (increment 0) literally does not have. The class is @lucene.experimental, and its author called it the index-time filter "that assumes a linear stream (or sausage)" (LUCENE-8202).

Why it fails for the stacked shape: the literal reading makes the output depend on an order Lucene documents as undefined; GraphTokenStreamFiniteStrings gives stacked tokens their stack head's increment (a rule the FixedShingleFilter author added in LUCENE-8916); the 7.x filter produced the expected shingles; and a stack of single-position tokens after a gap is a sausage. Experimental status allows API changes; it does not make wrong output intended. Verdict: a defect (adjudication: confirmed, high).

The multi-position shape is weaker (devil's advocate: medium): upstream tests assert route semantics (testTrailingGraphsOfDifferingLengths expects "a b f" over a three-position "b") and TokenStreamToAutomaton handles "a token spanned over a hole" on purpose (TokenStreamToAutomaton.java:157-161, it accepts "login page"). Against it: position length is not indexed, so in the flat index "login page" is not an exact phrase while "login _ page" is, and the exact-phrase goal above can be read in favour of today's output (@jpountz made that point about a multi-length path in the same comment; upstream's "a b f" over a three-position "b" is not a flat-index phrase either). GraphTokenStreamFiniteStrings drops the "login" route for this input, and the only demonstrated stock trigger is a token-removing filter (StopFilter in the run) after a graph filter, the territory of the open LUCENE-8717 (#9763). Present it second.

Suggested fix

Recommended Route gap from GraphTokenFilter, plus skipping the duplicate it would create

Measure the gap along the route: GraphTokenFilter records each cached token's position and exposes the gap through a new protected getRouteGap(); FixedShingleFilter fills from it, and skips the duplicate shingle that fillers would end before a stacked token. +43/-5 lines; the repro, the whole analysis-common module and a bounded-exhaustive model check (0 of 265,716 differ) pass.

-        int posInc = incAtt.getPositionIncrement();
+        // count the gap from the previous token on this route, not from the previous token in the
+        // stream: a stacked token has increment 0 even when its stack follows a gap
+        int posInc = getRouteGap() + 1;
         if (posInc > 1) {
           // if we have a posInc > 1, we need to fill in the gaps
           if (i + posInc > shingleSize) {
+            if (incAtt.getPositionIncrement() == 0) {
+              // this token is stacked behind the one the previous route had here, so the
+              // previous route ended with the same fillers: skip the duplicate shingle
+              continue outer;
+            }
All options and trade-offs5 options · 981 words

The gap must be measured along the route. FixedShingleFilter alone cannot do that for multi-position tokens, because the token that carries the right increment is not on the route; GraphTokenFilter has the positions. A3 is recommended; A and B fix every wrong shingle but leave duplicates; C fixes only the stacked shape.

Route gap from GraphTokenFilter, plus skipping the duplicate it would create (validated by the checks) (recommended)

  1. GraphTokenFilter records each cached token's absolute position when it reads it (one int per cached token, at most 100) and, on every incrementGraphToken, the number of empty positions between the end of the previous route token and the start of the current one. A new protected final int getRouteGap() returns it; attribute values are not changed.
  2. FixedShingleFilter uses posInc = getRouteGap() + 1 instead of the stream increment (FixedShingleFilter.java:143).
  3. With 1-2 alone, a shingle that fillers complete comes out once per stacked token after the gap (a _ twice). When fillers complete the shingle before a route token whose stream increment is 0, that token is stacked behind the one the previous route had at the same depth (incrementGraph advances exactly one depth to the next stacked token, and a token found by nextTokenInGraph always has increment >= 1), so the previous route already emitted the same shingle: skip it.
-        int posInc = incAtt.getPositionIncrement();
+        // count the gap from the previous token on this route, not from the previous token in the
+        // stream: a stacked token has increment 0 even when its stack follows a gap
+        int posInc = getRouteGap() + 1;
         if (posInc > 1) {
           // if we have a posInc > 1, we need to fill in the gaps
           if (i + posInc > shingleSize) {
+            if (incAtt.getPositionIncrement() == 0) {
+              // this token is stacked behind the one the previous route had here, so the
+              // previous route ended with the same fillers: skip the duplicate shingle
+              continue outer;
+            }

Trade-offs

  • Adds a protected method to a public core base class (an API addition; the name is a suggestion). GraphTokenFilter is public and, unlike FixedShingleFilter, not marked @lucene.experimental, so a new protected final method could clash with a same-named method in an external subclass. The GraphTokenFilter part (+35/-4) is in the full diff.
  • The duplicate skip relies on the order in which incrementGraph walks routes; the set-based JUnit does not pin how many copies come out, only the model's multiset comparison does.
  • In the bounded model, output changes only in the two input shapes, which were wrong; existing shingle fields need reindexing to get the corrected terms.
  • Two text-identical stacked tokens (KeywordRepeatFilter where the stem equals the word) still give one shingle each, as today ("coffee walk the dogs", size 3: "coffee walk _" twice, before and after).
  • Not benchmarked; the extra work is one addition per token read and one subtraction per route step.

What the checks validated

  • Scratch worktree of a9779ef3c193, gradle, JDK 25: the new test fails 5 of 6 on stock (assertions on and off) and passes 6 of 6 with the fix; with assertions on, the whole analysis-common module (340 suites, 1,915 tests, 18 skipped), TestGraphTokenFilter and TestGraphTokenizers (27 tests) and TestRandomChains pass; checkGoogleJavaFormat, ecjLint, forbiddenApis and core renderJavadoc pass.
  • With assertions off: the 5 shingle suites, TestSynonymGraphFilter, TestFlattenGraphFilter and the 2 core graph suites (198 tests) pass.
  • Standalone runner: the new test 6 of 6 with assertions on and off, the finder's 4 of 4, the independent reproducer's 9 of 9.
  • Bounded-exhaustive model (every stream of 1-5 tokens with increments 0-2, position lengths 1-3 and final increment 0-1, fixed terms and offsets, shingle sizes 2-4, compared as multisets of position, term and offsets), assertions on and off: 0 of 265,716 checks differ from the all-paths reference, against 146,634 on stock.

Route gap only (steps 1-2) (validated by the checks)

The same getRouteGap() without the duplicate skip (+38/-5).

Trade-offs

Removes every wrong, extra and missing shingle but leaves identical duplicates: 18,180 of 265,716 model checks differ only by them (for example [a _, a _], [coffee at, at _, at _]). If the field indexes frequencies, the duplicates raise the filler shingle's term frequency.

What the checks validated

The new test 6 of 6 (it compares sets); the exhaustive model: 18,180 checks, all duplicate-only.

Overwrite the route token's position increment in GraphTokenFilter (the finder's sketch) (validated by the checks)

GraphTokenFilter sets the route token's PositionIncrementAttribute to 1 + the empty positions after the previous route token's end; FixedShingleFilter is unchanged.

Trade-offs

Same output as A, duplicates included. It changes what every GraphTokenFilter subclass sees in PositionIncrementAttribute on route tokens (no longer the stream's value), the concern raised by a cross-model review and the adjudication.

What the checks validated

The new test 6 of 6, the finder's 4 of 4, the independent reproducer's 9 of 9 (also re-run by the adjudication), the 19 upstream TestGraphTokenFilter and TestFixedShingleFilter tests; the exhaustive model: 18,180 duplicate-only checks.

FixedShingleFilter only: reuse the stack head's increment (validated by the checks)

Remember the increment of the first token of the stack at each route depth, and use it for a route token whose increment is 0 (+12 lines in FixedShingleFilter.java).

Trade-offs

No base-class change, and it fixes the headline stacked shape, but not the multi-position one: the successor's correct increment is not visible to the subclass. The duplicate skip of A3 can be added to it.

What the checks validated

The new test 5 of 6 (the multi-position test still gives login _); the exhaustive model: 136,458 checks still differ, all with position length > 1 or duplicate-only.

Document the limitation

Say in the javadoc that fillers count from the previous token in the stream.

Trade-offs

Not recommended: it documents output that depends on an undefined token order.

Downstream note

Search servers that index FixedShingleFilter output for phrase or as-you-type matching store these wrong shingles whenever their analyzer has such a chain (not verified here).

How it was found

A bounded-exhaustive check compared the stock filter with an independent all-paths reference on every small token stream (1-5 tokens, 265,716 checks): 146,634 differed, all in two input shapes. A side check showed the route walk itself is exact; a public-API JUnit with stock filters then reproduced it.

The method in full162 words

Method: bounded-exhaustive translation validation on the stock main jars (semi-formal), then a JUnit.

  • The hunt unit on regressions around recent fixes started from the #16577 fix to FixedShingleFilter and checked the filter against an independent reference that enumerates every path through the token graph, with hole semantics taken from core TokenStreamToAutomaton (one filler per empty position).
  • Scope (a bounded model): every token stream of 1-5 tokens with position increments 0-2, position lengths 1-3 and final increment 0-1, fixed distinct terms and fixed offsets (88,572 streams) at shingle sizes 2-4: 265,716 checks, 146,634 mismatching, all with a stacked group right after a gap or a multi-position token whose last covered position starts no token.
  • A side check drove GraphTokenFilter through a minimal subclass: its route enumeration matched the reference exactly (0 route mismatches; 50,712 on a deliberate mutant).
  • An AI finder reduced it to a 4-test public-API JUnit with stock filters; independent AI tools then reproduced, reviewed and judged it (see the checks).

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27): 251 recorded queries over GitHub issues, PRs and commits, JIRA, four mailing lists, downstream projects and the web, plus the files of all 408 open PRs. Closest: #14137, #16384 and #16577 (the emitted shingle's own increment) and LUCENE-8717 (stop words at graph articulation points).

Searches4 searches

No issue, PR, JIRA ticket, list thread or downstream report describes the gap before a route token being read from the stream increment, in either shape. Upstream main f7bcc0abd816, branch_10x and branch_10_5 have the same code.

Closest, not this bug: #14137 (open) with open PR #16384 (diff read: it changes the emitted shingle's increment and leaves the gap loop alone) and merged #16577, backported as #16590 and #16630 (the bug reproduces on main and 10.5.2, which have it); #9763 / LUCENE-8717 (open since 2019: stop words removed at articulation points of a graph lose routes; its uncommitted patch rebased increments only for deleted-marked tokens); #9610 / LUCENE-8564 (the origin); #9765 / LUCENE-8719 (missed trailing paths, fixed 8.1); #9249 / LUCENE-8202 (the exact-phrase goal proposed in review); #4549 and #9084 (the older ShingleFilter); #9959, #9563, #9418, #13271 (other FixedShingleFilter defects).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr, downstream search servers), dev@ / java-user@ / issues@ / commits@ lists, JIRA archive, web2026-10-27two scripted prior-report search runs (103 and 81 queries) plus the finder's 51-query runrelated items only; 0 same-bug hits
GitHub (own searches)2026-10-2748 searchesnone reports the bug
JIRA (LUCENE, SOLR)2026-10-275 queriesthe same known issues
Open PRs2026-10-27the changed files of all 408 open apache/lucene PRsonly #16384 touches FixedShingleFilter.java (gap loop untouched); none touches GraphTokenFilter.java

History

Introduced in 8.0.0 by LUCENE-8564 (f5867a14131, 2018-12), which rebuilt FixedShingleFilter on the new GraphTokenFilter; 7.4.0-7.7.3 produced the right stacked shingles. A portable check fails on all 60 releases from 8.0.0 to 10.5.2 and on main.

Versions tested6 rows

Introduced by LUCENE-8564 "Add GraphTokenFilter" (@romseygeek; f5867a14131, author date 2018-12-03, committed 2018-12-04; a JIRA patch committed directly, now #9610). It added GraphTokenFilter, whose route walk copies each cached token's stream attributes unchanged, and rebuilt FixedShingleFilter on it, reading the fillers from the route token's increment. Plain git blame credits the GraphTokenFilter route lines to it; for the gap loop it credits LUCENE-8719, which only re-indented the unchanged loop, and whitespace-insensitive git blame -w credits the loop's logic to LUCENE-8564. The stacked shape is a regression of that rewrite (a release bracket, not a commit bisect); the multi-position shape never worked, because 7.x ignored position length. Later commits (LUCENE-8719 in 8.1, the LUCENE-9570 reformatting, and #16577, merged on main and backported as #16590 and #16630, so released in 10.5.2) leave the gap read alone. Not fixed upstream.

Caveat: 7.x is not a fully correct reference; it drops fillers after advancing an earlier stacked slot.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test 5 of 6 fail (gradle and the standalone runner, assertions on and off); the portable main() 7 of 7 checks fail
10.5.2, 9.12.3 affectedthis doc's test 5 of 6 fail; the finder's 4 of 4; the independent reproducer's 7 of 9
8.0.0 to 10.5.2 (all 60 releases) affectedthe portable main(): 7 of 7 bug checks fail, 0 controls fail, byte-identical output
7.4.0 to 7.7.3 stacked shape right[a _], [a _ b, a _ c], "at _ café"; the multi-position checks fail because 7.x ignores position length
7.3.1 and older no APIno FixedShingleFilter (compile error)
main + recommended fix not affectedthe test passes; the whole analysis-common module, 1,915 tests, 0 failed; exhaustive model 0 of 265,716 differ

Independent checks

An independent reproducer and a devil's advocate reproduced it with their own tests; read-only reviews by another model family agreed on the cause; the adjudication confirmed it (high), and a registry re-run and an audit reproduced it again.

Checks and verdicts385 words

The cross-model reviews could not run Java (read-only) and read main ea57360912ed, whose two files are byte-identical to a9779ef3c193; separate runs re-ran the finder's JUnit.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateThe exhaustive check found 146,634 of 265,716 checks differing, all in the two shapes; the 4-test public-API JUnit fails 4 of 4 on main (-ea, -da), 10.5.2 and 9.12.3 and passes with fix B.
cross-model review, refute lens procedural "not real" (low)An independent reviewer from another model family (OpenAI GPT-5.6) ran read-only and could not run Java; it agreed with the root cause and that the inputs are in contract, and found no guard or intended-behaviour documentation. A separate run re-ran the finder's JUnit on 10.5.2 -da: 4 of 4 fail.
cross-model review, intended lens real (medium)Rated the intended-behaviour case moderate and flagged the fix-design caveats of fix B; a separate run ran the finder's JUnit (4 of 4 fail, -ea, -da), a controls JUnit (3 of 3 pass) and an order JUnit (1 of 1 fail).
Independent reproducer realOwn JUnit from the claim only: 7 of 9 fail with both controls passing on main (-ea, -da), 10.5.2 and 9.12.3, including the stack-order test and synonyms then StopFilter; 9 of 9 with fix B.
Devil's advocate real (high / medium)Built the "stream-relative by definition" case and rejected it; own main() fails on main (-ea, -da), 10.5.2, 9.12.3, 8.11.4 and 8.0.0, while 7.4.0 and 7.7.3 pass the stacked checks.
adjudication confirmed (high)Re-ran the independent reproduction's JUnit on main (7 of 9 fail), the finder's with -da (4 of 4 fail) and the independent reproduction's with fix B (9 of 9 pass).
registry re-run and audit keepThe finder's JUnit fails 4 of 4 with -ea and -da; the independent reproduction's 7 of 9 with -da; an own main() with a new chain ("walk the dogs" gives "walk dog").
this doc's brief reproduced, fix validatedA new 6-test JUnit fails 5 of 6 on main (gradle and the standalone runner, -ea, -da), 10.5.2 and 9.12.3; the recommended fix passes it, the whole analysis-common module and the exhaustive model.

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

Finding record

LFV-781, snapshot of 2026-11-06

Registry title
FixedShingleFilter takes the gap before each token of a graph route from that token's stream position increment, so tokens stacked right after a hole (a removed stop word followed by a preserved original, a stemmed variant or a synonym) get shingles that skip the filler ('at café' for 'at the café') while the correct ones ('at _ café') are missing, and a token after a multi-position token gets a filler it should not have ('login _' instead of 'login page'); released since 8.0.0
Area
analysis-common / shingle (FixedShingleFilter, FixedShingleFilterFactory) over core analysis GraphTokenFilter (route walk)
Priority
Medium, rank 134: FixedShingleFilter silently indexes wrong shingles on ordinary text but only in an opt-in stop-word-plus-stacking chain.
Impact class
silent wrong results
Impact note
Hits applications that run FixedShingleFilter (directly or through FixedShingleFilterFactory 'fixedShingle', at its default settings) after a chain that both removes tokens (StopFilter) and stacks tokens at one position (ASCIIFoldingFilter with preserveOriginal=true, KeywordRepeatFilter plus a stemmer, or single-word synonyms followed by stop removal): wherever a removed word is directly followed by a stacked group, the field gets shingles that join the words across the gap as if they were adjacent and lacks the shingles with the filler, so an exact shingle lookup can match text where the two words are not adjacent and miss text where they are; ordinary text, no scale, deterministic, with -ea and -da; chains of single-position tokens with holes but no stacked group right after one, and chains without holes, are not affected (0 mismatches in those shapes of the exhaustive check).
Summary
You run 'coffee at the café' through WhitespaceTokenizer, StopFilter{the}, ASCIIFoldingFilter(preserveOriginal=true) and FixedShingleFilter(2), and expect the filler that stands for the removed 'the' in every shingle that crosses it, as the folded 'cafe' gets it ('at _'), because the class javadoc says the filter 'handles position increments > 1 by inserting filler tokens' and 'will output stacked shingles' for stacked tokens. Instead the original 'café', stacked behind 'cafe' with position increment 0, is joined straight to 'at': you get [coffee at, at _, at café], at size 3 'coffee at café' appears and 'at _ café' is missing, and on a canned stream a(+1) b(+2) c(+0) swapping only the stream order of the two stacked tokens changes the output from [a _, a c] to [a _, a b]. The same misreading gives 'login _' instead of 'login page' after a two-position token 'login' over 'log' plus a removed 'in', and nothing throws, with assertions on or off.
Introduced
f5867a1413189675cf69ea88412237e32429786e ('LUCENE-8564: Add GraphTokenFilter', GitHub romseygeek; a JIRA patch committed directly, no PR; issue migrated as apache/lucene#9610) [read: git blame on main credits FixedShingleFilter.java:143-161 and GraphTokenFilter.java:97-139/200-210 to it, later edits are only the LUCENE-9570 reformatting; 8.0.0's two files are identical to f5867a14131] [exec: release bracket, not a commit bisect: 7.4.0-7.7.3, which ship the pre-GraphTokenFilter FixedShingleFilter, pass the stacked-after-hole checks, and 8.0.0 fails them]. The commit added core GraphTokenFilter, whose route walk copies each cached token's stream attributes unchanged, and rebuilt FixedShingleFilter on it so the fillers before a route token come from that token's stream position increment; CHANGES.txt 8.0.0 announces no behaviour change and FixedShingleFilterTest changed no shingle expectation. The stacked-after-hole half is a regression of that rewrite; the multi-position half arrived with the position-length-aware routes of the same commit (7.x ignores position length altogether, so it never produced 'login page' either) (2018-12-03 (author date; committed 2018-12-04))
First affected version
8.0.0 [exec] (7.4.0-7.7.3 pass the stacked-after-hole checks [exec]; 7.3.1 and older have no FixedShingleFilter [exec: compile error, the class first shipped in 7.4.0])
Affected versions
8.0.0 through 10.5.2 (the last release before 10.6.0, which was not checked; the latest 9.x is 9.12.3) and main a9779ef3c193: every release of those lines, 60 in all (27 8.x, 21 9.x, 12 10.x). [exec] the follow-up's version-portable main() check (core + analysis-common public API, 7 checks and 4 controls) fails 7 of 7 with 0 failed controls, byte-identical, on all 60 releases and on main (-da also on 8.0.0); the finder's JUnit fails 4 of 4 on main -ea/-da, 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 7 of 9 (controls pass) on main -ea/-da, 10.5.2 and 9.12.3; the intended-behaviour check's main() fails on main -ea/-da, 10.5.2, 9.12.3, 8.11.4 and 8.0.0. Not affected (stacked-after-hole half): 7.4.0, 7.5.0, 7.6.0, 7.7.0, 7.7.1, 7.7.2, 7.7.3 [exec]; 7.x is not a correct reference though (it ignores position length and has its own filler defect after an earlier stacked slot). Not fixed upstream: upstream main f7bcc0abd816 (2026-10-27), branch_10x and branch_10_5 have the same code [read, prior-art step]
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-27, read-only; the prior-report record plus the finder's run, verdict new, 0 same-bug hits): 251 recorded queries (two scripted prior-report searches of 103 and 81 queries over apache/lucene GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 48 own GitHub searches; 5 own JIRA queries; 8 web searches; 6 enumerations, including the changed files of all 408 open apache/lucene PRs) plus the finder's 51 scripted prior-report search queries. Related, not this bug: #14137 (open) with open PR #16384 (diff read: it changes only the emitted shingle's increment and leaves the gap loop and GraphTokenFilter untouched) and merged #16577 (pendingPosInc for the base token's increment; backports #16590, #16630; the bug reproduces on main, which contains it); #9763/LUCENE-8717 (open since 2019: stop words removed at articulation points of a graph lose routes; its uncommitted patch added deleted positions to the next route token's increment for deleted-marked tokens, a precedent for route-relative increments, but it covers neither shape here); #9610/LUCENE-8564 (the origin); #9765/LUCENE-8719 (missed trailing paths, fixed 8.1); #9249/LUCENE-8202 (design goal: the shingles should equal the exact phrases searchable with the wrapped analyzer); #4549/LUCENE-3475 and #9084/LUCENE-8036 (the older ShingleFilter); #9959/LUCENE-8916, #9563, #9418, #13271 (other FixedShingleFilter defects); downstream elastic/elasticsearch#157557 (root cause #16577) and #30450 (design context). Only #16384 among open PRs touches FixedShingleFilter.java; none touches GraphTokenFilter.java; upstream main f7bcc0abd816 still has the code
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2 and 9.12.3 release jars. A version-portable main() ran on all 60 releases from 8.0.0 to 10.5.2 and on 7.3.1-7.7.3. The recommended fix was validated in the scratch worktree: the new test, the whole analysis-common module (340 suites, 1,915 tests), the core graph tests, TestRandomChains, checkGoogleJavaFormat, ecjLint, forbiddenApis and core renderJavadoc; a bounded-exhaustive model check (265,716 small cases: 1-5 tokens, increments 0-2, position lengths 1-3, fixed terms and offsets) matches the all-paths reference with it.

Lucene and upstream sources

Finding record
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/FixedShingleFilter.java:32-34 (at a9779ef3c193) (class javadoc: 'if the filter encounters stacked tokens (eg synonyms), then it will output stacked shingles'), :40-41 ('This filter handles position increments > 1 by inserting filler tokens'), :43 (@lucene.experimental), :81 (fillerToken 'represent gaps in the input stream (due to eg stopwords)'), :125-164 (shingle loop; :143 'int posInc = incAtt.getPositionIncrement()', :144-161 fillers from posInc)
  • lucene/core/src/java/org/apache/lucene/analysis/GraphTokenFilter.java:97-112 (at a9779ef3c193) (incrementGraphToken copies the route token's cached attributes unchanged), :119-139 (incrementGraph), :200-210 (nextTokenInGraph: the successor is reached by subtracting stream increments from the position length); both files identical in Lucene main ea57360912ed
  • lucene/core/src/java/org/apache/lucene/util/graph/GraphTokenStreamFiniteStrings.java:237-240 (at a9779ef3c193) ('stacked token should have the same increment as original token at this position'); lucene/core/src/java/org/apache/lucene/analysis/package-info.java:341-345 (the output order of stacked synonyms is undefined); lucene/core/src/java/org/apache/lucene/analysis/TokenStreamToAutomaton.java:157-161 ('A token spanned over a hole; add holes "under" it')
The finding in 30 seconds
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/FixedShingleFilter.java:34-35, (at a9779ef3c193) :40-41, :81, :96-173 (:143-162)
  • lucene/core/src/java/org/apache/lucene/analysis/GraphTokenFilter.java:97-112 (at a9779ef3c193) (:100, :110), :119-139 (:125-126), :200-210, :213-216
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/FixedShingleFilterFactory.java:41 (at a9779ef3c193) ("fixedShingle")
Why it looks unintended
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/FixedShingleFilter.java:34-35, (at a9779ef3c193) :40-41, :43, :81; lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/FixedShingleFilterFactory.java:32
  • lucene/core/src/java/org/apache/lucene/analysis/package-info.java:341-345 (at a9779ef3c193)
  • lucene/core/src/java/org/apache/lucene/util/graph/GraphTokenStreamFiniteStrings.java:237-240; (at a9779ef3c193) lucene/core/src/java/org/apache/lucene/analysis/TokenStreamToAutomaton.java:157-161
  • lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/PositionIncrementAttribute.java:22, (at a9779ef3c193) :35-39
  • lucene/analysis/common/src/test/org/apache/lucene/analysis/shingle/TestFixedShingleFilter.java:250-264 (at a9779ef3c193) (testTrailingGraphsOfDifferingLengths); lucene/core/src/test/org/apache/lucene/analysis/TestGraphTokenFilter.java
  • GitHub apache/lucene #9249 (LUCENE-8202) comments, read-only: the exact-phrase goal, "should address all of Adrien's concerns", the "the usa is" multi-length example
Suggested fix
  • lucene/core/src/java/org/apache/lucene/analysis/GraphTokenFilter.java:47-50 (at a9779ef3c193) (MAX_GRAPH_STACK_SIZE, MAX_TOKEN_CACHE_SIZE), :124-136, :200-210
History
  • a Lucene clone: git log releases/lucene/10.5.2 (fa58af2eb98, #16577 via #16630) and its lucene/CHANGES.txt (GITHUB#16577); gh pr view 16590 / 16630 (base branch_10x / branch_10_5), read-only

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

Repro files

About the diagrams

A three-panel still (tokens and positions, what the filter reads on each route, expected and actual shingles), made in parallel with this doc from the executed repro on main a9779ef3c193 and the code.

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.