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.
- Analyze "coffee at the café" with
WhitespaceTokenizer,StopFilter{"the"},ASCIIFoldingFilter(preserveOriginal = true)andFixedShingleFilter(2). "the" leaves an empty position; "cafe" and the original "café" are stacked at position 3, two positions after "at". - 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é]. - 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. - 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]. - 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.
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.
- Shingle and filler:
FixedShingleFilter(input, shingleSize)emits every run ofshingleSizeconsecutive 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 byKeywordRepeatFilterplus 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).
- Both route methods copy the cached token's attributes unchanged from the stream (
GraphTokenFilter.java:100,:110). FixedShingleFilterreads the gap from them:int posInc = incAtt.getPositionIncrement();(FixedShingleFilter.java:143) and writesposInc - 1fillers (:144-161).- 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.
- 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.
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 throughFixedShingleFilterFactory("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,
preserveOriginalis not the default and stacks the original only when folding changes the word, andRemoveDuplicatesTokenFilterwould 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).
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.
/*
* 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"));
}
}
# 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].
More files, environment, full output40 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: 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)- 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}.TokenStreamToAutomatonaccepts "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 lengthshingleSizethat 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 removedtestShingleCountLimits, an exception-message test for the old shingle-count limit). - No test pins the current output. Upstream
TestFixedShingleFilter(15 tests) andTestGraphTokenFilter(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.
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)
GraphTokenFilterrecords each cached token's absolute position when it reads it (oneintper cached token, at most 100) and, on everyincrementGraphToken, the number of empty positions between the end of the previous route token and the start of the current one. A newprotected final int getRouteGap()returns it; attribute values are not changed.FixedShingleFilterusesposInc = getRouteGap() + 1instead of the stream increment (FixedShingleFilter.java:143).- 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 (incrementGraphadvances exactly one depth to the next stacked token, and a token found bynextTokenInGraphalways 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).
GraphTokenFilteris public and, unlikeFixedShingleFilter, not marked@lucene.experimental, so a newprotected finalmethod could clash with a same-named method in an external subclass. TheGraphTokenFilterpart (+35/-4) is in the full diff. - The duplicate skip relies on the order in which
incrementGraphwalks 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),TestGraphTokenFilterandTestGraphTokenizers(27 tests) andTestRandomChainspass;checkGoogleJavaFormat,ecjLint,forbiddenApisand corerenderJavadocpass. - With assertions off: the 5 shingle suites,
TestSynonymGraphFilter,TestFlattenGraphFilterand 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.
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.
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.
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.
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).
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
GraphTokenFilterthrough 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).
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).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr, downstream search servers), dev@ / java-user@ / issues@ / commits@ lists, JIRA archive, web | 2026-10-27 | two scripted prior-report search runs (103 and 81 queries) plus the finder's 51-query run | related items only; 0 same-bug hits |
| GitHub (own searches) | 2026-10-27 | 48 searches | none reports the bug |
| JIRA (LUCENE, SOLR) | 2026-10-27 | 5 queries | the same known issues |
| Open PRs | 2026-10-27 | the changed files of all 408 open apache/lucene PRs | only #16384 touches FixedShingleFilter.java (gap loop untouched); none touches GraphTokenFilter.java |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this 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 | affected | this 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) | affected | the 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 API | no FixedShingleFilter (compile error) |
| main + recommended fix | not affected | the test passes; the whole analysis-common module, 1,915 tests, 0 failed; exhaustive model 0 of 265,716 differ |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | The 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 | real | Own 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 | keep | The 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 validated | A 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. |
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 372 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,495 words
- 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-portablemain()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,forbiddenApisand corerenderJavadoc; 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.
- 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 ea57360912edlucene/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:32lucene/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-161lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/PositionIncrementAttribute.java:22, (at a9779ef3c193) :35-39lucene/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.javaGitHub 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
- TestFixedShingleFilterStackedAfterHole.java (JUnit test):
repro/LFV-781/src/org/apache/lucene/analysis/shingle/TestFixedShingleFilterStackedAfterHole.java(46 lines)
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.
assets/LFV-781/bug.png(372 KB)
