All findings · Lucene finding · LFV-055 · automatontermsenum-nfa-dead-state

AutomatonTermsEnum throws ArrayIndexOutOfBoundsException for an NFA with a reachable dead state

An AutomatonQuery over a hand-made non-deterministic automaton whose dead branch ends in a state with no transitions throws ArrayIndexOutOfBoundsException on the doc-values, MemoryIndex and other non-default term paths once the enumeration reaches that dead end; the default inverted index returns the right hits. 10.0.0 to 10.6.0 and main are affected.

Summary

Priority
Low, rank 1142AutomatonTermsEnum throws ArrayIndexOutOfBoundsException for hand-built NFAs with a reachable dead state through non-block-tree consumers, and Lucene's own builders did not produce one in the study's checks.
Impact
crash
Since
10.0.0 (LUCENE-10010, #225, commit c972c6a7754e, 2021)introduced 2021-12-20 · about 4 years
Affected
10.0.0-10.6.0 and main
Status
documented
Found by
semi-formal: exhaustive translation validation

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

One document and one query are enough: the very first seek already walks into the dead state.

Background: automata and the term enumerator

  1. An automaton is a set of states joined by labelled transitions. A term matches if reading its letters leads from the start state to an accepting state. In a deterministic automaton (DFA) each letter leads to at most one next state; a non-deterministic one (NFA) may branch.
  2. A dead state is reachable but cannot reach any accepting state, so a term that enters it can never match. It may still have transitions (to itself or other dead states); a dead end is one with none. Most of Lucene's automaton operations (concatenate, repeat, union, intersection, complement, reverse) remove dead states from their results; optional and determinize do not.
  3. Since 10.0, AutomatonQuery runs an NFA directly, turning it into DFA states on the fly. On the doc-values, MemoryIndex and other non-default paths, AutomatonTermsEnum jumps through the sorted terms by building the next possible term from the smallest transitions.
Diagram of the bug. A 4-state NFA: from state 0, "a" leads to dead state 1, "b" leads to accepting state 2 and to state 3, and "c" leads from 3 to 2, so it accepts b and bc. One document holds the doc value b. AutomatonTermsEnum builds the next candidate term from the smallest transitions: it takes "a" into state 1, assumes a non-accepting state has a transition, and NFARunAutomaton.getNextTransition reads index 5 of a 5-entry table of missing transitions: ArrayIndexOutOfBoundsException. Expected: count 1, as block tree returns.
The NFA for {b, bc} with a dead branch "a": the enumerator follows the smallest transition, "a", into the dead state, finds no way out and reads past the end of its transition table. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build a 4-state NFA by hand. From the start, "b" leads both to an accept state and to a state that accepts after "c", so it matches {b, bc}. A third branch, "a", leads to state 1, from which no accept state can be reached and which has no transitions at all: a dead end.
  2. Index one document with the doc value "b" and count new AutomatonQuery(new Term("f"), nfa, false, MultiTermQuery.DOC_VALUES_REWRITE).
  3. You expect 1. The javadoc forbids dead states only for deterministic automata, and the same NFA over a StringField with the default codec counts 1.
  4. Instead the count throws ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5. The term enumerator builds candidate terms from the smallest transitions, so it follows "a" into state 1, assumes every non-accepting state has a way out, and reads past the end of its 5-entry transition table.
  5. SortedSetDocValues.intersect, MemoryIndex, FilterTerms wrappers and the SimpleText codec fail the same way (doc values and MemoryIndex also with assertions off). In a probe, the same NFA with a self-loop added on the dead state did not crash and counted correctly: the crash needs a dead end that the walk reaches. Lucene's own regexp, wildcard and fuzzy queries did not build such an automaton in the study's checks.

Who is affected

Only applications that build a non-deterministic automaton themselves (or return one from an AutomatonProvider) with a dead end the term walk reaches, and only on paths that enumerate with AutomatonTermsEnum: doc-values rewrite or intersect, MemoryIndex, FilterTerms wrappers, SimpleText. The default inverted index answers correctly. The query fails loudly; no wrong result was seen.

Mechanism, terms and numbers713 words

Since 10.0.0 CompiledAutomaton runs non-deterministic automata (NFAs) directly, and its javadoc forbids dead states only for deterministic ones. It does not remove them from an NFA it runs that way. AutomatonTermsEnum, which the doc-values rewrite, SortedSetDocValues.intersect, MemoryIndex and every Terms without its own intersect use, builds the next candidate term from the smallest transitions and assumes every non-accepting state has one. When the walk reaches a dead end, a dead state with no transitions, NFARunAutomaton.getNextTransition reads past the end of its transition table: ArrayIndexOutOfBoundsException, with assertions on and off. The default codec with the default rewrite (block tree) returns the right answer. Only applications that build such an automaton themselves are affected; calling Operations.removeDeadStates for NFAs in the CompiledAutomaton constructor fixes it.

Terms

  • CompiledAutomaton: the compiled form every AutomatonQuery (and RegexpQuery, WildcardQuery, ...) runs. For an NFA it keeps an NFARunAutomaton, which determinizes lazily: each DFA state is the set of NFA states the input could be in.
  • AutomatonTermsEnum: the generic term enumerator for an automaton. From the current term it constructs the next term that could still match, by appending the smallest transition of each state, and seeks the terms dictionary there. Block tree has its own IntersectTermsEnum instead.

The bug

  1. AutomatonQuery compiles the NFA with new CompiledAutomaton(automaton, false, true, isBinary) (AutomatonQuery.java:99). The NONE/ALL/SINGLE checks run only for DFAs ("simplify requires a DFA", CompiledAutomaton.java:159-160), so the NFA is NORMAL. Its UTF-8 form binary is non-deterministic too, so it is wrapped as is: new NFARunAutomaton(binary, 0x100) (:253-257; if either automaton were deterministic, the constructor would determinize binary instead, :258-261). Nothing removes dead states. The javadoc allows this: "For deterministic automaton, it must not have dead states ... For non-deterministic automaton, it will be executed using NFARunAutomaton" (:36-40).
  2. DOC_VALUES_REWRITE gives the query an anonymous Terms over the doc values (DocValuesRewriteMethod.java:98-104); getTermsEnum calls terms.intersect(compiled, null) (CompiledAutomaton.java:348-357), and the default Terms.intersect builds an AutomatonTermsEnum (Terms.java:64-86). SortedSetDocValues.intersect builds one directly (SortedSetDocValues.java:109-119).
  3. In NFARunAutomaton, the dead NFA state 1, a dead end, becomes the DFA state {1}. It has no outgoing transitions, so all 5 entries of its transition table (one per character class) are MISSING (NFARunAutomaton.java:399-403), and initTransition returns 0.
  4. The first seek: the empty term does not match, so nextString (called at :223) takes the smallest transition out of the start state, "a", into {1} (AutomatonTermsEnum.java:283-291). For a state that is neither visited nor accepting it then takes the next smallest transition without checking the count (:294-302), trusting the comment "we work with a DFA with no transitions to dead states ... if it is not an accept state, then there MUST be at least one transition" (:297-299).
  5. getNextTransition scans while (dState.transitions[++t.transitionUpto] == MISSING) (NFARunAutomaton.java:214) and reads index 5 of a 5-entry table. Its comment states the rule that was broken: "this shouldn't throw AIOOBE as long as this function is only called numTransitions times" (:215-216).

Which dead states trigger it: the walk stops at a state it has already visited, so the crash needs a dead end that the walk reaches. In the fact check's probe (doc-values rewrite, assertions on and off), the repro NFA throws; the same NFA with a self-loop on state 1 counts correctly (1, and 2 for the docs ax, axx, b, bc); {b, bc} with a dead end on "z", which sorts after every match, counts 1 for the doc "b" but throws for the docs "b" and "c"; an empty-language NFA ending in a dead end throws.

Block tree is fine for this repro because IntersectTermsEnum walks the terms that exist and takes a transition only when the state has one (IntersectTermsEnumFrame.java:123-137); here the dead end simply rejects.

The numbers

NFA 0 -a-> 1, 0 -b-> 2, 0 -b-> 3, 3 -c-> 2, state 2 accepting (language {b, bc}); one document with SortedSetDocValuesField("f", "b"). Expected count 1; actual ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5 at NFARunAutomaton.getNextTransition(NFARunAutomaton.java:214) from AutomatonTermsEnum.nextString(AutomatonTermsEnum.java:302). Controls: the same NFA over StringField("f", "b") with the default codec and default rewrite counts 1; DOC_VALUES_REWRITE over Operations.removeDeadStates(nfa) counts 1.

Exposure and likelihood317 words
  • Trigger. A non-deterministic automaton with a reachable dead end (a dead state with no transitions) that the term walk reaches, built by the application: Automaton / Automaton.Builder, an AutomatonProvider for RegExp <name> leaves, or Intervals.multiterm over a CompiledAutomaton. On main, since #15939 (11.0.0, unreleased), RegexpQuery no longer determinizes, so a provider-supplied NFA reaches NFARunAutomaton unchanged; the study's RegexpQuery + provider test fails the same way. That is most direct for a bare <name> leaf, whose automaton is returned as is; in composed expressions the cleaning operations usually remove dead states (code reading).
  • Paths. Everything that enumerates through AutomatonTermsEnum: MultiTermQuery.DOC_VALUES_REWRITE (the repro), SortedSetDocValues.intersect / SortedDocValues.intersect, MemoryIndex, FilterLeafReader.FilterTerms wrappers without their own intersect, and postings formats without one. The default rewrite on a StringField with the SimpleText codec throws the same exception. The test framework's random codec can route even the default rewrite here: the cross-model reviewer's own default-rewrite control failed under it.
  • Not affected. The default codec with the default rewrite (block tree) counts 1. Lucene's own builders, in the study's checks: no reachable dead state in any RegExp AST up to size 5 (1,127,357 ASTs) or in about 100k RegExp and 173k WildcardQuery NFAs from a random scan; LevenshteinAutomata (fuzzy) builds DFAs without dead states. concatenate, repeat, complement, intersection, union and reverse return removeDeadStates(...) of their result; optional ("This may create a dead state") and determinize do not, and the exhaustive RegExp check includes ?. Bounded evidence, not an API guarantee.
  • Effect. An unchecked ArrayIndexOutOfBoundsException for that query, with assertions on and off. No wrong results, corruption or hang in the recorded runs; the probe's variants that do not throw (a self-loop on the dead state, a dead end the walk never reaches) return the right count. An empty-language NFA that ends in a dead end throws too.
  • Scale. One document and one term are enough.

Minimal repro

One JUnit test, one method: build the 4-state NFA, index one document with the doc value "b", count with DOC_VALUES_REWRITE. Public API only, deterministic, single thread, about 1.5 s; fails with assertions on and off. It catches the exception only to print expected vs actual.

TestAutomatonQueryNfaDeadState.java (JUnit, one test)62 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;

import java.util.List;
import org.apache.lucene.document.SortedSetDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.automaton.Automaton;

public class TestAutomatonQueryNfaDeadState extends LuceneTestCase {

  public void testDocValuesRewriteOfNfaWithReachableDeadState() throws Exception {
    Automaton nfa = new Automaton();
    int s0 = nfa.createState();
    int dead = nfa.createState();
    int accept = nfa.createState();
    int s3 = nfa.createState();
    nfa.setAccept(accept, true);
    nfa.addTransition(s0, dead, 'a');
    nfa.addTransition(s0, accept, 'b');
    nfa.addTransition(s0, s3, 'b');
    nfa.addTransition(s3, accept, 'c');
    nfa.finishState();
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        w.addDocument(List.of(new SortedSetDocValuesField("f", new BytesRef("b"))));
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query q = new AutomatonQuery(new Term("f"), nfa, false, MultiTermQuery.DOC_VALUES_REWRITE);
        int count;
        try {
          count = new IndexSearcher(reader).count(q);
        } catch (ArrayIndexOutOfBoundsException e) {
          // Expected: count 1 (the doc with "b"); actual: AutomatonTermsEnum throws AIOOBE.
          throw new AssertionError("expected count 1, got " + e, e);
        }
        assertEquals(1, count);
      }
    }
  }
}
Command
cp TestAutomatonQueryNfaDeadState.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestAutomatonQueryNfaDeadState -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false instead (same result)
Expected
Count 1: the document with "b", as block tree returns for the same NFA and doc values return once the dead state is removed.
Observed
ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5 at NFARunAutomaton.getNextTransition(NFARunAutomaton.java:214), with assertions on and off.

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

