All findings · Lucene finding · LFV-070 · fst-shortestpaths-topn-zero

Util.shortestPaths and FreeTextSuggester throw NoSuchElementException when asked for 0 results

Asking Lucene's FST top-N search for 0 results, through Util.shortestPaths(..., 0, ...) or FreeTextSuggester.lookup(key, 0), throws NoSuchElementException instead of returning an empty list: a queue limited to 0 paths counts as full while empty, so the code asks it for its last entry. Every release tested since 4.1.0 has it.

Summary

Priority
Low, rank 1120Util.shortestPaths and some suggesters throw NoSuchElementException for a result count of 0, a loud boundary input with a local fix ready.
Impact
crash
Since
4.1.0 (LUCENE-4480, commit 0e77eec4366, 2012-10-12)introduced 2012-10-12 · over 14 years
Affected
4.1.0-10.6.0 and main
Status
documented
Found by
non-formal: code reading while writing a brute-force FST oracle

AI-assisted page: this finding was first signalled by code reading or testing 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

Background: top-N search over an FST

  1. An FST (finite state transducer) is Lucene's compact map from keys to outputs, such as weights. Lucene's suggesters store their suggestions in one.
  2. Util.shortestPaths(fst, node, ..., topN, ...) returns the topN keys with the smallest total output reachable from a node. It keeps candidate paths in a sorted queue (TopNSearcher) of at most maxQueueDepth entries, and shortestPaths sets that limit to topN.
  3. When the queue is full, a new candidate is compared with the worst queued path, queue.last(), and kept only if it is better.
Summary card of the bug. Title: Util.shortestPaths(..., topN = 0, ...) throws NoSuchElementException instead of returning no paths; since 4.1.0 (2013), and main. Four cards in a chain, from the diagram's example FST (keys cat = 5, cow = 2, dog = 7, so the start node has arcs c and d): 1, ask for 0 paths with shortestPaths(fst, start, 0L, cmp, 0, false) or FreeTextSuggester.lookup(key, 0); the javadoc sets no lower bound (Util.java:455). 2, no queue slot: new TopNSearcher<>(fst, topN, topN, cmp) (:467) makes maxQueueDepth = 0, a queue that holds nothing. 3, empty counts as full: addIfCompetitive checks queue.size() == maxQueueDepth (:215), 0 == 0 is true for the first start arc (arc c, cost 2), so it takes the full-queue branch. 4, no worst path: queue.last() (:216) on an empty TreeSet throws NoSuchElementException before search() runs; search() would return [] for topN = 0 (:310, :409). Expected: an empty result, top.topN.size() = 0, isComplete = true. Actual: java.util.NoSuchElementException at TreeSet.last from Util$TopNSearcher.addIfCompetitive(Util.java:216). Also reached by FreeTextSuggester.lookup(key, 0) with assertions on and off, and with assertions off by WFST, Analyzing and Fuzzy suggester lookups. Every release tested from 4.1.0 to 10.6.0, and main; 4.0.0 returned []. A fix: return early in addIfCompetitive when maxQueueDepth == 0.
How topN = 0 travels from shortestPaths into a queue limited to 0 paths, why the empty queue counts as full, and where queue.last() throws; then what you expect and what you get. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build an FST with one key, "a" with output 1, and ask for its cheapest paths from the root: Util.shortestPaths(fst, root, 0L, naturalOrder, topN, false). With topN = 1 you get one path, a = 1.
  2. Now pass topN = 0, as code does that forwards a caller's count unchanged, for example a request for 0 suggestions sent to FreeTextSuggester.lookup(key, 0).
  3. You expect an empty result: the top 0 paths are none. Lucene's own search loop already returns an empty, complete result for 0, topN = -1 returns empty, and Lucene 4.0.0 returned empty for 0.
  4. Instead it throws java.util.NoSuchElementException. shortestPaths limits the queue to topN paths, here 0. While seeding it, addIfCompetitive checks queue.size() == maxQueueDepth: 0 == 0, so the empty queue counts as full, and queue.last() has nothing to return (Util.java:215-216).
  5. It fails with assertions on and off, on every release tested from 4.1.0 to 10.6.0 and on main. FreeTextSuggester.lookup("the", 0) throws on the same line, and with assertions off so do some WFSTCompletionLookup, AnalyzingSuggester and FuzzySuggester lookups. Nothing is corrupted: only that one call fails.

Who is affected

Code that asks for 0 results: direct users of the experimental Util.shortestPaths or TopNSearcher, and applications that forward a count of 0 to FreeTextSuggester.lookup (no check; fails with assertions on or off for a key that matches its model). With assertions off, some WFSTCompletionLookup, AnalyzingSuggester and FuzzySuggester lookups fail too. One call throws; nothing else breaks. Rare.

