All findings · Lucene finding · LFV-062 · toposort-self-loop

Operations.topoSortStates misses self-loop cycles and returns an order instead of throwing

Operations.topoSortStates documents that it "will throw an IllegalArgumentException if a cycle is detected", but it misses a cycle that is a loop from a state back to itself: the 2-state automaton for ab* comes back sorted as [0, 1]. Every release since 9.7.0 is affected; Lucene's own callers pass only acyclic automata.

Summary

Priority
Low, rank 1213Operations.topoSortStates misses self-loop cycles instead of throwing, but its javadoc assumes acyclic input and every in-tree caller complies.
Impact
minor
Since
9.7.0 (#12286, merged 2023-05-15)introduced 2023-05-15 · about 3 years
Affected
9.7.0 to 10.6.0 (all 25 releases) and main
Status
documented
Found by
semi-formal: exhaustive translation validation and TLA+/TLC model checking

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

The cycle check is a depth-first search with an "on the stack" bit per state. The code sets that bit on the parent when the parent pushes a child, not on the child it pushes. So a state stays unmarked until it pushes something, and a loop from a state that pushes nothing back to itself is never seen.

Background: automata, cycles and topological order

  1. An Automaton is a set of numbered states joined by labelled transitions; state 0 is the start. A transition from a state to itself is a self-loop, the shortest possible cycle.
  2. A topological sort lists the states so that every transition points forward. It exists only when there is no cycle: with a loop on state 1, no order can put 1 before itself.
  3. Operations.topoSortStates finds cycles the textbook way: a depth-first search keeps the states on the current path marked, and an edge to a marked state is a cycle.
The automaton 0 -a-> 1 (accepting), 1 -b-> 1 and the depth-first search over it: state 0 pushes state 1 and is marked; state 1 scans its loop 1 -> 1 while unmarked, so no cycle is reported, and topoSortStates returns [0, 1] instead of throwing IllegalArgumentException.
The 2-state automaton for ab* and the search over it: state 1 checks its own loop before it is marked as on the path, so no cycle is seen and [0, 1] comes back. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build the 2-state automaton for ab* with the public API: state 0 -a-> state 1 (accepting), and a loop 1 -b-> 1. It accepts a, ab, abb, ..., so it has a cycle.
  2. Call Operations.topoSortStates(a). You expect IllegalArgumentException("Input automaton has a cycle."): the javadoc says it "will throw an IllegalArgumentException if a cycle is detected".
  3. You get [0, 1] and no exception, with assertions on and off. Determinized RegExp("ab*") and RegExp("a+") (3 states each) return [0, 1, 2]; a* and (ab)*c are rejected as they should be.
  4. Why: the search marks a state as "on the current path" only after it steps to a new child. State 1 has none, so it checks its own loop while still unmarked, and the loop looks like an edge to a finished state. Longer cycles, and loops on state 0, are caught.
  5. Of 121,584 cyclic cases (every graph with up to 4 states, in two scan orders), 2,906 slip through, each with only self-loop cycles. Lucene's two callers pass acyclic token graphs, so only code that calls the method itself is affected.

Who is affected

Only code that calls the public Operations.topoSortStates itself and relies on its exception to reject cyclic automata, for example as a finiteness check on automata built from patterns like ab* or a+. Not the default: Lucene's two callers pass acyclic token graphs. Effect: a missing exception; no hang, crash or wrong search result.

Mechanism, terms and numbers862 words

Operations.topoSortStates(Automaton) documents that it "will throw an IllegalArgumentException if a cycle is detected". Its iterative depth-first search marks a state as on-stack only when that state pushes a child (it marks the parent, not the pushed child; the two coincide only when a loop 0 -> 0 pushes state 0 again), and pushes state 0 without any mark. A reachable non-initial state whose only remaining edge is a loop to itself therefore scans that loop while unmarked, sees "visited, not on stack", and is emitted. For the 2-state automaton 0 -a-> 1 (accept), 1 -b-> 1 (the language ab*) the method returns [0, 1]; determinized RegExp("ab*") and RegExp("a+") return [0, 1, 2]. Cycles of two or more states and a loop on state 0 are still caught. The gap dates from #12286 (9.7.0), which added the check on purpose, and is in every release up to 10.6.0 and main. Lucene's own callers (ConcatenateGraphFilter, AnalyzingSuggester) pass acyclic token graphs, so only direct callers that rely on the exception are affected. The fix is the textbook marking: mark a state when it is pushed.

The code (main 645d4fe14a20, Operations.java:1396-1432, verbatim)

  private static int topoSortStates(Automaton a, int[] states) {
    BitSet onStack = new BitSet(a.getNumStates());
    BitSet visited = new BitSet(a.getNumStates());
    var stack = new ArrayDeque<Integer>();
    stack.push(0); // Assuming that the initial state is 0.
    int upto = 0;
    Transition t = new Transition();

    while (!stack.isEmpty()) {
      int state = stack.peek(); // Just peek, don't remove the state yet

      int count = a.initTransition(state, t);
      boolean pushed = false;
      for (int i = 0; i < count; i++) {
        a.getNextTransition(t);
        if (!visited.get(t.dest)) {
          visited.set(t.dest);
          stack.push(t.dest); // Push the next unvisited state onto the stack
          onStack.set(state);
          pushed = true;
          break; // Exit the loop, we'll continue from here in the next iteration
        } else if (onStack.get(t.dest)) {
          // If the state is on the current recursion stack, we have detected a cycle
          throw new IllegalArgumentException("Input automaton has a cycle.");
        }
      }

      // If we haven't pushed any new state onto the stack, we're done with this state
      if (!pushed) {
        onStack.clear(state); // remove the node from the current recursion stack
        stack.pop();
        states[upto] = state;
        upto++;
      }
    }
    return upto;
  }

The lines that matter (notes, not source comments):

  • :1400 pushes state 0 without setting its visited or onStack bit.
  • :1414 sets the bit of state, the parent on top of the stack, not of the pushed t.dest.
  • :1417-1419 is the only cycle check: a visited successor whose onStack bit is set.
  • :1424-1429 clear, pop and emit a state that pushed nothing in this pass; every peek rescans its edges from the first one (:1407).

The trace for 0 -a-> 1 (accept), 1 -b-> 1

steptopedgevisited afteronStack afterwhat happens
100 -> 1{1}{0}1 is new: push 1, mark the parent 0
211 -> 1{1}{0}1 is visited but not marked: no throw; nothing new, so pop and emit 1
300 -> 1{1}{}visited, not marked: skip; pop and emit 0

The public method reverses the emitted [1, 0] (Operations.java:1378-1383) and returns [0, 1].

  • Why longer cycles are caught. Every state below the top of the stack has pushed a child, so it is marked; a cycle of two or more states ends in an edge to such an ancestor.
  • When a self-loop is missed. A state with a self-loop that also pushes an unvisited child is marked by that push, and its loop is caught when it rescans its edges after the child is done. The miss needs a non-initial state whose other successors are all visited already, or that has none. Transitions are scanned in (min, max, dest) order (Automaton.java:297-298), so the shape and the labels decide.
  • Why a loop on state 0 is caught. State 0 is never marked visited, so an edge back to it (also 0 -> 0) pushes 0 a second time, and the second copy finds a marked state (the study verifier's argument; the exhaustive check agrees).
  • What still holds. visited keeps any state but 0 from being pushed twice, so the method ends, and its stack holds at most the number of states plus one (state 0 can be pushed twice). Memory stays O(numStates), as the javadoc's RAM sentence says (Operations.java:1359-1360).

The numbers

Re-run on main for this doc: of 121,584 cyclic cases (every graph with up to 4 states, in two scan orders), 2,906 are sorted instead of rejected; of 97,648 cyclic random graphs with 2 to 40 states, 13,713. Every miss has only self-loop cycles; 0 false alarms and 0 invalid orders on acyclic graphs. Regexp shapes: ab* and a+ (determinized, 3 states) return [0, 1, 2]; a* (loop on state 0), (ab)*c (cycle 0 -> 1 -> 0) and the cycle 1 -> 2 -> 1 throw.

The method sorts only the states reachable from state 0 (Operations.java:1348), so "cycle" here means a cycle reachable from state 0; neither the check nor the fixes look at unreachable states.

Exposure and likelihood187 words
  • Who. Code that calls Operations.topoSortStates (public; the class is @lucene.experimental) and relies on the documented exception to reject cyclic automata, for example before walking or enumerating the automaton's strings. It accepts ab* or a+ and gets an order back; what happens next depends on that code. Core has had no public Operations.isFinite since #11813 (2022); it now lives in the test framework (AutomatonTestUtil.isFinite).
  • Not default, no in-tree path for conforming token streams. The only main-source callers, ConcatenateGraphFilter.replaceSep and AnalyzingSuggester.replaceSep, get TokenStreamToAutomaton output. Its edges run forward from a position to pos + positionLength, and both stock attribute implementations (PackedTokenAttributeImpl, the default, and PositionLengthAttributeImpl) reject positionLength < 1, so for token streams that follow that contract it is acyclic. TokenStreamToAutomaton does not re-check the value; a custom attribute implementation that skips the check was not examined.
  • Effect. A missing exception. The returned array orders the states as if the self-loops were removed. No hang, no out-of-memory, no crash, no wrong search result inside Lucene. FiniteStringsIterator has its own cycle check.
  • Scale. One call on a 2-state automaton; no index.

Minimal repro

One JUnit test, one method, public API only, 2 states, deterministic, single thread: build 0 -a-> 1 (accept), 1 -b-> 1 and expect topoSortStates to throw IllegalArgumentException.

TestTopoSortSelfLoop.java34 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;

public class TestTopoSortSelfLoop extends LuceneTestCase {

  public void testSelfLoopIsACycle() {
    Automaton a = new Automaton();
    int s0 = a.createState();
    int s1 = a.createState();
    a.setAccept(s1, true);
    a.addTransition(s0, s1, 'a');
    a.addTransition(s1, s1, 'b');
    a.finishState();
    // Expected: IllegalArgumentException for the cycle 1 -b-> 1; actual: returns the order [0, 1].
    expectThrows(IllegalArgumentException.class, () -> Operations.topoSortStates(a));
  }
}
Command
cp $REPRO/TestTopoSortSelfLoop.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/automaton/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.automaton.TestTopoSortSelfLoop -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
Expected
Operations.topoSortStates(a) throws IllegalArgumentException ("Input automaton has a cycle.").
Observed
It returns [0, 1]: "Expected exception IllegalArgumentException but no exception was thrown", with assertions on and off.

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

More files, environment, full output172 words
  • API. Automaton.createState, setAccept, addTransition, finishState, Operations.topoSortStates and LuceneTestCase.expectThrows; the package is for locality only, no package-private member is used. Nothing random is used.
  • Smallest input. Two states, the smallest automaton with a self-loop on a non-initial state; a self-loop on state 0 alone is caught.
  • Releases. The same file fails unchanged on every release 9.7.0 to 10.6.0.
  • Regression test. This same method, for example added to TestOperations.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on every release 9.7.0 to 10.6.0
jdkOpenJDK 25.0.3
osLinux, aarch64
Command (environment)
cp $REPRO/TestTopoSortSelfLoop.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/automaton/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.util.automaton.TestTopoSortSelfLoop -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on
TestTopoSortSelfLoop > testSelfLoopIsACycle FAILED
    junit.framework.AssertionFailedError: Expected exception IllegalArgumentException but no exception was thrown
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:3FBC0C235F1821E]:0)
        at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
        at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
        at app//org.apache.lucene.util.automaton.TestTopoSortSelfLoop.testSelfLoopIsACycle(TestTopoSortSelfLoop.java:32)
