Files
hsmc/docs/002. hsmc-semantics-formal.md
T
swaits 081bd28cca 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.
2026-04-27 20:15:05 -06:00

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(())`. |