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.
- An
Automatonis 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. - 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.
Operations.topoSortStatesfinds 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.
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.- 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. - Call
Operations.topoSortStates(a). You expectIllegalArgumentException("Input automaton has a cycle."): the javadoc says it "will throw an IllegalArgumentException if a cycle is detected". - You get
[0, 1]and no exception, with assertions on and off. DeterminizedRegExp("ab*")andRegExp("a+")(3 states each) return[0, 1, 2];a*and(ab)*care rejected as they should be. - 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.
- 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.
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.
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):
:1400pushes state 0 without setting itsvisitedoronStackbit.:1414sets the bit ofstate, the parent on top of the stack, not of the pushedt.dest.:1417-1419is the only cycle check: a visited successor whoseonStackbit is set.:1424-1429clear, pop and emit a state that pushed nothing in this pass; every peek rescans its edges from the first one (:1407).
| step | top | edge | visited after | onStack after | what happens |
|---|---|---|---|---|---|
| 1 | 0 | 0 -> 1 | {1} | {0} | 1 is new: push 1, mark the parent 0 |
| 2 | 1 | 1 -> 1 | {1} | {0} | 1 is visited but not marked: no throw; nothing new, so pop and emit 1 |
| 3 | 0 | 0 -> 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.
visitedkeeps 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).
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 acceptsab*ora+and gets an order back; what happens next depends on that code. Core has had no publicOperations.isFinitesince #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.replaceSepandAnalyzingSuggester.replaceSep, getTokenStreamToAutomatonoutput. Its edges run forward from a position topos + positionLength, and both stock attribute implementations (PackedTokenAttributeImpl, the default, andPositionLengthAttributeImpl) rejectpositionLength < 1, so for token streams that follow that contract it is acyclic.TokenStreamToAutomatondoes 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.
FiniteStringsIteratorhas its own cycle check. - Scale. One call on a 2-state automaton; no index.
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.
/*
* 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));
}
}
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)throwsIllegalArgumentException("Input automaton has a cycle.").- Observed
- It returns
[0, 1]: "Expected exception IllegalArgumentException but no exception was thrown", with assertions on and off.
More files, environment, full output172 words
- API.
Automaton.createState,setAccept,addTransition,finishState,Operations.topoSortStatesandLuceneTestCase.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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on every release 9.7.0 to 10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
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)
# 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- The javadoc promises it: the method "will throw an IllegalArgumentException if a cycle is detected"; the helper's
@throwsis unconditional. - #12286 added the check on purpose, replacing "Behavior is undefined if this automaton has cycles", and
TestOperations.testCycledAutomatonasserts 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
IllegalArgumentExceptionif 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.
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.TestOperations23/23),TestConcatenateGraphFilterand its factory test,TestAnalyzingSuggester,TestFuzzySuggesterandorg.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:testorcheck.
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 andTestOperations); 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).
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 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.
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
topoSortStateswith 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 -> 1in 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.
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).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, incl. the migrated JIRA archive) | 2026-10-22, 2026-11-01 | the 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 PRs | 2026-11-01 | the changed files of all 409 open PRs | 9 touch util/automaton, none touches Operations.java or its callers |
| Mailing lists, downstream repositories, web | 2026-11-01 | dev@, java-user@, issues@, commits@; Elasticsearch, OpenSearch, Solr; 6 web queries | no report; only notifications of #12284 / #12286 and an old recursion-depth report |
| Related threads read in full | 2026-11-01 | 17 threads, incl. #12286 (20 comments, 9 review comments) | self-loops are not discussed anywhere |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | fails, assertions on and off (gradle and standalone) |
| 10.6.0 | affected | fails, assertions on and off |
| 9.7.0 to 10.5.2 (24 releases) | affected | each fails with assertions on; also off on 9.7.0 and 10.5.2 |
76e8a4201a5 (#12286 backport) vs its parent 972de546e9b | commit pair | the backport misses the self-loop but catches a 2-state cycle; its parent has no cycle check |
| 9.6.0 and earlier | no contract | same order returned, but no cycle check exists and the javadoc calls cyclic input undefined |
fix A, fix B on 645d4fe14a20 | not affected | the test passes |
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.
| Check | Verdict | What it found |
|---|---|---|
| study verifier | real | Own 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 reproduces | b83101cdd2 (2026-10-23) and a9779ef3c193 (2026-10-26): 9 run, 4 failed. |
| minimal repro | verified | One test; fails on main with assertions on and off; the fix passes. |
| current-main verification | real | Main, 10.5.2 and 9.7.0 executed; with the fix, TestOperations passes 23/23. |
| cross-model review, refute lens | real, high | OpenAI GPT-5.6: own RegExp("ab+") test fails on main, passes with the fix; rated the precondition argument moderate. |
| history | confirmed | All 25 releases 9.7.0 to 10.6.0 and the backport commit pair executed. |
| doc gradle runs | fails before, passes with A and B | Scratch worktree of 645d4fe14a20; 352 tests with A, 239 with B; the study suite on both. |
| fact checks of this doc | real; wording fixed | An 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. |
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.
- PNG 382 KB
Finding record, Lucene and upstream sources, repro files and diagram notes381 words
- 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 main7cecf79523,b83101cdd2anda9779ef3c193.
- 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)
- TestTopoSortSelfLoop.java:
repro/LFV-062/src/org/apache/lucene/util/automaton/TestTopoSortSelfLoop.java(34 lines)
Made in parallel with this doc.
assets/LFV-062/bug.png(382 KB)
![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.](../assets/LFV-062/bug.png)