All findings · Lucene finding · LFV-1052 · topnsearcher-final-output-skips-acceptresult

FreeTextSuggester can drop its best suggestion when the last typed word is complete: Util.TopNSearcher skips acceptResult for a queued final output

FreeTextSuggester answers lookup("foo bar", 1) with foo bar (0.111) instead of the better barn (0.117), though lookup("foo ba", 1) gives barn. Because bar is itself a word, Util.TopNSearcher returns it without the suggester's acceptResult filter, so the already-offered bar takes the only single-word slot and is then thrown away.

Summary

Priority
Low, rank 269Silent 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
silent wrong results
Since
4.6.0 (FreeTextSuggester, 2013); the root since 4.1.0 (2012)introduced 2012-09-28 · over 14 years
Affected
4.6.0 to 10.6.0 and main
Status
documented
Found by
two exhaustive reference checks (of TopNSearcher and of FreeTextSuggester), then a JUnit

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

Background: how FreeTextSuggester picks suggestions

  1. It stores word counts and word-pair counts in one FST. For lookup("foo bar") it first takes pairs that start with foo bar (score: pair count / count of foo), then single words that start with bar (score: 0.4 x count / all tokens).
  2. A word is offered once, by the highest level that has it. The lower level's search rejects such words in acceptResult and gets num + seen.size() queue places, so a rejected word costs no result slot.
  3. Each level keeps its top num; the levels are merged, sorted by score and cut to num.
  4. Util.TopNSearcher finds 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 is acceptResult asked.
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].
The repro's model and the two levels of 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.

The finding, step by step

  1. Build a FreeTextSuggester (default bigram model) from one line: foo 9 times, bar 8 times, barn 7 times, 24 tokens. The pair foo bar occurs once.
  2. Call lookup("foo bar", 1). The pair level offers foo bar = 1/9 = 0.111. The single-word level, scaled by 0.4, should skip bar (already offered) and offer barn = 0.4 x 7/24 = 0.117. You expect [barn]; lookup("foo ba", 1) does return [barn].
  3. You get [foo bar], with no error. The single-word search starts at the FST node for bar, which is itself a word. Util.TopNSearcher pops bar first (count 8 beats 7) and adds it in its "Empty string!" branch, which never calls acceptResult, the hook that rejects already-offered words.
  4. So bar fills the level's only slot and is dropped afterwards as a duplicate; barn is never reached, and isComplete still says true. The same branch skips the filter of any TopNSearcher subclass whenever a final output it had queued comes off the queue.

Who is affected

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).

Terms

  • FreeTextSuggester: a Lookup that "uses the stupid backoff language model to smooth scores across ngram models" (FreeTextSuggester.java:93-95 on main 645d4fe14a20); 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, Util is @lucene.experimental, :41). addStartPaths offers every arc leaving the start node to the bounded queue through addIfCompetitive, which keeps only competitive paths (:208-252, :264-292); the END_LABEL arc (the start node's own final output) is eligible when allowEmptyString is true (:284). acceptResult is the protected hook a subclass overrides to reject a complete key (:412-423); TopResults.isComplete is rejectCount + topN <= maxQueueDepth (:409, javadoc :434-439).

The bug

  1. 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: no acceptResult, no rejectCount++ (:333-338).
  2. Otherwise it follows zero-output arcs to the cheapest completion and there calls acceptResult(path), counting a rejection (:391-399).
  3. 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.
  4. FreeTextSuggester.lookup runs one search per level with topN = num, maxQueueDepth = num + seen.size() (FreeTextSuggester.java:605-608), rejects seen words in acceptResult (:624-637), and starts with allowEmptyString=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 top num leaks through branch 1. The post-search check drops it (:674-677), and nothing fills the slot.

The numbers

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; bar becomes seen.
  • Single-word level, prefix bar, maxQueueDepth 2: the queue holds bar itself (count 8, the cheapest) and the n arc toward barn (7). bar comes off first through branch 1, fills the one result slot and ends the search; isComplete is true (0 + 1 <= 2). The post-search check drops bar; 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 above bar, and bar's own key (count 8) is its cheapest way on (the arcs leaving it are 7), so bar is reached through path 2 and rejected, and the result is [barn]. lookup("foo bar", 2) gives [barn, foo bar].

At the Util level

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 as FreeTextLookupFactory.
  • 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 most num - 1 new 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; isComplete stays true, so assert 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 TopNSearcher subclass with an acceptResult filter 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) and NRTSuggester (collects only inside acceptResult, 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.

Minimal repro

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.

TestFreeTextSuggesterSeenWord.java (lucene/suggest, 1 test)38 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.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);
    }
  }
}
Command
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 gives foo bar 0.111, the single-word level skips the already-offered bar and gives barn 0.117.
Observed
[foo bar] (expected:<[barn]> but was:<[foo bar]>), with no error.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 and 9.12.3 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is pure Java)
Command (environment)
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