Mechanism, terms and numbers938 words

Util.shortestPaths (core, @lucene.experimental) finds "the top N min cost completions to a final node" of an FST, with no stated range for N. Called with topN = 0, it throws java.util.NoSuchElementException instead of returning an empty result as soon as the start node offers one arc to the queue (every labelled arc; the end-of-string arc only with allowEmptyString). It builds a TopNSearcher whose queue may hold maxQueueDepth = topN = 0 paths (Util.java:467); while seeding the queue, addIfCompetitive treats queue.size() == maxQueueDepth as "full, compare with the worst entry", which is true for the empty queue, and queue.last() throws (Util.java:215-216). The rest of the search already handles 0, topN = -1 returns empty, and 4.0.0 returned empty. FreeTextSuggester.lookup(key, 0) has no check on num and reaches the same line, for a key that matches its model, with assertions on and off. With assertions off, so do WFSTCompletionLookup for a prefix that is not itself a key, AnalyzingSuggester without EXACT_FIRST, and AnalyzingSuggester / FuzzySuggester with their default EXACT_FIRST when the key is itself a suggestion. The throw came with LUCENE-4480 (2012) and is in every release executed from 4.1.0 to 10.6.0 and in main; all 121 release tags have the same unguarded check. A four-line early return for maxQueueDepth == 0 fixes every caller; it passes the core FST tests, the suggest module and core's static checks.

Terms

  • FST: Lucene's finite state transducer, a compact map from keys (byte or char sequences) to outputs such as weights. Suggesters store their suggestions in one.
  • Top-N search (Util.TopNSearcher, used by Util.shortestPaths): walks the FST from a start node and returns the N completions with the smallest total output. It keeps candidate paths in a sorted TreeSet of at most maxQueueDepth entries; shortestPaths passes N as both topN and maxQueueDepth.

