All findings · Lucene finding · LFV-021 · finitestringsiterator-empty-string-twice

FiniteStringsIterator returns the empty string twice

Listing the strings of a small deterministic automaton such as a? with FiniteStringsIterator gives "", "a", "": the empty string comes back twice, and LimitedFiniteStringsIterator.size() says 3 instead of 2. No error, in every release since 5.3.0.

Summary

Priority
Low, rank 1148FiniteStringsIterator returns the empty string twice, an experimental API whose in-tree callers never build that shape, with a mild off-by-one effect.
Impact
silent wrong results
Since
5.3.0 (LUCENE-6365, 2015-07-04)introduced 2015-07-04 · over 11 years
Affected
5.3.0 to 10.5.2 and main (all 103 releases run)
Status
documented
Found by
Jazzer fuzzing with exact set oracles

AI-assisted page: this finding was first signalled by a testing baseline (randomized, fuzz, property-based or stress testing, or static analysis) 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

FiniteStringsIterator lists every string an acyclic automaton accepts. For a? that is two strings, "" and "a"; Lucene returns three.

Summary card titled 'Lucene FiniteStringsIterator returns the empty string twice': a deterministic automaton accepts "" and "a"; next() returns "", "a", "", and LimitedFiniteStringsIterator.size() is 3, with no error. Panel 1, the example: state 0 (accepting) with a transition a to state 1 (accepting), isDeterministic() true; expected "" and "a"; actual "", "a", "" (duplicate); size() expected 2, actual 3. Panel 2, inside FiniteStringsIterator, two paths return "": line 86, emitEmptyString = a.isAccept(0) is true; lines 89-92, state 0 has a transition and is pushed as nodes[0]; call 1 returns EMPTY (path 1); call 2 follows a to state 1, which accepts, and returns "a"; call 3 pops nodes[0], depth 0, so the string is ""; lines 142-144, isAccept(0) returns it again (path 2); call 4 finds the stack empty and returns null. Panel 3, when it happens: the start state accepts and has a transition; makeEmptyString() (no transition) gives one ""; a start state that does not accept, {"a", "b"}, gives "a", "b"; line 33, 'If the automaton is not determinized then it's possible this iterator will return duplicates.' This one is determinized. Footer: executed on main ea57360912ed with assertions on and off and on every release from 5.3.0 to 10.5.2; FiniteStringsIterator is @lucene.experimental and no in-tree caller is known to build this shape.
The automaton for {"", "a"}, the strings expected and returned, and the two places inside FiniteStringsIterator that both return "". Select the still to open it at full size. Generated with AI, checked against the code.
  1. Build an automaton that accepts the empty string and something else, for example new RegExp("a?").toAutomaton() (isDeterministic() is true), and read all strings from new FiniteStringsIterator(a).
  2. You expect 2 strings, "" and "a", each once: the only case the javadoc names for duplicates is an automaton that is "not determinized", and this one is deterministic.
  3. You get 3: "", "a", "". LimitedFiniteStringsIterator(a, -1).size() is 3, and the union of "", a, ab, b gives 5 strings. No exception, with assertions on or off.
  4. Why: two code paths return "". A special case returns it first because state 0, the start state, accepts (FiniteStringsIterator.java:86). The start state is also the bottom of the iterator's path stack; popping it at the end leaves an empty string in an accepting state, and that is returned again (:139-145).
  5. With the one-argument constructor it needs a start state that accepts and has a transition: makeEmptyString() and a|b are correct. The extra "" is always last, so a limit no larger than the true count hides it.

Who is affected

Code that calls the experimental FiniteStringsIterator or LimitedFiniteStringsIterator directly on an automaton whose start state accepts and has a transition, such as a?, (ab)? or a word list that includes "" with other words. It gets one extra "" and a count one too high. No in-tree caller is known to build that shape; low severity.

Mechanism, terms and numbers753 words