...
1 test completed, 1 failed
# assertions off (-Ptests.asserts=false): the same AssertionFailedError
# with fix A or B, assertions on and off: ":lucene:core:test (SUCCESS): 1 test", BUILD SUCCESSFUL

Why it looks unintended

  • The javadoc promises it: the method "will throw an IllegalArgumentException if a cycle is detected"; the helper's @throws is unconditional.
  • #12286 added the check on purpose, replacing "Behavior is undefined if this automaton has cycles", and TestOperations.testCycledAutomaton asserts it.
  • The code means the textbook check but marks the parent, not the pushed state, so self-loops escape.
The full argument200 words
  • Contract. Operations.java:1348-1350: "This method assumes that the automaton does not contain cycles, and will throw an IllegalArgumentException if a cycle is detected." :1359-1360: "as this method detects cycles, it will never attempt to use infinite RAM." :1394: "@throws IllegalArgumentException if the input automaton has a cycle."
  • History. Before #12286 the javadoc said "Behavior is undefined if this automaton has cycles". In the PR description the author asked "Perhaps we should check if the automaton contains cycles, and throw an IllegalArgumentException if it does?"; mikemccand answered "That would be nice, though it really is illegal usage of the API -- can you detect that w/o much added cost in the iterative solution?"; the author added "a BitSet to track whether the current state is already on the stack"; rmuir wrote "the cycle detection is a really nice bonus. thanks for the unit test for that."; and mikemccand's "Since we detect cycles we will never have an 'attempt to use infinite RAM'" became the javadoc of #12292.
  • Test. TestOperations.testCycledAutomaton (TestOperations.java:80-85) expects the exception. Its random automaton's only back edge goes to the initial state (:295-297), which the code catches, so it cannot see this gap.