The bug

  1. Util.shortestPaths creates new TopNSearcher<>(fst, topN, topN, comparator) (Util.java:467), calls addStartPaths (:471), then search() (:472). It checks no argument.
  2. addStartPaths calls addIfCompetitive(path) for each arc leaving the start node (:283-286; the end-of-string arc only if allowEmptyString). So one offered arc is enough: an FST whose only key is the empty string returns empty for topN = 0 with allowEmptyString = false, and throws with true (the study verifier's characterization test).
  3. addIfCompetitive first asks whether the queue is full, if (queue.size() == maxQueueDepth) { (:215), and if so compares the new path with the worst queued one, FSTPath<T> bottom = queue.last(); (:216). That assumes a full queue has a last entry. With maxQueueDepth == 0 the empty queue is already "full" (0 == 0), and TreeSet.last() on an empty set throws NoSuchElementException.
  4. Everything else already means "nothing" for 0: the non-full branch would add the path and drop it again at size maxQueueDepth + 1 (:247-249), after building it and asking the protected acceptPartialPath hook (:239-246); search() loops while (results.size() < topN) (:310) and reports isComplete = rejectCount + topN <= maxQueueDepth (:409), true for 0. It is never reached.
  5. Negative values do not throw: the size is never -1, so the queue grows without pruning (it keeps every seed path), and search() returns empty because results.size() < -1 is false.
  6. Suggesters reach the same line. FreeTextSuggester.lookup never checks num (FreeTextSuggester.java:426); for each model whose last tokens have a matching prefix in the FST (others are skipped, :526-561) it builds TopNSearcher(fst, num, num + seen.size(), ...) (:607-608) with seen empty for the first model; its addIfCompetitive override delegates to super (:613-617). WFSTCompletionLookup.lookup has only assert num > 0 (:156) and calls Util.shortestPaths(..., num, ...) (:198). AnalyzingSuggester has only assert num > 0 (:670); its main searcher gets topN = num - results.size() and depth num * maxAnalyzedPathsForOneInput, so depth 0 (:785-786), and is seeded with the prefix paths (:819-821). Without EXACT_FIRST that happens for any matching prefix. With the default EXACT_FIRST, a key that is not itself a suggestion returns early at :777 (results.size() == num, 0 == 0); a key that is itself a suggestion is added by the exact-first pass (:769-775), so results.size() is 1, the early return is skipped, and the searcher (topN = -1, depth 0) throws. FuzzySuggester extends AnalyzingSuggester.

The numbers

One-key FST "a" -> 1: topN = 1 returns a = 1; topN = 0 throws at Util.java:216, called from addStartPaths (:285), with assertions on and off. Suggesters with num = 0 (keys car, cat, dog; the FreeText model built from "the cat sat", "the car", "a dog"):

  • FreeTextSuggester.lookup("the", 0) throws, with assertions on and off; lookup("the", 1) returns 1 result.
  • WFSTCompletionLookup.lookup("c", false, 0) throws with assertions off (with assertions on, assert num > 0 fires first).
  • AnalyzingSuggester built without EXACT_FIRST, lookup("c", false, 0), throws with assertions off (the study fix author's probe on main 7cecf79523, and the fact check's probe on 645d4fe14a20).
  • AnalyzingSuggester / FuzzySuggester with their default EXACT_FIRST, assertions off: lookup("cat", false, 0) (the key is itself a suggestion) throws at Util.java:216; lookup("c", false, 0) returns 0 results. With assertions on, assert num > 0 (:670) fires first.
  • FSTCompletionLookup and TSTLookup return 0 results; the NRT TopSuggestDocsCollector rejects 0 with IllegalArgumentException("'num' must be > 0"). (The study's reach test asserts only that FreeText must not throw; the other rows are its printed outcomes, re-checked by the fact check's probe.)
Exposure and likelihood240 words
  • Who is hit. Code that passes an explicit 0: direct callers of Util.shortestPaths / Util.TopNSearcher (public, @lucene.experimental); applications that pass a caller-supplied count to FreeTextSuggester.lookup(key, num), for example a request asking for 0 suggestions for a key that matches the model; and, with assertions off as in production, WFSTCompletionLookup.lookup(prefix, false, 0) for a prefix that is not itself a key, AnalyzingSuggester built without EXACT_FIRST for a matching prefix, and AnalyzingSuggester / FuzzySuggester with their default options for a key that is itself a suggestion. Lookup.lookup documents num as the "maximum number of results to return", with no minimum.
  • What happens. That one call fails at once with an internal NoSuchElementException from TreeSet.last(), not with an error about the count. Code that catches IllegalArgumentException for bad arguments does not catch it.
  • What does not happen. No wrong results, no corruption, no hang, no meaningful resource use, no effect on the index or on other requests. Data and scale play no part: one start arc offered to the queue is enough.
  • Default vs opt-in. Nothing calls these with 0 by default; it takes an explicit count of 0. Likelihood: rare. The behaviour also differs between suggesters: FSTCompletionLookup and TSTLookup return 0 results for num = 0, AnalyzingSuggester / FuzzySuggester with default options (assertions off) return 0 results for a key that is not itself a suggestion and throw for one that is, and the NRT suggester rejects 0.

Minimal repro

One JUnit test on the public API: build a one-key FST and call Util.shortestPaths with topN = 0. It expects an empty result and fails only when the NoSuchElementException escapes. Deterministic, single thread, about 1.4 s; the same with assertions on and off.

TestShortestPathsTopNZero.java (JUnit, one test)41 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.util.fst;

import java.util.Comparator;
import java.util.NoSuchElementException;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.IntsRefBuilder;

public class TestShortestPathsTopNZero extends LuceneTestCase {

  public void testTopNZeroReturnsEmptyResult() throws Exception {
    PositiveIntOutputs outputs = PositiveIntOutputs.getSingleton();
    FSTCompiler<Long> compiler = new FSTCompiler.Builder<>(FST.INPUT_TYPE.BYTE1, outputs).build();
    compiler.add(Util.toIntsRef(new BytesRef("a"), new IntsRefBuilder()), 1L);
    FST<Long> fst = FST.fromFSTReader(compiler.compile(), compiler.getFSTReader());
    try {
      Util.TopResults<Long> top =
          Util.shortestPaths(
              fst, fst.getFirstArc(new FST.Arc<>()), 0L, Comparator.naturalOrder(), 0, false);
      assertEquals(0, top.topN.size());
    } catch (NoSuchElementException e) {
      throw new AssertionError("expected an empty result for topN=0, got " + e, e);
    }
  }
}
Command
cp TestShortestPathsTopNZero.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestShortestPathsTopNZero -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
An empty TopResults (topN.size() == 0), which search() already computes for topN = 0.
Observed
java.util.NoSuchElementException from TreeSet.last() at Util$TopNSearcher.addIfCompetitive(Util.java:216), called from addStartPaths (Util.java:285); the same with assertions off.

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

More files, environment, full output247 words

TestShortestPathsTopNZero builds a one-key FST ("a" -> 1, PositiveIntOutputs, BYTE1) with FSTCompiler, then calls Util.shortestPaths(fst, fst.getFirstArc(new FST.Arc<>()), 0L, Comparator.naturalOrder(), 0, false) and asserts top.topN.size() == 0. A NoSuchElementException is rethrown as an AssertionError that states expected and actual, with the original as its cause. Public API only (it sits in org.apache.lucene.util.fst like TestFSTs, but uses nothing package-private); no randomness of its own, so the seed does not matter (two seeds gave the same result). It compiles on 9.10.0 and later: FST.fromFSTReader, FSTCompiler.getFSTReader and a compile() that returns the FST metadata came together in #12831, first released in 9.10.0.

As the regression test: this same test, for example as testShortestPathsTopNZero in TestFSTs. With fix A it passes ./gradlew :lucene:core:check -x test unchanged. If the maintainer picks option B (reject 0), its assertion becomes expectThrows(IllegalArgumentException.class, () -> Util.shortestPaths(...)).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 9.12.3, 10.0.0, 10.5.2 and 10.6.0; every release tested from 4.1.0 to 10.6.0 is affected
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestShortestPathsTopNZero.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestShortestPathsTopNZero -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

TestShortestPathsTopNZero > testTopNZeroReturnsEmptyResult FAILED
    java.lang.AssertionError: expected an empty result for topN=0, got java.util.NoSuchElementException
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:E5AE15C911A0C3BF]:0)
        at org.apache.lucene.util.fst.TestShortestPathsTopNZero.testTopNZeroReturnsEmptyResult(TestShortestPathsTopNZero.java:38)

        Caused by:
        java.util.NoSuchElementException
            at java.base/java.util.TreeMap.key(TreeMap.java:1638)
            at java.base/java.util.TreeMap.lastKey(TreeMap.java:310)
            at java.base/java.util.TreeSet.last(TreeSet.java:404)
            at org.apache.lucene.util.fst.Util$TopNSearcher.addIfCompetitive(Util.java:216)
            at org.apache.lucene.util.fst.Util$TopNSearcher.addStartPaths(Util.java:285)
            at org.apache.lucene.util.fst.Util$TopNSearcher.addStartPaths(Util.java:257)
            at org.apache.lucene.util.fst.Util.shortestPaths(Util.java:471)
            at org.apache.lucene.util.fst.TestShortestPathsTopNZero.testTopNZeroReturnsEmptyResult(TestShortestPathsTopNZero.java:34)

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=false; identical with assertions on