FiniteStringsIterator lists the strings that an acyclic automaton accepts. When the start state accepts and also has an outgoing transition, as for a?, (ab)? or a string union that includes "" with other strings, the iterator returns "" twice: first, and again as the very last result. For new RegExp("a?").toAutomaton(), which reports isDeterministic() == true, it returns "", "a", "", and LimitedFiniteStringsIterator(a, -1).size() is 3. The only case the class javadoc names for duplicates is an automaton that is "not determinized". Two code paths produce "": a special case for an accepting state 0, and the pop branch, which returns the current string whenever it pops an accepting state; popping the start state leaves the empty string. It has been there since the class was added (LUCENE-6365, 2015): every release from 5.3.0 to 10.5.2 and main. The API is @lucene.experimental and no in-tree caller is known to build such an automaton.

Terms

  • Accepted strings: the finite set of strings an automaton without cycles accepts. a? accepts "" (zero a) and "a".
  • Start state: state 0 ("State 0 is always the initial state", Automaton.java:37-38); the three-argument constructor takes a startState. The empty string is accepted exactly when the start state accepts.
  • FiniteStringsIterator (public, @lucene.experimental): "Iterates all accepted strings"; the only case it names for duplicates is "If the automaton is not determinized" (FiniteStringsIterator.java:26, 33). LimitedFiniteStringsIterator stops after limit strings and counts them in size().

The bug (code at ea57360912ed)

  1. Path 1, the special case. The constructor sets emitEmptyString = a.isAccept(0) (:86), and the first next() returns EMPTY (:102-106, comment "Special case the empty string, as usual").
  2. The start state is also on the path stack. If it has an outgoing transition, the constructor pushes it as the bottom node and sets the string length to 1 (:88-93).
  3. Strings come out in post-order. A transition into an accepting state with no transitions returns the string (:130-132). When a node runs out of transitions it is popped: depth--; string.setLength(depth); and if (a.isAccept(state)) return string.get(); (:134-145). That is how a string ending at an inner accepting state comes out, such as "x" in {"x", "xy"}, so the branch is needed.
  4. Path 2, popping the start node. It is popped last: depth becomes 0, the string is empty and the start state accepts, so "" is returned again, always as the final result before null.

The two-state automaton of {"", "a"} (state 0 accepts, 0 -a-> 1, state 1 accepts; a? behaves the same):

callwhat happensreturns
constructoremitEmptyString = true; state 0 has a transition, so it is pushed
1the special case""
2a leads to state 1, which accepts and has no transitions"a"
3state 0 has no more labels: pop, depth 0, isAccept(0)"" again
4the stack is emptynull

When (one-argument constructor, which starts at state 0). State 0 accepts and has at least one transition. Without a transition it is never pushed (makeEmptyString() gives "" once); if it does not accept, neither path fires. The language need not hold a second string: an accepting state 0 whose only transition leads to a non-accepting dead end accepts only "", and it too returns "" twice (such an automaton is not minimal, so this case is weaker). The three-argument constructor checks state 0 for the special case but startState for the push and the pop: there a duplicate needs both to accept, and if only state 0 accepts it returns a "" that is on no path from startState (a side finding, see the fixes).

The numbers (main, 10.5.2 and 9.12.3; main also with -da)

automatonexpectedactual
new RegExp("a?").toAutomaton()23: "", "a", ""
new RegExp("(ab)?").toAutomaton()23: "", "ab", ""
Automata.makeStringUnion(["", "a", "ab", "b"])45: "", "ab", "a", "b", ""
LimitedFiniteStringsIterator(a?, -1).size()23
controls a|b and makeEmptyString()2 and 12 and 1

All of them report isDeterministic() == true. With a limit: for {"", "a"} limits 1 and 2 are right and 3, 4, 5 and -1 return the duplicate; for {"", "a", "ab", "b"} limits 1 to 4 are right and 5 or -1 return 5 strings. LimitedFiniteStringsIterator counts every non-null result (LimitedFiniteStringsIterator.java:56-69), so size() counts the duplicate; that is a consequence, not a second defect.