The devil's-advocate check173 words

Strongest case for "not a bug": the same sentence begins "This method assumes that the automaton does not contain cycles", and its promise is worded "will throw ... if a cycle is detected", which can be read as best-effort detection; mikemccand called cyclic input "illegal usage of the API", so cyclic input breaks a precondition, the exception is best effort, and Operations is @lucene.experimental. A cross-model reviewer (OpenAI GPT-5.6, refute lens) built this argument and rated it moderate.

Why it fails: the helper's @throws has no such condition ("if the input automaton has a cycle"), #12286 replaced "undefined" with that promise and tested it, and #12292's javadoc relies on it; no comment, test or PR remark treats self-loops as acceptable. @lucene.experimental is about API stability, not correctness. The reviewer reproduced the miss with its own RegExp("ab+") input and judged it real with high confidence. The dispute is about how serious the gap is, not whether it happens; the precondition reading is why the impact stays low: no correct in-tree caller hits it.

Suggested fix

Recommended A. Mark the pushed state on-stack, and mark state 0 at the start

Mark a state as on-stack when it is pushed, not its parent when the parent pushes, and mark state 0 at the start: the textbook check, +3/-1 lines, same output for acyclic input. Validated: the test fails before and passes after; 352 automaton, analysis and suggester tests pass, and the study's exhaustive check finds 0 misses.

     stack.push(0); // Assuming that the initial state is 0.