More files, environment, full output216 words

The test sits in org.apache.lucene.search, next to the upstream TestAutomatonQuery; unchanged, it is the suggested regression test (it fits as a new method of TestAutomatonQuery). The doc-values field keeps it independent of the randomized postings format: DOC_VALUES_REWRITE always enumerates through AutomatonTermsEnum. It fails on stock main and on the 10.0.0, 10.5.2 and 10.6.0 release jars, and passes with fix A or fix B.

Not shown by the test (same AutomatonTermsEnum code, executed elsewhere): SortedSetDocValues.intersect, MemoryIndex, a FilterTerms wrapper, RegexpQuery with an AutomatonProvider (the study's 10-test file) and the SimpleText codec with the default rewrite (a path probe).

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); the same test also fails on the 10.0.0, 10.5.2 and 10.6.0 release jars
jdkOpenJDK 25.0.3
osLinux, aarch64
Command (environment)
cp TestAutomatonQueryNfaDeadState.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestAutomatonQueryNfaDeadState -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off: -Ptests.asserts=false instead (same result)

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (the same with =false)
TestAutomatonQueryNfaDeadState > testDocValuesRewriteOfNfaWithReachableDeadState FAILED
    java.lang.AssertionError: expected count 1, got java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:D6B9123FC978E9DF]:0)
        at org.apache.lucene.search.TestAutomatonQueryNfaDeadState.testDocValuesRewriteOfNfaWithReachableDeadState(TestAutomatonQueryNfaDeadState.java:56)

        Caused by:
        java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5
            at org.apache.lucene.util.automaton.NFARunAutomaton.getNextTransition(NFARunAutomaton.java:214)
            at org.apache.lucene.index.AutomatonTermsEnum.nextString(AutomatonTermsEnum.java:302)
            at org.apache.lucene.index.AutomatonTermsEnum.nextString(AutomatonTermsEnum.java:223)
            at org.apache.lucene.index.AutomatonTermsEnum.nextSeekTerm(AutomatonTermsEnum.java:142)
            at org.apache.lucene.index.FilteredTermsEnum.next(FilteredTermsEnum.java:230)
            at org.apache.lucene.search.DocValuesRangeIterator.buildOrdinalSet(DocValuesRangeIterator.java:152)
            at org.apache.lucene.search.DocValuesRangeIterator.forOrdinalSet(DocValuesRangeIterator.java:194)
            at org.apache.lucene.search.DocValuesRewriteMethod$MultiTermQueryDocValuesWrapper$1$2.get(DocValuesRewriteMethod.java:174)
            ...