Exposure and likelihood323 words
  • Who is hit. Direct callers of the public, @lucene.experimental iterators on a finite automaton whose start state accepts and has a transition: an optional pattern (a?, (ab)?), a makeStringUnion word list that includes "" with other words, or a hand-built automaton of that shape.
  • What they see. One extra "", always last, and a count one too high. With the one-argument constructor, drained to the end, the set of distinct strings is right; only the multiplicity and the count are wrong. Code that uses the strings as a Set or as an OR of alternatives is unaffected; code that counts them, keeps a list, weights them, builds one clause per string or relies on size() gets one too many. (The separate three-argument side finding can add a "" that is not accepted from startState; that one changes the set.) No exception, with assertions on or off.
  • In-tree callers (read, not executed): GraphTokenStreamFiniteStrings (used by QueryBuilder for graph token streams), ConcatenateGraphFilter, FuzzyCompletionQuery, FuzzySuggester and AnalyzingSuggester. The study verifier argued that they build their automata from token streams whose start state accepts only when it has no transitions, so none reaches the bug. Their tests pass with and without the fix.
  • Downstream. A 2020 pull request to one downstream search engine uses the iterator for regex support in a wildcard field type; whether that code is still in use, and whether it can see an automaton of this shape, was not checked.
  • Default vs opt-in. No flag is needed, but only direct users of this experimental API are exposed.
  • Scale. None: a two-state automaton is enough.
  • Long-standing. Every release since 5.3.0 (2015).
  • Workaround. Collect the strings into a Set, or skip a second "" (the duplicate always comes after the first one, as the last result; a lone "", as from makeEmptyString(), is not a duplicate); do not rely on LimitedFiniteStringsIterator.size() for such automata.

Minimal repro

The test shows that FiniteStringsIterator over the deterministic 2-state automaton {"", "a"} (start state accepting, one transition) returns the empty string twice.

TestFiniteStringsIteratorEmptyString.java (JUnit test)44 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.automaton;

import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.IntsRef;

public class TestFiniteStringsIteratorEmptyString extends LuceneTestCase {

