journal + determinism tests + Creusot verification scaffolding
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.
This commit is contained in:
55 files changed
+6476
-151
No files matched your search
@@ -0,0 +1,371 @@
|
||||
# `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(())`. |
|
||||
Reference in new issue
Block a user