All findings · Lucene finding · LFV-891 · compiledautomaton-floor-npe-non-dfa-types

CompiledAutomaton.floor throws NullPointerException for empty, match-all and single-term automata, and for non-deterministic ones

CompiledAutomaton.floor should return the largest accepted term <= the input, but for the compact forms the default constructor builds for an empty, match-all or single-term language, and since 10.0.0 for NFA-backed ones, it throws a NullPointerException. Example: new CompiledAutomaton(Automata.makeString("m")).floor("x") should return "m".

Summary

Priority
Low, rank 826CompiledAutomaton.floor throws a loud NPE for ordinary SINGLE, NONE, ALL and NFA automata, but only for direct callers of this experimental method.
Impact
crash
Since
4.0.0 (LUCENE-3030, 2011); NFA form 10.0.0 (#225)introduced 2011-08-20 · over 15 years
Affected
4.0.0 to 10.6.0 and main; NFA form 10.0.0 to 10.6.0 and main
Status
documented
Found by
non-formal: a sweep of public core APIs against their javadoc, then a JUnit

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

The default constructor stores three easy languages in a compact form with no state machine, and since 10.0.0 runs an automaton that stays non-deterministic in UTF-8 on a separate NFA runner. floor assumes a DFA is always there.

Compiled automata and floor

  1. Lucene turns regexp, wildcard and fuzzy patterns into an Automaton and compiles it into a CompiledAutomaton, which matches the UTF-8 bytes of the terms in the index.
  2. With simplify=true, which the one-argument constructor passes, three easy languages get a compact form with no state machine: NONE (matches nothing), ALL (matches everything, when an approximate test recognizes it) and SINGLE (one fixed term, kept in term). Everything else is NORMAL.
  3. A NORMAL instance holds a DFA in runAutomaton, or, since 10.0.0, an NFA runner instead when the input and its UTF-8 form are both non-deterministic. getByteRunnable() returns whichever one exists.
  4. floor(input, output) returns the largest accepted term <= input, or null. Fuzzy queries use it to pick the term to resume from when they move to a smaller edit distance.
Diagram of the bug: CompiledAutomaton.floor("x") on the one-term language {"m"}. Compiled as NORMAL (simplify=false), the automaton has a state machine, and floor walks it and returns "m". Compiled with the default one-argument constructor, it becomes type SINGLE: the term "m" is kept but runAutomaton is null, so floor's first step, runAutomaton.step, throws NullPointerException. The same happens for NONE, ALL and, since 10.0.0, NFA-backed NORMAL automata.
The language {"m"} compiled two ways: with simplify=false it keeps a state machine and floor("x") walks it to "m"; the default constructor stores it as a bare SINGLE term with no state machine, and floor steps into the missing one. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You compile the one-term language {"m"} with the documented one-argument constructor, new CompiledAutomaton(Automata.makeString("m")), and call floor(new BytesRef("x"), new BytesRefBuilder()).
  2. You expect "m": floor returns the largest accepted term <= the input, or null if there is none. The same language compiled with simplify=false does return "m".
  3. You get NullPointerException: ... because "this.runAutomaton" is null, with assertions on and off. The constructor stored {"m"} as type SINGLE, a bare term with no state machine, and floor always steps through the state machine without checking the type.
  4. The empty language (NONE, expected null) and the match-all language (ALL, expected the input) fail the same way, and since 10.0.0 so does an NFA-backed one: new RegExp("(a|ab)c").toAutomaton() should give floor("b") = "ac"; 9.12.3 still returns it.
  5. Only code that calls floor itself is hit, but then on every call; since 10.0.0 RegexpQuery("m").getCompiled() and WildcardQuery("*").getCompiled() hand out failing instances. Lucene's own caller, the fuzzy query, always passes a plain DFA.

Who is affected

Only application code that calls the experimental floor itself: on an instance the one-argument constructor built for an empty, match-all or single-term language (every release since 4.0.0), or on any NFA-backed instance (10.0.0 on). Every such call fails. Lucene's own queries never reach it, and the failure is a loud exception, not a wrong result.

Mechanism, terms and numbers655 words

CompiledAutomaton.floor(input, output) promises the largest term the automaton accepts that is <= input, or null. The documented one-argument constructor passes simplify=true, which stores an empty or single-string language, and a match-all one its approximate totality test recognizes, as type NONE, SINGLE or ALL with no state machine (runAutomaton and automaton are null); since 10.0.0 an automaton that is non-deterministic, and still so in UTF-8, stays type NORMAL but runs on an NFARunAutomaton, again with runAutomaton null. floor walks runAutomaton and automaton without checking the type, so for all four forms it throws NullPointerException instead of answering: new CompiledAutomaton(Automata.makeString("m")).floor("x") throws where "m" is expected, with assertions on and off. NONE, ALL and SINGLE have failed since the method was created (LUCENE-3030, first released in 4.0.0); the NFA form is a 9.x-to-10.0 regression (#225), where 9.12.3 returns the right floor. Only application code that calls the experimental floor itself is hit: Lucene's own caller (FuzzyTermsEnum) uses plain DFAs. A fix that answers the three special forms directly and runs NORMAL through the DFA-or-NFA accessors was validated; for an NFA with a dead state it still returns a wrong floor, so it needs the no-dead-states rule the class already states for DFAs.

The bug (main 645d4fe14a20, CompiledAutomaton.java)

  1. The default constructor simplifies. public CompiledAutomaton(Automaton automaton): "Create this, passing simplify=true, so that we try to simplify the automaton" (:106-109).
  2. Three special forms keep no state machine. With simplify and a DFA (:160), an empty language becomes NONE (:166-177), a total one ALL when the totality test recognizes it (:181-199; the code comment calls it "only approximate, because automaton may not be minimal") and a single string SINGLE (:203-221). Each branch sets runAutomaton = null and this.automaton = null and returns.
  3. NFAs keep no DFA either (10.0.0 and later). Other input becomes NORMAL (:224). If it and its UTF-8 form are both non-deterministic (:253), with or without simplify, the constructor sets automaton and runAutomaton to null and builds an NFARunAutomaton (:253-257). The class javadoc allows this: the automaton "could either be deterministic or non-deterministic", and an NFA "will be executed using NFARunAutomaton" (:36-40).
  4. floor never checks. floor (:390) calls runAutomaton.isAccept(state) for an empty input (:398), runAutomaton.step(state, label) for each byte (:411), then automaton.getNumTransitions (:433) and addTail, which walks automaton too (:277-339). There is no switch (type) and no null check.
  5. The siblings do check. getTermsEnum (:348-362) and visit (:365-382) switch on the type, and NORMAL consumers such as AutomatonTermsEnum use getByteRunnable() / getTransitionAccessor() (:486-493, :516-523; AutomatonTermsEnum.java:85-86). Where Lucene requires NORMAL it rejects the other forms with IllegalArgumentException: Terms.intersect, which also documents it (Terms.java:58, :79-81), and the AutomatonTermsEnum constructor (AutomatonTermsEnum.java:81-83; its javadoc does not mention it).
  6. Why Lucene itself never hits it. The only non-test caller, FuzzyTermsEnum.getAutomatonEnum (FuzzyTermsEnum.java:185), gets its automata from FuzzyAutomatonBuilder as new CompiledAutomaton(levBuilder.toAutomaton(i, prefix), true, false) (FuzzyAutomatonBuilder.java:63, :73): simplify=false Levenshtein DFAs, always NORMAL with a runAutomaton. Upstream's floor test builds its automata the same way (TestCompiledAutomaton.java:38-60).

Executed forms (main, assertions on and off)

compiled from (one-argument constructor)typecallexpectedgot
Automata.makeString("m")SINGLEfloor("x")"m"NPE at :411
Automata.makeEmpty()NONEfloor("x")nullNPE
Automata.makeAnyString()ALLfloor("x")"x"NPE
new RegExp("(a|ab)c").toAutomaton()NORMAL, NFA-backedfloor("b")"ac"NPE
RegexpQuery("m").getCompiled(), WildcardQuery("*").getCompiled()SINGLE, ALLfloor("x")"m", "x"NPE
controls: {"m"} with simplify=false; {ac, abc} determinized firstNORMAL, DFAfloor("x"), floor("b")"m", "ac"as expected

For an empty input the NPE comes from isAccept at :398 instead. The NFA-backed form also comes from the constructors with simplify=false, since that branch does not depend on simplify. The ALL row's "x" is exact for valid UTF-8 input (see the fixes).

Exposure and likelihood224 words
  • Who is hit. Code that calls CompiledAutomaton.floor directly. The failing instances come from ordinary inputs: the documented one-argument constructor on a single-string, empty or any-string automaton, a non-deterministic automaton on 10.x that stays non-deterministic in UTF-8 (for example new RegExp("(a|ab)c").toAutomaton(), since RegExp stopped determinizing in 10.0.0), and AutomatonQuery.getCompiled() of RegexpQuery("m") (SINGLE) and WildcardQuery("*") (ALL), executed on 10.0.0, 10.5.2, 10.6.0 and main.
  • Every call. Once code calls floor on such an instance, it fails deterministically, with assertions on and off. One term is enough; index size does not matter.
  • Default vs opt-in. The trigger is the default constructor with ordinary inputs, but calling floor is an expert, opt-in act: the class is @lucene.experimental.
  • Not hit. Lucene's own query paths: the only in-tree caller, the fuzzy query's terms enum, passes simplify=false Levenshtein DFAs. A code search of the default branches of Elasticsearch, OpenSearch and Solr found no call of floor on a CompiledAutomaton (not exhaustive).
  • What goes wrong. A NullPointerException that does not name the real restriction, instead of the documented result. No wrong result, no hang, no lasting state.
  • Regression for NFAs. On 9.12.3 the constructor and RegExp determinize, so floor("b") of (a|ab)c returns "ac"; from 10.0.0 it throws. In the 10.x releases the default RegexpQuery still holds a DFA; on main (#15939, unreleased) it keeps the NFA, so RegexpQuery("(a|ab)c").getCompiled() fails there too.

Minimal repro

One JUnit class, one method, public API only: compile {"m"} with the one-argument constructor and assert that floor("x") is "m". It fails with assertions on and off on main, and on 10.6.0, 10.5.2, 10.0.0 and 9.12.3.

TestCompiledAutomatonFloorSingle.java (one test class)35 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 org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
import org.apache.lucene.util.BytesRefBuilder;

public class TestCompiledAutomatonFloorSingle extends LuceneTestCase {

  public void testFloorOfSingleTermAutomaton() {
    CompiledAutomaton compiled = new CompiledAutomaton(Automata.makeString("m"));
    BytesRef floor;
    try {
      floor = compiled.floor(new BytesRef("x"), new BytesRefBuilder());
    } catch (NullPointerException e) {
      throw new AssertionError("floor(\"x\") of {\"m\"}: expected \"m\", got " + e, e);
    }
    assertEquals(new BytesRef("m"), floor);
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.automaton.TestCompiledAutomatonFloorSingle -Ptests.seed=DEADBEEF
Expected
floor("x") returns "m", the only accepted term, which is <= "x".
Observed
NullPointerException: Cannot invoke "ByteRunAutomaton.step(int, int)" because "this.runAutomaton" is null at CompiledAutomaton.floor(CompiledAutomaton.java:411).

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

More files, environment, full output329 words

The class sits in org.apache.lucene.util.automaton, next to TestCompiledAutomaton, and uses nothing package-private. The catch only turns the NPE into a failure message that states expected vs actual; the cause is kept. Deterministic: 20 of 20 derived seeds fail the same way. It needs org.apache.lucene.tests.util, so it compiles on 9.1.0 and later.

SINGLE was chosen for the repro because it needs no assumption about RegExp determinization and fails in every release since 4.0.0. The same NPE for NONE (Automata.makeEmpty()), ALL (Automata.makeAnyString()) and, since 10.0.0, NFA-backed NORMAL (new RegExp("(a|ab)c").toAutomaton(), floor("b") expected "ac") is in the table under "The finding in 30 seconds".

Regression test: this same test, as written. With the recommended fix it passes, and the core format, ECJ and forbidden-apis checks pass with it in place. If upstream chooses to reject these forms instead, the same test becomes an expectThrows of that exception.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2, 10.0.0, 9.12.3: fail; with the recommended fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; plain Java logic)
Command (environment)
cp $REPRO/TestCompiledAutomatonFloorSingle.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/automaton/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.automaton.TestCompiledAutomatonFloorSingle -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false (the recorded runs added --offline --no-daemon)

Observed output

# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, assertions on
TestCompiledAutomatonFloorSingle > testFloorOfSingleTermAutomaton FAILED
    java.lang.AssertionError: floor("x") of {"m"}: expected "m", got java.lang.NullPointerException: Cannot invoke "org.apache.lucene.util.automaton.ByteRunAutomaton.step(int, int)" because "this.runAutomaton" is null
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:63B807B3C458016A]:0)
        at org.apache.lucene.util.automaton.TestCompiledAutomatonFloorSingle.testFloorOfSingleTermAutomaton(TestCompiledAutomatonFloorSingle.java:31)

        Caused by:
        java.lang.NullPointerException: Cannot invoke "org.apache.lucene.util.automaton.ByteRunAutomaton.step(int, int)" because "this.runAutomaton" is null
            at org.apache.lucene.util.automaton.CompiledAutomaton.floor(CompiledAutomaton.java:411)
            at org.apache.lucene.util.automaton.TestCompiledAutomatonFloorSingle.testFloorOfSingleTermAutomaton(TestCompiledAutomatonFloorSingle.java:29)

# assertions off (-Ptests.asserts=false): the same message
# standalone runner, seed DEADBEEF: 1 of 1 failed, same message, on main (-ea and -da), 10.6.0, 10.5.2, 10.0.0, 9.12.3 (there at line 401)
# before 6.3.0 (4.0.0 to 6.2.1; release probes on 4.0.0-ALPHA to 5.5.5): the NPE names ByteRunAutomaton.getInitialState() instead; 6.6.6 and later name step(int, int)
# with the recommended fix: the test passes (assertions on and off)

Why it looks unintended

  • floor's javadoc states no condition, and the documented default constructor is what builds the failing forms.
  • The sibling methods handle these forms; where Lucene really requires NORMAL, as in Terms.intersect, it says so and throws IllegalArgumentException.
  • Upstream fixed this pattern in Terms.intersect as a bug (LUCENE-7576); for NFAs, a call that worked on 9.12.3 throws since 10.0.0.
The full argument301 words
  • The contract is unconditional. "Finds largest term accepted by this Automaton, that's <= the provided input term. ... The returned result is either the provided output, or null if there is no floor term (ie, the provided input term is before the first term accepted by this Automaton)" (CompiledAutomaton.java:384-389). No type, no "NORMAL only".
  • The default constructor produces the failing forms. It is documented as passing simplify=true (:106), so NONE, ALL and SINGLE are what a plain new CompiledAutomaton(a) gives for those languages.
  • NFAs are accepted by the class. The class javadoc accepts non-deterministic automata (:36-40), and an NFA-backed instance has type NORMAL (:224).
  • The NFA migration skipped floor. LUCENE-10010 / #225 (zhaih, 2021) added the NFA runner and moved AutomatonTermsEnum and the codecs' intersect enums to getByteRunnable() / getTransitionAccessor(); floor is not in its diff. visit() was missed too and moved only by #15961 (2026, main only), whose description says "some code paths (especially query visiting/highlighting and equality checks) still assumed a DFA-only representation". "floor" appears 0 times in the discussions of #225, #485, #11049 and #15961.
  • Upstream fixed the same pattern as a bug. LUCENE-7576, CHANGES 6.4.0 "Bug Fixes": "Fix Terms.intersect in the default codec to detect when the incoming automaton is a special case and throw a clearer exception than NullPointerException" (lucene/CHANGES.txt:8691-8693). Michael McCandless wrote there: "This is unfortunately a confusing expert API; other terms dicts were checking that the provided compiled automaton is NORMAL and throwing a clearer exception if not, so I carried that same check over ... I also added a note to the javadocs for Terms.intersect." floor got neither the check nor the note.
  • Nothing pins the current behaviour. No test, javadoc, CHANGES or MIGRATE entry says floor should throw for these forms.
The devil's-advocate check172 words

The strongest case for "intended" (a devil's-advocate verifier, the cross-model reviews, a tie-break): the runAutomaton and automaton fields are documented "only valid for NORMAL" (:69-80); the only in-tree caller and the only test use simplify=false DFAs; the class is @lucene.experimental; MIGRATE.md for 10.0 says "It is the responsibility of the caller to call Operations.determinize() for DFA execution" (lucene/MIGRATE.md:922-926); and floor takes a BytesRefBuilder, which is @lucene.internal.

Why it fails: the "only valid for NORMAL" notes are on fields, not on floor, and an NFA-backed instance is NORMAL. @lucene.experimental means the API may change between releases, not that a public method may throw an undocumented NPE on legal input; LUCENE-7576 treated exactly that as a bug in an equally expert API. The MIGRATE note covers the NFA form at most: the minimal repro (makeString("m")) is already a DFA and fails in every release since 4.0.0. BytesRefBuilder is also a required parameter of public non-internal core APIs, for example Term(String, BytesRefBuilder) (Term.java:63).

Suggested fix

Recommended Answer the special forms, run NORMAL on the DFA or the NFA (recommended)

Answer NONE (null), ALL (the input) and SINGLE (the term if <= the input) at the top of floor, and run NORMAL through getByteRunnable() and getTransitionAccessor() (+39/-15). The repro, 2,578 core tests, a 50,000-call differential check and lint pass. An NFA with a dead state still gets a wrong floor, so NFAs need the no-dead-states rule too.

switch (type) {
  case NONE: return null;
  case ALL: output.copyBytes(input); return output.get();
  case SINGLE:
    if (term.compareTo(input) <= 0) { output.copyBytes(term); return output.get(); }
    return null;
  case NORMAL: break;
}
// NORMAL: a DFA or an NFA; the rest of floor and addTail use these instead of the fields
final ByteRunnable runnable = getByteRunnable();
final TransitionAccessor accessor = getTransitionAccessor();
All options and trade-offs3 options · 904 words

Two fixes were built and run through gradle in a scratch worktree of 645d4fe14a20; the recommended one gives the javadoc result for all four forms in every executed case except an NFA with a dead state (see its trade-offs). A third option, documenting and rejecting these forms as Terms.intersect does, was not built.

Answer the special forms, run NORMAL on the DFA or the NFA (recommended) (validated by the checks)

At the top of floor, answer the three special forms directly; for NORMAL, read the DFA-or-NFA accessors that AutomatonTermsEnum uses (since #225) and visit() uses (since #15961) instead of the runAutomaton and automaton fields, also inside addTail. The rest of the diff renames the field uses to the two locals. Condensed:

switch (type) {
  case NONE: return null;
  case ALL: output.copyBytes(input); return output.get();
  case SINGLE:
    if (term.compareTo(input) <= 0) { output.copyBytes(term); return output.get(); }
    return null;
  case NORMAL: break;
}
// NORMAL: a DFA or an NFA; the rest of floor and addTail use these instead of the fields
final ByteRunnable runnable = getByteRunnable();
final TransitionAccessor accessor = getTransitionAccessor();

Trade-offs

  • ALL returns the input. Exact for a binary ALL and for valid UTF-8 input. For a unicode ALL and input its UTF-8 form does not accept (for example the byte ff), it returns the input anyway (executed: input [ff] gives [ff]). For such input no largest accepted term exists: any accepted term below it can be extended, for example by a NUL character, and still stays below it. So any answer is a convention a reviewer may want documented. CompiledAutomaton does not keep isBinary, so floor cannot tell the two ALLs apart.
  • NFA path: correct only without dead states. It walks the lazily determinized states of NFARunAutomaton, as AutomatonTermsEnum does, and like the DFA path it assumes every state can still reach an accept state. The class javadoc requires that of DFAs only ("must not have dead states"). An independent reviewer from another model family (OpenAI GPT-5.6) gave a counterexample, which we then ran: the NFA for {"b"} with 0 -[a-b]-> 1 (not accepting, no outgoing transitions) and 0 -b-> 2 (accepting). With the fix, floor("ab") returns "a" (not in the language) with assertions off and throws AssertionError with them on, where null is right; a second NFA with a dead state, {"b", "bc"}, fails the same way. That matches what 9.12.3 returns for the same NFAs (its constructor determinizes and keeps the dead state) and what main returns today for the same language built as a DFA with a dead state and simplify=false. After Operations.removeDeadStates the second NFA is still NFA-backed and the fix gives the right floor for every input tried. So the fix should come with one of: extending the class javadoc's no-dead-states rule to NFAs (the caller then trims, as for DFAs today), or removing dead states in the constructor's NFA branch (not built). Lucene's own caller keeps the unchanged DFA path.
  • Shared scratch field. Like the stock code, the fix uses the per-instance transition field in floor and addTail, although the class javadoc calls the class immutable. It changes nothing about concurrent floor calls on one instance.
  • Cost. One switch per call; no hot path calls floor on these forms.
  • Not covered. The separate non-termination of floor on some infinite languages (another bug in the same method) is unchanged.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the repro fails. With the fix: it passes with assertions on and off.
  • The repro, the finder's five-form probe (SINGLE "m", NONE null, ALL "x", NFA "ac", DFA reference) and the suites org.apache.lucene.util.automaton.*, TestFuzzy*, TestAutomatonQuery*, TestRegexp*, TestWildcard*, TestPrefix* and index.TestTermsEnum*: 32 suites, 321 tests, 3 skipped, 0 failures.
  • A differential check (not the repro): 5 iterations of 500 random finite languages (unions of up to 4 short strings through the one-argument constructor, so NONE, SINGLE, NFA-backed and DFA NORMAL) with 20 random inputs each, against a brute-force floor over the sorted terms: 0 failures; on stock code it fails with the NPE. Its NFAs are unions of strings, which have no dead states, so it does not cover the dead-state case above.
  • :lucene:core:test over org.apache.lucene.search.* and org.apache.lucene.util.automaton.*: 230 suites, 2,578 tests, 51 skipped, 0 failures.
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest (with the repro test in place), forbiddenApisMain, forbiddenApisTest: pass.

Special forms plus a clear exception for NFAs (validated, smaller)

The same switch for NONE, ALL and SINGLE, and throw new UnsupportedOperationException("floor requires a deterministic automaton") when NORMAL has no runAutomaton (+18 lines). This is the study's first sketch.

Trade-offs

Smaller, but the NFA form still gets no result, so the 9.x-to-10.0 regression stays and the restriction needs a javadoc note. ALL has the same convention as above.

What the checks validated

The repro passes with assertions on and off (standalone runner and gradle). Through gradle with util.automaton.* and TestFuzzy*, the only 2 failures are the five-form probe's NFA case and the differential check, both hitting the deliberate exception.

Document and reject, as LUCENE-7576 did for Terms.intersect (not built)

Add "only works for NORMAL deterministic automata" to the floor javadoc and throw IllegalArgumentException (or IllegalStateException) for the other forms.

Trade-offs

The smallest behaviour change and an existing precedent, but callers must then special-case three common forms themselves, and the regression test becomes an expectThrows.

Downstream note

No call of CompiledAutomaton.floor was found in Elasticsearch, OpenSearch or Solr; an application that calls it on an automaton built from a user's query gets a catchable exception on that call and nothing worse.

How it was found

Non-formal. A hunt unit swept public core and queries APIs that no earlier work had named and checked each against its javadoc. Reading floor next to the constructor showed it never checks the type; a probe and a public-API JUnit with a determinized-DFA reference confirmed the NPE.

The method in full153 words

Method: non-formal (code reading plus a probe, then public-API JUnit tests); no formal tool produced the signal.

  • The hunt unit's plan listed public methods in core and queries that no registry row, upstream issue or earlier study had named, with the function their javadoc states. For CompiledAutomaton.floor, reading the constructor showed four forms with a null runAutomaton and floor showed no dispatch on the type.
  • A probe confirmed it, and the finder reduced it to a JUnit with five tests on the public API: SINGLE, NONE, ALL, the NFA from RegExp("(a|ab)c"), and a reference that compiles the same NFA language as a determinized DFA (which returns "ac").
  • An independent reproducer, a devil's-advocate verifier, cross-model reviews (OpenAI GPT-5.6, reading only) and an adjudication checked it; the repro was then minimized to the single SINGLE test, re-checked on main 645d4fe14a20, and a tie-break re-ran it with an independent probe outside the package.

Prior reports, history and checks

Prior reports

No prior report found (GitHub issues, PRs and commits, all open PRs' files, the dev@, java-user@, issues@ and commits@ lists, JIRA, downstream projects and the web; searched 2026-10-28 and again 2026-10-31). Precedents for the same pattern at other methods: LUCENE-7576 (Terms.intersect) and #15961 (visit()).

Searches2 searches

Verdict new: no issue, PR, review comment, commit message, JIRA entry, list thread, downstream report or web page reports CompiledAutomaton.floor failing for NONE, ALL, SINGLE or NFA-backed instances; the exact message because "this.runAutomaton" is null from floor has 0 hits across GitHub. Precedents, not reports (same null runAutomaton, other methods and fix sites): LUCENE-7576 / #8627 (Terms.intersect, 2016), LUCENE-7646 / #8697 (AutomatonTermsEnum with a match-all regexp), a 2016 java-user thread on the Terms.intersect symptom, and #15961 (visit() moved to getByteRunnable(); a downstream highlighter NPE, OpenSearch#18721, came from that site). Related open items: #16361 (addTail lookup), #16816 (rewrites AutomatonQuery by type, does not touch CompiledAutomaton.java), #13715 (CompiledAutomaton equality with an NFA runner). Upstream main 2f424fd428da (2026-10-30) still has the same file.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene and downstream), dev@, java-user@, issues@, commits@, JIRA, web2026-10-28a scripted prior-report search (71 queries, 976 hits, all high and medium read), the finder's 45 queries plus 6 web, 20 REST and 17 GraphQL GitHub searches, the changed files of all 410 open PRs, 'floor' greps over #225, #485, #11049, #15939, #15961, #16816no report; precedents LUCENE-7576 (#8627), LUCENE-7646 (#8697), #15961
the same sources (repeat)2026-10-3185 prior-art queries (1,140 hits, 1 high and 21 medium, all read), 24 REST and 22 GraphQL searches incl. the exact NPE text GitHub-wide, the files of all 409 open PRs, 'floor' greps over 34 related threadsno report; only #16564 and #16361 touch CompiledAutomaton.java, neither changes floor

History

Born with the method in LUCENE-3030 (b7846ffaa1f, 2011; first release 4.0.0) for NONE, ALL and SINGLE. The NFA form came with #225 (c972c6a7754e, 2021; first release 10.0.0; 9.12.3 still determinizes and works). Affected through 10.6.0 and main; not fixed.

Versions tested8 rows

NONE, ALL, SINGLE. LUCENE-3030 "add BlockTreeTermsReader/Writer" (Michael McCandless, b7846ffaa1f, 2011-08-20, Subversion era, no PR) created CompiledAutomaton with floor walking runAutomaton unconditionally and the simplify path (the one-argument constructor's default) nulling it for NONE, ALL, SINGLE and PREFIX. A core build of that commit throws the NPE for all four, assertions on and off. PREFIX failed the same way until LUCENE-6367 removed that type in 5.1.0. 3.6.2 has no util.automaton package.

NFA-backed NORMAL. apache/lucene#225 (LUCENE-10010 "Introduce NFARunAutomaton to run NFA directly", zhaih, merged 2021-12-20, c972c6a7754e) let the constructor accept NFAs with runAutomaton = null. It followed #485 (b2e866b7036: the constructor stopped determinizing and rejected NFAs) and #513 (84e4b85b094: RegExp.toAutomaton() stopped determinizing). Builds of single commits: the parent of #485 returns the right floor for an NFA, the parent of #225 rejects it, #225 throws the NPE. #15939 (main only, unreleased) made the default RegexpQuery keep NFAs.

None of the 124 release tags with CompiledAutomaton.java (4.0.0-alpha to 10.6.0) has a type or null guard at the top of floor. Upstream main 2f424fd428da (2026-10-30) has the same file; its newest commit is d7d12403905 (#16390, 2026-07-14).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails, assertions on and off; NONE, ALL and NFA forms fail too
10.6.0, 10.5.2, 10.0.0 affectedminimal repro fails; the NFA form fails too
9.12.3 NFA form worksminimal repro fails (NONE, ALL, SINGLE); the NFA language returns "ac" because the 9.x constructor and RegExp determinize
4.0.0-ALPHA, 4.0.0, 4.9.1, 4.10.0, 5.0.0, 5.1.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0 affectedrelease probes: NONE, ALL, SINGLE throw the NPE (PREFIX too until 5.0.0)
3.6.2 not affectedno util.automaton package
b7846ffaa1f (LUCENE-3030) introducedcore build of the commit: SINGLE, NONE, ALL, PREFIX throw; NORMAL DFA control correct
5f09bb3fab0 (parent of #485) / e79012550444 (parent of #225) / c972c6a7754e (#225) NFA bisectNFA: right floor / constructor rejects it / NPE
with the recommended fix (645d4fe14a20) not affectedrepro passes; core search and automaton suites, differential check and lint pass

Independent checks

An independent reproducer, a devil's-advocate verifier and a cross-model review said real; the adjudication and a later tie-break confirmed with high confidence. Reading-only cross-model reviews returned a procedural "not real" because they could not run Java, while agreeing with the root cause.

Checks and verdicts462 words

Every executing check ran on stock builds of main (a9779ef3c193 or 645d4fe14a20, with an identical CompiledAutomaton.java) or on releases.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (JUnit) candidateFive-form public-API JUnit: 5 run, 4 failed on main with assertions on and off, branch_10_6, 10.5.2 and 10.0.0; 9.12.3 3 of 4 (NFA test skipped); the determinized-DFA reference passes.
Independent reproducer realOwn JUnit with 3 DFA controls: 8 run, 5 failed on main, 10.5.2 and 10.0.0; 4 of 8 on 9.12.3. Reach test: floor throws on getCompiled() of RegexpQuery("m"), WildcardQuery("*") and RegexpQuery("(a|ab)c") on main (3 of 3).
Cross-model review, intended lens (OpenAI GPT-5.6, reading only) real (low, then high)Root cause agrees, inputs in contract, the intended-behaviour case weak; noted the first fix sketch leaves NFAs unsolved.
Cross-model review, refute lens (OpenAI GPT-5.6, reading only) not real (low, then medium), proceduralCould not run Java, so "not reproduced" by construction; did not dispute the root cause, and the second run agreed with it and called the NPE deterministic by inspection. Its contract argument (BytesRefBuilder is internal) was answered by the tie-break.
Devil's advocate realBuilt the strongest intended case and rejected it; its probe shows Terms.intersect handling the same NFA-backed instance while floor throws.
Adjudication confirmed, highRe-ran the independent reproduction's JUnit (8 run, 5 failed, assertions on and off) and the finder's (5 run, 4 failed).
Repro minimization verifiedThe one-test repro fails on 645d4fe14a20: assertions on, 20 of 20 derived seeds; assertions off, seed DEADBEEF. It passes with the first sketch.
Second adjudication real, highRepro fails on main, 10.6.0, 10.5.2 and 9.12.3; an independent probe outside the package fails 5 of 7 while its 2 controls give the javadoc answers; the sketch fixes all but the NFA case.
History follow-up confirmsCommit-level builds of LUCENE-3030 and #225 and their neighbours; release probes 3.6.2 to 10.6.0; corrected which query paths reach the NFA form per version.
This doc (gradle, fixes) reproduced, fix validatedRepro fails on 645d4fe14a20 with assertions on and off; the recommended fix passes it, the core search and automaton suites, a differential check and the lint checks.
Fact checks and diagram check of this doc (2026-10-31): independent, cross-model (OpenAI GPT-5.6, reading only) and diagram bug holds; corrections appliedThe independent check re-ran the repro on main and four releases and fix A; the cross-model check found that fix A returns a wrong floor for an NFA with a dead state, which we then ran and added to the fix's trade-offs; the diagram check passed. Wording fixes: the pre-6.3.0 NPE message range, the ALL and NFA conditions, which NORMAL-only checks are documented, and the assertions-off evidence.

Diagram file

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

Sources and provenance

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

Finding record

LFV-891, snapshot of 2026-11-06

Registry title
CompiledAutomaton.floor throws NullPointerException for NONE, ALL and SINGLE automata (what the documented one-argument constructor builds from an empty, any-string or single-string automaton) and, since 10.0.0, for NFA-backed NORMAL automata
Area
util / automaton
Priority
Low, rank 826: CompiledAutomaton.floor throws a loud NPE for ordinary SINGLE, NONE, ALL and NFA automata, but only for direct callers of this experimental method.
Impact class
crash
Impact note
Only application code that calls the experimental CompiledAutomaton.floor itself is hit, but then on every call for instances that the documented one-argument constructor builds from an empty, any-string or single-string automaton (every release since 4.0.0) and for any non-deterministic automaton, for example RegExp("(a|ab)c") or AutomatonQuery.getCompiled() of ordinary RegexpQuery and WildcardQuery inputs (since 10.0.0); Lucene's only in-tree caller, FuzzyTermsEnum, passes simplify=false Levenshtein DFAs and never reaches it, no Lucene query path calls floor on these shapes, and the failure is a loud exception, not a wrong result.
Summary
You build a CompiledAutomaton with the documented one-argument constructor, for example from new RegExp("m").toAutomaton() (type SINGLE), Automata.makeEmpty() (NONE), Automata.makeAnyString() (ALL) or, on 10.x, the non-deterministic RegExp("(a|ab)c").toAutomaton() (type NORMAL, run by NFARunAutomaton), and call floor(input, output). You expect what the javadoc promises, the largest accepted term <= input or null: floor("x") = "m", null, "x", and floor("b") = "ac" (the value the determinized form of the same language returns). Instead floor throws NullPointerException ('Cannot invoke ByteRunAutomaton.step(int, int) because this.runAutomaton is null'), because it always walks runAutomaton and the constructor leaves runAutomaton null for these four shapes.
Introduced
b7846ffaa1ff8cd050feaa924e3a6ab12a43549b, LUCENE-3030 'add BlockTreeTermsReader/Writer' (Subversion era, svn r1159905, no PR) [read: git log -L and git blame on floor; it created CompiledAutomaton with floor() already walking runAutomaton unconditionally and the simplify path, the one-argument constructor's default, already setting runAutomaton = null for NONE, ALL, SINGLE and PREFIX; no commit since added or removed a type guard in floor]. Second shape (NFA-backed NORMAL): c972c6a7754e9ff1f838ed72ff09e7def00be5f8, apache/lucene#225 'LUCENE-10010 Introduce NFARunAutomaton to run NFA directly' (merged 2021-12-20), which moved the other runAutomaton consumers to getByteRunnable()/getTransitionAccessor() but not floor; preceded by #485 (b2e866b7036, 2021-12-03, constructor stopped determinizing and rejected NFAs) and widened by #513 (84e4b85b094, 2021-12-08, RegExp no longer determinizes) [read]. Not a regression for NONE/ALL/SINGLE (born with the method); a 9.x-to-10.0 regression for NFA input (9.12.3 determinizes and returns the right floor [exec]). (2011-08-20 (b7846ffaa1f, trunk, 19:20:45 UTC); NFA shape 2021-12-20 (#225 merge; first released in 10.0.0))
First affected version
4.0.0 [exec: 4.0.0-ALPHA and 4.0.0] for NONE, ALL and SINGLE (3.6.2 has no org.apache.lucene.util.automaton package [exec]); 10.0.0 [exec] for the NFA shape (last unaffected: 9.12.3 [exec])
Affected versions
every release that ships CompiledAutomaton, 4.0.0 through 10.5.2, for NONE/ALL/SINGLE [exec on 4.0.0-ALPHA, 4.0.0, 4.9.1, 4.10.0, 5.0.0, 5.1.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2; read on all 123 release tags in the full-history clone that contain CompiledAutomaton.java]; the NFA shape in 10.0.0 through 10.5.2 [exec on 10.0.0 and 10.5.2]; plus branch_10_6 4ae946e5a9b (10.6.0, released 2026-10-29) and main a9779ef3c193 [exec], -ea and -da alike. PREFIX (a fourth special type in 4.0.0-5.0.0) failed the same way until LUCENE-6367 removed it in 5.1.0 [exec]. Not fixed on upstream main c5ab3e57b9b (CompiledAutomaton.java byte-identical, 2026-10-28); branch_10x and branch_10_6 heads have the same floor() opening [read].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code and voted “not real” while agreeing with the root cause (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (the prior-report record verdict new, searched 2026-10-28 against upstream main c5ab3e57b9ba, refreshed 2026-10-28, read-only): no issue, PR, review comment, commit message, JIRA entry, mailing-list thread, downstream report or web page reports CompiledAutomaton.floor failing for NONE, ALL, SINGLE or NFA-backed NORMAL instances. Searched: a scripted prior-report search with 71 queries (32 GitHub issue/PR, 5 commit, 4 downstream-repository, 20 over the dev@, java-user@, issues@ and commits@ lists, 4 JIRA, 6 web; 976 hits, 1 high, 23 medium, all read), the finder's run (45 queries plus 6 web), 20 REST and 17 GraphQL GitHub searches, the changed files of all 410 open apache/lucene PRs (only #16564 and #16361 touch CompiledAutomaton.java, floor unchanged), and 'floor' greps over the discussions of #225, #485, #11049, #15939, #15961 and #16816 (0 mentions). Refresh for this row: GitHub search 'CompiledAutomaton floor' (only #16361, addTail), 'runAutomaton is null' (0), PRs mentioning CompiledAutomaton updated since 2026-10-26 (only #16816, which touches AutomatonQuery and TestAutomatonQuery, not CompiledAutomaton.java), the files of all 61 PRs updated since 2026-10-26 (none touches CompiledAutomaton.java), and the newest upstream commit to the file by author date, d7d12403905 (#16390, 2026-07-14). Precedents, not reports (same null-runAutomaton pattern, different methods and fix sites): LUCENE-7576 / #8627 (2016, Terms.intersect and AutomatonTermsEnum now require NORMAL and throw a clear exception; floor untouched), LUCENE-7646 / #8697, a 2016 java-user thread on the Terms.intersect symptom, and #15961 (2026, visit() moved to getByteRunnable(), fixing the highlighter NPE reported as opensearch-project/OpenSearch#18721).
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails through gradle in a scratch worktree (assertions on and off) and through a standalone runner on main, 10.6.0, 10.5.2, 10.0.0 and 9.12.3; with the recommended fix it passes, together with 2,578 core search and automaton tests, a differential check of 50,000 random floor calls and the core lint checks. The hunt, the verifiers and the adjudication ran main a9779ef3c193; CompiledAutomaton.java is identical there, at 645d4fe14a20 and on upstream main 2f424fd428da (2026-10-30).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-42 (class javadoc: the automaton 'could either be deterministic or non-deterministic'; NFAs run with NFARunAutomaton; @lucene.experimental), :69-80 (runAutomaton and automaton 'only valid for NORMAL'), :106-108 (one-argument constructor 'passing simplify=true'), :168/171, :190/193, :205/207 (NONE, ALL, SINGLE set runAutomaton = null), :224 (NORMAL), :253-257 (NFA branch: automaton and runAutomaton null, nfaRunAutomaton set), :385-389 (floor javadoc, no precondition), :390, :398 (runAutomaton.isAccept), :411 (runAutomaton.step)
  • lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:185 (only non-test caller of floor) and search/FuzzyAutomatonBuilder.java (simplify=false Levenshtein DFAs)
  • lucene/core/src/java/org/apache/lucene/search/AutomatonQuery.java:161 (public getCompiled())
  • lucene/core/src/java/org/apache/lucene/index/Terms.java:58, :79-80 (intersect: 'will only work for NORMAL compiled automata', IllegalArgumentException; LUCENE-7576 precedent)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-42, :69-80, :106-109, :160-224, :253-257, :277-339, :348-382, :384-411, :433, :486-523
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/Terms.java:51-81; a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/AutomatonTermsEnum.java:79-86
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/FuzzyTermsEnum.java:176-188; a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/FuzzyAutomatonBuilder.java:59-77
  • a Lucene main checkout lucene/core/src/test/org/apache/lucene/util/automaton/TestCompiledAutomaton.java:38-60
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:42 (@lucene.experimental)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-42, :69-80, :106, :224, :384-389
  • a Lucene main checkout lucene/CHANGES.txt:8691-8693 (LUCENE-7576), :2179-2181 (LUCENE-10010); lucene/MIGRATE.md:922-926
  • a Lucene clone: git show c972c6a7754e --stat (#225 changed files); release tag releases/lucene/10.6.0 CompiledAutomaton.java:365-370 (visit passes () -> runAutomaton)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-40 (DFAs 'must not have dead states'), :486-523; a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/NFARunAutomaton.java:199-258 (TransitionAccessor on lazily determinized states)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/Terms.java:58, :79-81 (the LUCENE-7576 pattern)
Prior reports
  • https://github.com/apache/lucene/issues/8627, https://github.com/apache/lucene/issues/8697, https://github.com/apache/lucene/pull/15961, https://github.com/apache/lucene/pull/225

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