  public void testEmptyStringReturnedOnce() {
    Automaton a = new Automaton();
    int s0 = a.createState();
    int s1 = a.createState();
    a.setAccept(s0, true);
    a.setAccept(s1, true);
    a.addTransition(s0, s1, 'a');
    a.finishState();
    assertTrue(a.isDeterministic());

    List<String> strings = new ArrayList<>();
    FiniteStringsIterator it = new FiniteStringsIterator(a);
    for (IntsRef s = it.next(); s != null; s = it.next()) {
      strings.add(new String(s.ints, s.offset, s.length));
    }
    // accepts {"", "a"}: expected 2 strings, actual 3 ("" is returned again at the end)
    assertEquals("strings: " + strings, 2, strings.size());
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-021/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.util.automaton.TestFiniteStringsIteratorEmptyString
Expected vs actual
Expected 2 strings ("" and "a"); actual 3: strings: [, a, ] expected:<2> but was:<3> (-ea and -da).

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

More files, environment, full output37 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: strings: [, a, ] expected:<2> but was:<3>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.util.automaton.TestFiniteStringsIteratorEmptyString.testEmptyStringReturnedOnce(TestFiniteStringsIteratorEmptyString.java:42)

Why it looks unintended

  • The javadoc: "Iterates all accepted strings"; it names duplicates only for an automaton that is "not determinized". These are deterministic and minimal.
  • Lucene's own test helper asserts that no string is emitted twice; no test that checks for duplicates uses "".
  • The API it replaced returned a Set; the 2015 discussion of duplicates was only about non-determinized input.
The full argument444 words

The contract, in its own words (at ea57360912ed):

  • FiniteStringsIterator javadoc: "Iterates all accepted strings." (FiniteStringsIterator.java:26) and "If the automaton is not determinized then it's possible this iterator will return duplicates." (:33). That sentence names a sufficient condition, not an "only if"; but every failing automaton reports isDeterministic() == true and is minimal, so the one case it names does not apply.
  • Automata.makeStringUnion: "Returns a new (deterministic and minimal) automaton" (Automata.java:634); TestRegExpParsing.testOptional asserts that a? is a minimal DFA (TestRegExpParsing.java:521).
  • LimitedFiniteStringsIterator.size(): "Number of iterated finite strings" (LimitedFiniteStringsIterator.java:71).
  • Lucene's test helper: // Check that no string is emitted twice. followed by the assertion (TestFiniteStringsIterator.java:196-200).
  • The code has one explicit empty-string path, labelled "Special case the empty string, as usual" (:102); the second "" comes from the general pop branch reaching the start node. The comment labels the special case; on its own it does not say that this is the only source.

Why the tests miss it. testRandomFiniteStrings1 uses strings of length 1 to 200 (TestFiniteStringsIterator.java:46); its oracle AutomatonTestUtil.getFiniteStringsRecursive adds a string only when a transition enters an accepting state, so it never holds "" (AutomatonTestUtil.java:539-540); TestOperations.getFiniteStrings collects into a HashSet (TestOperations.java:247-254). No test has an explicit empty-string case. Two random tests can build an automaton that accepts "" (TestLimitedFiniteStringsIterator.testRandomFiniteStrings, TestStringsToAutomaton.testRandom), but they check only that the run completes or that each string is expected, not how often it comes back.

History.

  • LUCENE-6365 ("Optimized iteration of finite strings", #7425; committed on 2015-07-04) added the class. The API it replaced, Operations.getFiniteStrings, collected results in a HashSet, produced "" only through the same isAccept(0) special case, and returned nothing from its pop branch; 5.0.0 to 5.2.1 give {"", "a"}.
  • The issue text changed the emission order on purpose: "Old: emit current node, if accept / recurse. New: recurse / emit current node, if accept." The new post-order step was not skipped for the start node, whose empty string the kept special case already returns.
  • Duplicates came up once (2015-07-05/06), for a test that fed a non-determinized union containing the same string twice. The patch author noted that "The de-duplication has prior been implemented by the big set containing the result, which has been removed"; a committer (mikemccand) replied that "if you pass a non-minimal automaton to it, it's fair game for it to return dups ... so this is a test bug". The javadoc sentence at :33 was added right after (f0b550a092c).
  • None of the 44 comments on #7425 mentions the empty string or the start state.
The devil's-advocate check268 words

An independent reviewer from another model family (OpenAI GPT-5.6), asked to refute the claim, made the strongest case for "intended" and rated it "moderate": the API is @lucene.experimental; "Iterates all accepted strings" does not literally say "exactly once"; the iteration order is unspecified; and the LUCENE-6365 discussion says the result Set, and with it de-duplication, was removed on purpose, so callers could be expected to tolerate repeated values. LimitedFiniteStringsIterator.size() literally counts what was iterated, so 3 is "right".

Why it fails:

  1. The javadoc sentence about duplicates was written in response to that very discussion, and the only case it names is an automaton that is not determinized; the committer's "fair game" remark was about a non-minimal automaton. The sentence is a sufficient condition, not an "only if", so it does not by itself forbid duplicates elsewhere; but these automata are deterministic and minimal, and nothing in the javadoc or the discussion covers that case.
  2. The removed Set de-duplicated a string reached by two different paths of a non-deterministic automaton. Here there is one path, and the iterator returns it twice from its own bookkeeping.
  3. Lucene's own test helper asserts that no string is emitted twice. The tests that use it never give it an automaton that accepts "", and the random tests that can build one do not check how often a string comes back.
  4. @lucene.experimental permits API changes, not results that contradict the class's own javadoc.

The reviewer itself reproduced the bug with its own RegExp("x?") test, agreed on the root cause and that the inputs are in contract, and concluded real, high confidence.

Suggested fix

Recommended Return "" once, from the start state

Take the empty string from startState instead of state 0 in the constructor, and in the pop branch return the string only when depth > 0. With it the repro and the new regression tests pass, and so do the whole :lucene:core suite (8,873 tests, 0 failures, 322 skipped) and the suggest, queryparser and ConcatenateGraphFilter tests.

// constructor (was: this.emitEmptyString = a.isAccept(0);)
this.emitEmptyString = a.getNumStates() > startState && a.isAccept(startState);

// pop branch in next() (was: if (a.isAccept(state)) {)
// depth == 0: we popped the start node; "" was already returned by the special case
if (depth > 0 && a.isAccept(state)) {
  return string.get();
}
All options and trade-offs3 options ยท 676 words

One option was validated with gradle in a scratch worktree at ea57360912ed (removed after the runs); the study's one-line variant was validated and fails one case; the third option was not run.

Return "" once, from the start state (validated by the checks) (recommended)

Two lines in FiniteStringsIterator. With the one-argument constructor (the only one LimitedFiniteStringsIterator offers), "" stays the first result, as today, so a small limit keeps its current output; only the trailing duplicate disappears. Regression tests for TestFiniteStringsIterator and TestLimitedFiniteStringsIterator are in the repro section.

// constructor (was: this.emitEmptyString = a.isAccept(0);)
this.emitEmptyString = a.getNumStates() > startState && a.isAccept(startState);

// pop branch in next() (was: if (a.isAccept(state)) {)
// depth == 0: we popped the start node; "" was already returned by the special case
if (depth > 0 && a.isAccept(state)) {
  return string.get();
}

Trade-offs

  • With the three-argument constructor the startState change alters three more cases (state 0 is not startState; executed on stock and with the fix): (1) startState accepts and has a transition, state 0 does not accept: stock returns the correct "" last (["a", ""]), the fix first (["", "a"]), and the one-line variant drops it (["a"]); (2) the same startState with no transition: stock returns nothing, the fix returns [""], the zero-length path from an accepting startState; (3) startState does not accept but state 0 does (a separate side finding): stock returns a "" that is on no path from startState, the fix does not. Only GraphTokenStreamFiniteStrings uses that constructor in-tree; its tests pass with the fix, and the study found no impact there.
  • It does not define every three-argument case: endState is checked only when a transition reaches it (:117, :130), so with startState == endState neither stock nor the fix treats the zero-length path as ending at endState, and all regression tests use endState == -1 (read). The fix is validated for LFV-021 and the existing callers, not for every (startState, endState) combination.
  • Cost: no asymptotic change. The fix adds one comparison per pop and a constant-time guard in the constructor (not benchmarked); today a consumer pays for one extra result and one extra next() call per drained iterator.

What the checks validated

  • The JUnit repro and the regression tests (3 classes, 21 tests, seed DEADBEEF): stock 5 failures, with assertions on and off; with the fix 0.
  • Assertions off, seed 1234ABCD: util.automaton.*, util.graph.*, TestQueryBuilder, TestUnicodeUtil: 294 tests, 0 failures.
  • :lucene:suggest:test 236 tests, :lucene:queryparser:test 533, TestConcatenateGraphFilter* 18: 0 failures.
  • The whole :lucene:core:test with the fix, the regression tests and the repro: 748 classes, 8,873 tests, 0 failures, 322 skipped.
  • :lucene:core:checkGoogleJavaFormat passes (tests formatted with tidy).
  • Through the repro runner earlier: 142 core tests (seeds DEADBEEF and 1234ABCD) and 52 caller tests (seed DEADBEEF) pass stock and patched.

Only guard the pop branch with depth > 0 (not recommended) (validated by the checks)

The study's one-line variant: if (depth > 0 && a.isAccept(state)), keeping emitEmptyString = a.isAccept(0).

Trade-offs

Fixes the repro, but for new FiniteStringsIterator(a, 1, -1) on 0 -x-> 1 (accept) -a-> 2 (accept) it drops the "" that stock Lucene returns correctly, because the special case still looks at state 0. It also leaves the separate state-0 side finding unfixed.

What the checks validated

With gradle: 21 tests, 1 failure, testEmptyStringFromAcceptingStartStateOtherThanZero expected:<2> but was:<1>. Through the repro runner the two start-state checks fail 2 of 2 (the dropped "", expected:<[, a]> but was:<[a]>, and the side-finding "", expected:<[b]> but was:<[, b]>); stock fails only the second.

Drop the up-front special case and return "" only from the pop branch (not run)

Keep the special case only for a start state with no transitions (it is then never pushed).

Trade-offs

Also returns "" once, but last instead of first. The javadoc allows any order, but it changes which strings LimitedFiniteStringsIterator keeps at its limit (for a? with limit 1: "a" instead of ""). Not recommended.

How it was found

Fuzzing with Jazzer: an AI tool ran a target that checks FiniteStringsIterator and other automaton utilities against exact set oracles, and it flagged a duplicate "". A separate AI verifier wrote its own tests, traced the root cause and read the 2015 discussion.

The method in full143 words

Method: fuzzing with exact set oracles (Jazzer 0.30.0), the study's non-formal baseline tier, 2026-10-22, main 7cecf79523.

  • An AI tool ran the AutomatonMiscFuzzer target, which compares several automaton utilities with exact set oracles: <n-m> decimal intervals, makeBinaryInterval, makeStringUnion, FiniteStringsIterator, CompiledAutomaton.floor, finite concatenate and repeat(min, max). About 4.4 M executions in 10 + 20 minutes produced this finding. The study's attribution record classes it as a blind find.
  • A separate AI verifier wrote its own 6-test JUnit (4 fail, the controls pass), ran 7.4.0, 8.11.4, 10.1.0 and main, confirmed the root cause with a one-line patch, and read the LUCENE-6365 discussion.
  • On 2026-10-26 the hunt re-ran it on ea57360912ed (-ea, -da, 10.5.2), obtained a cross-model refute review, built the release matrix and searched for prior reports. For this page the fix was validated with gradle.

Prior reports, history and checks

Prior reports

No report found in GitHub issues and PRs, the JIRA archive, the dev@, java-user@, issues@ and commits@ lists, three downstream trackers or the web (2026-10-26). Not fixed upstream. The closest item is LUCENE-6365 (#7425), which added the iterator; its duplicates discussion was about non-determinized input.

Searches5 searches

No report of this bug was found, and upstream has not fixed it: the last functional change to FiniteStringsIterator.java is #13721 (2024), the last commit to LimitedFiniteStringsIterator.java is #13060 (2024), and no open PR touches either file. Closest items, none of them this bug: #7425 / LUCENE-6365 (added the iterator; duplicates discussed only for a non-determinized union), #13721 (the last functional change, a push guard), #8689 / LUCENE-7638 (added endState), #8654 / LUCENE-7603 and LUCENE-8332 (added two in-tree callers), #12458 (invalid UTF-8), #6690 / LUCENE-5628 (the old Set-based API), #4184 / LUCENE-3111 (an FST-builder "empty string twice" bug from 2011), elastic/elasticsearch#55548 (a downstream user; no duplicate reported).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), via a scripted prior-report search2026-10-2653 queries: FiniteStringsIterator, LimitedFiniteStringsIterator (also in titles), emitEmptyString, getFiniteStrings, LUCENE-6365, LUCENE-7638, startState, endState; "empty string twice", "returns duplicates", "accepts the empty string"; the callers (AnalyzingSuggester, FuzzySuggester, ConcatenateGraphFilter, GraphTokenStreamFiniteStrings, maxGraphExpansions)no report; #7425 is the only high-overlap hit
Commits, downstream, mailing lists, JIRA, web (same tool)2026-10-263 commit queries, 2 downstream, 48 on dev@, java-user@, issues@ and commits@, 10 in the JIRA archive, 15 web; 131 queries in total, 0 errorsnothing about this bug
Manual searches2026-10-2647 GitHub GraphQL queries (symptom phrases, helper and test names, in:comments, is:open, apache/lucene-solr PRs, downstream phrasings); 28 date-bounded lists.apache.org queries; 8 JIRA REST queries; all 44 comments of #7425 readunrelated items only
Open PRs and branches2026-10-26changed files of all 413 open PRs; the history of both files on main, branch_10x, branch_10_6, branch_10_5 and branch_9xno open PR touches either file (#16239 and #14262 change the caller GraphTokenStreamFiniteStrings)
Earlier searches2026-10-20 to 2026-10-26the study (16 GitHub phrasings, JIRA, web), the public-list check and the candidate stepno report

History

Introduced on 2015-07-04 by LUCENE-6365 (9a3ec6ce777 on branch_5x, first released in 5.3.0; trunk twin 3d8bfad40b0). A probe on all 103 releases from 5.3.0 to 10.5.2 and on main: all affected. 5.0.0 to 5.2.1 have no such class, and the old API returned a Set.

Versions tested6 rows

LUCENE-6365 ("Optimized iteration of finite strings"; credited in CHANGES as "Markus Heiden via Mike McCandless") was committed twice on 2015-07-04 as "switch to iterator API to get all finite strings from an Automaton", with byte-identical files: 9a3ec6ce777 on branch_5x (first released in 5.3.0) and, about 20 minutes earlier, 3d8bfad40b0 on trunk (first released in 6.0.0). git blame on main still credits the special case and the pop-branch lines to 3d8bfad40b0. Later changes (the duplicates javadoc sentence, LUCENE-7638's startState/endState constructor, #13721's push guard, formatting, a typo fix) kept both empty-string paths.

Every row ran a main() probe (three bug automata, two controls) on the release's Maven Central jars, SHA-1 checked.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed (unreleased 11.0) affected"", "a", "" and size() 3, with -ea and -da; the JUnit repro fails 3 of 5
5.3.0 to 10.5.2 (all 103 releases) affected"", "a", "" and size() 3 in every release; -da checked on 5.3.0 and 10.5.2
5.0.0, 5.1.0, 5.2.0, 5.2.1 not affectedno FiniteStringsIterator; the old Operations.getFiniteStrings returns the Set {"", "a"}
5.2.1 + the two files of 9a3ec6ce777 forward checkthe new class alone brings the bug
5.3.0 with only depth > 0 added to the pop branch root causecorrect: ["", "a"], size() 2
main ea57360912ed with the fix fixthe repro and the regression tests pass; the whole core suite passes (8,873 tests, 0 failures, 322 skipped)

Independent checks

The study's verifier, the public-list rerun, the hunt's re-verification and a cross-model refute review (with its own RegExp("x?") test) all reproduced it; the reviewer rated it real with high confidence. For this page the fix was validated with gradle.

Checks and verdicts366 words

Each check worked from the code or its own runs; none disagreed on whether it is a bug.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier (fuzzing/PBT) new bug, severity lowIts own 6 tests: 4 fail, the controls pass. Ran 7.4.0, 8.11.4, 10.1.0 and main, confirmed the root cause with a one-line patch (121 in-tree tests pass with and without it), and read the LUCENE-6365 discussion.
Public-list rerun reproduces on b83101cdd26 tests, 4 failures, the same tests as the study.
Hunt verification realThe 5-test JUnit fails 3 of 5 on main with -ea and -da and on 10.5.2 with both; a main() check fails on main, 9.12.3 and 5.3.0; the fix makes the repro pass.
Cross-model refute review real, high confidenceAn independent reviewer from another model family (OpenAI GPT-5.6) wrote its own test on new RegExp("x?").toAutomaton(): ["", "x", ""]. The hunt re-ran that test: it fails on main with -ea and -da and on 10.5.2, and passes with the fix. The reviewer rated the intended-behaviour case moderate and rejected it.
History follow-up introduced by LUCENE-6365, first released in 5.3.0107 releases plus main, a forward check on 5.2.1 and a root-cause check on 5.3.0.
Prior-report follow-up new131 tool queries plus manual searches; nothing found.
This page's own runs reproduced; fix validatedGradle in a scratch worktree: stock 3 of 5 repro tests and 2 of 3 new regression tests fail (also with assertions off); with the fix the whole core suite (8,873 tests, 0 failures, 322 skipped), suggest, queryparser and the ConcatenateGraphFilter tests pass. The plain program gives the same output on main, with -da, on 10.5.2 and on 9.12.3.
Fact checks of this page bug stands; wording fixedAn independent fact check (re-ran every repro), a cross-model fact check (OpenAI GPT-5.6) and a diagram check. None changed a conclusion; their findings (a seed count, the trigger for the three-argument constructor, the fix's effect there, the javadoc read as an "only if", the cost wording, the test-coverage wording, a dead-end trigger, the option-B run) are fixed here, with new runs, as listed in the hunt's record.

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

Finding record

LFV-021, snapshot of 2026-11-06

Registry title
FiniteStringsIterator returns the empty string twice
Area
util / automaton
Priority
Low, rank 1148: FiniteStringsIterator returns the empty string twice, an experimental API whose in-tree callers never build that shape, with a mild off-by-one effect.
Impact class
silent wrong results
Impact note
Default configuration; only automata that accept the empty string, iterated with FiniteStringsIterator; released since 5.3.0.
Summary
You iterate the finite strings of an automaton that accepts the empty string with FiniteStringsIterator. You expect each accepted string once. Instead the empty string is returned twice, and size() counts the duplicate.
Introduced
3d8bfad40b0 (LUCENE-6365, iterator rewrite) (2015-07-04)
First affected version
5.3.0 [read: LUCENE-6365 released in 5.3.0]
Affected versions
5.3.0+ [read]; 7.4.0, 8.11.4, 10.1.0 and main [exec]; 5.0.0 has no FiniteStringsIterator
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 baseline: fuzzing/PBT
Upstream and prior reports
none found (verifier gh searches 2026-10-22; public-list check 2026-10-20..23)
Lucene commit tested
main ea57360912ed (commit 2026-10-24; tested 2026-10-26): the JUnit repro and new regression tests with gradle in a scratch worktree, stock (assertions on and off) and with the fix, plus the whole :lucene:core suite, suggest, queryparser and TestConcatenateGraphFilter* with the fix (the worktree was removed after the runs); the JUnit repro through the repro runner with -ea and -da on main and 10.5.2; a plain-Java program on main (-ea, -da), 10.5.2, 9.12.3 and the fix; a probe on the Maven Central jars of every release from 5.0.0 to 10.5.2 (SHA-1 checked). The study reproduced it on 7cecf79523 and the public-list rerun on b83101cdd2; upstream main HEAD was still ea57360912ed at 2026-10-26.

Lucene and upstream sources

The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/automaton/FiniteStringsIterator.java at ea57360912ed (lines 26, 31, 33, 35, 64-66, 76-94, 101-151)
  • lucene/core/src/java/org/apache/lucene/util/automaton/LimitedFiniteStringsIterator.java at ea57360912ed (lines 21-23, 56-74); lucene/core/src/java/org/apache/lucene/util/automaton/Automaton.java:37-38; Automata.java:634
  • lucene/core/src/test/org/apache/lucene/util/automaton/TestFiniteStringsIterator.java (testShortAccept: "x" in {"x", "xy"})
Who is affected
  • lucene/core/src/java/org/apache/lucene/util/graph/GraphTokenStreamFiniteStrings.java:133; lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConcatenateGraphFilter.java:166; lucene/suggest/src/java/org/apache/lucene/search/suggest/document/FuzzyCompletionQuery.java:168; lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FuzzySuggester.java:243; AnalyzingSuggester.java:416-417 (at ea57360912ed)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/automaton/FiniteStringsIterator.java:26, 33, 102 at ea57360912ed; Automata.java:634; lucene/core/src/java/org/apache/lucene/util/automaton/LimitedFiniteStringsIterator.java:71
  • lucene/core/src/test/org/apache/lucene/util/automaton/TestFiniteStringsIterator.java:46, 196-200; lucene/test-framework/src/java/org/apache/lucene/tests/util/automaton/AutomatonTestUtil.java:510-553; lucene/core/src/test/org/apache/lucene/util/automaton/TestOperations.java:247-254 (at ea57360912ed)
  • lucene/core/src/test/org/apache/lucene/util/automaton/TestRegExpParsing.java:515-525; TestLimitedFiniteStringsIterator.java:31-46; TestStringsToAutomaton.java:113-150; lucene/test-framework/src/java/org/apache/lucene/tests/util/TestUtil.java:1190-1200 (at ea57360912ed)

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.