1 test completed, 1 failed

Why it looks unintended

  • The javadoc allows it: CompiledAutomaton forbids dead states only in deterministic automata; non-deterministic ones "will be executed using NFARunAutomaton".
  • The answer depends on the path: block tree counts 1, doc values throw; without the dead state both count 1.
  • LUCENE-10010 (#225) dropped the old dead-state rule for NFAs and added no cleanup.
The full argument216 words
  • Public contract. CompiledAutomaton (@lucene.experimental): "The Automaton could either be deterministic or non-deterministic, For deterministic automaton, it must not have dead states ... For non-deterministic automaton, it will be executed using NFARunAutomaton" (CompiledAutomaton.java:36-40). AutomatonQuery: the automaton "can be constructed with the org.apache.lucene.util.automaton API", and "terms that are accepted are considered a match" (AutomatonQuery.java:29-42, :59-65), with no restriction. MIGRATE.md 10.0: these classes "no longer determinize behind the scenes. It is the responsibility of the caller to call Operations.determinize() for DFA execution" (lucene/MIGRATE.md:922-926), so an undeterminized NFA is meant to run as an NFA.
  • Path-dependent result. The same query counts 1 on block tree and throws on doc values, SimpleText or MemoryIndex. Nothing documents that.
  • History. Before #225 the javadoc read "The Automaton must be deterministic, must not have dead states but is not necessarily minimal" and the constructor rejected NFAs. #225 (c972c6a7754e, LUCENE-10010) removed the check, scoped the dead-state rule to DFAs and wrapped NFAs as they are; the "MUST be at least one transition" loop stayed. In the LUCENE-10010 discussion rmuir wrote that the current algorithm "requires not just DFA, but also no dead states" (#11049).
  • Maintainers clean dead states out of NFAs. #14218 added AutomatonTestUtil.assertCleanNFA; #14211/#14212 stopped reverse() from adding dead states.
The devil's-advocate check158 words

Strongest case for "intended": AutomatonTermsEnum is @lucene.internal and its precondition is written in the code (private nextString javadoc, AutomatonTermsEnum.java:190-191, and the comment at :297-299). For DFAs the same input is a documented violation, Lucene's own producers did not create such an NFA in the study's checks, and the same input threw in every sampled release from 4.0.0 to 9.12.3. A maintainer could call it garbage in and only document the rule.

Why it fails: users do not call AutomatonTermsEnum directly; they call public AutomatonQuery, CompiledAutomaton or SortedSetDocValues.intersect, whose javadoc limits the rule to DFAs. In 4.x to 9.x the javadoc did forbid dead states; #225 dropped that rule for NFAs without adding cleanup. Block tree accepts the same input, and the fix costs one liveness pass that returns the input instance when nothing is dead. At the least, code and docs disagree; the cross-model reviewer reached the same verdict. Verdict: a defect, low severity.

Suggested fix

Recommended A. Remove dead states from non-deterministic input in CompiledAutomaton

In the CompiledAutomaton constructor, call Operations.removeDeadStates when the automaton is not deterministic (one if); it returns the same instance when nothing is dead. The repro then passes with assertions on and off, and 22 related core test classes, the memory module and the core static checks pass.

   public CompiledAutomaton(
       Automaton automaton, boolean finite, boolean simplify, boolean isBinary) {
 
+    // AutomatonTermsEnum needs an automaton without dead states; an NFA is executed via lazy
+    // determinization, whose DFA states inherit any NFA dead state.
+    if (automaton.isDeterministic() == false) {
+      automaton = Operations.removeDeadStates(automaton);
+    }
+
     if (automaton.getNumStates() == 0) {
All options and trade-offs3 options ยท 747 words

Fix A and fix B were both run through gradle in a scratch worktree of 645d4fe14a20 (2026-11-01). A is recommended: it restores the guarantee AutomatonTermsEnum relies on for every consumer at once and makes the NFA path match the javadoc. B is cheaper on the normal path but fixes only the enumerator. C only documents the rule.

A. Remove dead states from non-deterministic input in CompiledAutomaton (validated by the checks) (recommended)

At the top of the 4-argument constructor, before the existing 0-state check (the study verifier's fix, re-applied to 645d4fe14a20).

   public CompiledAutomaton(
       Automaton automaton, boolean finite, boolean simplify, boolean isBinary) {
 
+    // AutomatonTermsEnum needs an automaton without dead states; an NFA is executed via lazy
+    // determinization, whose DFA states inherit any NFA dead state.
+    if (automaton.isDeterministic() == false) {
+      automaton = Operations.removeDeadStates(automaton);
+    }
+
     if (automaton.getNumStates() == 0) {

Trade-offs

  • Covers every consumer at once: doc values, MemoryIndex, FilterTerms, SimpleText, Intervals. Before the 0-state check, an all-dead NFA (empty language) reuses that check; the study's empty-language NFA then returns 0.
  • Cost: one liveness pass per compiled NFA (forward reachability, plus backward reachability over a reversed copy). The pass allocates these even when nothing is dead; only then does it return the input instance instead of a cleaned copy. Once per query construction, not per segment or term. On main, where #15939 and #15961 made RegexpQuery and WildcardQuery run NFAs directly, every non-deterministic regexp or wildcard automaton pays it although built-in NFAs are already clean.
  • If removing dead states makes the automaton deterministic, the DFA path and its NONE/ALL/SINGLE simplification take over, which is correct.
  • Scope: only non-deterministic input is cleaned; a DFA with dead states stays outside the documented DFA domain and can still fail. Cleaning before the UTF-8 conversion appears sufficient (UTF32ToUTF8.convert follows only reachable states; code reading, no dedicated test). AutomatonQuery keeps the caller's automaton for equals/toString, and its sharesAutomaton check counts a cleaned copy in RAM accounting.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20: the repro fails on stock main and passes with A, with -Ptests.asserts=true and =false.
  • With A: 22 core test classes run 222 tests, 0 failures, 2 skipped (@Nightly): the repro, TestAutomatonQuery, TestAutomatonQueryUnicode, TestRegexpQuery, TestRegexpRandom, TestRegexpRandom2, TestWildcardQuery, TestWildcardRandom, TestPrefixQuery, TestPrefixRandom, TestFuzzyQuery, TestDocValuesRewriteMethod, TestMultiTermQueryRewrites, TestTermRangeQuery, TestCompiledAutomaton, TestNFARunAutomaton, TestOperations, TestAutomaton, TestRegExp, TestDeterminism, TestTermsEnum, TestTermsEnum2. The memory module runs 51 tests, 0 failures. :lucene:core:check -x test passes. The full core suite was not run.
  • Earlier: the study's 10-test file passes 10/10; the cross-model reviewer's own test and TestCompiledAutomaton, TestAutomatonQuery and TestNFARunAutomaton pass with the fix shadowed.

B. Stop the walk in AutomatonTermsEnum at a state with no transitions (validated by the checks)

In AutomatonTermsEnum.nextString(int, int) (:301), seek to the prefix that led into a dead state instead of taking a transition it does not have.

-          transitionAccessor.initTransition(state, transition);
+          if (transitionAccessor.initTransition(state, transition) == 0) {
+            // a dead state, which an NFA may have: seek to this prefix, the terms dictionary and
+            // backtracking take it from there
+            return true;
+          }
           transitionAccessor.getNextTransition(transition);

Trade-offs

One comparison on the normal path. By code reasoning the prefix is still a valid lower bound: the terms dictionary seeks to the next real term, which is checked as usual, and the next nextString backtracks past the dead branch. Narrower: it fixes only AutomatonTermsEnum, its "no dead states" comment and javadoc need rewording, and a dead branch costs an extra seek. The tested cases pass; repeated seeks or loops are not ruled out by those suites, so broader randomized testing would be prudent before preferring B.

What the checks validated

Gradle, same worktree: the repro passes with B, with assertions on and off; the same 22 core test classes run 222 tests, 0 failures, 2 skipped. The memory module and the static checks were not run with B.

C. Document the rule for NFAs

Say in the CompiledAutomaton and AutomatonQuery javadoc that NFAs must not have dead states either (callers can use Operations.removeDeadStates), optionally with a check that throws IllegalArgumentException (Operations.hasDeadStatesFromInitial; hasDeadStates would also reject harmless unreachable states).

Trade-offs

Least behaviour change, but without the check the result still differs between block tree and the other paths; the check costs the same liveness pass as A. Not tested.

Downstream note

Downstream code that builds automata itself and runs them on doc values, MemoryIndex or wrapped readers can call Operations.removeDeadStates first until a fix ships; built-in regexp, wildcard and fuzzy parsing did not produce such automata in the study's checks.

How it was found

Semi-formal: the study checked CompiledAutomaton's term enumeration on a real index against an exact reference semantics for every automaton with at most 2 states over three labels (16,401 automata). 10 failed, all NFAs with a reachable dead state. An independent verifier and a cross-model reviewer then reproduced it.

The method in full140 words

Method: semi-formal (bounded-exhaustive end-to-end translation validation). The prior study's automaton harness compares Lucene's automaton code with an exact reference semantics. One check indexes 161 terms and, for every automaton with at most 2 states and labels {a}, {b}, [a-b] (16,401 automata) and their determinizations, compares the terms that getTermsEnum, AutomatonTermsEnum and Terms.intersect return with the exact accepted set. 10 of the 16,401 failed in 4 s; over all scopes the failures (158 code-point and 66 binary cases) were all NFAs with a reachable dead state.

The study's verifier wrote its own 10-test file (block tree correct; doc-values rewrite, SortedSetDocValues.intersect, MemoryIndex, a FilterTerms wrapper and RegexpQuery + provider fail), a plain main() without assertions, a scan of built-in producers, and the fix. The hunt then reduced it to the one-test repro and re-verified it on main and releases.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits (including all 409 open PRs), the JIRA archive, the Lucene mailing lists, downstream repositories or the web (2026-11-01). Not fixed upstream as of 2026-11-01.

Searches4 searches

Closest items, none a report of this bug: #11049 (LUCENE-10010 design discussion: the algorithm "requires not just DFA, but also no dead states"); #225 (the introducing PR; its review does not cover dead states in NFAs); #14218, #14211/#14212, #14210 (dead states created by Operations, not user NFAs); #12906/#12909 (a different NFARunAutomaton transition bug); OpenSearch #19319 (a concurrency AIOOBE in NFARunAutomaton, fixed under #15939); #8678, #8697, #8627 (SINGLE/ALL/NONE automata on the same paths); #13706/#13707, #11020 (isTotal, getCommonSuffix with dead states). Open PRs #16801, #16564, #13072, #16361 and #16816 touch the same files without changing the failing path.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch and Solr repositories; the web2026-11-01a scripted prior-report search (60 issue/PR, 6 commit, 5 downstream and 6 web queries; 366 distinct hits read by overlap) and 63 GraphQL phrasings, including the symptom texts ("Index 5 out of bounds", "at least one transition", "transitions to dead states")no report
Open PRs2026-11-01changed files of all 409 open apache/lucene PRs20 touch the affected files; none changes the NFA dead-state path
JIRA archive and mailing lists2026-11-016 JIRA-archive and 32 mailing-list queries (dev@, java-user@, issues@, commits@); full threads of #225, #11049, #485, #13715, #14210, #14211, #14218, #15939, #15961dead states in NFAs reaching AutomatonTermsEnum are not discussed
Study search2026-10-22about 20 phrasings in GitHub issue and PR search and two web queriesno report

History

Introduced in 10.0.0 by LUCENE-10010 (#225, c972c6a7754e, 2021), which let CompiledAutomaton run NFAs directly without removing dead states; 10.0.0 to 10.6.0 and main are affected. In 4.0.0 to 9.12.3 the same input also threw, but the javadoc then forbade dead states.

Versions tested6 rows

Introduced by c972c6a7754e "LUCENE-10010 Introduce NFARunAutomaton to run NFA directly (#225)" (by zhaih, merged 2021-12-20; CHANGES.txt 10.0.0, New Features). It removed the constructor's "Automaton must be deterministic" check, rewrote the javadoc so that only DFAs "must not have dead states", and wrapped NFAs as they are. Its parent e79012550444 rejects the NFA with IllegalArgumentException when the query is built; c972c6a7754e throws the AIOOBE. git tag --contains c972c6a7754e lists 10.0.0 to 10.6.0 and no 9.x tag.

Before 10.0: every sampled release from 4.0.0 to 9.12.3 also throws for the same input (AIOOBE in AutomatonTermsEnum in 4.0.0 to 4.9.1; AssertionError in Automaton.getNextTransition from 4.10.4, and AIOOBE Index -1 on 9.12.3 with assertions off), but there the javadoc said the automaton "must not have dead states". That crash dates from LUCENE-3030 (b7846ffaa1f, 2011, first released in 4.0.0-alpha), which dropped the minimize() call that used to remove dead states in the AutomatonQuery constructor. So on the same input it is not a regression; #225 made the input documented-legal.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (gradle and standalone); passes with fix A or B
10.0.0, 10.5.2, 10.6.0 first affected 10.0.0minimal repro fails, the same AIOOBE (getNextTransition:196)
c972c6a7754e (#225) introducing commitAIOOBE on the MemoryIndex and doc-values probes, assertions on and off
e79012550444 (parent of #225, unreleased) fails loudlyIllegalArgumentException: Automaton must be deterministic when the query is built
4.0.0 to 9.12.3 (sampled) fails loudlythe same input also throws, but the javadoc then forbade dead states
2011 trunk ea946d6d434d (before LUCENE-3030, unreleased) not affectedcorrect: minimize() removed dead states

Independent checks

Confirmed: the study's independent verifier, the main refresh, a re-verification, a refute-lens review by a reviewer from another model family (OpenAI GPT-5.6, real, high confidence) and this doc's gradle runs all reproduce it. Everyone agrees on the root cause and that the impact is minor: a loud exception for a hand-built automaton.

Checks and verdicts300 words

The cross-model reviewer wrote an independent test (language {m, mn} with a dead branch on "c"); it failed on main with assertions on and off and another seed, and the cleaned-NFA control passed. Its attempted default-rewrite control also failed, because the test framework's random codec picked a postings format that enumerates with AutomatonTermsEnum; re-run on 645d4fe14a20 it fails the same way.

Each independent check and its verdict
CheckVerdictWhat it found
study verifier real, low severityOwn 10-test file: 5 fail (doc-values rewrite, SortedSetDocValues.intersect, MemoryIndex, FilterTerms, RegexpQuery + provider); block tree and controls pass; the fix passes 10/10.
main refresh a9779ef3c193 still reproducesThe same 5 of 10 study tests fail.
re-verification realMain and 10.5.2 with assertions on and off, 10.0.0, a 9.12.3 probe; controls 3/3; the fix shadow passes.
cross-model review, refute lens (OpenAI GPT-5.6) real, highOwn independent test fails on main with assertions on and off; the cleaned-NFA control passes; the fix passes its test and three upstream suites.
this doc fails on stock main; fixes A and B validatedGradle runs of the repro, fix A with 22 core test classes, the memory module and the static checks, fix B with the same classes; path probes per codec.
fact check real; four errors in trigger scope and wording, fixedRe-ran the repro (assertions on and off) and the gradle command, re-ran fixes A and B, and probed which dead-state shapes crash: a dead end the walk reaches throws; a dead state with a self-loop counts correctly.
cross-model fact check (OpenAI GPT-5.6) real; no high-severity error; wording fixedA review of the brief against the code: precision of the NFA branch condition, the Operations claim, sampled releases, scoped claims and caveats on the fixes.

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

Finding record

LFV-055, snapshot of 2026-11-06

Registry title
AutomatonTermsEnum throws ArrayIndexOutOfBoundsException for an NFA with a reachable dead state
Area
util / automaton
Priority
Low, rank 1142: AutomatonTermsEnum throws ArrayIndexOutOfBoundsException for hand-built NFAs with a reachable dead state through non-block-tree consumers, and Lucene's own builders did not produce one in the study's checks.
Impact class
crash
Impact note
Only applications that build an NFA themselves (or return one from an AutomatonProvider) with a reachable dead end (a dead state with no transitions) that the term walk reaches, run through a consumer that enumerates with AutomatonTermsEnum (doc-values rewrite or intersect, MemoryIndex, FilterTerms wrappers, SimpleText); the default codec with the default rewrite answers correctly; the query throws ArrayIndexOutOfBoundsException, with no wrong results; released since 10.0.0 (10.0.0 to 10.6.0 and main) [exec].
Summary
You compile an NFA that has a reachable dead end (a dead state with no transitions) on the term walk into a CompiledAutomaton and intersect it through a non-block-tree consumer such as DOC_VALUES_REWRITE, SortedSetDocValues.intersect or MemoryIndex. You expect the matching terms, which the block-tree path returns. Instead AutomatonTermsEnum throws ArrayIndexOutOfBoundsException.
Introduced
c972c6a7754e (LUCENE-10010 'Introduce NFARunAutomaton to run NFA directly', apache/lucene#225, by zhaih, merged 2021-12-20, first released in 10.0.0): it removed CompiledAutomaton's 'Automaton must be deterministic' check, scoped the no-dead-states rule to DFAs and wrapped NFAs as they are [exec: single-commit builds; its parent e79012550444 rejects the NFA with IllegalArgumentException when the query is built, c972c6a7754e throws the ArrayIndexOutOfBoundsException; read: git tag --contains lists 10.0.0 to 10.6.0 and no 9.x tag]; the same input also threw in the sampled releases 4.0.0 to 9.12.3, where the javadoc forbade dead states (since LUCENE-3030, b7846ffaa1f, 2011) (2021-12-20)
First affected version
10.0.0 [exec] (the minimal repro fails on the 10.0.0 release jars; before 10.0 the javadoc forbade dead states)
Affected versions
10.0.0 to 10.6.0 and main [exec: the minimal repro fails on the 10.0.0, 10.5.2 and 10.6.0 release jars and on main 645d4fe14a20 (-ea and -da on main); read: git tag --contains c972c6a7754e lists 10.0.0 to 10.6.0]; not fixed upstream: upstream main f815e679bf6c has the same code [read, 2026-11-01]
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
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-11-01, read-only: a scripted prior-report search with 60 GitHub issue/PR, 6 commit, 5 downstream, 32 mailing-list (dev@, java-user@, issues@, commits@), 6 JIRA-archive and 6 web queries, 63 GitHub GraphQL phrasings and the changed files of all 409 open apache/lucene PRs; earlier study searches 2026-10-22); closest items, none a report of this bug: #11049 (the LUCENE-10010 design discussion), #225 (the introducing PR), #14218 and #14211/#14212 (dead states created by Operations, not user NFAs); not fixed upstream
Lucene commit tested
main 645d4fe14a20 (the minimal repro through gradle with -Ptests.asserts=true and =false, stock and with fix A and fix B; fix A with 22 related core test classes, the memory module and :lucene:core:check -x test; fix B with the same 22 classes; path probes for the default, test-framework, asserting and SimpleText codecs; a fact-check probe of dead-state shapes with assertions on and off), the 10.0.0, 10.5.2 and 10.6.0 release jars (minimal repro), single-commit builds of c972c6a7754e (#225) and its parent, the releases 4.0.0 to 9.12.3 (probes), and, by the study and the main refresh, main 7cecf79523, b83101cdd2 and a9779ef3c193. The files involved are byte-identical on upstream main f815e679bf6c (2026-11-01, read).

Lucene and upstream sources

The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-40, :159-160, :253-257, :348-357 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/automaton/NFARunAutomaton.java:199-206, :209-221, :399-403, :464 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/AutomatonTermsEnum.java:129-146, :190-191, :262-310 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/Terms.java:64-86; index/SortedSetDocValues.java:109-119; search/DocValuesRewriteMethod.java:98-104; search/AutomatonQuery.java:99 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/IntersectTermsEnumFrame.java:126-129; IntersectTermsEnum.java:415-429 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:253, :258-261; codecs/lucene103/blocktree/IntersectTermsEnumFrame.java:123-137 (at 645d4fe14a20)
Who is affected
  • lucene/CHANGES.txt:94-97 (GITHUB#15939, 11.0.0); lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:143, 264, 322, 356, 436, 501; index/FilterLeafReader.java:90-96 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:147-193 (optional), :1305 (reverse); RegExp.java:711-726 (a `<name>` leaf is returned as is) (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-40; search/AutomatonQuery.java:29-42, :59-65; index/AutomatonTermsEnum.java:190-191, :297-299; util/automaton/Operations.java:1007-1012 (at 645d4fe14a20)
  • lucene/MIGRATE.md:922-926 (at 645d4fe14a20)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:1007-1012 (removeDeadStates returns the input when every state is live) (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:455 (hasDeadStatesFromInitial), :926-1000 (getLiveStates, getLiveStatesToAccept); UTF32ToUTF8.java:291-336; search/AutomatonQuery.java:99-104; lucene/CHANGES.txt:94-100 (GITHUB#15939, GITHUB#15961) (at 645d4fe14a20)
History
  • lucene/CHANGES.txt:2304 (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 code at 645d4fe14a20 and the repro's input and output.

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.