Choreo FSM: Comprehensive Walkthrough
Mix.install([
{:choreo, github: "code-shoily/choreo", branch: "main"},
{:kino_vizjs, "~> 0.8"}
])
Introduction
Choreo.FSM is a finite-state machine builder that supports deterministic finite automata (DFAs). You define states, transitions, and analyse reachability, acceptance, and more. Everything renders to DOT for visualisation.
alias Choreo.FSM
alias Choreo.FSM.Analysis
fsm =
FSM.new()
|> FSM.add_initial_state(:idle)
|> FSM.add_state(:authenticating)
|> FSM.add_final_state(:logged_in)
|> FSM.add_transition(:idle, :authenticating, label: "submit_credentials")
|> FSM.add_transition(:authenticating, :logged_in, label: "success")
Kino.VizJS.render(FSM.to_dot(fsm))
State Types
There are three ways to add states. Each stores type information in the FSM’s meta field, so analysis and rendering always stay in sync.
| Function | Purpose |
|---|---|
add_state/3 |
Normal state. Optionally pass type: :initial or type: :final. |
add_initial_state/3 |
Entry point of the machine. Rendered with a black dot arrow. |
add_final_state/3 |
Accepting state. Rendered as a double circle. |
You can also remove a special status without deleting the state:
# Promote and demote
fsm = FSM.new() |> FSM.add_state(:a, type: :initial)
IO.inspect(:a in FSM.initial_states(fsm), label: "Is :a initial?")
fsm = FSM.remove_initial_state(fsm, :a)
IO.inspect(:a in FSM.initial_states(fsm), label: "Is :a initial after removal?")
A state can be both initial and final
This is useful for modelling “already accepted” start conditions (e.g. an empty string match).
empty_match =
FSM.new()
|> FSM.add_initial_state(:q0)
|> FSM.add_final_state(:q0)
|> FSM.add_transition(:q0, :q0, label: "a")
Kino.VizJS.render(FSM.to_dot(empty_match))
Building Larger Graphs
order_flow =
FSM.new()
|> FSM.add_initial_state(:cart_created)
|> FSM.add_state(:checkout)
|> FSM.add_state(:payment_pending)
|> FSM.add_state(:fraud_check)
|> FSM.add_final_state(:order_shipped)
|> FSM.add_final_state(:cancelled)
|> FSM.add_transition(:cart_created, :checkout, label: "proceed")
|> FSM.add_transition(:checkout, :payment_pending, label: "pay")
|> FSM.add_transition(:payment_pending, :fraud_check, label: "authorized")
|> FSM.add_transition(:fraud_check, :order_shipped, label: "passed")
|> FSM.add_transition(:payment_pending, :cancelled, label: "declined")
Kino.VizJS.render(FSM.to_dot(order_flow))
Determinism
An FSM is deterministic when two conditions hold:
- Exactly one initial state.
- No state has two outgoing transitions with the same label.
Choreo.FSM ensures validation tools help track structural determinism.
# Deterministic — single start, unique labels per state
dfa =
FSM.new()
|> FSM.add_initial_state(:q0)
|> FSM.add_state(:q1)
|> FSM.add_final_state(:q2)
|> FSM.add_transition(:q0, :q1, label: "a")
|> FSM.add_transition(:q1, :q2, label: "b")
Analysis.deterministic?(dfa)
Analysis
Reachability & Dead States
IO.inspect(Analysis.reachable_states(order_flow), label: "Reachable")
IO.inspect(Analysis.dead_states(order_flow), label: "Dead (trap) states")
Acceptance
IO.inspect(Analysis.accepts?(order_flow, ["proceed", "pay", "authorized", "passed"]), label: "Accepts happy path")
IO.inspect(Analysis.accepts?(order_flow, ["proceed", "pay", "declined"]), label: "Accepts cancelled path")
IO.inspect(Analysis.accepts?(order_flow, ["proceed"]), label: "Rejects incomplete path")
Path Finding
IO.inspect(Analysis.shortest_accepting_path(order_flow), label: "Shortest accepting path")
IO.inspect(Analysis.accepted_strings(order_flow, 3), label: "Accepted strings up to length 3")
Alphabet & Completeness
IO.inspect(Analysis.alphabet(order_flow), label: "Alphabet")
IO.inspect(Analysis.complete?(order_flow), label: "Complete?")
Validation
validate/1 returns a list of structural issues found in the FSM.
bad_fsm =
FSM.new()
|> FSM.add_state(:orphan)
|> FSM.add_state(:trap)
|> FSM.add_initial_state(:a)
|> FSM.add_final_state(:c)
|> FSM.add_transition(:a, :c, label: "x")
|> FSM.add_transition(:a, :c, label: "y")
Analysis.validate(bad_fsm)
|> Enum.each(fn {severity, msg} ->
IO.puts("[#{severity}] #{msg}")
end)
bad_fsm
|> Choreo.FSM.Render.DOT.to_dot()
|> Kino.VizJS.render()
Transforms
Complement
Swap final and non-final states.
comp = FSM.complement(dfa)
IO.inspect(FSM.final_states(comp), label: "Final states in complement")
Kino.VizJS.render(FSM.to_dot(comp))
Prune
Remove unreachable states and dead (trap) states, producing a smaller equivalent FSM.
pruned = FSM.prune(bad_fsm)
IO.inspect(FSM.states(pruned), label: "States after pruning")
Kino.VizJS.render(FSM.to_dot(pruned))
Theming
Render with the built-in :dark theme or a custom Choreo.Theme.
dot_dark = FSM.to_dot(order_flow, theme: :presentation)
Kino.VizJS.render(dot_dark)
Summary
| Task | Function |
|---|---|
| Build |
FSM.new/1, add_state/3, add_initial_state/3, add_final_state/3, add_transition/4 |
| Demote |
remove_initial_state/2, remove_final_state/2 |
| Determinism check |
Analysis.deterministic?/1 |
| Acceptance |
Analysis.accepts?/2 |
| Reachability |
Analysis.reachable_states/1, Analysis.dead_states/1 |
| Paths |
Analysis.shortest_accepting_path/1, Analysis.accepted_strings/2 |
| Validation |
Analysis.validate/1 |
| Transforms |
FSM.complement/1, FSM.prune/1 |
| Render |
FSM.to_dot/2 |