Observed output

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)

Why it looks unintended

  • FreeTextSuggester sizes the queue num + 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 top num: here it gives [barn].
  • TopNSearcher's other result path calls acceptResult; 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) and assert 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 to num (:586-590), and asserts equality with lookup() (:600). Its random 1-5 character terms rarely hit the trigger.
  • The TopNSearcher contract. The zero-output path calls acceptResult and counts the rejection (Util.java:391-399); isComplete may 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 reach acceptResult (:1389) and only the accepted key is returned (:1392). It passes only because adcd does not weigh more than its extension adcde (both 17, :1368-1369; at 16 it passes too); with adcd at 18 its own assertions fail on stock ([aac/7, adcd/18]) and pass with the fix.
  • Every in-tree override uses acceptResult as 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), when TopNSearcher was private and had no filter; LUCENE-3842 (f2f91bae46c, 2012-09-28) added acceptResult only 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.

Suggested fix

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 acceptResult call per popped final output; nothing changes for callers without a filter (the default acceptResult returns true).
  • FreeTextSuggester's assert completions.isComplete still holds: each seen word matches at most one completion, so rejectCount <= seen.size().
  • AnalyzingSuggester and NRTSuggester now 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. acceptResult is a protected hook (Util.java:412-423) that a subclass may use for more than a yes/no answer: NRTSuggester checks deleted documents and the filter, records seen surface forms and collects the hit inside it, and ignores search()'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/suggest module passes with assertions on and off: 32 suites, 237 tests, 7 skipped, 0 failures (it includes TestFreeTextSuggester, TestAnalyzingSuggester, TestFuzzySuggester, TestSuggestField and TestWFSTCompletion).
  • The core org.apache.lucene.util.fst.* tests pass with assertions on and off: 8 suites, 37 tests, 0 failures (TestFSTs with testRejectNoLimits).
  • :lucene:core:check :lucene:suggest:check -x test passes (google-java-format, ecjLint, forbiddenApis).
  • Earlier, with the same change shadowed: the exhaustive TopNSearcher run 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 separate queue = null shortcut, LFV-660); testRejectNoLimits with adcd at 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).

FreeTextSuggester-only workaround: do not queue the start node's key when it was already seen

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.

Document that acceptResult is not called for queued final outputs

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 note

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.

How it was found

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.TopNSearcher with 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 FreeTextSuggester against a stupid-backoff reference in the per-level top-N form that testRandom asserts 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=true with 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 upstream testRejectNoLimits with one weight changed. The no-noise gate later cut the test to one line of input.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits; downstream repositories2026-10-29a 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 greppedno report; only the design history above
Open PRs2026-10-29the changed files of all 409 open apache/lucene PRsfive touch util/fst or FreeTextSuggester; none touches TopNSearcher.search
Mailing lists and JIRA archive2026-10-29dev@, java-user@, issues@, commits@, solr-user@ and users@solr; LUCENE and SOLR JIRA for FreeTextSuggester and TopNSearcherno report; Solr issues about building and configuring FreeTextSuggester only
GitHub (re-check)2026-10-29items 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_6nothing new; no change to either file after 645d4fe14a20

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe one-test repro fails (gradle and standalone, assertions on and off); the Util-level port fails
10.6.0 affectedthe one-test repro fails; so do the earlier three-line test and the Util-level port
9.12.3 affectedthe 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 affectedthe 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 FreeTextSuggesterthe main() port returns [foo bar]
4.5.1 API absentno FreeTextSuggester
4.1.0 root onlythe Util-level port returns [ab/1, a/5]; acceptResult is called only for ab
4.0.0 API absentTopNSearcher is private and has no acceptResult
main + fix not affectedthe repro passes; the whole suggest module and the core FST tests pass, assertions on and off

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat 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) candidateOne 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), executedOpenAI 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 realOwn 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 realBuilt 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 noneRe-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; claimFresh 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 validatedThe 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,273 words

Finding record

LFV-1052, snapshot of 2026-11-06

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 a Util-level port on 4.1.0, 10.5.2, 10.6.0 and main.

Lucene and upstream sources

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-709
  • lucene/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-709
  • lucene/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-709
  • lucene/suggest/src/test/org/apache/lucene/search/suggest/analyzing/TestFreeTextSuggester.java:318, (at 645d4fe14a20) :529-544, :559-564, :586-590, :600
  • lucene/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-1413
  • lucene/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)

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

Repro files

About the diagrams

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.