For each typed key, FreeTextSuggester searches its n-gram FST once per model level and relies on the search's acceptResult hook to skip words a higher level already offered. Util.TopNSearcher calls that hook on only one of its two result paths.
- It stores word counts and word-pair counts in one FST. For
lookup("foo bar")it first takes pairs that start withfoo bar(score: pair count / count offoo), then single words that start withbar(score: 0.4 x count / all tokens). - A word is offered once, by the highest level that has it. The lower level's search rejects such words in
acceptResultand getsnum + seen.size()queue places, so a rejected word costs no result slot. - Each level keeps its top
num; the levels are merged, sorted by score and cut tonum. Util.TopNSearcherfinds the top keys under a node by always expanding the cheapest queued path. A queued path that already ends on a key (a final output) is returned as it comes off the queue; any other path is followed to its cheapest key, and only there isacceptResultasked.
lookup("foo bar", 1): the already-offered bar comes off the queue without acceptResult, takes the only slot, and barn is lost. Select the still to open it at full size. Generated with AI, checked against the code.- Build a
FreeTextSuggester(default bigram model) from one line:foo9 times,bar8 times,barn7 times, 24 tokens. The pairfoo baroccurs once. - Call
lookup("foo bar", 1). The pair level offersfoo bar= 1/9 = 0.111. The single-word level, scaled by 0.4, should skipbar(already offered) and offerbarn= 0.4 x 7/24 = 0.117. You expect[barn];lookup("foo ba", 1)does return[barn]. - You get
[foo bar], with no error. The single-word search starts at the FST node forbar, which is itself a word.Util.TopNSearcherpopsbarfirst (count 8 beats 7) and adds it in its "Empty string!" branch, which never callsacceptResult, the hook that rejects already-offered words. - So
barfills the level's only slot and is dropped afterwards as a duplicate;barnis never reached, andisCompletestill says true. The same branch skips the filter of anyTopNSearchersubclass whenever a final output it had queued comes off the queue.
Applications that use FreeTextSuggester (opt-in, @lucene.experimental; the default constructor and bigram model are enough) on ordinary text. A level loses a slot whenever the last typed word is complete, already offered and in that level's top num; the shown list changes only if the lost word would beat every higher-level prediction. Rare.
Mechanism, terms and numbers828 words
FreeTextSuggester (lucene/suggest, @lucene.experimental) predicts the next word with an n-gram model and stupid backoff, and offers each word once, from the highest-order model that predicts it (FreeTextSuggester.java:521-523 on main 645d4fe14a20). Lower-order models skip already-offered words inside the FST search: a Util.TopNSearcher subclass rejects them in acceptResult (:624-637), with the queue sized num + seen.size() "because we may reject up to seen.size() paths in acceptResult()" (:605-606). But TopNSearcher.search() calls acceptResult on only one of its two result paths (Util.java:391-399): a final output that was queued, such as the start node's own key when the caller passes allowEmptyString=true (as FreeTextSuggester does, :642), is added by the "Empty string!" branch with no filter and no rejection count (Util.java:333-338). So when the last typed word is a complete word that a higher-order model already offered and that ranks in the lower level's top num, it takes a slot and is then dropped by the post-search check (:674-677), and a better backed-off word can be lost: for a model of foo x9, bar x8, barn x7, lookup("foo bar", 1) returns [foo bar] (0.111) instead of [barn] (0.117). No error; isComplete stays true. Every release from 4.6.0 through 10.6.0 and main (the root since 4.1.0). Calling acceptResult in that branch and counting the rejection, as the other path does, fixes it (+5/-1).
- FreeTextSuggester: a
Lookupthat "uses the stupid backoff language model to smooth scores across ngram models" (FreeTextSuggester.java:93-95on main645d4fe14a20); bigrams by default (:116-117, constructor:157-159), n-gram words joined by the separator byte 0x1e (:147-151),@lucene.experimental(:103).ALPHA= 0.4 is the backoff factor (:121-126). - Seen words: "We only add a given suffix once, from the highest order model that saw it; for subsequent lower order models we skip it" (
:521-524). - Util.TopNSearcher: a best-first search over an FST (
Util.java:166-167,Utilis@lucene.experimental,:41).addStartPathsoffers every arc leaving the start node to the bounded queue throughaddIfCompetitive, which keeps only competitive paths (:208-252,:264-292); the END_LABEL arc (the start node's own final output) is eligible whenallowEmptyStringis true (:284).acceptResultis the protected hook a subclass overrides to reject a complete key (:412-423);TopResults.isCompleteisrejectCount + topN <= maxQueueDepth(:409, javadoc:434-439).
search()pops the cheapest path (Util.java:321). If its arc is END_LABEL, the "Empty string!" branch strips the label and adds the result: noacceptResult, norejectCount++(:333-338).- Otherwise it follows zero-output arcs to the cheapest completion and there calls
acceptResult(path), counting a rejection (:391-399). - A final output is queued, and so takes the first path, when it is the start node's own key and the caller passed
allowEmptyString=true, or when it is an inner node's key that costs more than its cheapest extension (addIfCompetitive,:209-252). So the start node's own key, once queued, always takes the first path; for a key below the start node, which path it takes depends only on the weights. FreeTextSuggester.lookupruns one search per level withtopN = num,maxQueueDepth = num + seen.size()(FreeTextSuggester.java:605-608), rejects seen words inacceptResult(:624-637), and starts withallowEmptyString=true(:642). When the last typed word is a word, the start node is final, so a seen word that ranks in the level's topnumleaks through branch 1. The post-search check drops it (:674-677), and nothing fills the slot.
Model from foo x9, bar x8, barn x7 (24 tokens; pairs foo foo 8, foo bar 1, bar bar 7, bar barn 1, barn barn 6). lookup("foo bar", 1):
- Pair level, prefix
foo bar: one key,foo bar, score 1/9 = 0.1111;barbecomes seen. - Single-word level, prefix
bar,maxQueueDepth2: the queue holdsbaritself (count 8, the cheapest) and thenarc towardbarn(7).barcomes off first through branch 1, fills the one result slot and ends the search;isCompleteis true (0 + 1 <= 2). The post-search check dropsbar;barn(0.4 x 7/24 = 0.1167) was never reached. - Result
[foo bar]. With the partial word,lookup("foo ba", 1), the search starts abovebar, andbar's own key (count 8) is its cheapest way on (the arcs leaving it are 7), sobaris reached through path 2 and rejected, and the result is[barn].lookup("foo bar", 2)gives[barn, foo bar].
Keys {a=5, ab=1}, acceptResult rejects a, topN=2: the result is [ab/1, a/5] with isComplete=true, with allowEmptyString false and true; with the weights swapped, {a=1, ab=5}, acceptResult is called for a and the result is [ab/5].
Exposure and likelihood303 words
- Who is hit. Users of
FreeTextSuggester, the "long tail" next-word suggester (FreeTextSuggester.java:82-88), with the default settings, any corpus size, one thread. Downstream, Solr offers it asFreeTextLookupFactory. - When the slot is wasted. The last typed word is complete (no trailing space), a higher-order model already predicted it, and it ranks in the lower level's top
num. Then that level returns at mostnum - 1new words. - When users see it. The final list changes only when the lost word would have outranked every higher-order prediction, because the other words of that level rank above it. With the default bigrams this needs P(word | context) < 0.4 x P(word): a typed pair that follows its context at most 0.4 times as often as the word occurs overall. In the repro, P(bar | foo) = 1/9 = 0.111 < 0.4 x 8/24 = 0.133.
- Effect. Silent: one weaker suggestion in place of a better one; no exception;
isCompletestays true, soassert completions.isComplete(:645) does not fire. The same with assertions on and off. - How likely: rare. An exhaustive comparison with a stupid-backoff reference found 508 disagreements in 41.8M lookups over random 4-sentence corpora; the recorded prior-art searches found no public report since 4.6.0.
- The wider root. Any
TopNSearchersubclass with anacceptResultfilter can hit the same branch when a key it would reject is queued as an END_LABEL path and then popped. Keys that are prefixes of other keys make that possible but do not guarantee it: it then depends on the weights, ties and queue depth. In Lucene,AnalyzingSuggester/FuzzySuggester(dedup and exact-first,AnalyzingSuggester.java:790) andNRTSuggester(collects only insideacceptResult,NRTSuggester.java:190,:254-256) reach it only when an indexed form contains U+0000, as one symptom each of LFV-601 and LFV-798.
One JUnit test for lucene/suggest: build the 24-token model, call lookup("foo bar", 1) and assert barn. It fails on main (assertions on and off), 10.6.0 and 9.12.3, and passes with the fix.
/*
* 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.search.suggest.analyzing;
import java.io.StringReader;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.search.spell.PlainTextDictionary;
import org.apache.lucene.tests.analysis.MockAnalyzer;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestFreeTextSuggesterSeenWord extends LuceneTestCase {
public void testAlreadyPredictedWordDoesNotTakeUnigramSlot() throws Exception {
try (Analyzer a = new MockAnalyzer(random())) {
FreeTextSuggester sug = new FreeTextSuggester(a);
sug.build(
new PlainTextDictionary(
new StringReader("foo ".repeat(9) + "bar ".repeat(8) + "barn ".repeat(7))));
// expected barn (0.4*7/24 = 0.117 beats "foo bar" 1/9 = 0.111), actual "foo bar"
String top = sug.lookup("foo bar", 1).get(0).key.toString().replace('\u001e', ' ');
assertEquals("barn", top);
}
}
}
cp $REPRO/TestFreeTextSuggesterSeenWord.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests TestFreeTextSuggesterSeenWord -Ptests.seed=5EED1052 -Ptests.asserts=true
# and with -Ptests.asserts=false (the recorded run used -Ptests.seed=5EED1053)
- Expected
[barn]: the pair level givesfoo bar0.111, the single-word level skips the already-offeredbarand givesbarn0.117.- Observed
[foo bar](expected:<[barn]> but was:<[foo bar]>), with no error.
More files, environment, full output242 words
TestFreeTextSuggesterSeenWord (38 lines with the license header, one test method, package org.apache.lucene.search.suggest.analyzing like TestFreeTextSuggester). Public API only: the default FreeTextSuggester(Analyzer), PlainTextDictionary, lookup. MockAnalyzer(random()) splits on whitespace and lower-cases; its only random choice is a test payload filter, which changes neither the tokens nor the counts, and every recorded seed gave the same result. The key is printed with the separator byte 0x1e shown as a space.
Use the same test as the regression test of the fix, as its own class or as one method in TestFreeTextSuggester.
The hunt's larger evidence is not part of the repro: an exhaustive check of TopNSearcher against a brute-force reference (26,810 FSTs, 69.6M searches), a Util-level test, and upstream TestFSTs#testRejectNoLimits with one weight changed.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 and 9.12.3 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/TestFreeTextSuggesterSeenWord.java $LUCENE/lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/
cd $LUCENE && ./gradlew :lucene:suggest:test --tests TestFreeTextSuggesterSeenWord -Ptests.seed=5EED1052 -Ptests.asserts=true
TestFreeTextSuggesterSeenWord > testAlreadyPredictedWordDoesNotTakeUnigramSlot FAILED
org.junit.ComparisonFailure: expected:<[barn]> but was:<[foo bar]>
# the same with -Ptests.asserts=false, through the standalone runner (assertions on and off) and on 10.6.0 and 9.12.3
# with the fix: the test passes
# scores for the same model (doc probe, values divided by Long.MAX_VALUE)
stock main with the fix
lookup("foo bar", 1) [foo bar=0.1111] [barn=0.1167]
lookup("foo bar", 2) [barn=0.1167, foo bar=0.1111] (both)
lookup("foo ba", 1) [barn=0.1167] (both)FreeTextSuggestersizes the queuenum + seen.size()"because we may reject up to seen.size() paths in acceptResult()": a rejected word should cost no slot.- Upstream
testRandom's reference model skips seen words before keeping each level's topnum: here it gives[barn]. TopNSearcher's other result path callsacceptResult; below the start node, weights alone pick the path.
The full argument294 words
- The suggester's design. The queue sizing comment (
FreeTextSuggester.java:605-606) andassert completions.isComplete(:645) only make sense if seen words are rejected inside the search; the post-search check (:674-677) is a second line. - The upstream reference model.
TestFreeTextSuggester#testRandom(TestFreeTextSuggester.java:318) builds the expected list per model: "First pass, gather all predictions for this model" skipping seen terms (:529-544), "Second pass, trim to only top N" (:559-564), merge, sort, cut tonum(:586-590), and asserts equality withlookup()(:600). Its random 1-5 character terms rarely hit the trigger. - The TopNSearcher contract. The zero-output path calls
acceptResultand counts the rejection (Util.java:391-399);isCompletemay be false "if the TopNSearcher rejected too many results" (:434-438); LUCENE-5519's CHANGES entry: the result "indicates if the result is complete in the sense of the top N" (lucene/CHANGES.txt:12519-12525). - Upstream
TestFSTs#testRejectNoLimits(TestFSTs.java:1360-1413) asserts that all 4 rejected keys reachacceptResult(:1389) and only the accepted key is returned (:1392). It passes only becauseadcddoes not weigh more than its extensionadcde(both 17,:1368-1369; at 16 it passes too); withadcdat 18 its own assertions fail on stock ([aac/7, adcd/18]) and pass with the fix. - Every in-tree override uses
acceptResultas the result filter:FreeTextSuggester(seen words),AnalyzingSuggester(dedup, exact-first,AnalyzingSuggester.java:790),NRTSuggester("we do not use it, instead collect at acceptResult",NRTSuggester.java:255). - History. The branch came first (LUCENE-3714,
a519b630eea, 2012-02-19), whenTopNSearcherwas private and had no filter; LUCENE-3842 (f2f91bae46c, 2012-09-28) addedacceptResultonly on the zero-output path. The reviewed commits, tests, CHANGES and MIGRATE entries, issues and threads show no exemption for queued final outputs.
The devil's-advocate check240 words
The intended-behaviour checker and a reviewer from another model family (OpenAI GPT-5.6) built the strongest case: acceptResult has no javadoc of its own and Util and FreeTextSuggester are @lucene.experimental; the branch is labelled "Empty string!" and the caller opts in with allowEmptyString=true, so "the typed prefix is always a result" could be intended; Lookup.lookup promises only "possible completion" with num the "maximum number of results" (Lookup.java:184-192); FreeTextSuggester re-checks seen words after the search (FreeTextSuggester.java:674), as if leaks were expected; and the comment at :515-516 ("Try highest order models first, and if they return results, return that; else, fallback") describes hard backoff, under which [foo bar] would be right.
Why it fails: with allowEmptyString=false the filter is still skipped (keys {a=5, ab=1}), and swapping the two weights makes it run, so the bypass is not limited to the empty string; the queue sizing and the isComplete assertion show the post-search check is a backstop; the committed code and class javadoc merge all levels with stupid backoff (:93-95, :692-709), and the hard-backoff comment is left over from a work-in-progress patch on LUCENE-5214 (#6278); and testRandom's reference model, written in the same commit as the class, gives [barn]. The intended-lens review rated the works-as-designed case "moderate", not sufficient. Verdict: a defect. Do not argue from "top-1 should be the head of top-2": the per-level top-num merge can break that legitimately.
Recommended Call acceptResult in the "Empty string!" branch and count the rejection
In the "Empty string!" branch of Util.TopNSearcher.search, call acceptResult(path) and add the result only if it is accepted, else increment rejectCount, as the other result path does (+5/-1). Validated: the repro passes, and the whole suggest module and the core FST tests pass with assertions on and off.
if (path.arc.label() == FST.END_LABEL) {
// Empty string!
path.input.setLength(path.input.length() - 1);
- results.add(new Result<>(path.input.get(), path.output));
+ if (acceptResult(path)) {
+ results.add(new Result<>(path.input.get(), path.output));
+ } else {
+ rejectCount++;
+ }
continue;
}All options and trade-offs3 options ยท 508 words
One fix was built and validated; two alternatives were considered and not recommended.
Call acceptResult in the "Empty string!" branch and count the rejection (validated by the checks) (recommended)
Mirror the zero-output path (Util.java:394-398). acceptResult runs after the END_LABEL is stripped from the input, so the subclass sees the key itself.
if (path.arc.label() == FST.END_LABEL) {
// Empty string!
path.input.setLength(path.input.length() - 1);
- results.add(new Result<>(path.input.get(), path.output));
+ if (acceptResult(path)) {
+ results.add(new Result<>(path.input.get(), path.output));
+ } else {
+ rejectCount++;
+ }
continue;
}Trade-offs
- One more
acceptResultcall per popped final output; nothing changes for callers without a filter (the defaultacceptResultreturns true). FreeTextSuggester'sassert completions.isCompletestill holds: each seen word matches at most one completion, sorejectCount <= seen.size().AnalyzingSuggesterandNRTSuggesternow filter these paths too. That clears one symptom each of LFV-601 (a duplicate) and LFV-798 (a hidden suggestion), which reach this branch through U+0000; a CHANGES entry could say so. It does not close LFV-601: two of its four tests still fail with the fix.- A behaviour change for every subclass.
acceptResultis a protected hook (Util.java:412-423) that a subclass may use for more than a yes/no answer:NRTSuggesterchecks deleted documents and the filter, records seen surface forms and collects the hit inside it, and ignoressearch()'s list (NRTSuggester.java:190-227,:254-256). With the fix, such a subclass is also called for queued final outputs. Source compatibility is kept, but the module tests cannot rule out a subclass outside Lucene that depends on the bypass.
What the checks validated
- Scratch worktree of
645d4fe14a20, gradle, OpenJDK 25: the repro test fails on the stock tree with assertions on and off, and passes with the fix. - With the fix and the test, the whole
lucene/suggestmodule passes with assertions on and off: 32 suites, 237 tests, 7 skipped, 0 failures (it includesTestFreeTextSuggester,TestAnalyzingSuggester,TestFuzzySuggester,TestSuggestFieldandTestWFSTCompletion). - The core
org.apache.lucene.util.fst.*tests pass with assertions on and off: 8 suites, 37 tests, 0 failures (TestFSTswithtestRejectNoLimits). :lucene:core:check :lucene:suggest:check -x testpasses (google-java-format, ecjLint, forbiddenApis).- Earlier, with the same change shadowed: the exhaustive
TopNSearcherrun drops from 7,456,380 rejected results returned and 4,349,970 wrong-but-complete results to 0 and 0 (the 57,500 that remain are the separatequeue = nullshortcut, LFV-660);testRejectNoLimitswithadcdat 18 passes; LFV-798's minimal repro passes and LFV-601's duplicate test passes (LFV-601's tests: 3 of 4 fail on stock, 2 of 4 with the fix, so LFV-601 stays open).
Pass allowEmptyString = !seen.contains(lastToken) instead of true at FreeTextSuggester.java:642 (from the twin hunt). Not recommended.
Trade-offs
Hides the visible symptom for the start-node case, but leaves TopNSearcher filtering by weight for every other subclass and for inner nodes.
Javadoc only. Not recommended.
Trade-offs
Keeps isComplete true while a rejected key is returned, and every caller would need its own post-filter.
Downstream, Solr offers this suggester as FreeTextLookupFactory, and code outside Lucene that subclasses Util.TopNSearcher and trusts search()'s returned list over its own acceptResult filter can get a rejected key back.
Two hunts found it ten minutes apart on 2026-10-27: an exhaustive check of Util.TopNSearcher against a brute-force reference ("every returned result passes acceptResult" failed on the first FST), and an exhaustive check of FreeTextSuggester against a stupid-backoff reference (508 of 41.8M lookups disagreed). An adjudication unit merged them and reduced them to one JUnit.
The method in full179 words
Method: semi-formal, bounded-exhaustive translation validation on the stock jars.
- First finder (2026-10-27). A unit checking FST code compared
Util.TopNSearcherwith an independent brute-force reference (all completions from the start node, filtered, sorted by cost and input) over every small FST in its scope. The check "every returned result passes acceptResult" failed on the first FST,{""=0}with every result rejected, which returned[""=0]. - Twin. A unit checking
FreeTextSuggesteragainst a stupid-backoff reference in the per-level top-N form thattestRandomasserts found 508 disagreements in 41.8M lookups, all on random 4-sentence training sets, and then found the branch by reading the code. - Adjudication unit. Re-ran both, reduced them to one public-API JUnit, re-ran the exhaustive scope on main (26,810 FSTs, 69,647,420 searches: 7,456,380 return a rejected result and 4,349,970 report
isComplete=truewith a wrong list; 0 and 0 with the fix; a mutant of the reference with the cost order reversed fires, so the check is not vacuous), and ran upstreamtestRejectNoLimitswith one weight changed. The no-noise gate later cut the test to one line of input.
No prior report found (searched 2026-10-29): 102 queries over GitHub issues, PRs and commits, downstream projects, the Lucene and Solr lists, the JIRA archive and the web, and the changed files of all 409 open PRs. Nearest, design history only: #4915 (added acceptResult on one path), #5547 and #6582 (rejection accounting).
Searches4 searches
Related, not reports of this bug: #4915 / LUCENE-3842 (added acceptResult on the zero-output path only), #4788 / LUCENE-3714 (added the branch), #5547 / LUCENE-4481 (rejection accounting and maxQueueDepth), #6582 / LUCENE-5519 (isComplete), #8737 / LUCENE-7686 (acceptPartialPath placed just above the branch); open #5583 / LUCENE-4517 (a feature request that describes acceptResult as the completion filter); open #10038 / LUCENE-8995 with the unmerged apache/lucene-solr#913 (a different rejection leak, whose diff edits only the queue = null shortcut, the LFV-660 site). Tracking issue #16937 does not mention this bug. Gap: GitHub search hit its rate limit once; the query was re-run after the reset.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits; downstream repositories | 2026-10-29 | a scripted prior-report search (51 queries: issues and PRs, commits, downstream, lists, JIRA archive, web; 248 hits, 0 high) and 51 manual searches over apache/lucene (END_LABEL, "Empty string" TopNSearcher, acceptResult, addStartPaths, allowEmptyString, TopNSearcher, FreeTextSuggester), apache/lucene-solr, Solr, Elasticsearch, OpenSearch and Lucene.NET; the comments of 18 issues grepped | no report; only the design history above |
| Open PRs | 2026-10-29 | the changed files of all 409 open apache/lucene PRs | five touch util/fst or FreeTextSuggester; none touches TopNSearcher.search |
| Mailing lists and JIRA archive | 2026-10-29 | dev@, java-user@, issues@, commits@, solr-user@ and users@solr; LUCENE and SOLR JIRA for FreeTextSuggester and TopNSearcher | no report; Solr issues about building and configuring FreeTextSuggester only |
| GitHub (re-check) | 2026-10-29 | items updated since 2026-10-28 for TopNSearcher and FreeTextSuggester, acceptResult, all PRs created since 2026-10-28, commits on both files on main, branch_11x and branch_10_6 | nothing new; no change to either file after 645d4fe14a20 |
The root dates from LUCENE-3842 (f2f91bae46c, 2012, first in 4.1.0), which added acceptResult on one result path only; FreeTextSuggester (LUCENE-5214, 2013, 4.6.0) made it visible. Fails on every executed release that has the API, up to 10.6.0, and on main; not a regression; unchanged upstream.
Versions tested9 rows
Introduced by f2f91bae46c on trunk, "LUCENE-3842: add AnalyzingSuggester" (Michael McCandless, 2012-09-28; migrated as #4915), backported to branch_4x as 0c89e05d765, first released in 4.1.0. It made TopNSearcher public, added addStartPaths(.., allowEmptyString, ..) and the protected acceptResult, and called acceptResult only where a zero-output completion reaches END_LABEL. The "Empty string!" branch is older: a519b630eea, "LUCENE-3714: add weighted FST suggester impl" (Robert Muir, 2012-02-19, #4788), written when TopNSearcher was private and had no filter.
Made visible by b50a2edbb23 / 9494be7ec2a, "LUCENE-5214: add FreeTextSuggester" (2013-10-02, #6278), first released in 4.6.0.
Later changes that kept the branch unfiltered: LUCENE-4481 (dc49949558d, maxQueueDepth, rejectCount), LUCENE-5519 (803a9907d39, isComplete, testRejectNoLimits), LUCENE-6459 (be4d58c7740, the acceptResult(FSTPath) overload, again on the zero-output path only), LUCENE-7686 (4e2cf61ac76, acceptPartialPath directly above the branch), LUCENE-8920 (760f2dbdcb2) and later refactors. The last upstream commit to Util.java is 378ca8d1bbf (2026-01-15, javadoc).
Range. The executed releases are in the table; the releases between them carry the unchanged branch (read at the release tags). Not new in 10.6.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the one-test repro fails (gradle and standalone, assertions on and off); the Util-level port fails |
| 10.6.0 | affected | the one-test repro fails; so do the earlier three-line test and the Util-level port |
| 9.12.3 | affected | the one-test repro fails; so does the earlier three-line test |
| 10.5.2, 9.1.0, 8.11.4, 7.7.3, 6.6.6, 5.5.5, 4.10.4 | affected | the earlier three-line test (or its main() port) returns [foo bar] instead of [barn]; the Util-level port also fails on 10.5.2 |
| 4.6.0 | first release with FreeTextSuggester | the main() port returns [foo bar] |
| 4.5.1 | API absent | no FreeTextSuggester |
| 4.1.0 | root only | the Util-level port returns [ab/1, a/5]; acceptResult is called only for ab |
| 4.0.0 | API absent | TopNSearcher is private and has no acceptResult |
| main + fix | not affected | the repro passes; the whole suggest module and the core FST tests pass, assertions on and off |
An executing review by another model family (OpenAI GPT-5.6), an independent reproducer, a reading-only intended-lens review, a devil's advocate and an adjudication all call it real; a credibility audit and a no-noise gate kept it. This doc re-ran the one-test repro and validated the fix with gradle.
Checks and verdicts418 words
The intended-lens cross-model review was reading-only (it could not run Java); the refute-lens review executed its own test. Both used the same model family, OpenAI GPT-5.6.
| Check | Verdict | What it found |
|---|---|---|
| first finder and twin | candidate (same root, collapsed) | Exhaustive TopNSearcher check failed "every returned result passes acceptResult"; the twin's FreeTextSuggester reference check found 508 of 41.8M lookups wrong, and its repro passes with this fix. |
| finder (judge take-over) | candidate | One public-API JUnit fails with assertions on and off; exhaustive scope on main: 7,456,380 rejected results returned and 4,349,970 wrong-but-complete, 0 and 0 with the fix; vacuity mutant fires. |
| cross-model review, refute lens | real (high confidence), executed | OpenAI GPT-5.6, on main ea57360912ed, an ancestor of 645d4fe14a20 with no change to util/fst or lucene/suggest in between. Own test (cedar mist, mist 19 > mister 18: [cedar mist] instead of [mister]; control with mist 16 passes) fails at three seeds with assertions on and off and passes with the fix, as do upstream TestFSTs, TestFreeTextSuggester, TestAnalyzingSuggester, TestFuzzySuggester and TestWFSTCompletion. |
| Independent reproducer | real | Own Util-level test ({car=3, card=1, care=4}, rejects car: [card/1, car/3] instead of [card/1, care/4]) and own FreeTextSuggester test ([red cat] instead of [catalog]) fail on main, 10.6.0, 10.5.2 and 9.12.3; own one-branch fix passes both. |
| Cross-model review, works-as-designed lens | real (medium confidence) | OpenAI GPT-5.6, reading-only; rated the works-as-designed case moderate, not sufficient, because the bypass also happens with allowEmptyString=false. Since it could not run Java, a separate run re-ran the earlier test TestFreeTextSuggesterSeenLastToken on 645d4fe14a20 (assertions off, seed DEADBEEF): it failed as expected, expected:<[barn]> but was:<[foo bar]>, reproducing the bug. |
| Intended-behaviour devil's advocate | real | Built the strongest intended case and refuted it; own probe gives the same output on main and on jars built from 10.6.0's commit. |
| adjudication | confirmed (high confidence); not noise; security none | Re-ran the finder's test (assertions on and off) and the independent reproduction's two tests on main: all fail. |
| Registration check, credibility audit and no-noise gate | keep; claim | Fresh seeds: the repro fails with assertions on and off and passes with the fix shadowed; cut to the one-line-input test. |
| brief re-runs | reproduced; fix validated | The one-test repro through gradle (assertions on and off) and standalone on main 645d4fe14a20, on 10.6.0 and 9.12.3; the fix with the repro, the whole suggest module, the core FST tests and check; a score probe. |
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 531 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,273 words
- Registry title
- Util.TopNSearcher returns a queued final output without calling acceptResult, so FreeTextSuggester spends a top-N slot on a word it already predicted and drops a better suggestion
- Area
- util / FST (Util.TopNSearcher.search, 'Empty string!' branch) and suggest / FreeTextSuggester (lookup's per-level seen-token filter)
- Priority
- Low, rank 269: Silent but mild, with one weaker FreeTextSuggester suggestion replacing a better one in an experimental, opt-in suggester and only in a rare scoring setup (508 of 41.8M lookups, no user report since 4.6).
- Impact class
- silent wrong results
- Impact note
- Hits applications that use the opt-in, @lucene.experimental FreeTextSuggester (the default constructor and bigram model suffice) on ordinary text: whenever the key's last word is a complete word that a higher-order model already predicted and that ranks in the lower-order model's top num, that level returns one new word fewer, and the final list silently misses a suggestion when the lost word's backed-off score would have beaten every higher-order prediction; deterministic, with -ea and -da, in every release from 4.6.0 through 10.6.0 and on main. The root is in Util.TopNSearcher and also skips the filter of any subclass whose keys are prefixes of other keys; in-tree, AnalyzingSuggester/FuzzySuggester and the document suggester reach it only through U+0000 inputs (one symptom each of LFV-601 and LFV-798).
- Summary
- You build a FreeTextSuggester (default bigram model) from text in which 'foo' occurs 9 times (once followed by 'bar'), 'barn' 8 times and 'bar' 10 times, and call lookup("foo bar", 1). With stupid backoff 'foo bar' scores 1/9 = 0.111 and 'barn' 0.4 x 8/27 = 0.119, and 'bar' does not count at the unigram level because the bigram model already predicted it, so you expect [barn], which is also what upstream TestFreeTextSuggester's own reference model gives. You get [foo bar]: 'bar' is itself a word, so Util.TopNSearcher returns it from its 'Empty string!' branch without calling the acceptResult filter that rejects already-predicted words; it takes the unigram level's only slot, is then thrown away as a duplicate, and 'barn' is never considered.
- Introduced
- f2f91bae46cbc4a883324521767e4701fbbbc14a (trunk, LUCENE-3842 'Analyzing Suggester', JIRA, migrated as apache/lucene#4915; svn era, no pull request; first authored on the lucene3842 feature branch as bc0d717c8f6, 2012-09-23) and its branch_4x backport 0c89e05d765 (svn r1391686, the commit in 4.1.0): it added the protected acceptResult hook to Util.TopNSearcher but called it only on the zero-output completion path, and left the older 'Empty string!' branch (a519b630eea, LUCENE-3714 / apache/lucene#4788, 2012-02-19, written when TopNSearcher was private and had no filter) unfiltered [read: git log -S acceptResult on Util.java in the full-history clone; exec: 4.0.0 has no acceptResult (API absent), 4.1.0 fails the Util-level port]. The user-visible FreeTextSuggester symptom exists since that class was added: b50a2edbb23 (trunk) / 9494be7ec2a (branch_4x), LUCENE-5214 (apache/lucene#6278), 2013-10-02, first released in 4.6.0. Later changes to TopNSearcher (LUCENE-4481 dc49949558d, LUCENE-5519 803a9907d39, LUCENE-6459 be4d58c7740, LUCENE-7686 4e2cf61ac76, LUCENE-8920 760f2dbdcb2) never added the call to the branch. Not a regression. (2012-09-28 (f2f91bae46c, acceptResult added on the zero-output path only; first authored 2012-09-23); 2013-10-02 (FreeTextSuggester, b50a2edbb23); the branch itself 2012-02-19 (a519b630eea))
- First affected version
- 4.1.0 for the root [exec: the Util-level port fails on 4.1.0; 4.0.0 is not affected because TopNSearcher is private there and has no acceptResult: API absent]; 4.6.0 for the FreeTextSuggester symptom [exec: 4.6.0 fails; 4.5.1 has no FreeTextSuggester: API absent]
- Affected versions
- [exec] main 645d4fe14a20 (-ea and -da: finder, the independent reproduction, the intended-behaviour check, adjudication, the cross-model refute review and the re-run); 10.6.0, released (the tag releases/lucene/10.6.0 exists and peels to 4ae946e5a9b; the independent reproduction and the history step on the published jars, the intended-behaviour check on jars built from 4ae946e5a9b, -da); 10.5.2 and 9.12.3 (JUnit, -da); 9.1.0 (finder); 8.11.4, 7.7.3, 6.6.6, 5.5.5, 4.10.4 and 4.6.0 (FreeTextSuggester ports, -da); the Util-level port fails on 4.1.0, 10.5.2, 10.6.0 and main. [read] every release in between, the branch being unchanged. Not fixed upstream: upstream main, branch_11x and branch_10_6 have no commit on Util.java or FreeTextSuggester.java after 645d4fe14a20 (checked 2026-10-29), and no open PR touches TopNSearcher. Not new in 10.6. Executed on Linux aarch64 with JDK 25 only.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: exhaustive check; also reached by code reading
- Upstream and prior reports
- none found (2026-10-29, read-only). The prior-report record (verdict new, high): a scripted prior-report search (51 queries over GitHub issues and PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and the web; 248 hits, 0 high), 51 manual searches (apache/lucene issues, PRs and commits for END_LABEL, 'Empty string' TopNSearcher, acceptResult, addStartPaths, allowEmptyString, TopNSearcher and FreeTextSuggester; apache/lucene-solr, Solr, Elasticsearch, OpenSearch, Lucene.NET; Solr JIRA and solr-user@/users@solr), comment greps of 18 issues, a changed-file scan of all 409 open apache/lucene PRs (five touch util/fst or FreeTextSuggester, none TopNSearcher.search), plus the candidate's 51-query pass and the refresh (no TopNSearcher or FreeTextSuggester item updated since 2026-10-28; none of the 28 open PRs updated since then touches Util.java or lucene/suggest; no commit on either file after 645d4fe14a20 on main, branch_11x or branch_10_6). Related, not reports: #4915 (LUCENE-3842, added acceptResult on the zero-output path only), #4788 (LUCENE-3714, added the branch), #5547 (LUCENE-4481, rejection accounting and maxQueueDepth), #6582 (LUCENE-5519, isComplete), #8737 (LUCENE-7686, acceptPartialPath placed directly above the branch), open #5583 (LUCENE-4517, feature request that names acceptResult as the completion filter), open #10038 (LUCENE-8995) with unmerged apache/lucene-solr#913 (a collector-side rejection leak; #913 edits only the last-path shortcut, the LFV-660 site).
- Lucene commit tested
- main
645d4fe14a20(commit of 2026-10-28; runs of 2026-10-30) through gradle in a scratch worktree and through a standalone runner, assertions on and off; the 10.6.0 and 9.12.3 release jars. Earlier checks ran an equivalent three-line test on 10 releases from 4.6.0 to 10.6.0 and main, and aUtil-level port on 4.1.0, 10.5.2, 10.6.0 and main.
- Finding record
lucene/core/src/java/org/apache/lucene/util/fst/Util.java:41 (at 645d4fe14a20) (@lucene.experimental), :255-292 (addStartPaths; allowEmptyString queues the start node's END_LABEL arc, :284), :307 (rejectCount), :333-338 (the 'Empty string!' branch: a popped END_LABEL path is added to the results with no acceptResult call and no rejectCount++), :391-399 (zero-output completion path: acceptResult(path), rejectCount++ on rejection), :409 (isComplete = rejectCount + topN <= maxQueueDepth), :412-423 (acceptResult overloads), :434-446 (TopResults.isComplete)lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FreeTextSuggester.java:84 (at 645d4fe14a20) ('long tail'), :93-95 (stupid backoff), :103 (@lucene.experimental), :126 (ALPHA = 0.4), :515-516 (stale hard-backoff comment), :521-523 ('We only add a given suffix once, from the highest order model that saw it'), :605-606 ('Must do num+seen.size() for queue depth because we may reject up to seen.size() paths in acceptResult()'), :608-638 (the TopNSearcher subclass; acceptResult at :624-637 rejects seen tokens), :642 (addStartPaths(arc, prefixOutput, true, ...)), :645 (assert completions.isComplete), :674-677 (post-search seen check), :707-709 (final list trimmed to num)
- The finding in 30 seconds
lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FreeTextSuggester.java:93-95, (at 645d4fe14a20) :103, :116-117, :121-126, :147-151, :157-159, :521-524, :550, :605-645, :674-678, :695-709lucene/core/src/java/org/apache/lucene/util/fst/Util.java:41, (at 645d4fe14a20) :166-167, :209-252, :264-292, :294-410, :412-423, :434-439
- Who is affected
lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FreeTextSuggester.java:82-88, (at 645d4fe14a20) :645, :674-709lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:790, (at 645d4fe14a20) :820; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/NRTSuggester.java:190, :245-256
- Why it looks unintended
lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FreeTextSuggester.java:93-95, (at 645d4fe14a20) :515-516, :605-606, :645, :674-677, :692-709lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestFreeTextSuggester.java:318, (at 645d4fe14a20) :529-544, :559-564, :586-590, :600lucene/core/src/java/org/apache/lucene/util/fst/Util.java:391-399, (at 645d4fe14a20) :409, :434-438; lucene/CHANGES.txt:12519-12525; lucene/core/src/test/org/apache/lucene/util/fst/TestFSTs.java:1360-1413lucene/suggest/src/java/org/apache/lucene/search/suggest/document/NRTSuggester.java:255; (at 645d4fe14a20) lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:790; lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:184-192
- Suggested fix
lucene/core/src/java/org/apache/lucene/util/fst/Util.java:333-338, (at 645d4fe14a20) :391-399, :412-423; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/NRTSuggester.java:190-227, :254-256 (state and collection inside acceptResult)
- TestFreeTextSuggesterSeenWord.java (lucene/suggest, 1 test):
repro/LFV-1052/TestFreeTextSuggesterSeenWord.java(38 lines)
assets/LFV-1052/bug.png(531 KB)
![Diagram of the repro: FreeTextSuggester built from foo x9, bar x8, barn x7; lookup("foo bar", 1). The pair level offers foo bar (1/9 = 0.111) and marks bar as seen. The single-word level's TopNSearcher pops bar first and returns it from the "Empty string!" branch without calling acceptResult, so it fills the only slot and is dropped as a duplicate; barn (0.4 x 7/24 = 0.117) is never reached. Expected [barn], actual [foo bar].](../assets/LFV-1052/bug.png)