+    visited.set(0);
+    onStack.set(0);
...
         if (!visited.get(t.dest)) {
           visited.set(t.dest);
+          onStack.set(t.dest);
           stack.push(t.dest); // Push the next unvisited state onto the stack
-          onStack.set(state);
           pushed = true;
All options and trade-offs3 options ยท 533 words

For an acyclic input both code fixes return exactly the same array as today: the changed lines only decide when to throw, and marking state 0 visited only matters when an edge leads back to state 0, which is itself a cycle.

A. Mark the pushed state on-stack, and mark state 0 at the start (validated by the checks) (recommended)

In the private helper: after stack.push(0) add visited.set(0) and onStack.set(0); when pushing an unvisited child, call onStack.set(t.dest) and drop onStack.set(state). A state is then marked from its push to its pop, so its own loop, like any back edge, finds it marked.

     stack.push(0); // Assuming that the initial state is 0.
+    visited.set(0);
+    onStack.set(0);
...
         if (!visited.get(t.dest)) {
           visited.set(t.dest);
+          onStack.set(t.dest);
           stack.push(t.dest); // Push the next unvisited state onto the stack
-          onStack.set(state);
           pushed = true;

Trade-offs

  • +3/-1 lines, no extra work per transition; matches the standard DFS cycle check.
  • A back edge to state 0 is now reported at once instead of after pushing state 0 a second time.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF: the minimal test fails before the change and passes after it, with assertions on and off (the mode is set on the command line).
  • org.apache.lucene.util.automaton.* (16 classes, incl. TestOperations 23/23), TestConcatenateGraphFilter and its factory test, TestAnalyzingSuggester, TestFuzzySuggester and org.apache.lucene.search.suggest.document.*: 352 tests, 2 skipped, 0 failures.
  • The study's characterization suite against the patched class: 0 misses, 0 false alarms, 0 invalid orders on 132,132 cases (every graph with up to 4 states, in two scan orders) and 200,000 random graphs (stock main: 2,906 and 13,713 misses). The suite's JVM still reports "Tests run: 9, Failures: 2" on the patched class, because two of its tests assert that the stock method misses cases; the counters are the result.
  • The study's TLA+ model checked the same fix on all graphs with 3 states (every scan order) and 4 states (sorted order).
  • Not run: the whole :lucene:core:test or check.

B. Treat a self-loop as a cycle explicitly (validated by the checks)

Change the check to else if (t.dest == state || onStack.get(t.dest)).

-        } else if (onStack.get(t.dest)) {
+        } else if (t.dest == state || onStack.get(t.dest)) {
           throw new IllegalArgumentException("Input automaton has a cycle.");

Trade-offs

  • One condition, at the check itself.
  • Keeps the unusual marking (the parent is marked; an edge back to state 0 still pushes 0 twice before the cycle is seen), so it is correct only through the argument that every longer cycle ends at an ancestor that already pushed.

What the checks validated

  • Gradle, seed DEADBEEF: org.apache.lucene.util.automaton.*, 239 tests, 2 skipped, 0 failures (incl. the minimal test and TestOperations); the minimal test alone passes with assertions on and off (in a separate re-run).
  • The study's characterization suite against the patched class: 0 misses, 0 false alarms, 0 invalid orders on the same graphs (its JVM again reports 2 failures, for the same reason as under A).

Not recommended: change only the javadoc

Say that self-loops may go undetected.

Trade-offs

  • No code change, but it takes back a promise #12286 made on purpose and leaves callers without a reliable check. Not run.

Downstream note

Downstream code that uses this exception to reject cyclic automata built from user-supplied patterns lets patterns such as ab* or a+ through; Lucene's own query and analysis code does not pass such automata to it, for token streams that follow the attribute contract.

How it was found

Semi-formal. An exhaustive check of the real method against an exact reference, over all 2,097,152 automata with 3 states on the letters a and b, flagged 14,616 misses, and a TLA+ model of the search (TLC) produced the 2-state counterexample 0 -> 1, 1 -> 1 in 2 seconds. An independent verifier then reproduced it with its own tests.

The method in full166 words
  • Method. The study's automaton experiment compared Lucene's real topoSortStates with an exact reference over every automaton with 3 states on the letters a and b (2,097,152 automata): 14,616 were sorted instead of rejected.
  • Model. A TLA+ model of the iterative search, with the graph and every successor order chosen freely, and the invariants "no missed cycle", "no false cycle" and "the output is a reverse topological order of exactly the reachable states". TLC found a counterexample trace of 5 TLC states (the initial state and 4 search steps) for 0 -> 1, 1 -> 1 in 2 s; with the fix, no error on all graphs with 3 states in every order and with 4 states in sorted order.
  • Verifier. An independent verifier wrote its own 9 tests (the minimal case, ab*, a+, a self-loop with a visited sibling, 3 controls, and an exhaustive and a random characterization against Kahn's algorithm, a different sorting method): 4 contract failures, 5 passes, every miss self-loop-only.

Prior reports, history and checks

Prior reports

No matching report found in the recorded searches (2026-10-22 and 2026-11-01): GitHub issues and PRs incl. the JIRA archive, commits, mailing lists, Elasticsearch, OpenSearch and Solr, the web, and the files of all 409 open PRs. Closest: #12286, which added the check, and #12292 (javadoc); neither mentions self-loops.

Searches4 searches

Not fixed upstream: Operations.java on upstream main f815e679bf6c (2026-11-01), branch_11x and the 10.6.0 tag carries the same helper lines, and no open PR touches the file (a snapshot of 2026-11-01).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, incl. the migrated JIRA archive)2026-10-22, 2026-11-01the study's 16 queries; 36 scripted queries; 48 GraphQL phrasings (topoSortStates, toposort, self-loop, "has a cycle", "cycle detection", onStack, acyclic, isFinite, replaceSep, testCycledAutomaton, ...)related only: #12286, #12292, #12284, #11813, #11809, #8963, #7425, #7422, #7022, #4044, #6721, #6690
Open PRs2026-11-01the changed files of all 409 open PRs9 touch util/automaton, none touches Operations.java or its callers
Mailing lists, downstream repositories, web2026-11-01dev@, java-user@, issues@, commits@; Elasticsearch, OpenSearch, Solr; 6 web queriesno report; only notifications of #12284 / #12286 and an old recursion-depth report
Related threads read in full2026-11-0117 threads, incl. #12286 (20 comments, 9 review comments)self-loops are not discussed anywhere

History

Introduced by #12286 (5d203f8337c, merged 2023-05-15), which replaced the recursive sort with an iterative one and added the cycle check; first released in 9.7.0. Fails on all 25 releases 9.7.0 to 10.6.0 and on main; 9.6.0 and earlier had no cycle check and called cyclic input undefined. Not fixed upstream.

Versions tested6 rows

Introduced by #12286 "toposort use iterator to avoid stackoverflow" (tang-hi; merged by uschindler on 2023-05-15 as 5d203f8337c; branch_9x backport 76e8a4201a5, identical hunks; CHANGES 9.7.0). The helper has not changed since. #12292 (the same day, under two hours later) added the RAM sentence to the javadoc. topoSortStates itself dates from 5.3.0 (LUCENE-6365); until 9.6.0 it was recursive, had no cycle check and documented cyclic input as undefined.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedfails, assertions on and off (gradle and standalone)
10.6.0 affectedfails, assertions on and off
9.7.0 to 10.5.2 (24 releases) affectedeach fails with assertions on; also off on 9.7.0 and 10.5.2
76e8a4201a5 (#12286 backport) vs its parent 972de546e9b commit pairthe backport misses the self-loop but catches a 2-state cycle; its parent has no cycle check
9.6.0 and earlier no contractsame order returned, but no cycle check exists and the javadoc calls cyclic input undefined
fix A, fix B on 645d4fe14a20 not affectedthe test passes

Independent checks

A study verifier, three main re-runs, the current-main verification and this doc's gradle runs all reproduce it. A cross-model reviewer (OpenAI GPT-5.6) reproduced it with its own RegExp("ab+") test, agreed on the cause and the fix, and judged it real with high confidence.

Checks and verdicts243 words

The cross-model reviewer ran on main ea57360912ed, whose Operations.java is byte-identical to 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
study verifier realOwn 9 tests: 4 contract failures (ab*, a+, the minimal case, a visited sibling), 5 control and characterization tests pass (3 controls, an exhaustive and a random characterization); every miss in the exhaustive and random scopes is self-loop-only.
main re-runs still reproducesb83101cdd2 (2026-10-23) and a9779ef3c193 (2026-10-26): 9 run, 4 failed.
minimal repro verifiedOne test; fails on main with assertions on and off; the fix passes.
current-main verification realMain, 10.5.2 and 9.7.0 executed; with the fix, TestOperations passes 23/23.
cross-model review, refute lens real, highOpenAI GPT-5.6: own RegExp("ab+") test fails on main, passes with the fix; rated the precondition argument moderate.
history confirmedAll 25 releases 9.7.0 to 10.6.0 and the backport commit pair executed.
doc gradle runs fails before, passes with A and BScratch worktree of 645d4fe14a20; 352 tests with A, 239 with B; the study suite on both.
fact checks of this doc real; wording fixedAn independent fact check re-ran the repro (standalone and gradle, assertions on and off, before and after fixes A and B) and its own exhaustive probe; a cross-model fact check (OpenAI GPT-5.6), which could not run code, re-read the code and logs. Both confirm the bug; their wording corrections are applied.

Diagram file

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

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes381 words

Finding record

LFV-062, snapshot of 2026-11-06

Registry title
Operations.topoSortStates misses self-loop cycles and returns an order instead of throwing
Area
util / automaton
Priority
Low, rank 1213: Operations.topoSortStates misses self-loop cycles instead of throwing, but its javadoc assumes acyclic input and every in-tree caller complies.
Impact class
minor
Impact note
Only direct API callers that rely on the exception as a cycle check; in-tree callers pass acyclic automata; released 9.7.0-10.6.0.
Summary
You call Operations.topoSortStates on an automaton whose only cycles are self-loops on states that push no child, such as determinized ab* or a+. You expect IllegalArgumentException for a cyclic automaton. Instead it returns an order.
Introduced
#12286 (5d203f8337c) (2023-05-15)
First affected version
9.7.0 [exec]
Affected versions
9.7.0-10.6.0 (all 25 releases) and main [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
Found by
semi-formal: TLA+/TLC, exhaustive check
Upstream and prior reports
none found (study searches 2026-10-22; searched again 2026-11-01)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test through gradle with assertions on and off, before and after fixes A and B, in a scratch worktree (2026-11-01), and standalone (2026-10-29 and 2026-11-01); all 25 releases 9.7.0 to 10.6.0, 9.6.0 and the commit pair of the #12286 backport (2026-11-01); the study's characterization suite on stock main and on both fixes (2026-11-01); earlier study runs on main 7cecf79523, b83101cdd2 and a9779ef3c193.

Lucene and upstream sources

The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:1347-1432 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/automaton/Automaton.java:297-298 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:53-57 (at 645d4fe14a20)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/miscellaneous/ConcatenateGraphFilter.java:299 (at 645d4fe14a20)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/AnalyzingSuggester.java:278 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/analysis/TokenStreamToAutomaton.java:174; lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/PackedTokenAttributeImpl.java:80-86; lucene/core/src/java/org/apache/lucene/analysis/tokenattributes/PositionLengthAttributeImpl.java:30-34; lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:81-83 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/automaton/FiniteStringsIterator.java:120; lucene/test-framework/src/java/org/apache/lucene/tests/util/automaton/AutomatonTestUtil.java:559 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:1347-1364, :1388-1395 (at 645d4fe14a20)
  • lucene/core/src/test/org/apache/lucene/util/automaton/TestOperations.java:80-85, :267-297 (at 645d4fe14a20)
  • github.com/apache/lucene/pull/12286 (thread re-read 2026-11-01 for the quotes)
History
  • lucene/CHANGES.txt:3554 (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.

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.