Phase 1 — semantics doc
docs/002. hsmc-semantics-formal.md
Phase 2 — deterministic execution journal
hsmc/src/journal.rs (TraceEvent, JournalSink, ActionKind)
hsmc-macros: __chart_journal! emission at every observable site
(entry/exit/action/transition/timer arm-cancel-fire/during/emit/
event delivery/terminate). Per-state during count helpers, stable
chart-hash via FNV-1a. Action observation moved out of run_action
and into call sites so ActionKind is accurate.
Public API on the generated machine: journal(), take_journal(),
clear_journal(), CHART_HASH.
Phase 3 — verification crate (Creusot scaffolding)
verification/{Cargo.toml,mise.toml,requirements.txt,INVARIANTS.md}
verification/src/{event_queue.rs,timer_table.rs} — Pearlite
contracts on simplified mirrors of the runtime types. cargo
creusot generates Coma IR cleanly; VCs not yet discharged.
Correspondence tests drive the verified mirror and the runtime
type with the same op sequences and assert observable parity.
Tests — 113 deterministic-flow tests across 22 files. Each compares a
hand-built expected journal byte-for-byte against actual, anchored
to a spec section: initial entry, lateral/self/up transitions,
bubbling, timers, emit, ordering, durings, step/dispatch
equivalence, root-targeting, current_state, termination.
Tooling — mise.toml (root): stable Rust + cargo subcommands.
verification/mise.toml: pinned nightly-2025-11-13 + opam + python
venv + uv. just verify is fully self-installing.
Bug fix — root state targetable by the chart's own name (e.g.
`on(Trig) => ChartName;`) per spec §"How transitions work".
CHART_HASH — stable FNV-1a fingerprint over the chart's structural
definition; mismatch on replay = different chart.
372 lines
19 KiB
Markdown
372 lines
19 KiB
Markdown
# `hsmc` — How Your Chart Behaves
|
|
|
|
**Status:** DRAFT. This is the canonical answer to "what will my statechart
|
|
actually do?" When this document and another disagree, this one wins.
|
|
|
|
**Audience:** anyone writing a chart. The goal is that after reading this,
|
|
you can predict every action call, transition, and timer fire just by
|
|
looking at your `statechart!` block.
|
|
|
|
---
|
|
|
|
## Why `hsmc` is small (read this first)
|
|
|
|
`hsmc` ships the minimum set of useful statechart primitives. No guard
|
|
conditions, no orthogonal/parallel regions, no history states, no deferred
|
|
events, no internal transitions, no state-local context.
|
|
|
|
The bet is on **simple, consistent semantics**. The small surface area
|
|
makes hsmc easy to use, easy to reason about, and easy to predict. Once
|
|
you internalize a small set of rules, every edge case below just follows
|
|
from them — you don't have to memorize anything new.
|
|
|
|
---
|
|
|
|
## What a chart contains
|
|
|
|
Each primitive, in roughly the order a developer first meets them.
|
|
|
|
- **State** — a named node. The root state (the machine itself) is just a
|
|
state. Every state is just a state.
|
|
- **Nested state** — a state inside another state. Same thing as a state.
|
|
- **Entry action** — a function called once when a state is entered.
|
|
- **Exit action** — a function called once when a state is exited.
|
|
- **Transition** — exits one state and enters another, anywhere else in the
|
|
chart. Walks from the current state up to the lowest common ancestor
|
|
(LCA) of source and target, calling exits along the way, then walks down
|
|
to the target, calling entries along the way.
|
|
- **Action (without transition)** — a function call attached to a trigger.
|
|
No state change. That's it.
|
|
- **Default child** — every non-leaf state declares one default child.
|
|
Whenever a state with children is entered, the default child is entered
|
|
immediately after that state's entry actions finish — it's effectively
|
|
an automatic transition into the child.
|
|
- **Trigger: event vs. duration** — an event is a variant of your event
|
|
enum. A duration is a timer (`after Duration` fires once, `every
|
|
Duration` fires repeatedly). Triggers attach to transitions and to
|
|
actions. Timers and events are dispatched slightly differently — see
|
|
the bubbling rules below.
|
|
- **`emit()`** — pushes an event onto the machine's queue from inside an
|
|
action. The event is dispatched after the current event has fully
|
|
finished processing. No re-entrant dispatch.
|
|
- **`during:` activity** — a cancellable async function. Started **after**
|
|
the entry actions of its owning state finish; cancelled **before** the
|
|
exit actions of that state begin to run when the state is left.
|
|
- **`terminate`** — names an event that, when received, exits the entire
|
|
active path bottom-up and shuts the machine down. Only valid at root.
|
|
|
|
---
|
|
|
|
## Where the machine "is"
|
|
|
|
At any moment, the machine is in a path of states from root down to one
|
|
innermost state. If you're in `C` inside `B` inside `A` (which is inside
|
|
the root), then you are simultaneously in **all** of `Root`, `A`, `B`, and
|
|
`C`. The root is always active until the machine terminates.
|
|
|
|
This is the most important rule in this document. Every other rule below
|
|
is downstream of it. **You are simultaneously in every state on the path
|
|
from the root to the innermost active state.** If a rule somewhere else
|
|
seems weird, come back to this one — it's probably the resolution.
|
|
|
|
`current_state()` returns the innermost active state, which is usually a
|
|
leaf, but can be a composite (a state with children) if you got there via
|
|
an up-transition (see below) and haven't moved since.
|
|
|
|
---
|
|
|
|
## How events are handled
|
|
|
|
Event comes in. Start at the innermost active state. Does it have a
|
|
handler for this event? If yes, that handler runs and the event is
|
|
consumed — no further handling.
|
|
|
|
If no, walk up to the parent. Repeat. If you walk all the way to the root
|
|
and still nothing handles it, the event is silently discarded.
|
|
|
|
That's all. Three other things follow from this rule:
|
|
|
|
- A handler on a leaf state shadows a handler on its ancestor for the same
|
|
event. The leaf wins because the search starts there.
|
|
- If a single state has multiple handlers (actions and/or a transition)
|
|
for the same trigger, the actions fire in declaration order, then the
|
|
transition fires last.
|
|
- Timer triggers do **not** bubble. A timer belongs to the state that
|
|
declared it; it fires only in that state's scope. Events bubble; timers
|
|
don't.
|
|
|
|
---
|
|
|
|
## How transitions work
|
|
|
|
The current state (for transition purposes) is the innermost active state.
|
|
The target can be **any** state in the entire chart — there's a global
|
|
namespace, so state names must be unique across the chart.
|
|
|
|
The root state is targetable too: it's named after the chart itself. So
|
|
in `statechart! { Foo { ... } }`, an `on(Trig) => Foo;` is a valid
|
|
transition whose target is the root. From any state inside the chart this
|
|
is an up-transition (root is always on the active path) — exiting only
|
|
the states strictly between current and root, with no entry actions and
|
|
no default-descent on root. Use this when you want to wind everything
|
|
back up to the chart's own scope.
|
|
|
|
To run a transition:
|
|
|
|
1. Find the LCA of current and target. (They might share a parent; they
|
|
might both be on the same root-to-leaf path; the LCA is always defined
|
|
because the root is always an upper bound.)
|
|
2. Walk **up** from the current state, exiting each state on the way
|
|
(cancel durings → run exit actions) until you hit the LCA. The LCA
|
|
itself is **not** exited.
|
|
3. Walk **down** from the LCA to the target, entering each state on the
|
|
way (run entry actions → start durings). The LCA is **not** re-entered.
|
|
4. If the target has children, follow the default-child chain down to a
|
|
leaf, entering each as you go.
|
|
|
|
The three flavors below are just three relationships between current and
|
|
target. The same algorithm covers all of them — they only look different
|
|
in which steps fire.
|
|
|
|
### Transitioning to a sibling (or anywhere not on your active path)
|
|
|
|
The standard case. LCA is somewhere strictly above both current and
|
|
target. Exit up to LCA, enter down to target, default-descend if the
|
|
target has children. Already covered above.
|
|
|
|
### Transitioning to yourself
|
|
|
|
Self-transition: target equals the innermost active state. The standard
|
|
transition algorithm above gives the clean answer: the LCA of the target
|
|
with itself is its parent, so we exit only the target and re-enter only
|
|
the target. **Nothing above the target is exited or re-entered.**
|
|
|
|
Concrete example. Active path is `Root → SomeState → SomeOtherState`. A
|
|
transition fires whose target is `SomeOtherState`.
|
|
|
|
- Exit `SomeOtherState` (cancel durings, run exits).
|
|
- Re-enter `SomeOtherState` (run entries, start durings).
|
|
- `SomeState` is **never** exited, **never** re-entered, **never**
|
|
touched. Its timers and durings keep running.
|
|
|
|
Timers declared on the target restart, because the target itself was
|
|
exited and re-entered.
|
|
|
|
If the target happens to be a composite (it has children, but no child is
|
|
active right now because you got there via an up-transition), the same
|
|
default-child rule still applies on re-entry: exit the composite, re-enter
|
|
it, then descend through its default chain. Self-transitions don't add a
|
|
new rule — they're just the case where source and target are the same
|
|
state.
|
|
|
|
Personally I don't find self-transitions all that valuable, but people
|
|
talk about them all the time, so they're supported.
|
|
|
|
### Transitioning to one of your parents (the important one)
|
|
|
|
The case to internalize. When you transition to an ancestor of the
|
|
current state, you exit the current state and any states between, but you
|
|
do **not** re-enter the ancestor. You were already there.
|
|
|
|
- The ancestor's entry actions don't fire.
|
|
- The ancestor's `default` is not followed.
|
|
- The ancestor's timers don't restart.
|
|
- The ancestor's durings keep running.
|
|
|
|
You cannot enter a state you never left. Spelling it out: starting from
|
|
leaf `C` inside `B` inside `A`, a transition to `A` exits `C` then `B`,
|
|
and now `A` is the innermost active state with no active child.
|
|
|
|
---
|
|
|
|
## Timers
|
|
|
|
- A timer starts when its state is entered.
|
|
- A timer is cancelled when its state is exited.
|
|
- Descending into a child state is **not** an exit — the parent's timers
|
|
keep running.
|
|
- Timers don't bubble. Only the state that declared the timer handles its
|
|
expiry.
|
|
- Re-entering a state restarts its timers.
|
|
|
|
If two timers happen to expire on the same `step()` tick — rare under
|
|
real wall-clock timing, but possible at `step()` granularity since
|
|
`step()` decrements all timers at once — the runtime today fires the
|
|
*oldest* (longest-armed) timer first, because it sits earlier in the
|
|
active-timers array. **Don't depend on this.** It's a likely outcome of
|
|
the current implementation, not a contract. If two timers must fire in a
|
|
specific order, model them as one timer plus an explicit `emit()` chain.
|
|
|
|
---
|
|
|
|
## Entry and exit ordering
|
|
|
|
Putting it all together:
|
|
|
|
- **Entry** runs **outer-to-inner**: when entering a path of states, the
|
|
outermost state's entry actions fire first, then its child's, and so on
|
|
down to the innermost. Within a single state, entry actions fire in
|
|
declaration order. After entry actions of a state finish, that state's
|
|
durings start. Then the default child (if any) is entered, recursively.
|
|
- **Exit** runs **inner-to-outer**: when leaving a path of states, the
|
|
innermost state exits first, then its parent, and so on up. Within a
|
|
single state, exit actions fire in declaration order. Before exit
|
|
actions of a state begin, that state's durings are cancelled.
|
|
|
|
So a full transition reads top-to-bottom as: cancel durings on the way
|
|
out → run exits on the way out → run entries on the way in → start
|
|
durings on the way in.
|
|
|
|
---
|
|
|
|
## `emit()` — sending events from inside an action
|
|
|
|
An action can call `self.emit(Ev)`. The event goes on the machine's
|
|
internal queue. It is **not** processed immediately. The runtime finishes
|
|
handling whatever event it's currently handling — including every entry
|
|
and exit action of any transition — and only then dequeues `Ev` and
|
|
dispatches it normally. No re-entrant dispatch.
|
|
|
|
Once the current handler finishes, the runtime drains anything queued
|
|
during handling before sleeping or returning to the outer loop.
|
|
|
|
If the queue is full, `emit()` returns `Err(QueueFull)`. Subsequent
|
|
actions in the same chain still run; they may attempt further emits, but
|
|
those also fail. At end of tick, the runtime surfaces `Err(QueueFull)`
|
|
out of `step()` / `run()` so the top-level driver can react. A queue
|
|
overflow is a hard signal that something is wrong with sizing or with the
|
|
producer; it is never silently swallowed.
|
|
|
|
---
|
|
|
|
## `during:` activities — async work scoped to a state
|
|
|
|
A `during:` is an async function that runs while its owning state is on
|
|
the active path. It is started after the entry actions of that state
|
|
finish, and it is cancelled before the exit actions of that state begin.
|
|
|
|
A during's future may be **dropped at any `.await` point** — when an
|
|
external event arrives, when a timer fires, when another during on the
|
|
same active path completes, or when a transition leaves the during's
|
|
state. This is the Rust async concept of *cancellation*: the future is
|
|
not given a chance to "finish gracefully" — it's dropped, and dropping
|
|
runs whatever destructors `Drop` impls have, but no further `.await`s
|
|
will ever resume.
|
|
|
|
The practical rule: write durings as cancel-safe state machines. Every
|
|
`.await` must be a clean resume point where dropping the future leaves
|
|
the borrowed fields in a re-enterable state. Don't straddle awaits with
|
|
mutations that need to commit atomically. If you need to learn what
|
|
cancel-safety looks like in practice, the README has the worked examples,
|
|
and the tokio docs on cancellation are the standard reference.
|
|
|
|
Multiple `during:` declarations on the same active path race against each
|
|
other (and against incoming events and timers). Each during borrows
|
|
disjoint `&mut` slices of the context — Rust's split-borrow checker
|
|
rejects overlapping borrows at compile time, so concurrent durings can
|
|
never step on each other's state.
|
|
|
|
---
|
|
|
|
## Termination
|
|
|
|
When a `terminate` event is received, the machine exits the entire active
|
|
path bottom-up: innermost first, then each parent, up to and including
|
|
the root. After that, the machine is done — `is_terminated()` is true,
|
|
`run()` returns `Ok(())`, further `step()` calls are no-ops returning
|
|
`None`. Any pending events in the queue are dropped. Any in-flight
|
|
durings are dropped at their next `.await`.
|
|
|
|
Once you adopt the simple, consistent semantics above, this just falls
|
|
out — termination is "exit everything you're currently in, in the usual
|
|
exit order, then stop."
|
|
|
|
---
|
|
|
|
## `step()` vs `run()` vs `dispatch()`
|
|
|
|
Three ways to drive the machine. Same underlying behavior; the differences
|
|
are about who decides when to advance time.
|
|
|
|
- **`step(elapsed)`** — synchronous. You call it; it does at most one unit
|
|
of work (one timer expiry, or one queued event). For bare-metal /
|
|
testing.
|
|
- **`dispatch(ev).await`** — async helper. Pushes `ev` onto the queue, then
|
|
drains the queue (and any timers that come due) until quiescent. For
|
|
injecting events from the same task that owns the machine.
|
|
- **`run().await`** — async. Owns the task body. Races durings against the
|
|
external event channel and timers, and processes whatever wins, in a
|
|
loop, until termination.
|
|
|
|
For the same external event sequence and the same elapsed-time inputs,
|
|
the three produce the same observable trace.
|
|
|
|
---
|
|
|
|
## What you cannot do, and why
|
|
|
|
The "minimum useful primitives" bet, made concrete. For each missing
|
|
feature: why we don't have it, and what to do instead. _(First-pass
|
|
drafts. Edit freely.)_
|
|
|
|
- **Guard conditions on transitions** — _Why not:_ guards bury control
|
|
flow inside an action body, so the chart's structure stops telling you
|
|
the whole story. _What to do instead:_ split the source into two
|
|
states whose entry conditions differ, or have an action emit a
|
|
follow-up event (`Ok` vs. `Bad`) and let the chart branch on that.
|
|
- **Orthogonal / parallel regions** — _Why not:_ true parallelism
|
|
multiplies state-space and forces a join-semantics for shared events.
|
|
_What to do instead:_ run two charts as two tasks and connect them with
|
|
channels, or use multiple `during:` activities for concurrent async
|
|
work within a single state.
|
|
- **History states (shallow or deep)** — _Why not:_ history is hidden
|
|
mutable state — re-entering a parent goes "somewhere" based on
|
|
invisible bookkeeping. _What to do instead:_ store the last child
|
|
explicitly in your context, then transition directly to it on
|
|
re-entry.
|
|
- **Deferred events** — _Why not:_ deferring means an event you sent now
|
|
can resurface much later, with non-obvious ordering. _What to do
|
|
instead:_ handle the event where it arrives (even if just to log and
|
|
drop it), or have the producer check state before sending.
|
|
- **Internal transitions** — _Why not:_ "transition that doesn't exit or
|
|
enter" is a separate semantics layer just to skip exit/entry. _What to
|
|
do instead:_ declare an `action(Trig) => fn` (no transition). That's
|
|
exactly what an internal transition is.
|
|
- **State-local context** — _Why not:_ hierarchical context types make
|
|
borrowing painful and tie the chart's structure into your data model.
|
|
_What to do instead:_ put all your state in the root context;
|
|
`during:` activities can borrow disjoint fields of it.
|
|
- **Event priorities beyond FIFO** — _Why not:_ priorities allow
|
|
starvation and make ordering implementation-dependent. FIFO is
|
|
predictable. _What to do instead:_ if some events are higher-priority,
|
|
deliver them on a separate channel into a separate, faster-cadence
|
|
machine.
|
|
- **Runtime statechart modification** — _Why not:_ the chart is the
|
|
static structural definition. Mutating it at runtime defeats
|
|
compile-time validation and makes the trace meaningless. _What to do
|
|
instead:_ design every variation up front as states or sub-states.
|
|
- **Inter-machine communication primitives** — _Why not:_ a built-in
|
|
messaging layer would have to pick winners between embassy / tokio /
|
|
bare-metal channels. _What to do instead:_ use whatever channel
|
|
primitive your runtime gives you (embassy `Channel`, tokio `mpsc`),
|
|
and feed events into `dispatch()` or `Sender::send()`.
|
|
|
|
---
|
|
|
|
## Quick reference
|
|
|
|
| Scenario | What fires |
|
|
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| Initial machine start | Root entries → default-descend down through each child's entries → durings start at each entered state. |
|
|
| Transition to sibling | Exit current up to LCA (cancel durings, run exits) → enter from LCA down to target (run entries, start durings) → default-descend if target has children. |
|
|
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If innermost has children, default-descend after re-entry. |
|
|
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. No default-descent. Ancestor's durings and timers continue. |
|
|
| Transition into a state with children | After the target's entries fire, default-descend through each default child's entries down to a leaf. |
|
|
| Timer fires | The declaring state's handler runs (timers don't bubble). |
|
|
| Event with no handler | Silently discarded. |
|
|
| Event handled at innermost | Innermost state's handler runs (actions in declaration order, then transition if any). |
|
|
| Event handled at ancestor | Search bubbles up; first ancestor with a handler runs it. Innermost is still innermost; transition (if any) starts from innermost. |
|
|
| Action calls `emit()` | Event queued. Dispatched after the current event's handling fully completes. |
|
|
| Queue is full when `emit()` is called | `emit()` returns `Err(QueueFull)`. Remaining actions in the same chain still run; further emits in this tick also fail. `step()` / `run()` surfaces `QueueFull` at end of tick. |
|
|
| `terminate` event delivered | Exit innermost → … → exit root. Pending events dropped. Durings dropped. `run()` returns `Ok(())`. |
|