ShingleFilter glues neighbouring words into shingles and, by default, also outputs every word on its own (a unigram). A removed stop word leaves a hole, filled with _.
incrementToken() call for "a cup of tea": the window holds only _ and tea; the filler unigram is skipped, a 3-word shingle does not fit, and return false ends the stream before tea is output. Select the still to open it at full size. Generated with AI, checked against the code.- Analyze "a cup of tea" with
WhitespaceTokenizer,StopFilter{a, of, the} andnew ShingleFilter(ts, 3, 3); unigrams are on by default. The words left are cup (position 1) and tea (position 3); the holes at 0 and 2 become the filler_. - You expect every word as a unigram plus the 3-word shingles:
_ cup _,cup,cup _ tea,tea. The javadoc says unigrams are "always output" and that this is "independent of the min shingle size". - You get
_ cup _,cup,cup _ tea, then end of stream. After indexing,TermQuery(title:tea)finds 0 documents whiletitle:cupfinds 1. Nothing throws, with assertions on or off. - Why: the 4th call sees the window [
_, tea]. It skips the filler-only unigram and jumps to a 3-word shingle, but only 2 entries are left, so it builds nothing and returns false, which means end of stream. - With
minShingleSize4 the last two words can go ("one of two three" keeps only "one"); with min 3, "the cat" yields no token at all. Min 2 is fine, and so is a value whose last hole is followed by at leastminShingleSize-1 words, such as a hole in the middle of a value.
Applications that run ShingleFilter with minShingleSize 3 or more (directly, through the shingle factory or ShingleAnalyzerWrapper) after a stop filter or anything else that leaves holes, with unigrams on (the default). Values in which a removed word is followed by only 1 to minShingleSize-2 words at the very end lose those words. The default minShingleSize 2 is not affected.
Mechanism, terms and numbers534 words
ShingleFilter builds shingles (word n-grams) and, by default, also outputs every input word as a unigram. A removed stop word leaves a hole, which the filter fills with the token _. With minShingleSize 3 or more, a hole followed by only 1 to minShingleSize-2 words at the end of a value ends the stream early: incrementToken() skips the filler-only unigram, finds too few entries for a shingle and returns false while real words are still in its window. "a cup of tea" is indexed without "tea", "the cat" without any token, and term queries for those words miss the document. Nothing throws. It has been in every release since 3.1.0 (LUCENE-2400, 2010); minShingleSize 2, the default, is not affected.
- Shingle: a word n-gram, for example
cup _ tea.minShingleSizeandmaxShingleSizebound its length (both default 2). Unigram: a single input word, output too whenoutputUnigramsis true (the default). - Hole and filler: a position with no token, for example where
StopFilterremoved "of".ShingleFilterputs a filler token_there so shingles keep their spacing; LUCENE-2400 (2010) made it skip shingles and unigrams that are only fillers. - Window: the filter keeps up to
maxShingleSizeentries (words and fillers) and builds one unigram or shingle from the head of the window per call.
incrementToken()makes one attempt per call and returns whether that attempt produced a token (ShingleFilter.java:262-315,return tokenAvailableat:314).- When the window starts with a filler, the filler-only unigram is skipped by jumping to the next gram size,
minShingleSize(:285-288). - Near the end of the value the window can hold fewer than
minShingleSizeentries (words and fillers both count). Then nothing is built either (:293), and the call returnsfalse, whichTokenStreamdocuments as "end of stream" (TokenStream.java:138). The consumer stops, although the words after the filler are still in the window. - Before the input is exhausted the window is full, so a filler-led window still yields a shingle and the next call outputs the word; with
minShingleSize2 the jump lands on a 2-entry shingle such as_ tea, which fits. That is why onlyminShingleSize>= 3 and only the end of a value are affected.
Stock main a9779ef3c193, WhitespaceTokenizer + StopFilter {a, of, the} + ShingleFilter, words as term@position:
| input | min, max | expected unigrams | output unigrams |
|---|---|---|---|
| "a cup of tea" | 3, 3 | cup@1, tea@3 | cup@1 |
| "war of the worlds" | 3, 3 | war@0, worlds@3 | war@0 |
| "one of two three" | 4, 4 | one@0, two@2, three@3 | one@0 |
| "the cat" | 3, 3 or 3, 4 | cat@1 | no token at all |
"the cat", unigrams off, outputUnigramsIfNoShingles on | 3, 3 | cat@1 | no token at all |
Controls give every word: the same "a cup of tea" with min 2, "cup tea" (no hole), "war of the worlds of" (with min 3, a trailing stop word adds a trailing filler and brings worlds@3 back; with min 4 one trailing stop word is not always enough: "one of two the" still loses two) and "please divide the sentence into shingles" (a hole mid-value). After indexing "a cup of tea", TermQuery(title:tea) counts 0 and title:cup 1.
Exposure and likelihood196 words
- Entry points.
new ShingleFilter(ts, min, max),ShingleFilterFactory(SPI nameshingle, argumentminShingleSize) andShingleAnalyzerWrapper(analyzer, min, max); executed withnew ShingleAnalyzerWrapper(new EnglishAnalyzer(), 3, 3): "the lord of the rings" has no unigramring. Downstream shingle filters that pass a minimum shingle size through inherit it. - Opt-in.
minShingleSizedefaults to 2, which is not affected. Within the opt-in nothing else is needed:outputUnigramsdefaults to true. Unigrams off withoutputUnigramsIfNoShingleson is hit too. - No scale, no crafted input. One short ordinary value, every time: "a cup of tea", "war of the worlds", "the cat". At most
minShingleSize-2 words per value are lost; with min 3 a two-word value such as "the cat" is not findable through that field at all. - Silent. No exception, with assertions on or off;
CheckIndexis clean; the stored text still shows the words. Term, match and phrase-of-unigram queries for them miss the document, and indexes built before a fix keep the gap until reindexed. - Not affected.
FixedShingleFilter(it never outputs unigrams,FixedShingleFilter.java:34),minShingleSize2, holes in the middle of a value, and values whose last hole is followed by at leastminShingleSize-1 words.
The test shows that ShingleFilter(3, 3) with unigrams on ends the stream early after a removed stop word, so "the cat" yields no token at all.
/*
* 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.StopFilter;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.tests.analysis.BaseTokenStreamTestCase;
public class TestShingleFilterHoleBeforeLastWord extends BaseTokenStreamTestCase {
public void testUnigramAfterRemovedStopWord() throws Exception {
TokenStream ts =
new ShingleFilter(
new StopFilter(whitespaceMockTokenizer("the cat"), StopFilter.makeStopSet("the")),
3,
3);
CharTermAttribute term = ts.addAttribute(CharTermAttribute.class);
List<String> tokens = new ArrayList<>();
ts.reset();
while (ts.incrementToken()) {
tokens.add(term.toString());
}
ts.end();
ts.close();
// expected [cat] (unigrams are on by default); actual: no token, the stream ends before "cat"
assertEquals(List.of("cat"), tokens);
}
}
# in a copy of this finding's repro folder (repro/LFV-528/, 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.TestShingleFilterHoleBeforeLastWord
- Expected vs actual
- Expected: [cat] (the javadoc says unigrams are always output, even when no shingle fits). Actual (-ea and -da): [].
More files, environment, full output34 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:<[cat]> but was:<[]>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.analysis.shingle.TestShingleFilterHoleBeforeLastWord.testUnigramAfterRemovedStopWord(TestShingleFilterHoleBeforeLastWord.java:43)- The javadoc says unigrams are "always output, regardless of whether any shingles are available" and "independent of the min shingle size".
- The same word is output with min 2, mid-value, or (with min 3) when another stop word follows it;
testTriGramFilterMinTriGramasserts tail unigrams for min 3. - LUCENE-2400 meant to drop filler-only tokens; "tea" is a real word.
The full argument259 words
- Javadoc.
setOutputUnigramsIfNoShingles(ShingleFilter.java:200-201, andShingleAnalyzerWrapper.java:70-71): "Note that if outputUnigrams==true, then unigrams are always output, regardless of whether any shingles are available."setMinShingleSize(:228): "The unigram output option is independent of the min shingle size."setOutputUnigrams(:184-185): the output contains "the input tokens (unigrams) as well as shingles". The class javadoc (:40-41) says the filter "handles position increments > 1 by inserting filler tokens". outputUnigramsIfNoShingles. A narrower promise: unigrams "for those times when no shingles are available (because there are fewer than minShingleSize tokens in the input stream)" (ShingleFilter.java:196-197). "the cat" is such a value:shiftInputWindow()switches to unigrams for it (:439-444), and the call then ends the stream before outputting one.- End of stream.
TokenStream.incrementToken()returns "false for end of stream" (TokenStream.java:138);ShingleFilterreturnsfalsewith unread input tokens in its window. - Tests.
testTriGramFilterMinTriGram(min 3, max 3) asserts the trailing unigramsintoandshingleswhere no trigram fits; every upstream test with holes usesminShingleSize2, and the min 3 and 4 tests have no holes, so no upstream test asserts the combination. - History. LUCENE-2400 (#3475, 2010) changed the filter to "don't output all-filler shingles and unigrams". Precedents in the same package treat missing output at the end of a stream as a bug: LUCENE-5180 (#6244, trailing holes) and LUCENE-8719 (#9765,
FixedShingleFiltermissed output at the end of a stream, the same "too short, so stop" pattern). - Legal trigger. Validated settings (
minShingleSize3,maxShingleSize3), an ordinaryStopFilter, public constructors.
The devil's-advocate check147 words
Strongest case for "intended": LUCENE-2400 deliberately skips filler-led unigrams, and near the end a filler-led window shorter than minShingleSize has nothing left to output, so the tail could be called "no more n-grams", an old best-effort limit of fillers combined with minShingleSize >= 3.
Why it fails: the skipped token is the filler, not the word after it; the javadoc promises unigrams "always" and "independent of the min shingle size"; the same word is output with min 2, mid-value, and (in the min 3 example) when a trailing stop word follows it; "the cat" loses every token; no test, javadoc, issue or CHANGES entry allows dropping real words. The devil's advocate itself, the independent reproducer and the adjudication concluded real; the cross-model reviewer's intended-behaviour lens said real and rated the "intended" case weak, and its refute-lens review said "not real" only because it could not run code.
Recommended Move on to the next position instead of returning false
When a position yields no token and the window is not empty, move on to the next position instead of returning false, and add the skipped positions to the next token's position increment. ShingleFilter.java only, +20 lines. Validated: it passes the new test and the whole analysis-common module, assertions on and off.
int skippedPositions = 0;
while (true) {
if (nextGram()) { // the old incrementToken() body
if (skippedPositions > 0) {
posIncrAtt.setPositionIncrement(posIncrAtt.getPositionIncrement() + skippedPositions);
}
return true;
}
if (inputWindow.isEmpty()) {
return false;
}
gramSize.reset(); // no token at this position: move on to the next one
skippedPositions++;
}All options and trade-offs4 options · 572 words
Option A was run through gradle in a scratch worktree at a9779ef3c193; the others are untested alternatives or workarounds.
shingle-skip-empty-position.diff (the finder's sketch with reworded comments): the old body of incrementToken() becomes a private nextGram(), and incrementToken() loops while a position yields no token and the window still has entries, resetting gramSize so the next attempt shifts the window. The first token after skipped positions gets them added to its position increment, so tea lands at position 3, as with minShingleSize 2.
int skippedPositions = 0;
while (true) {
if (nextGram()) { // the old incrementToken() body
if (skippedPositions > 0) {
posIncrAtt.setPositionIncrement(posIncrAtt.getPositionIncrement() + skippedPositions);
}
return true;
}
if (inputWindow.isEmpty()) {
return false;
}
gramSize.reset(); // no token at this position: move on to the next one
skippedPositions++;
}Trade-offs
- Output changed only where words were lost in the bounded exhaustive check and the test suites (observed, not proved);
end()still reports the input's final position increment. No token starts at the skipped filler position, which maintainers may want to discuss; the test pins the positions. - Terminates: each iteration resets
gramSize, so the next attempt shifts the window, and the window empties once the input is exhausted. Cost: at most a window's worth of extra attempts at the end of a value; a 1.75-million-character timing probe ran at 0.85x the time of the min 2 control (one loaded host). No benchmark beyond that. - Indexes built before the fix keep the missing terms until reindexed. The open refactor PR #13390 touches the same file and may conflict textually.
What the checks validated
- The new test: 5 of 5 pass (4 of 5 fail on stock), assertions on and off.
- The shingle package with the finder's 4 tests added: 6 classes, 93 tests (84 upstream), 0 failures, assertions on and off.
- The whole analysis-common module: 341 classes, 1,918 tests (1,909 upstream + 9 added), 0 failures, 18 skipped, assertions on and off;
:lucene:analysis:common:check -x testpasses. TestRandomChains(a separate module,:lucene:analysis.tests), run by the fact check with the fix: 2 tests pass, seed DEADBEEF, assertions on. One seed, and a random chain need not includeShingleFilter: a single data point, not coverage.- The independent reproducer's 8-test JUnit passes 8 of 8 with the fix (5 of 8 fail on stock); in the hunt, the exhaustive check gave 0 mismatches with this change (14,884 without), against the finder's own reference.
Inside the existing method, when the head is a filler and the window has fewer than minShingleSize entries, shift the window and try again, keeping the position increment.
Trade-offs
Narrower, but it duplicates the shift logic; the recommended loop already covers this case and the outputUnigramsIfNoShingles case. Not run.
Say in the javadoc that trailing words after a hole may be dropped when minShingleSize >= 3.
Trade-offs
Contradicts the existing promise that unigrams are always output, independent of the min shingle size, and leaves values like "the cat" unsearchable.
Use minShingleSize 2 (every word is output; executed as the control), or index the unigrams in a separate field whose analyzer has no ShingleFilter and query both fields.
Trade-offs
Each application has to know about it. The separate-field workaround was not run.
A rule that excludes documents by one of the lost words (for example a MUST_NOT term clause) does not exclude a document whose value ends that way, while the stored text still shows the word.
A checker compared the real ShingleFilter with a reference built on its javadoc over every token stream of up to 5 tokens and every small setting: 14,884 of 157,248 checks lost trailing unigrams, all with minShingleSize >= 3 and a hole. A public-API test, independent verifiers and an adjudication then confirmed it.
The method in full202 words
Method: bounded-exhaustive translation validation (a hunt unit, analysis pattern, shingle and compound filters, 2026-10-26, main ea57360912ed). No model checker, SMT solver or prover was involved.
- The finder (an AI tool) wrote a reference built on
ShingleFilter's documented unigram and filler behaviour (javadoc and option docs), with the other attribute details (filler cap, position lengths, offsets, types,end()state) taken from the implementation. - Its harness (
ShingleTV.java) ran the real filter against it on every token stream of 0-5 tokens with position increments 1-3, final position increments 0-2,minShingleSize2-4,maxShingleSizemin..5, both unigram options, 2 separators and 2 fillers: 157,248 checks, 14,884 mismatches, every one withminShingleSize>= 3, a hole and a missing trailing unigram; 0 with the fix. The check is bounded to those sizes: strong testing, not a proof. A registry re-run reproduced the log byte for byte; it uses the same reference, so it is not independent. The same log lists 3,237 differences betweenFixedShingleFilterandShingleFilter, a separate question and not part of this report. - Then a public-API JUnit, a cross-model review, independent verifiers, an adjudication, a 69-release sweep with a commit bracket, a prior-report search and a security screen (see Independent checks).
No prior report found: 118 recorded queries plus GitHub searches over issues, PRs, commits, JIRA, the mailing lists and the web; the closest items (#9084, #3475, #6244, #9765) describe other behaviour.
Searches4 searches
None of the searched issues, PRs, commits, JIRA entries, list threads or forum posts describes trailing words lost after a hole with minShingleSize >= 3. Gaps: two Stack Overflow pages (titles about removing stop words from shingles) could not be read, and a private report by someone else would not be visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene) | 2026-10-26 | a scripted prior-report search (12 GitHub and 2 commit queries) and 17 GraphQL searches enumerating about 105 shingle-related items (ShingleFilter, ShingleAnalyzerWrapper, ShingleFilterFactory, minShingleSize, outputUnigrams, fillerToken, "filler token") | no report; nearest #9084 / LUCENE-8036 (open: option to skip fillers, unigrams off), #3475 / LUCENE-2400 (introduced the skip), #6244 / LUCENE-5180 (trailing holes), #9765 / LUCENE-8719 (same pattern in FixedShingleFilter) |
| Open PRs | 2026-10-26 | the changed files of all 408 open apache/lucene PRs | only #13390 (ShingleFilter refactor; incrementToken() unchanged) and #16384 (FixedShingleFilter) touch shingle files |
| JIRA archive and mailing lists | 2026-10-26 | LUCENE and SOLR JIRA (every shingle-titled issue; 11 read in full) and dev@, java-user@, issues@, commits@, solr-user@ | no report; LUCENE-1370 (#2444) supports the unigram contract |
| Downstream and web | 2026-10-26 | downstream repository searches for the shingle settings; 7 web searches; Stack Overflow through public archive snapshots | no report; Stack Overflow 33193144 (linked from #9084) uses unigrams off and asks how to remove _, a different issue |
Introduced by LUCENE-2400 (2010, the skip of filler-only grams) and first released in 3.1.0. A version-agnostic probe fails on the last patch release of every minor line from 3.1 to 10.5 (69 releases) and on main.
Versions tested7 rows
Introduced by LUCENE-2400 "ShingleFilter: don't output all-filler shingles/unigrams" (issue #3475, a JIRA patch, no PR): trunk 7cd9a8da03f (2010-05-03), branch_3x 379c5fd00d4 (2010-05-04). It added the all-filler skip next to the one-attempt incrementToken() (return tokenAvailable) that LUCENE-2218 (fdf4ea24488, 2010-01-31) had introduced together with minShingleSize; both first shipped in 3.1.0, so no release has the option without the bug.
Commit bracket (executed): on the 3.1.0 jars, ShingleFilter.java from the parent of 7cd9a8da03f passes the probe and the 7cd9a8da03f version fails; only that class differs.
Later edits (LUCENE-1370, -3969, -5180, -5353, -7708, #13104, #15704) do not touch the skip-then-return path; blame still points the skip and the build condition at 7cd9a8da03f and return tokenAvailable at fdf4ea24488. Releases between the 69 that were run (for example 5.5.0 to 5.5.4) are affected by this history, not by a run.
| Version or commit | Verdict | Result |
|---|---|---|
| 3.0.3 | no API | no minShingleSize (compile error), so the trigger cannot be expressed |
| 3.1.0 + pre-LUCENE-2400 ShingleFilter | bracket | the probe passes: y@2, cat@1, two@2, three@3 present |
| 3.1.0 | first affected | the probe loses the trailing unigrams; also with only the 7cd9a8da03f ShingleFilter shadowed |
| 3.1 to 10.5 (the last patch release of every minor line, 69 releases) | affected | the probe fails on all of them with byte-identical output; both controls pass |
| 9.12.3, 10.5.2 | affected | the finder's test fails 4 of 4; this doc's test fails 4 of 5 (assertions off) |
| main a9779ef3c193 | affected | this doc's test fails 4 of 5 through gradle and the harness, assertions on and off; the finder's 4 of 4 and the reproducer's 5 of 8 |
| main with the fix | not affected | the new test 5 of 5; analysis-common 1,918 tests, 0 failures, assertions on and off |
An independent reproducer, a devil's advocate and an adjudication said real; a cross-model reviewer (OpenAI GPT-5.6, reading only) agreed on the root cause. This doc's test fails 4 of 5 on main with assertions on and off and passes with the fix.
Checks and verdicts331 words
The hunt's checks ran on main ea57360912ed; this doc's runs on a9779ef3c193 (identical ShingleFilter.java). The reviewer could not execute code, so its "not real" under the refute lens is procedural.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | The exhaustive check (14,884 of 157,248 mismatches, 0 with the fix), then a public-API JUnit that fails 4 of 4 on main (assertions on and off), 10.5.2 and 9.12.3 and passes with the fix; the 59 upstream TestShingleFilter and TestShingleFilterFactory tests pass with the fix. |
| Cross-model review (OpenAI GPT-5.6, reading only) | intended lens real; refute lens not real (procedural) | Both agreed on the root cause and found the inputs in contract; the intended-behaviour argument was rated weak. The refute lens said "not real" only because it could not run code; a re-run of the finder's test for each review failed. |
| Independent reproducer | real | Its own 8-test JUnit outside the shingle package fails its 5 bug tests on main, 10.5.2 and 9.12.3 (3 controls pass); its main() fails on 8.11.4, 6.6.6 and 4.10.4; 8 of 8 pass with the fix. |
| Devil's advocate | real | Built the strongest intended reading and showed why it fails; its probe found the outputUnigramsIfNoShingles symptom and confirmed that mid-value holes and hole-free tails are correct. |
| Adjudication | confirmed (high) | Re-ran the independent reproduction's test on main with assertions on and off (5 of 8 fail each) and found no convincing not-real argument. |
| Follow-up and registry re-runs | reproduced | The 69-release sweep and commit bracket, the security probe, the finder's test re-run on main (4 of 4 fail, assertions on and off) and the exhaustive harness re-run (byte-identical). |
| This doc's test and fix validation | reproduced; fix validated | The new 5-test JUnit fails 4 of 5 through gradle and the harness on main a9779ef3c193 (assertions on and off), 10.5.2 and 9.12.3; with the fix it passes, and so do the shingle package and the whole analysis-common module. |
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 411 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,211 words
- Registry title
- ShingleFilter with minShingleSize >= 3 ends the token stream early when a hole (for example a removed stop word) is near the end, so the last word(s) are never output as unigrams and are missing from the index ('a cup of tea' loses 'tea'; 'the cat' yields no token at all)
- Area
- analysis-common / shingle (ShingleFilter, ShingleFilterFactory, ShingleAnalyzerWrapper)
- Priority
- Medium, rank 133: ShingleFilter with minShingleSize >= 3 and unigrams silently drops the last words of ordinary values ending in a stop word plus one word, an opt-in analysis text loss just below the WordDelimiterGraphFilter row LFV-353.
- Impact class
- silent wrong results
- Impact note
- Hits applications that run ShingleFilter (directly, through ShingleFilterFactory 'shingle' or ShingleAnalyzerWrapper(analyzer, 3, 3), or a downstream shingle filter that passes min_shingle_size through) with minShingleSize >= 3 and unigrams on (the default) after a filter that leaves holes such as StopFilter: every field value that ends with a stop word followed by 1 to minShingleSize-2 words silently loses those words from the index, so term, match and phrase-of-unigram queries for them miss the document; ordinary text, no scale, deterministic, with -ea and -da; minShingleSize 2 (the default) and values without such a hole are not affected.
- Summary
- You index 'a cup of tea' with WhitespaceTokenizer, StopFilter{a, of, the} and ShingleFilter(min 3, max 3) with unigrams on (the default), and expect every real word to be indexed as a unigram, because the javadoc says unigrams 'are always output, regardless of whether any shingles are available' and that the unigram option 'is independent of the min shingle size'. Instead, when the window starts with the filler left by the removed stop word, incrementToken() skips the all-filler unigram, finds fewer than minShingleSize positions left for a shingle and returns false, which ends the stream although 'tea' is still in its window: 'tea' is never indexed and TermQuery(tea) finds 0 documents. With min 4 up to two trailing words are lost, a value such as 'the cat' yields no token at all, and nothing throws, with assertions on or off.
- Introduced
- 7cd9a8da03ff71b9a2b55ea1e25a6591b43faa8f (LUCENE-2400 'ShingleFilter: don't output all-filler shingles/unigrams', patch by GitHub sarowe, committed by GitHub uschindler; trunk SVN r940451; JIRA patch, no PR; issue migrated as apache/lucene#3475), backported to branch_3x in 379c5fd00d4ff291e5253bacd9f7f143c7a53145 (SVN r940806, 2010-05-04) [exec: commit bracket on the 3.1.0 jars, the parent's ShingleFilter.java shadowed passes and the 7cd9a8da03f version fails, only that class differs; read: git blame -w -M -C (ignoring the reformat commits 8ef6a0da568 and 54fba99cb13) attributes ShingleFilter.java:285-286, 289-290 and 293 on main to 7cd9a8da03f]. It added the all-filler skip next to the single-attempt incrementToken() of LUCENE-400 (return tokenAvailable, :314); minShingleSize itself came from LUCENE-2218 (fdf4ea24488, 2010-01-31). Not a recent regression (2010-05-03 (trunk; branch_3x backport 2010-05-04))
- First affected version
- 3.1.0 [exec] (first release with both minShingleSize and LUCENE-2400; 3.0.3 and older have no minShingleSize API [exec: compile error], so no release has the API without the bug)
- Affected versions
- 3.1.0 through 10.5.2 (the last release before 10.6.0; the latest 9.x is 9.12.3) and main ea57360912ed. [exec] the follow-up's version-agnostic main() probe fails on the last patch release of every minor line from 3.1 to 10.5 (69 releases) and on main -ea/-da, with byte-identical output and both controls (min 2, no hole) passing; the finder's JUnit fails 4 of 4 on main -ea/-da, 10.5.2, 10.0.0 and 9.12.3; the independent reproduction's JUnit fails 5 of 8 (controls pass) on main -ea/-da, 10.5.2 and 9.12.3, and the independent reproduction's main() fails on 8.11.4, 6.6.6 and 4.10.4. Not affected: 3.0.x and older (no minShingleSize API)
- 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 plus the finder's runs, verdict new, 0 same-bug and 0 partial hits): 118 recorded queries (a scripted prior-report search over apache/lucene GitHub issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), 17 GitHub GraphQL searches that enumerate about 105 shingle-related apache/lucene items, the changed files of all 408 open apache/lucene PRs, manual JIRA (LUCENE and SOLR shingle issues, 11 read in full) and list searches, and downstream repository searches. Related, not this bug: #9084/LUCENE-8036 (open feature request for an option to skip filler tokens; unigrams off in its example), #3475/LUCENE-2400 (introduced the all-filler skip; its discussion covers only filler-only output and performance), #6244/LUCENE-5180 (shingles from trailing holes), #9765/LUCENE-8719 (the analogous 'too short, so stop' fix in FixedShingleFilter; ShingleFilter unchanged), #2444/LUCENE-1370 (the outputUnigramsIfNoShingles contract), #4549, #5242, #8759, #6417, #13112; the Stack Overflow question 33193144 linked from #9084 uses outputUnigrams=false and is a different issue. The only open PR touching ShingleFilter.java, #13390 (refactor), leaves incrementToken() unchanged; upstream main a9779ef3c193 still has the code
- Lucene commit tested
- main
a9779ef3c193(2026-10-26; tested 2026-10-27), seed DEADBEEF: the doc's test through gradle with-Ptests.asserts=trueand=false(4 of 5 fail) and through the harness with assertions on and off, plus the release jars 10.5.2 and 9.12.3; the fix in a scratch worktree at the same commit. The hunt and its checks ran on mainea57360912ed(ShingleFilter.javaand its tests are identical at both commits) and, through a version-agnostic probe, on the last patch release of every minor line from 3.1 to 10.5 (69 releases).
- Finding record
lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/ShingleFilter.java:40-41 (at ea57360912ed) (class javadoc: 'handles position increments > 1 by inserting filler tokens'), :184-185 (setOutputUnigrams: 'Shall the output stream contain the input tokens (unigrams) as well as shingles? (default: true.)'), :200-201 ('Note that if outputUnigrams==true, then unigrams are always output, regardless of whether any shingles are available.'), :228 ('The unigram output option is independent of the min shingle size.'), :263-314 (incrementToken: :285-288 all-filler skip advances gramSize, :293 builds nothing when fewer than gramSize positions remain, :314 return tokenAvailable)
- The finding in 30 seconds
lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/ShingleFilter.java:40-41 (filler javadoc), :262-315 (incrementToken), :285-288 (all-filler skip), :293 (build condition), :314 (return tokenAvailable), :417-447 (shiftInputWindow), :501-510 (CircularSequence.advance)lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:138 ("@return false for end of stream; true otherwise")
- Who is affected
lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/ShingleFilterFactory.java:41 (NAME = "shingle"), :58 (minShingleSize), :87-89 (create)lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/ShingleAnalyzerWrapper.java:48 (constructor with min and max)lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/ShingleFilter.java:52 (DEFAULT_MIN_SHINGLE_SIZE = 2), :85 (outputUnigrams = true), :439-444 (outputUnigramsIfNoShingles)lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/FixedShingleFilter.java:34 ("never emits unigrams, even at the end of a TokenStream")
- Why it looks unintended
lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/ShingleFilter.java:40-41, :184-185, :196-197, :200-201, :228, :439-444, :232-241 (setMinShingleSize validation)lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/ShingleAnalyzerWrapper.java:70-71lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:138lucene/analysis/common/src/test/org/apache/lucene/analysis/shingle/TestShingleFilter.java:257-269 (TRI_GRAM_TOKENS_MIN_TRI_GRAM), :821-830 (testTriGramFilterMinTriGram), :726, :1017, :1260, :1275, :1290-1371 (hole tests, all min 2), :1373 (testPositionLength, no holes)
- Suggested fix
lucene/analysis/common/src/java/org/apache/lucene/analysis/shingle/ShingleFilter.java:266-268 (shift when gramSize is at its first value)
- TestShingleFilterHoleBeforeLastWord.java (JUnit test):
repro/LFV-528/src/org/apache/lucene/analysis/shingle/TestShingleFilterHoleBeforeLastWord.java(45 lines)
A picture of the token positions for "a cup of tea", the input window at the 4th call with the three code locations, and the expected and actual tokens and hits; made in parallel with this doc.
assets/LFV-528/bug.png(411 KB)