Why it looks unintended

  • The javadoc promises "the top N min cost completions" with no range for N; Lookup calls num a "maximum number of results".
  • The code already means empty for 0: search() returns an empty, complete result, and topN = -1 returns empty.
  • An apparently accidental 2012 regression: 4.0.0 returned empty for 0; tests only use topN 1 to 10.
The full argument277 words
  • Contract. shortestPaths: "Starting from node, find the top N min cost completions to a final node." (Util.java:455), with no range and no @throws. TopNSearcher: "@param topN the number of top scoring entries to retrieve", "@param maxQueueDepth the maximum size of the queue of possible top entries" (Util.java:185-186), no range. Lookup.lookup: "@param num maximum number of results to return" (Lookup.java:190, 206, 219); nothing there says 0 is invalid. The FST package docs show "N-shortest paths by weight" (package-info.java:82-95) with no range.
  • The code already means "empty" for 0. search() returns an empty, complete result for topN = 0 (Util.java:310, :409); the throw happens before it. topN = -1 returns empty. AnalyzingSuggester itself builds a TopNSearcher(0, 0) whenever an EXACT_FIRST lookup finds no exact match (count * maxSurfaceFormsPerAnalyzedForm with count == 0, AnalyzingSuggester.java:731-735), so 0 is a depth Lucene uses; it just never seeds that searcher.
  • History: by all appearances an accidental regression. Before LUCENE-4480 the searcher pruned only once a separate bottom field was set, which never happened for topN = 0: release 4.0.0 and the parent commit return an empty result. LUCENE-4480 ("handle insert into empty queue correctly", 2012-10-12), which fixed a different NoSuchElementException (queue exhaustion with topN = 4), replaced it with if (queue.size() == topN) { bottom = queue.last(); ..., which throws for 0. LUCENE-4481 gave today's queue.size() == maxQueueDepth. Neither issue (#5546, #5547) mentions 0.
  • Tests never tried 0. TestFSTs draws topN from TestUtil.nextInt(random, 1, 10) (TestFSTs.java:1517, :1641), and its fixed shortest-path tests use 3 (:1341-1347, :1442-1448).
The devil's-advocate check172 words

The strongest case for "not a bug" (the cross-model reviewer's and the verifiers'): some suggesters assert num > 0 (WFSTCompletionLookup.java:156, AnalyzingSuggester.java:670) and the NRT collector throws IllegalArgumentException for num <= 0 (TopSuggestDocsCollector.java:77-78), so the project's convention may be that 0 is not a valid count; Util is @lucene.experimental; and the call already fails loudly.

Why it fails: that convention is stated for none of shortestPaths, TopNSearcher, Lookup or FreeTextSuggester. An assert states an internal invariant, not an API precondition, and it is off in production, where WFSTCompletionLookup, AnalyzingSuggester and FuzzySuggester can then hit this exact throw. @lucene.experimental means the API may change, not that inputs are undefined. And even if 0 should be rejected, an internal NoSuchElementException from TreeSet.last() is not how Lucene rejects arguments: the facets module normalised topN <= 0 to IllegalArgumentException (#11503 / PR #751). Residual risk: a maintainer may prefer an IllegalArgumentException to an empty result; that is a choice of fix (option B), not "works as designed".

Suggested fix

Recommended A. Return early in addIfCompetitive when maxQueueDepth is 0 (empty result)

Return early from TopNSearcher.addIfCompetitive when maxQueueDepth == 0: a queue that can hold nothing keeps nothing. That one spot fixes shortestPaths and every suggester path that reaches the throw. With it the repro passes with assertions on and off, and the core FST tests, the whole suggest module and core's static checks pass.

--- a/lucene/core/src/java/org/apache/lucene/util/fst/Util.java
+++ b/lucene/core/src/java/org/apache/lucene/util/fst/Util.java
@@ -213,6 +213,10 @@
       T output = fst.outputs.add(path.output, path.arc.output());
 
       if (queue.size() == maxQueueDepth) {
+        if (maxQueueDepth == 0) {
+          // Queue can't hold any path, so nothing competes
+          return;
+        }
         FSTPath<T> bottom = queue.last();
         int comp = pathComparator.compare(path, bottom);
         if (comp > 0) {
All options and trade-offs3 options ยท 763 words

Fix A was validated through gradle in a scratch worktree of 645d4fe14a20. B and C are alternatives from the study's fix notes and the reviews; neither was run.

A. Return early in addIfCompetitive when maxQueueDepth is 0 (empty result) (validated by the checks) (recommended)

In Util.TopNSearcher.addIfCompetitive, inside the queue-full branch and before queue.last(), return when maxQueueDepth == 0. It leaves the queue as the non-full branch would (that branch adds the path and drops it again at once); it only skips building that path and asking the protected acceptPartialPath hook, which in the tree only NRTSuggester overrides (its collector rejects num <= 0).

--- a/lucene/core/src/java/org/apache/lucene/util/fst/Util.java
+++ b/lucene/core/src/java/org/apache/lucene/util/fst/Util.java
@@ -213,6 +213,10 @@
       T output = fst.outputs.add(path.output, path.arc.output());
 
       if (queue.size() == maxQueueDepth) {
+        if (maxQueueDepth == 0) {
+          // Queue can't hold any path, so nothing competes
+          return;
+        }
         FSTPath<T> bottom = queue.last();
         int comp = pathComparator.compare(path, bottom);
         if (comp > 0) {

Trade-offs

  • Fixes the wrong assumption where it lives, so every caller is covered: shortestPaths, the public TopNSearcher constructor, FreeTextSuggester (its override delegates to super), WFSTCompletionLookup, and AnalyzingSuggester / FuzzySuggester with and without EXACT_FIRST (no throw anywhere in the fact check's probe with the fix).
  • Restores the 4.0.0 result (empty; 4.0.0 returned a MinResult[], with no isComplete) and matches what search() computes: topN = 0 gives an empty result with isComplete = true; topN >= 1 with depth 0 gives empty with isComplete = false ("queue too small"), both from the existing formula (Util.java:409). No change for depth 1 or more, or for negative depth.
  • With assertions off, an exact-key lookup with 0 on AnalyzingSuggester / FuzzySuggester (default options) then returns its 1 exact match instead of throwing, as WFSTCompletionLookup already does: more results than num, from the exact-first step (option C).
  • Cost: one comparison of a final int, only inside the already-full branch, which then walks the tree anyway.
  • It silently accepts 0, which some may see as hiding a caller bug.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, 2026-11-01: without the fix the repro fails with assertions on and off; with it, it passes with assertions on and off.
  • ./gradlew :lucene:core:test --tests 'org.apache.lucene.util.fst.*': 9 classes (including TestFSTs and the repro), 38 tests, 0 failures, 5 skipped (2 @Nightly, 3 @Ignore).
  • ./gradlew :lucene:suggest:test (every other TopNSearcher caller lives there): 31 classes, 236 tests, 0 failures, 7 skipped.
  • ./gradlew :lucene:core:check -x test with the fix and the repro as a new test file: passes (google-java-format, ecj lint, forbidden APIs; error-prone not enabled). The full Lucene test suite was not run.
  • Earlier: with the fix shadowed into the stock jars the repro passes with assertions on and off; the cross-model reviewer's own tests and the TestFSTs tests it ran pass; FreeText, WFST (prefix) and Analyzing (no EXACT_FIRST) return 0 results. The fact check's probe with the fix: no throw on any core or suggester path; AnalyzingSuggester / FuzzySuggester default lookup("cat", false, 0) with assertions off returns 1 result; TestFSTs passes.

B. Reject 0 with IllegalArgumentException in shortestPaths and FreeTextSuggester (not run)

Check topN < 1 at the top of Util.shortestPaths and num < 1 in FreeTextSuggester.lookup, like TopSuggestDocsCollector does.

if (topN < 1) {
  throw new IllegalArgumentException("topN must be >= 1, got " + topN);
}

Trade-offs

  • Explicit; matches the NRT suggester and the facets precedent (#11503).
  • It must not go into the TopNSearcher constructor: AnalyzingSuggester builds TopNSearcher(0, 0) whenever an EXACT_FIRST lookup finds no exact match (AnalyzingSuggester.java:731-735), so a constructor check would break ordinary lookups.
  • Without that, the public constructor and AnalyzingSuggester / FuzzySuggester (without EXACT_FIRST, or with it for an exact-key lookup; assertions off) still reach the throw, so B needs A as well, or guards in each caller.
  • It turns shortestPaths(-1) from empty into an exception; WFSTCompletionLookup passes -1 today for num = 0 with an exact key.

What the checks validated

Not run.

C. Follow-up: one policy for a count of 0 across suggesters

Today Lookup.lookup(num = 0) asserts, throws IllegalArgumentException, returns empty, or hits this throw, depending on the class. One documented rule (reject or empty) would settle it.

Trade-offs

A policy question across many classes, separate from this bug; it also covers the older WFSTCompletionLookup quirk that returns 1 result for num = 0 on an exact key with assertions off, which fix A does not change, and the same 1-result answer that AnalyzingSuggester / FuzzySuggester give for an exact key with fix A.

Downstream note

Services that pass a user-chosen suggestion count straight to FreeTextSuggester.lookup turn a request for 0 suggestions into an internal NoSuchElementException for that request, so clamping or rejecting counts below 1 before the call avoids it.

How it was found

Non-formal: code reading. While an AI tool wrote a brute-force checker for the FST top-N search (which only tried topN of 1 or more), it noticed that a queue limited to 0 paths counts as full while empty. An independent verifier then reproduced it through the core API and through the suggesters.

The method in full140 words
  • Method: code reading, the study's non-formal tier. In the 2026-10-22 study, the FST verification experiment wrote a brute-force oracle for Util.shortestPaths to compare with Lucene's top-N search. The oracle's bounded scope used topN >= 1, so it did not find this; reading TopNSearcher.addIfCompetitive while writing it showed that queue.size() == maxQueueDepth holds for an empty queue when the depth is 0.
  • Verification: an independent verifier (FST) wrote its own core test (three-key FST) and a suggester reach test, reproduced the throw with assertions on and off, found the FreeTextSuggester and WFSTCompletionLookup paths, and called it a new, low-severity bug.
  • Later: the test was reduced to one test method on a one-key FST (2026-10-29), re-verified on main 645d4fe14a20 (2026-11-01), reviewed by a reviewer from another model family that ran its own tests, and its history was bisected by execution.

Prior reports, history and checks

Prior reports

No matching prior report found in the recorded searches of GitHub issues, PRs and commits, the JIRA archive, the Lucene mailing lists, downstream projects or the web (searched 2026-10-22 and 2026-11-01). The closest item, LUCENE-4480 (#5546), is the 2012 fix that introduced this throw. Not fixed upstream as of 2026-10-30.

Searches4 searches

Closest items, none a report of this bug: #5546 (LUCENE-4480, same method and exception with topN >= 1; its fix introduced this throw), #4919 (LUCENE-3846, the benchmark stack trace that led to LUCENE-4480), #5547 (LUCENE-4481, today's form of the check), #6582 (LUCENE-5519, isComplete), #5633 (LUCENE-4567, a different WFST edge case: an empty FST), #11503 (facets topN <= 0 to IllegalArgumentException: precedent for a fix), #5583 and #10038 (queue sizing in suggesters). Open PRs #12688 and #13390 touch Util.java or FreeTextSuggester.java but not TopNSearcher. Gaps: two downstream port queries were not run, GitHub search reads only the first page of each query, and the open-PR file scan covered selected labels.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits2026-11-01a scripted prior-report search: 30 issue/PR and 3 commit queries (TopNSearcher, shortestPaths, addIfCompetitive, maxQueueDepth, FreeTextSuggester, WFSTCompletionLookup, topN 0, num 0, NoSuchElementException); manual searches of the same names; changed files of 184 of 410 open PRsno report; #5546 (LUCENE-4480) introduced the throw
Mailing lists, JIRA archive, downstream, web2026-11-0124 queries over dev@, java-user@, issues@ and commits@; 6 JIRA queries (LUCENE, SOLR); Elasticsearch, OpenSearch, Solr and Lucene.NET issues; 6 web queries; 290 distinct hits in all, 0 high overlapno report
Earlier study searches2026-10-22 to 2026-10-23GitHub issue and PR searches for the class and method names and the symptom; all 404 open PRs' changed files; JIRAno report; no open PR touches TopNSearcher
Upstream main2026-11-01file contents and commits up to 2f424fd428da (2026-10-30); CHANGES.txtnot fixed; no entry

History

Introduced in 4.1.0 by LUCENE-4480 (0e77eec4366, 2012-10-12); today's form from LUCENE-4481 (dc49949558d). Release 4.0.0 returned an empty result. Bisected by single-commit builds; 13 releases from 4.1.0 to 10.6.0 and main executed as affected (12 in the history check, 10.5.2 in an earlier run), and all 121 release tags from 4.1.0 to 10.6.0 read.

Versions tested7 rows

Introduced by LUCENE-4480 (apache/lucene#5546, "handle insert into empty queue correctly", trunk 0e77eec4366, 2012-10-12; branch_4x backport a1b3981f322), which replaced a null-safe bottom field with if (queue.size() == topN) { bottom = queue.last(); .... LUCENE-4481 step 1 (7e45db75e3a, 2012-10-20) turned pruning off, which also removed the throw for about 26 hours; step 2 (dc49949558d, 2012-10-21; backport 5726a78f9d4) put it back as queue.size() == maxQueueDepth, today's form. No pull request (Subversion era). git blame at main: Util.java:215 is dc49949558d, :216 is 0e77eec4366. FreeTextSuggester throws from its first release, 4.6.0. A separate, older quirk, not this regression: with assertions off and the default exactFirst, WFSTCompletionLookup.lookup("cat", false, 0) for an exact key returns 1 result (--num makes -1 at WFSTCompletionLookup.java:189), already in 4.0.0; fix A does not change it. With fix A, AnalyzingSuggester / FuzzySuggester with default options give the same 1 exact match for an exact key with assertions off, instead of today's throw.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (standalone and gradle); passes with fix A
10.6.0, 10.5.2, 10.0.0, 9.12.3 affectedminimal repro fails (10.6.0, 10.5.2 and 9.12.3 with assertions on and off; 10.0.0 with assertions on); Util.java:220 on 9.12.3
9.0.0, 8.11.4, 7.7.3, 6.6.6, 5.5.5, 4.10.4, 4.7.2, 4.1.0 first affected 4.1.0a probe for each FST API generation, same FST and call, throws NoSuchElementException from addStartPaths; the topN = 1 control returns a = 1
4.6.0 suggestersFreeTextSuggester.lookup("the", 0) and WFSTCompletionLookup.lookup("c", false, 0) (assertions off) throw
4.0.0 last unaffectedreturns an empty result for topN = 0, with assertions on and off
1cadf9c5bb28 / 0e77eec4366 / 7e45db75e3a / dc49949558d bisectempty / throws / empty / throws: LUCENE-4480 introduced it, LUCENE-4481 step 2 gave today's form
121 release tags 4.1.0 to 10.6.0; upstream main 2f424fd428da readthe same unguarded check (source read, not executed)

Independent checks

Confirmed by the study's independent verifier, the re-verification on main, a reviewer from another model family (OpenAI GPT-5.6) that ran its own tests and judged it real with high confidence, and this doc's gradle run. The one-hunk fix passed every check it was given.

Checks and verdicts356 words

Seven checks, all agreeing on the root cause; the fact checks led to corrections, now applied in this doc.

Each independent check and its verdict
CheckVerdictWhat it found
study verifier new bug, low severityIts own core test (three-key FST) and a suggester reach test reproduce the throw with assertions on and off; found the FreeTextSuggester and WFSTCompletionLookup paths.
main refresh and re-verification still reproducesThe verifier's test still fails on main a9779ef3c193; the minimal repro fails on 645d4fe14a20 with assertions on and off, on 10.5.2 and on 9.12.3, and passes with the fix shadowed; the suggester table reproduces.
cross-model review, refute lens (OpenAI GPT-5.6, ran code on main ea57360912ed) real, high confidenceIts own two-key FST test and its own FreeTextSuggester test fail for 0 and pass for 1, with assertions on and off; with the fix shadowed they pass, as do the TestFSTs tests it ran. It weighed the positive-count convention of sibling suggesters as the strongest refutation and rejected it. It did not test the older WFST exact-key quirk.
history check introducing commit confirmed by executionSingle-commit builds of 2012 trunk and 13 releases (4.0.0 clean, 12 affected); with 10.5.2 from an earlier run, 13 affected releases were executed in all.
brief validation (gradle) fix A validatedRepro fails on stock main with assertions on and off and passes with fix A; core FST tests, the suggest module and core's static checks pass.
independent fact check core confirmed; one more trigger pathRe-ran the repro standalone and through gradle with assertions on and off, checked every cited line, commit and count, and with its own probe found that AnalyzingSuggester / FuzzySuggester with default options also throw for an exact-key lookup with 0 (assertions off). That path, and small wording fixes, are now in this doc.
cross-model fact check (OpenAI GPT-5.6, read-only) core sound; wording narrowedFound the diagnosis, history, framing and security call sound; asked for the exact trigger (one start arc offered to the queue), the reach of each suggester path, and softer wording on intent, novelty and releases, now applied.

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 notes550 words

Finding record

LFV-070, snapshot of 2026-11-06

Registry title
Util.shortestPaths with topN=0 throws NoSuchElementException
Area
util / FST
Priority
Low, rank 1120: Util.shortestPaths and some suggesters throw NoSuchElementException for a result count of 0, a loud boundary input with a local fix ready.
Impact class
crash
Impact note
Only callers that ask for 0 results (Util.shortestPaths, FreeTextSuggester.lookup(key, 0), WFSTCompletionLookup.lookup(.., 0)); released since 4.1.
Summary
You ask Util.shortestPaths for the top 0 paths, directly or through FreeTextSuggester.lookup(key, 0). You expect an empty result. Instead it throws NoSuchElementException; with assertions off, WFSTCompletionLookup.lookup(prefix, false, 0) throws the same for a prefix that is not itself a key (for an exact key it returns one result, an older, separate quirk).
Introduced
LUCENE-4480 (0e77eec4366) introduced the crash; LUCENE-4481 (dc49949558d) gave the current form [exec: single-commit builds: the parent 1cadf9c5bb28 returns an empty result for topN = 0, 0e77eec4366 throws, 7e45db75e3a (LUCENE-4481 step 1) returns an empty result, dc49949558d throws] (2012-10-12 (0e77eec4366))
First affected version
4.1.0 [exec: a probe throws on 4.1.0 and returns an empty result on 4.0.0, the last unaffected release; single-commit builds bisect it to 0e77eec4366, LUCENE-4480]
Affected versions
every release from 4.1.0 to 10.6.0, and main [exec: the one-test minimal repro fails on the 9.12.3, 10.0.0, 10.5.2 and 10.6.0 release jars (SHA-1 checked against Maven Central) and on main 645d4fe14a20; probes throw on 4.1.0, 4.6.0, 4.7.2, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4 and 9.0.0; read: all 121 release tags from 4.1.0 to 10.6.0 have the same unguarded check]; 4.0.0 is not affected [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (study searches 2026-10-22; public-list check 2026-10-20..23)
Lucene commit tested
main 645d4fe14a20: the minimal repro with assertions on and off, standalone and through gradle; the recommended fix in a scratch worktree with org.apache.lucene.util.fst.*, the whole suggest module and :lucene:core:check -x test. SHA-1-checked release jars: the minimal repro on 9.12.3, 10.0.0, 10.5.2 and 10.6.0; version-specific probes on 4.0.0 (clean), 4.1.0, 4.6.0, 4.7.2, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4 and 9.0.0; single-commit builds of the 2012 introducing commits and their parent. Util.java, FreeTextSuggester.java and WFSTCompletionLookup.java are byte-identical from 645d4fe14a20 to upstream main 2f424fd428da (2026-10-30, read).

Lucene and upstream sources

The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/fst/Util.java:41-43, :181-188, :209-252, :264-292, :294-410, :455-473 (at 645d4fe14a20)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FreeTextSuggester.java:426, :607-617, :642; fst/WFSTCompletionLookup.java:156, :198; analyzing/FreeTextSuggester.java:526-561; analyzing/AnalyzingSuggester.java:670, :769-777, :785-786, :819-821; analyzing/FuzzySuggester.java:66; document/NRTSuggester.java:159; document/TopSuggestDocsCollector.java:77-78 (at 645d4fe14a20)
Who is affected
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:190, :206, :219 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/fst/Util.java:185-186, :310, :409, :455; lucene/core/src/java/org/apache/lucene/util/fst/package-info.java:82-95 (at 645d4fe14a20)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/Lookup.java:190, :206, :219; analyzing/AnalyzingSuggester.java:670, :731-735; fst/WFSTCompletionLookup.java:156; document/TopSuggestDocsCollector.java:77-78 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/util/fst/TestFSTs.java:1341-1347, :1442-1448, :1517, :1641 (at 645d4fe14a20)
Suggested fix
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:731-735; fst/WFSTCompletionLookup.java:189; document/TopSuggestDocsCollector.java:77-78 (at 645d4fe14a20)

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

Repro files

About the diagrams

Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.

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.