5 Commits
Author SHA1 Message Date
swaits 9eb1d10b28 chore(release): 0.5.0 2026-04-28 21:03:05 -06:00
swaitsandClaude Opus 4.7 3fc0d7c57d feat(default)!: model default(...) as a real LCA-aware transition
`default(...)` is now a transition with the same semantics as any
event-driven transition. It fires immediately after the declaring
state's entries finish and its durings start, applying the standard
exit-to-LCA / enter-to-target algorithm. Three behavioral changes
follow from this unification:

* Optional. A state with children is no longer required to declare a
  `default(...)`. When omitted, the state itself is the resting
  innermost active state; substates are reachable only via explicit
  transitions. This is the "microwave Standby" pattern — Standby
  has substates like LowPower that you enter only on demand, and
  `=> Standby;` lands at Standby itself with no descent.

* Targets anywhere. The default target may be any state in the chart
  — a child (the classic descent case), a sibling, an ancestor,
  anywhere. `default(<sibling-or-ancestor>)` exits the just-entered
  state as part of the default-fire transition; the same rule fires
  whether you wrote it for descent, redirection, or up-bounce.

* Cycle-checked at compile time. The default-edge graph (out-degree
  ≤ 1 per node) is rejected if it contains any cycle, including
  `default(self)`. `state A { default(A); }` and `A → B → A` chains
  fail to compile with a pinpoint error message naming the cycle.
  This guarantees default chains terminate at runtime.

Implementation:

* `validate.rs`: drop the "must be direct child" and "must have
  children" restrictions; add `validate_default_graph` (iterative
  three-color cycle detection via chain walk).

* `ir.rs`: keep the unresolved default ident through to
  `resolve_transitions` so non-child targets resolve against the
  global state-name map (the same map transition targets use).

* `codegen.rs`: precomputed `(from, to)` paths no longer fold default
  descent into the enter slice — each path is LCA-relative
  `(exit, enter)` ending exactly at the explicit target.
  `descend_defaults` (a parent-pointer walk) is replaced by
  `follow_defaults`, which fires real transitions through
  `apply_path` and emits `TransitionFired{Internal}` /
  `TransitionComplete` brackets per link. Up-transitions don't
  trigger default-following (nothing was freshly entered, so no
  state's default fires).

* `active_durings_per_innermost_state`: predicate widened to include
  composites without a default (they can now be the innermost active
  state, so any durings they declare must be raced when current).

BREAKING CHANGES:

* Journal format. Default-fires now emit `TransitionFired` (with
  `reason: TransitionReason::Internal`) and `TransitionComplete`
  brackets around their entry sequence. `Internal` was previously
  reserved and never emitted; it is now active. Tests that
  hand-build expected journal sequences must include these brackets
  around every default-fire.

* Validation. `default(<non-child>)` and `default` on a childless
  state previously raised compile-time errors. Both are now valid.
  Code relying on these as guard rails must adapt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:57:01 -06:00
swaitsandClaude Opus 4.7 2e55484080 chore(release): 0.4.1
Bump workspace version 0.4.0 → 0.4.1.

Patch release: dispatch hot-path performance only. No public API
changes; every chart that compiled against 0.4.0 compiles unchanged
and behaves identically. CHANGELOG.md captures the per-pass writeup
under the 0.4.1 heading.

Headlines (deepest dispatch in the bench suite):
  h8_cross_tree     -82% instructions / -72% cycles  vs pre_lca_fix
  bubble_top        -22% instructions /  -6% cycles  vs handler walk
  terminate_event   -22% instructions / -23% cycles  vs PARENT walk

The runtime LCA helper, is_up_transition, the per-call heapless::Vec
ancestry build, the runtime PARENT bubble walk, and (on timer-free
charts) the entire TimerTable machinery are all gone. Callgrind
reports the entire HSM machinery as a single inlined `step` symbol.

Bench suite expanded from 7 to 13 benches: bubble_leaf/mid/top
quantify handler resolution at varying ancestor depths,
timer_fire_dispatch covers the only path that exercises pop_expired's
non-empty branch, terminate_event covers do_terminate, and
bubble_cross_tree covers the realistic HSM worst case (deep bubble +
deep cross-tree transition).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:11:29 -06:00
swaitsandClaude Opus 4.7 dc97419161 perf: precompute transition/dispatch/terminate paths; shrink TimerEntry to u64
Six codegen-side optimization passes against step() and friends. No
user-facing API changes; all 296 tests pass under no-default, tokio,
embassy, and tokio+journal. Cumulative on h8_cross_tree (deepest
dispatch in the suite): -82% instructions / -72% cycles vs the
pre_lca_fix baseline.

* __PATH_DATA + __PATH_RANGE: per-(from, to) precomputed exit/enter
  sequences. Up/self/lateral semantics fold into one indexed lookup;
  removes lca() and is_up_transition() runtime helpers and the
  per-call heapless::Vec<u16, 16> ancestry build. Hierarchy depth no
  longer bounded by Vec capacity.

* __DISPATCH[(state, event)]: precomputed handler resolution. The
  runtime __PARENT bubble walk + per-hop __handler_lookup call in
  step() collapses to one indexed lookup. Bubble resolution is now
  O(1) regardless of depth -- bubble_top (4-hop) is within 3
  instructions of bubble_leaf (handler on current).
  __handler_lookup retained for timer dispatch.

* __TERMINATE_DATA: per-state precomputed exit chain (root inclusive
  -- chart-root entry/exit actions are valid). do_terminate() indexes
  the slice instead of walking __PARENT, and uses Deque::clear()
  (O(1) in heapless 0.8) instead of pop_front-loop. terminate_event
  bench: -22.5% instr / -23.4% cycles.

* TimerTable empty-skip: pop_expired / min_remaining / decrement /
  cancel_state each get inlined is_empty() early-out with the
  non-empty body marked #[cold].

* Codegen-static timer-machinery elide: when chart has no Duration
  triggers, every TimerTable call is omitted from step() and
  exit_state(). step() returns None directly. dispatch_trigger and
  the timer-fired arms remain but are unreachable; LLVM DCEs them.

* TimerEntry.remaining_ns: u128 -> u64 (~584-year ceiling). Halves
  entry size; replaces u128 saturating-sub with u64. The smaller
  machine struct improves cache density on every bench, even
  timer-free ones.

Bench coverage: four new benches added to cover paths the original
suite missed -- bubble_leaf/mid/top (handler resolution at varying
ancestor depths), timer_fire_dispatch (only bench exercising
pop_expired's non-empty branch and dispatch_trigger), terminate_event
(only bench exercising do_terminate), bubble_cross_tree (deep bubble
+ 10-state cross-tree, the realistic HSM worst case).

See hsmc/benches/baseline.txt for the per-pass delta blocks and
docs/003. latency-analysis.md sections 1, 9, and 12 for the
cost-surface analysis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:51:21 -06:00
swaitsandClaude Opus 4.7 e633f5dc55 perf: O(H) lca() depth-walk + dispatch bench scoreboard
The runtime `lca()` helper used a `heapless::Vec<u16,32>` ancestry-set
with a linear `contains()` per node — O(H²) and capped at H ≤ 32. The
codegened `__DEPTH` table already had everything needed for an O(H)
depth-walk with no allocation and no hierarchy ceiling. Switch to that.

Also adds an iai-callgrind dispatch bench harness (Linux-only dev-dep)
so future optimizations can be measured deterministically by instruction
count. Seven benches cover: cold start, lateral H=2/4/8, up, self, and a
self-emit chain. Baseline numbers checked in at `hsmc/benches/baseline.txt`.

The latency analysis (`docs/003. latency-analysis.md`) is rounded out
with event-entry-point and cooperative-driving audits — three new
sections documenting the four event injection paths, the run-loop's
cooperative shape, and three correctness invariants (sticky overflow
flag, biased-select trap, cancel-safety on durings).

Bench deltas (cargo bench --bench dispatch -- --baseline=pre_lca_fix):

  h2_cold_start        -1.9%  /  -0.3%   (priming step, no lca() — noise)
  h2_warm_lateral     -22.8%  / -22.1%
  h4_cross_tree       -25.9%  / -10.6%
  h4_up                +1.0%  /  +0.4%   (up-transition, no lca() — noise)
  h4_self             -21.5%  / -11.7%
  h8_cross_tree       -46.2%  / -41.0%   ← headline: deepest LCA stress
  chain_self_emit     -15.4%  / -10.4%

Improvement scales with hierarchy depth, exactly as the O(H²) → O(H)
algorithmic change would predict.

All tests pass under --no-default-features, --features tokio, and
--features embassy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:58:53 -06:00
38 changed files with 3523 additions and 565 deletions

No files matched your search

+141
View File
@@ -4,6 +4,147 @@ All notable changes to this workspace are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/).
## 0.5.0 — 2026-04-28
### `default(...)` is a real LCA-aware transition
`default(...)` is now modeled the same way as any event-driven
transition. It fires immediately after the declaring state's entries
finish and its durings start, applying the standard exit-to-LCA /
enter-to-target algorithm. Three behavioral changes follow from this:
- **Optional.** A state with children no longer must declare a
`default(...)`. When omitted, the state itself is the resting
innermost active state; substates are reachable only via explicit
transitions. This is the "microwave Standby" pattern — `Standby`
has substates like `LowPower` you enter only on demand, and
`=> Standby;` lands at `Standby` itself with no descent.
- **Targets anywhere.** The default target may be any state in the
chart — a child (the classic descent case), a sibling, an ancestor,
anywhere. `default(<sibling-or-ancestor>)` exits the just-entered
state as part of the default-fire transition; the same uniform
rule covers descent, redirection, and up-bounce.
- **Cycle-checked at compile time.** The default-edge graph
(out-degree ≤ 1 per node) is rejected if it contains any cycle,
including `default(self)`. The check guarantees default chains
terminate at runtime — entering any state in a cycle would loop
forever before reaching user code.
### Breaking changes
- **Journal format.** Default-fires now emit `TransitionFired` (with
`reason: TransitionReason::Internal`) and `TransitionComplete`
brackets around their entry sequence. `Internal` was previously
reserved and never emitted; it is now active. Charts that
hand-build expected journal sequences (replay-style tests) must
include these brackets around every default-fire — see the
per-test diffs in this release for the pattern.
- **Validation.** Two compile-time errors went away:
`default(<non-direct-child>)` is now valid (the target may be
anywhere), and `default` on a childless state is now valid
(the leaf transitions out immediately on entry). Code relying
on these as guard rails must adapt. A new compile-time error
takes their place: any cycle in the default graph is rejected
with a pinpoint message naming the cycle (e.g.
`default-transition cycle detected: A -> B -> A`).
### Other
- README and `docs/002. hsmc-semantics-formal.md` rewritten so the
default-as-transition framing is the primary description; the old
"automatic transition into a child" language is gone.
- New integration tests covering each branch of the new semantics:
`det_optional_default` (microwave pattern), `det_default_sibling`
(lateral default), `det_default_chained` (multi-link default
chain), `det_default_ancestor` (up-default landing on an
ancestor).
- New compile-fail UI fixtures for cycle detection
(`t9_4_default_self_cycle`, `t9_5_default_two_cycle`) and new
compile-pass fixtures for non-child default targets
(`composite_without_default`, `default_targets_sibling`,
`default_targets_ancestor`).
## 0.4.1 — 2026-04-28
### Performance — dispatch hot path
Six codegen-side optimization passes against `step()` and friends. No
user-facing API changes; all 296 tests pass under `--no-default-features`,
`--features tokio`, `--features embassy`, and `--features tokio,journal`.
Cumulative on `h8_cross_tree` (the deepest dispatch in the bench suite):
**−82% instructions / −72% cycles** vs the pre-`pre_lca_fix` baseline.
See `hsmc/benches/baseline.txt` for per-pass delta blocks and
`docs/003. latency-analysis.md` §1/§9/§12 for the cost-surface analysis.
- **Precomputed transition paths.** For every `(from, to)` state pair,
codegen materializes the exit/enter sequence into a single flat
`__PATH_DATA` blob with a `__PATH_RANGE: [(u16, u16, u16); N²]`
index. `transition()` becomes one indexed range lookup + two slice
loops; the `lca()` runtime helper, `is_up_transition()`, and the
per-call `heapless::Vec<u16, 16>` ancestry build are gone. Up /
self / lateral semantics are baked into the table (empty enter
slice ⇒ up-transition, target excluded; non-empty enter slice
includes the default-descent tail). Hierarchy depth is no longer
bounded by the old 32-state Vec capacity.
- **Precomputed event-dispatch table.** For every `(state, event)`
pair, codegen resolves the handler the runtime `__PARENT` bubble
walk would have found. `__DISPATCH: [(u16, &'static [u16],
Option<u16>); N×E]` stores `(handler_state, action_ids,
transition_target)` per pair; `u16::MAX` in the handler-state slot
is the no-handler sentinel. The runtime walk + per-hop
`__handler_lookup` call in `step()` collapses to one indexed lookup.
Handler resolution is now O(1) regardless of bubble depth — the
`bubble_top` bench (4-hop walk) is now within 3 instructions of
`bubble_leaf` (handler on current state). `__handler_lookup` is
retained for timer dispatch (the `dispatch_trigger` path doesn't
bubble).
- **Precomputed terminate exit path.** Per-state `__TERMINATE_DATA`
table holds the full exit chain up to and including `__Root` (which
may carry root-level `entry:`/`exit:` actions). `do_terminate()`
indexes the table once and iterates the slice, replacing the
runtime `__PARENT` walk + per-iteration match. Queue drain switched
from `while pop_front().is_some()` to `Deque::clear()` (heapless
0.8 implements this as O(1) — resets indices instead of popping
each entry). `terminate_event` bench: −22.5% instr / −23.4% cycles.
- **TimerTable empty-skip.** `pop_expired`, `min_remaining`,
`decrement`, and `cancel_state` each get `#[inline]` + an
`is_empty()` early-out, with the non-empty body split into a
`#[cold]` helper. Runtime branch on charts with timers; the body
stays out-of-line.
- **Codegen-static timer-machinery elide.** When a chart declares no
`Duration` triggers (the common case for event-driven HSMs), every
`TimerTable` call is omitted from `step()` and `exit_state()` at
codegen. `step()` returns `None` directly for the next-tick
deadline. `dispatch_trigger()` and the timer-fired arms remain in
the binary but are unreachable; LLVM DCEs them.
- **`TimerEntry.remaining_ns: u128 → u64`.** Internal-only field type
change. u64 nanoseconds bounds at ~584 years — comfortably past any
realistic `Duration`. Eliminates u128 saturating-sub in
`decrement()` (4–6 instructions per entry → 1–2) and the manual
secs/nanos decomposition in `min_remaining()` (replaced with
`Duration::from_nanos`). The struct shrinks 32 B → 16 B with
alignment, so the entire machine struct gets smaller — every bench's
RAM-hit count drops from tighter cache packing.
### Bench coverage
Four new benches added to cover hot paths the original suite missed:
- **`bubble_leaf` / `bubble_mid` / `bubble_top`** — handler resolution
at varying ancestor depths (0 / 3 / 4 hops) on a single chart.
Quantifies the bubble-walk cost as a function of distance, and
confirms the `__DISPATCH` table makes resolution depth-independent.
- **`timer_fire_dispatch`** — only bench that exercises
`pop_expired`'s non-empty branch and `dispatch_trigger`. The most
expensive single-step bench in the suite (191 instr / 706 cycles).
- **`terminate_event`** — only bench exercising `__check_terminate`'s
true arm and `do_terminate`. 69 instr / 311 cycles.
- **`bubble_cross_tree`** — handler on L1 (4 bubble hops up from
current=L5) targets R5 in a sibling subtree (10-state cross-tree
transition). Realistic HSM worst case; 94 instr / 444 cycles —
almost the same as a plain 7-deep cross-tree because both bubble
resolution and transition path are now O(1) lookups.
## 0.4.0 — 2026-04-28
### Changed — observability (breaking)
Generated
+86 -3
View File
@@ -14,6 +14,15 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bincode"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -195,6 +204,27 @@ dependencies = [
"thiserror",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
[[package]]
name = "document-features"
version = "0.2.12"
@@ -465,7 +495,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hsmc"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"crossterm",
"defmt 0.3.100",
@@ -475,6 +505,7 @@ dependencies = [
"futures",
"heapless 0.8.0",
"hsmc-macros",
"iai-callgrind",
"log",
"ratatui",
"tokio",
@@ -484,7 +515,7 @@ dependencies = [
[[package]]
name = "hsmc-macros"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"hsmc",
"proc-macro2",
@@ -495,12 +526,48 @@ dependencies = [
[[package]]
name = "hsmc-verification"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"creusot-std",
"hsmc",
]
[[package]]
name = "iai-callgrind"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e4910d3a9137442723dfb772c32dc10674c4181ca078d2fd227cd5dce9db0"
dependencies = [
"bincode",
"derive_more",
"iai-callgrind-macros",
"iai-callgrind-runner",
]
[[package]]
name = "iai-callgrind-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d03775318d3f9f01b39ac6612b01464006dc397a654a89dd57df2fd34fb68c3"
dependencies = [
"derive_more",
"proc-macro-error2",
"proc-macro2",
"quote",
"serde",
"serde_json",
"syn",
]
[[package]]
name = "iai-callgrind-runner"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b74c9743c00c3bca4aaffc69c87cae56837796cd362438daf354a3f785788c68"
dependencies = [
"serde",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -773,6 +840,15 @@ dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "0.38.44"
@@ -804,6 +880,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.228"
@@ -811,6 +893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["hsmc", "hsmc-macros", "verification"]
[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["Stephen Waits <steve@waits.net>"]
+73 -182
View File
@@ -28,7 +28,9 @@ statechart! {
## What it does
- **Harel-style hierarchical states.** Nested states with `entry:`/`exit:`
actions, LCA-aware transitions, default-child descent, `terminate`.
actions, LCA-aware transitions, optional `default(...)` (a transition
that may target any state — descend, redirect to a sibling, bounce
to an ancestor — with a compile-time cycle check), `terminate`.
- **Event-driven transitions and actions.** Typed payload bindings:
`on(PacketRx { rssi: i16 }) => handler;`.
- **Timer triggers.** `on(after Duration::from_secs(5)) => Next;` and
@@ -50,12 +52,9 @@ statechart! {
records every action, transition, timer, and event in order — same
inputs, byte-identical journal. The runtime queue and timer table
carry Pearlite specs that Creusot + Why3 + alt-ergo + z3 discharge
mechanically (`just verify`). 159 tests pin every spec rule
(journal sequences + trace-format regression + integration).
- **Backward-compatible chart syntax.** Every v0.1 / v0.2 / v0.3
statechart compiles under v0.4 unchanged. The 0.4 breaks are in
observability surface area only — see
[Migration](#migration-from-v03).
mechanically (`just verify`). The integration suite pins every
spec rule with byte-equal expected-vs-actual journals, trace-format
regression checks, and per-feature behavior tests.
## How a chart behaves — the building blocks
@@ -66,27 +65,51 @@ The full canonical reference lives at
[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md);
this section is the on-ramp.
### 1. The active path — you are in every state from root to leaf
### 1. The active path — you are in every state from root to the innermost active state
At any moment, the machine is in a **path** of states from the root down
to one innermost state. If you're in `C` inside `B` inside `A`, you're
**simultaneously** in `Root`, `A`, `B`, and `C`. The root is always
active until termination.
to one innermost active state. If you're in `C` inside `B` inside `A`,
you're **simultaneously** in `Root`, `A`, `B`, and `C`. The root is
always active until termination.
The innermost active state is usually a leaf, but it can also be a
composite that has children but no `default(...)` — see rule 2.
This is the most important rule. Every rule below is downstream of it.
If a behavior somewhere else seems weird, come back here — it's
probably the resolution.
### 2. Default-descent — composites auto-enter their default child
### 2. `default(...)` — a transition that fires immediately on entry
Every state with children declares one `default(...)`. When a composite
state is entered, its entry actions run first, **then** the default
child is entered, recursively, until a leaf.
Any state **may** declare one `default(...)`. It's a transition — same
LCA-aware algorithm as any event-triggered transition (rule 4) — that
fires **immediately** after the declaring state's entry actions finish
(and its durings start). The target may be **any** state in the chart:
a child, a sibling, an ancestor, anywhere. The compiler rejects cycles
in the default graph, so the chain is always finite.
So `Root` with `default(A)` and `A` with `default(B)` means: the
machine starts by entering `Root` → `A` → `B`, all on first step.
The classic case is a composite descending to a leaf:
```
state Root { default(A); state A { default(B); state B {} } }
```
Entering `Root` fires its default → enter `A` → fires its default →
enter `B`. The path lands at `B`.
### 3. Event bubbling — leaf first, first handler wins
But because `default` is a real transition, you can also redirect:
```
state Foyer { default(LivingRoom); } // sibling — entering Foyer
state LivingRoom {} // exits Foyer to LivingRoom
```
Entering `Foyer` fires its default `Foyer → LivingRoom`. LCA = parent of
both; Foyer's exits run, LivingRoom's entries run. Foyer was entered
and exited as part of one tick.
If a state has no `default(...)`, nothing happens after its entries —
the state itself is the resting innermost active state until something
explicitly transitions away. This is the "microwave" pattern: a
`Standby` state with sub-modes that the chart enters only on demand.
### 3. Event bubbling — innermost first, first handler wins
An event arrives. Search starts at the **innermost** active state. If
that state has a handler, it runs and the event is consumed. If not,
@@ -95,7 +118,7 @@ with no handler, the event is silently discarded.
Three corollaries:
- Leaf shadows ancestor for the same trigger.
- Innermost shadows ancestor for the same trigger.
- Multiple handlers in one state on the same trigger fire in
declaration order; the transition (if any) fires last.
- **Timers don't bubble.** A timer belongs to the state that declared
@@ -110,7 +133,9 @@ Any transition's algorithm is uniform:
The LCA itself is **not** exited.
3. Walk **down** from the LCA to target, entering each state. The LCA
is **not** re-entered.
4. If target has children, default-descend (rule 2).
4. If the target declares a `default(...)`, fire it (rule 2). Defaults
chain — each link is itself a full transition — until you reach a
state with no default. The compiler rejects cycles.
The LCA always exists because root is always an upper bound. State
names are globally unique across the chart, so a transition can target
@@ -125,8 +150,9 @@ transition algorithm do nothing. You exit the states strictly between
current and target; the target itself is **not** re-entered.
So from leaf `C` inside `B` inside `A`, an `on(Up) => A;` exits `C`
then `B`. `A`'s entry actions don't fire. `A`'s `default` is not
followed. `A`'s timers don't restart. `A`'s durings keep running.
then `B`. `A`'s entry actions don't fire. `A`'s `default` does not
fire (it only fires when `A` is freshly entered, which it wasn't).
`A`'s timers don't restart. `A`'s durings keep running.
You cannot enter a state you never left.
@@ -136,10 +162,12 @@ Direction is set by the path:
- **Entries** fire **outer-to-inner**: when entering down through a
path, the outermost state's entries fire first, then the next, then
the leaf's. Within a single state, entry actions fire in declaration
order. After a state's entries finish, its `during:` activities
start. Then default-descent (rule 2) applies recursively.
- **Exits** fire **inner-to-outer**: leaf first, then parent, then
the innermost's. Within a single state, entry actions fire in
declaration order. After a state's entries finish, its `during:`
activities start. Then, if the state declared a `default(...)`,
it fires as a transition (rule 2) — chains continue until a state
with no default.
- **Exits** fire **inner-to-outer**: innermost first, then parent, then
grandparent. Within a single state, exit actions fire in declaration
order. Before a state's exits begin, its `during:` activities are
cancelled.
@@ -200,7 +228,7 @@ Add to `Cargo.toml`:
```toml
[dependencies]
hsmc = { version = "0.4", features = ["embassy"] } # or ["tokio"]
hsmc = { version = "0.5", features = ["embassy"] } # or ["tokio"]
```
Minimal timer-only machine:
@@ -357,7 +385,7 @@ For `statechart! Foo { ... }` the macro emits:
| `fn step(elapsed) -> Option<Duration>` | One unit of work (test/polling driver). |
| `fn send(ev)` | Raw push, non-draining. For tests. |
| `sender() -> Sender` | Clone handle for other tasks / ISRs. |
| `current_state()` | The active leaf state. |
| `current_state()` | The innermost active state. |
| `is_terminated()` | True after `terminate` event. |
| `has_pending_events()` | Queue non-empty. |
| `context()` / `context_mut()` | Borrow the root context. |
@@ -375,135 +403,6 @@ For `statechart! Foo { ... }` the macro emits:
`embassy` and `tokio` are mutually exclusive — enabling both is a compile
error.
## Migration from v0.3
The 0.4 breaks are in the observability surface area only; chart
syntax and runtime API are unchanged.
### 1. Trace prefix changed
Trace lines (when `trace-defmt` / `trace-log` / `trace-tracing` is
enabled) now use `[statechart:Name]` instead of `[Name]`. Update any
log filters or grep patterns. The verb after the prefix is now a
single hyphenated token followed by `key=value` pairs (logfmt).
### 2. `TraceEvent::TransitionFired` carries `reason`
Add `reason` to any pattern matches:
```rust
// before:
TraceEvent::TransitionFired { from, to } => …
// after:
TraceEvent::TransitionFired { from, to, reason } => …
// or, if you don't need it:
TraceEvent::TransitionFired { from, to, .. } => …
```
The new field carries `TransitionReason::Event { event }`,
`Timer { state, timer }`, or `Internal` so consumers can see what
triggered the transition.
### 3. New `TraceEvent` variants
If you `match` exhaustively on `TraceEvent`, add arms for the new
variants. Otherwise nothing changes:
- `EnterBegan { state }` — phase begin marker pairs with `Entered`.
- `ExitBegan { state }` — phase begin marker pairs with `Exited`.
- `TransitionComplete { from, to }` — pairs with `TransitionFired`.
- `EventReceived { event }` — fires when an event is popped from the
queue, before handler search.
These are pure observation — no runtime semantic change.
### 4. `trace;` keyword is a no-op
Charts that declared `trace;` continue to compile. The keyword no
longer gates emission — observation is now controlled purely by
which `trace-*` cargo feature you enable. You can leave `trace;` in
or remove it; both work.
### 5. Trace backends are no longer mutually exclusive
Previously, enabling more than one of `trace-defmt` / `trace-log` /
`trace-tracing` was a compile error. Now they fan out independently:
turn on whichever you want.
## Migration from v0.1
v0.1 statecharts continue to compile. New features are purely additive:
### 1. `events:` is now optional
Previously required; now omit for pure timer-only machines:
```rust
statechart! {
Blinker {
context: Ctx;
default(On);
state On { on(after Duration::from_millis(500)) => Off; }
state Off { on(after Duration::from_millis(500)) => On; }
}
}
```
### 2. `dispatch(ev).await` replaces the push+drain helper
Before (v0.1, in the dogfood firmware):
```rust
async fn push<M>(m: &mut M, ev: E) {
m.send(ev).expect("queue full");
while m.has_pending_events() {
m.step(Duration::ZERO).await;
}
}
push(&mut machine, ev).await;
```
After (v0.2):
```rust
machine.dispatch(ev).await.unwrap();
```
### 3. `during:` replaces the imperative I/O outer loop
Before — a radio task with per-state match and select:
```rust
loop {
match machine.current_state() {
RadioState::Receiving => {
match select(rx_once(&mut lora, &cfg, &mut rx_buf),
commands.receive()).await {
Either::First(r) => handle_rx(r, &mut machine, ...).await,
Either::Second(c) => handle_cmd(c, &mut machine, ...).await,
}
}
_ => { ... }
}
}
```
After — the statechart owns the loop:
```rust
state Receiving {
during: next_packet(lora, rx_buf);
on(PacketRx { rssi: i16, snr: i16 }) => record_packet;
on(StopRx) => Idle;
}
#[embassy_executor::task]
async fn radio_task(lora: LoRaDriver, ch: &'static RadioChan) {
let mut m = Radio::new(RadioCtx { lora, ... }, ch);
m.run().await.unwrap();
}
```
## Examples
| Example | Purpose |
@@ -535,13 +434,14 @@ spec-rule → proof mapping.
Beyond Creusot, the suite includes:
- **159 integration tests** — full chart behavior, byte-equal expected-
vs-actual journals for ~113 deterministic-flow tests, plus 11 trace-
format regression tests pinning every observation verb's exact
textual rendering.
- **0 missed mutants** under `cargo mutants --features
- **Integration tests** — full chart behavior and per-feature
determinism, with byte-equal expected-vs-actual journals on the
`det_*` deterministic-flow tests, plus trace-format regression
tests pinning every observation verb's textual rendering. Run
with `cargo test --workspace --features tokio,journal`.
- **`cargo mutants`** — full coverage under `cargo mutants --features
tokio,journal,trace-log`.
- **Miri**: 68 tests, no UB.
- **Miri** — clean, no UB on the runtime crates.
## Observability — one journal, multiple outputs
@@ -611,25 +511,16 @@ diffed against expected behavior the same way the journal can.
## Status
- v0.1 spec: fully implemented.
- v0.2: `during:`, optional `events:`, `dispatch()`, `STATE_CHART`,
drain-on-send. Up-transitions no longer exit/re-enter the target.
- v0.3: `journal` feature + deterministic trace + replay; root
state targetable by chart name; Creusot verification of the
runtime data structures; 113-test deterministic-flow suite.
- v0.4: unified observation pipeline (journal + trace share one
vocabulary, fan out to multiple sinks at compile time); logfmt-
style trace format with `[statechart:Name]` prefix; transition
reasons; entry/exit/transition begin/end markers; multiple trace
backends now compose; chart-level `trace;` keyword deprecated.
Breaking on the `TraceEvent` enum shape; chart syntax unchanged.
- Out of scope (deliberate): guard conditions, orthogonal/parallel
regions, history states, deferred events, internal transitions,
state-local context, event priorities beyond FIFO, runtime
statechart modification, multi-threaded tokio. See
[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md)
§ "What `hsmc` deliberately does NOT have" for the rationale and
what to use instead.
Pre-1.0. Per-release breaking changes are documented in
[`CHANGELOG.md`](CHANGELOG.md).
Out of scope (deliberate): guard conditions, orthogonal/parallel
regions, history states, deferred events, internal transitions,
state-local context, event priorities beyond FIFO, runtime
statechart modification, multi-threaded tokio. See
[`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md)
§ "What `hsmc` deliberately does NOT have" for the rationale and
what to use instead.
## License
+42 -24
View File
@@ -37,10 +37,16 @@ Each primitive, in roughly the order a developer first meets them.
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.
- **Default transition** — any state **may** declare one `default(...)`.
It's a transition (same LCA-aware algorithm as rule 4 / event-driven
transitions) that fires immediately after the declaring state's entry
actions finish and its durings start. The target may be any state in
the chart — a child (the classic descent case), a sibling, an
ancestor, anywhere. Defaults chain (each link is itself a transition)
until they reach a state with no default. The compiler rejects cycles
in the default graph at compile time, so every chain terminates. When
a state has no `default`, nothing else happens after entry — the
state itself is the resting innermost active state.
- **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
@@ -71,7 +77,8 @@ 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.
an up-transition (see below) and haven't moved since, or if you
transitioned into a composite that has no `default` declared.
---
@@ -86,8 +93,9 @@ 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.
- A handler on the innermost active state shadows a handler on any
ancestor for the same event. The innermost 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.
@@ -108,8 +116,8 @@ 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.
no default firing on root (the root wasn't freshly entered). Use this
when you want to wind everything back up to the chart's own scope.
To run a transition:
@@ -121,8 +129,10 @@ To run a transition:
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.
4. If the target declares a `default(...)`, fire it as a recursive
transition (using the same algorithm). Defaults chain until a state
with no default is reached. Compile-time cycle check guarantees
termination.
The three flavors below are just three relationships between current and
target. The same algorithm covers all of them — they only look different
@@ -131,8 +141,9 @@ 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.
target. Exit up to LCA, enter down to target, then if the target
declares a `default(...)`, fire it as a chained transition. Already
covered above.
### Transitioning to yourself
@@ -153,11 +164,12 @@ 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.
active right now because you got there via an up-transition or because
the composite has no `default`), the same rule still applies on re-entry:
exit the composite, re-enter it, then descend through its default chain
if it declares one. A composite without `default` is exited, re-entered,
and that's it — no further descent. 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.
@@ -207,7 +219,11 @@ Putting it all together:
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.
durings start. Then, if the state declares a `default(...)`, the
default fires as a transition (which may exit the state we just
entered if the target lies elsewhere in the chart) and the chain
continues from there. A state without `default` is the end of the
path — control stops there.
- **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
@@ -367,11 +383,13 @@ drafts. Edit freely.)_
| 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. |
| Initial machine start | Root entries → if root declares a `default(...)`, fire it as a transition; chain continues until a state with no default. 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) → if target declares a `default`, fire it; chain continues. |
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If the re-entered state declares a `default`, fire it. |
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. The ancestor's `default` is **not** fired (it wasn't freshly entered). Durings and timers continue. |
| Transition into a state with `default(<descendant>)` | After the target's entries fire, the default fires as a child-direction transition (no exits at this step). The chain continues from the new innermost. |
| Transition into a state with `default(<sibling/ancestor>)` | After the target's entries fire, the default fires as a transition that exits the just-entered target and lands elsewhere. The chain continues. |
| State has no `default(...)` | After its entries fire, control stops there. The state itself is the innermost active state until something explicitly transitions away. |
| 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). |
+500
View File
@@ -0,0 +1,500 @@
# 003. Latency analysis: hsmc hot-path cost surface
This is an audit of where wall-clock and code-size cost lives in `hsmc`'s
event-dispatch path. It is intended to inform mitigation work for embedded
and embassy-rs use cases where every microsecond of dispatch overhead
couples directly into hardware-control loop tightness and jitter.
The audit is **analytical**, with three categories of evidence:
1. **Source reading** of `hsmc/src/lib.rs`, `hsmc/src/journal.rs`, and
`hsmc-macros/src/codegen.rs`.
2. **`cargo expand --release`** of two test programs (one with no
observation features, one with `tokio + journal`) to confirm what the
macro really emits.
3. **`cargo rustc --release --emit=asm`** spot-checks of the compiled
output to confirm LLVM-level claims (jump-table lowering, dead-code
elimination of unused name tables, async-closure code size).
The audit deliberately stops short of recommending mitigations. A ranked
menu of candidates is included at the end so a follow-up planning round
can pick from it; nothing here is a commitment.
---
## 1. Cost surface, ranked by per-event impact
| # | Source | Class | Per-event cost (estimate) | Location |
|----|-----------------------------------------------|-----------------------------|------------------------------------------------------------------------------------------------------------|---------------------------------------|
| # | Source | Class | Per-event cost (estimate) | Status |
|----|-----------------------------------------------|-----------------------------|------------------------------------------------------------------------------------------------------------|---------------------------------------|
| 1 | `dispatch().await` loop | Discretionary, async-only | 2 awaits cold-start / 1 await steady-state per dispatched event. ~free if action body never `Pending`-yields. | open |
| 2 | LCA + transition path build | Discretionary | ~~O(H²) `heapless::Vec<u16,32>::contains` scan~~ → ~~O(H) depth-walk~~ → **O(1) `__PATH_RANGE` lookup**. Up/self/lateral folded into the precomputed table; `lca()`, `is_up_transition()`, and the per-call `heapless::Vec<u16,16>` ancestry build are all gone. | resolved |
| 3 | Parent walk for handler search | Discretionary | ~~O(H) `__PARENT` walk + per-hop `__handler_lookup` call~~ → **O(1) `__DISPATCH[(state, event)]` lookup**. Every (state, event) pair statically resolved at codegen. `__handler_lookup` retained for timer dispatch only. | resolved |
| 4 | `__handler_lookup` nested match (timer path) | Intrinsic | Still used for timer dispatch. LLVM lowers to jump tables (verified `LJTI*`). ~1 indirect jump per call. | open (already at floor) |
| 5 | `run_action` dispatch | Intrinsic | One match arm + monomorphized trait call + 1 vtable hop on `&mut dyn QueuePush` (verified devirtualized when callsite type is known). | open (already at floor) |
| 6 | Event queue push/pop | Intrinsic | `heapless::Deque::push_back` / `pop_front`, both O(1). | open (already at floor) |
| 7 | `__chart_observe!` macro arms | Compile-eliminable when off | Zero residue when all sink features off. Each call site collapses to `{};`. | verified zero-cost |
| 8 | Journal `Vec::push` per atom | Conditional on `journal` | Amortized O(1); ~16 B per `TraceEvent`. ~11 push sites fire per transition. | opt-in |
| 9 | Async-fn future size (binary cost) | Discretionary, async-only | ~3000 asm lines of chart machinery for a 5-state chart; dominated by async-fn state machines. | open (mitigation H3) |
| 10 | `tokio::sync::mpsc::unbounded_channel` | Tokio-only, std-bound | Heap-backed channel for cross-task event injection. Embassy uses bounded `embassy_sync::channel`. | feature-specific |
| 11 | TimerTable per-step / per-exit fixed cost | Discretionary | ~~Unconditional `pop_expired` + `cancel_state` calls every step / exit~~ → **inlined `is_empty()` + `#[cold]` non-empty body** (runtime branch on charts that use timers; compile-time elided on timer-free charts). | resolved |
| 12 | TimerEntry size + per-decrement work | Discretionary | ~~`remaining_ns: u128`, 32-byte entry, u128 saturating-sub per decrement~~ → **`u64`, 16-byte entry, u64 saturating-sub** (~584-year ceiling; Duration::from_nanos round-trips exactly). | resolved |
| 13 | `do_terminate()` exit walk | Discretionary | ~~Runtime `__PARENT` walk + `pop_front`-loop queue drain~~ → **precomputed `__TERMINATE_DATA` slice + `Deque::clear()`** (O(1)). | resolved |
Items 2, 3, 11, 12, 13 are fully resolved by the precompute / inline-
empty-check passes in this round of work — see `hsmc/benches/baseline.txt`
for the per-pass delta blocks. Items 4–6 are at or near the algorithmic
floor for hierarchical state machines. Item 7 is verified zero-cost.
Items 1 (async overhead) and 9 (async machinery binary size) are the
only meaningful open items; both are addressable via a `dispatch_sync()`
fast path (mitigation H3 in §9), which would be a user-facing API
change and is therefore out-of-scope for this round.
---
## 2. Per-event cost model (analytical)
For a chart with hierarchy depth `H=3`, one entry action + one exit action,
a simple lateral transition, no observation features, `cargo build
--release`, on a Cortex-M4-class core (~168 MHz):
| Stage | Cycles (rough) | Notes |
|----------------------------------------|--------------------|------------------------------------------------|
| `queue.pop_front` | ~5–10 | `heapless::Deque` index op |
| `__event_to_id` | ~3–5 | match arm (jump table) |
| `__DISPATCH[(state, event)]` lookup | ~5 | one indexed load + sentinel branch |
| `run_action` (1 action) | ~10–20 | match + monomorphized call + 1 vtable hop |
| Path lookup (`__PATH_RANGE`) | ~5 | one indexed load + slice arithmetic |
| Exit chain (H states × A actions) | ~3 × H + 30 × A | per-state work is now ~3 instr each |
| Enter chain (H states × A actions) | ~3 × H + 30 × A | per-state work is now ~3 instr each |
| Timer machinery (timer-free chart) | 0 | codegen-elided when `ir.duration_triggers.is_empty()` |
| Timer machinery (timer chart, no fire) | ~3 | inlined `is_empty()` branches × 3 |
| Timer machinery (timer firing) | ~30–40 | decrement + pop_expired + dispatch_trigger |
| **Total `.step()` steady-state** | ~0.4 – 0.8 µs | for H=3, A=1; user action body excluded |
| Async overhead (`.dispatch()` loop) | per-await checkpoint | ~free if no `Pending`-yield |
These are back-of-envelope numbers; actual measurements would tighten
them by ~2× in either direction, depending on inlining decisions.
For empirical numbers (deterministic, instruction-counted), see
`hsmc/benches/baseline.txt`. Headline numbers (current):
h2_warm_lateral 84 instr / 397 cycles; h4_cross_tree 101 instr / 456
cycles; h8_cross_tree 93 instr / 443 cycles; bubble_top 99 instr /
453 cycles (4-hop handler resolution); bubble_cross_tree 94 instr /
444 cycles (4-hop bubble + 10-state cross-tree); terminate_event 69
instr / 311 cycles; timer_fire_dispatch 191 instr / 706 cycles;
chain_self_emit 419 instr across 4 events (≈105 instr/event).
Cumulative on h8_cross_tree: **-82% instructions / -72% cycles** vs
the original O(H²) LCA scan.
---
## 3. Spot-check evidence
### 3.1 `__chart_observe!` is zero-cost when off (cargo expand)
Test: `tests/state_chart_const.rs`, with no observation features enabled.
In the expanded source (`/tmp/hsmc_expand_no_features.rs`, 911 lines), every
observation site collapses to an empty block:
```rust
// in `step()`:
if self.current.is_none() {
{}; // would be observe_started
self.enter_path(0);
return self.timers.min_remaining();
}
// ...
if let Some((state, trig)) = self.timers.pop_expired(&__DEPTH) {
let __t_state = state;
let __t_trigger = trig;
{}; // would be observe_timer_fired
// ...
}
```
A grep for any of `TraceEvent::`, `::log::`, `::tracing::`, `::defmt::`,
`JournalSink::push` in the expanded code returns **zero matches**. The
macro arms entirely vanish.
`JournalSink` itself becomes a unit struct when the feature is off:
```rust
#[cfg(not(feature = "journal"))]
pub struct JournalSink; // ZST — 0 bytes in the chart struct
```
so the `__journal` field on the chart costs zero bytes at runtime.
### 3.2 Name lookups are dead-code-eliminated (cargo rustc --emit=asm)
Test: `tests/det_lateral.rs`, built with `--features tokio,journal`.
Even with `journal` enabled, a grep across the 22,477 lines of generated
asm for `state_name`, `action_name`, `event_name`, or `timer_name` returns
**zero matches**. The journal stores `u16` indices, not strings, and no
trace-* feature is on, so the name-lookup helper functions are uncalled
and DCE'd from the final binary.
This means even if a user enables `journal` for replay/verification, they
pay zero binary cost for the human-readable name tables until they also
enable a `trace-*` sink.
### 3.3 LLVM lowers handler matches to jump tables
Same asm file. Grep for `LJTI`:
```asm
leaq .LJTI2_0(%rip), %rcx
jmpq *%rax
.LJTI2_0:
.long .LBB2_1-.LJTI2_0
.long .LBB2_28-.LJTI2_0
.long .LBB2_29-.LJTI2_0
.long .LBB2_23-.LJTI2_0
```
Multiple jump tables are present, each sized roughly to the chart's
state/event count. This corroborates the assumption that
`__handler_lookup`'s nested `match (state_id) { match (kind, trigger_id) }`
lowers to a single indirect jump on platforms LLVM can target with one,
not a chain of compares.
### 3.4 Async machinery code size
Symbol-by-symbol asm sizes from `det_lateral`:
| Symbol (async closure / future) | Asm lines | Notes |
|---------------------------------|-----------|----------------------------------------------|
| `Lat::exit_state` | ~275 | one `.await` per exit action |
| `Lat::run_action` | ~349 | match arm + N monomorphized async branches |
| `Lat::run_handlers` | ~826 | loop over actions, each `.await` |
| `Lat::enter_state_no_descent` | ~217 | |
| `Lat::step` | ~970 | inlines `lca`, `is_up_transition`, etc. |
| `Lat::dispatch` | ~137 | thin loop wrapper |
| **Lat-chart total** | **~2,800**| async machinery for one 5-state chart |
For comparison, `Lat::new` is 141 lines. The async coloring
roughly doubles to triples the binary footprint of a chart relative to a
pure sync codegen. On Cortex-M0/M0+ where flash is small, this matters.
### 3.5 Journal expansion adds N pushes, nothing else (cargo expand)
Same test, expanded with `--features tokio,journal`
(`/tmp/hsmc_expand_with_journal.rs`, 2,426 lines vs. 911 lines without).
Counts:
- 104 total `TraceEvent::*` constructions.
- 20 distinct variant names (the full vocabulary).
- 8 `Vec::push` call sites inside `step()` — but only one fires per
invocation (timer-fired path, event-received path, etc., are mutually
exclusive).
The diff against the no-feature expansion is exactly: every `{};`
placeholder becomes a `(&mut self.__journal).push(TraceEvent::...)`. No
other infrastructure is added. No locking, no allocation per push beyond
the amortized `Vec` growth.
---
## 4. Observability cost matrix
Distilled from `lib.rs:158-712` and the spot-checks above:
| Feature(s) | Per-call CPU | Per-call alloc | Off → fully elided? |
|-------------------|-------------------------------------------|--------------------------|-------------------------------|
| `(none)` | 0 | 0 | Yes (verified) |
| `journal` | `Vec::push` ~10 ns amortized | ~16-24 B amortized | n/a |
| `trace-defmt` | format-hash u32 + field write | 0 | Yes |
| `trace-log` | `log_enabled!()` check + format if passes | 0 unless format | Yes (with small residual when on but level filtered) |
| `trace-tracing` | unconditional field collection + span | 0 | Yes (with ~30-cycle residual when on but no subscriber) |
The codegen emits **~11 `__chart_observe!` calls per typical transition**
in deterministic order: `EventReceived` → `EventDelivered` →
`TransitionFired` → `ExitBegan` → `ActionInvoked{Exit}` → `Exited` →
`EnterBegan` → `ActionInvoked{Entry}` → `Entered` → `TransitionComplete`,
plus action-handler markers. That's the multiplier on every per-call
number above.
---
## 5. What is *not* a problem (audited and dismissed)
- **Handler-lookup growth with chart size** — confirmed jump-table
lowering (§3.3). O(1) per event regardless of state or event count
(modulo cache effects from larger jump tables).
- **Trait-object cost on action dispatch** — only the `&mut dyn QueuePush`
proxy is `dyn`. User actions are monomorphized trait methods (one match
arm per action ID, direct calls). One vtable hop per emit, *not* per
action.
- **Observation overhead with features off** — confirmed zero. Verified
three ways: macro arm structure (every body `#[cfg]`-gated), expanded
source (every site is `{};`), and asm (no name-lookup symbols, no trace
symbols).
- **Heap allocation in default build** — `extern crate alloc` is confined
to `journal.rs:26`. With `journal` off, the runtime is fully stack-only.
Verified: no `Vec`, `Box`, or `String` in expanded sync code.
- **`Pin<Box<...>>` futures on the hot path** — none. Chart futures are
inline state machines, not boxed.
- **Tokio `unbounded_channel` for embedded** — irrelevant: tokio is `std`
anyway. Embassy uses bounded `embassy_sync::channel::Channel` (no
alloc).
---
## 6. Event entry points
Events reach the chart's internal `heapless::Deque` via four distinct
paths. Each has its own latency, wakeup, ordering, and backpressure
profile, summarized below.
| Path | API | Backing | Wakeup | Ordering | Backpressure |
|------|-------------------------------------------|--------------------------------------------|-------------------------|----------------------------------------|------------------------------------|
| 1 | `chart.send(event)` (sync, single-task) | `heapless::Deque::push_back` | none — caller drives | FIFO within queue | `Err(QueueFull)` immediate |
| 2a | `chart.sender().send(ev)` (tokio) | `tokio::sync::mpsc::unbounded_channel` | tokio task wake | FIFO; biased select deprioritizes vs. durings | **unbounded memory** |
| 2b | `chart.sender().send(ev)` (embassy) | `embassy_sync::channel::Channel` (bounded) | embassy task wake | FIFO; same biased-select | `Err(Full)` (try_send) or backpressure-await |
| 3 | `actx.emit(ev)` from inside an action | same internal `Deque` as path 1 | none — drained inline | drained synchronously after current handler completes; *before* `step()` returns | sticky overflow flag |
| 4 | return from a `during:` future | `select!` arm in run loop | driver-specific | biased priority over channel events | silent drop on `Deque` full |
**Path 1 (sync send)** — `codegen.rs:2316-2319`. Bare
`Deque::push_back`; owning task only, no synchronization. ~5–10 cycles.
Caller is responsible for invoking `step()` (or `dispatch()` under an
async feature) to actually process the event.
**Path 2 (cross-task `sender()`)** — `codegen.rs:1492` (tokio sender),
`1638` (embassy sender), `1308` / `1405` (run-loop receive sites). The
canonical cross-thread / ISR path per the spec. Tokio uses an unbounded
MPSC (heap-backed; no backpressure signal); embassy uses a bounded
`Channel<M, E, N>` with `try_send` for ISR-safe non-blocking emits and
an `async send` that backpressures the producer. The receiver task
wakes on send; one iteration of the run-loop's `select!` consumes the
event and pushes it into the internal `Deque`.
**Path 3 (self-emit from action)** — `actx.emit(...)` goes through
`EmitProxy::push` (`hsmc/src/lib.rs`) which writes into the same
internal `Deque` as Path 1. Self-emitted events are drained in the
*same* `step()` call that emitted them — `dispatch()` runs `while
!queue.is_empty() { step().await }` until quiescence. Nothing crosses a
task boundary.
**Path 4 (`during:` return)** — `codegen.rs:1306–1313` (tokio with
durings), `1392+` (embassy). Durings are racing futures spawned in the
run-loop's `select!`. When one resolves to an event, the `select!`
unblocks and the event is pushed into the internal `Deque`. The
`select!` is `biased;` so durings have priority over the channel —
relevant if a during is a poll-based driver that's almost always
ready (see §8).
**One ordering caveat**: there is no global FIFO across paths 1–4.
Within each path, events are FIFO; across paths, the order depends on
when each path's wakeup arrives at the run loop. For most charts this
is a non-issue, but a chart that mixes high-frequency cross-task sends
with rapid self-emits could observe self-emits processed before
external events that arrived "first" in wall-clock terms.
---
## 7. Cooperative-driving audit
The library targets embassy-rs's single-priority cooperative scheduler,
where any tight loop without an `.await` can starve other tasks. An
audit of every loop and await pattern in the runtime and codegen:
| Loop / await | Location | Bounded? | Awaits per iter? | Verdict |
|---------------------------------------|---------------------------|-------------------------|--------------------------|---------------------|
| Main `run()` outer | codegen.rs:1290+, 1379+ | by `terminated` flag | yes — always | cooperative |
| `dispatch()` drain loop | codegen.rs:2339 | by queue size | only via `step().await` | suspect — fairness |
| Transition exit/enter slice loops | codegen.rs (transition) | by precomputed slice len| yes (async variant) | OK |
| Termination drain | codegen.rs:2750 | by hierarchy + queue | yes (async variant) | OK |
| Timer decrement / pop | runtime / lib.rs | by timer-table size | sync, off the event-loop | OK |
| Send/recv backends | codegen.rs:1308, 1405 | n/a | always `.await` | cooperative |
No `try_recv` patterns anywhere. No fixed-period polling. The library
is fundamentally event-driven and embassy-friendly.
**One latent fairness characteristic**: the `dispatch()` drain loop —
`while !self.queue.is_empty() { step(Duration::ZERO).await }` — never
yields to the executor between iterations. If user actions are all
synchronous and they emit a chain of follow-on events (action A emits
B, B emits C, …), each `step().await` completes inline, and the loop
becomes effectively synchronous for the duration of the chain. Embassy
high-priority tasks could be delayed by the chain length.
This is **not a bug** — `dispatch()` is documented to drain to
quiescence — but it's worth knowing about. A future option (not part of
this round of work) would be a feature-gated
`embassy_futures::yield_now().await` every Nth iteration, or a
chart-level config to bound chain length. Not done by default because
it would serialize the common case for no benefit.
---
## 8. Correctness invariants surfaced during the audit
Three contracts that aren't bugs, but warrant explicit documentation
because they are easy to miss when reading the code:
**Sticky overflow flag in `EmitProxy`** (`hsmc/src/lib.rs`,
`EmitProxy::push`). When a self-emit fails because the internal `Deque`
is full, an `__overflow` flag is set on the chart. *Every* subsequent
`actx.emit(...)` in the same dispatch chain returns `Err(QueueFull)`,
even after the queue has drained. The flag is cleared at the top of
each `dispatch()` call by `take_overflow()`. This is intentional — it
prevents a chart from observing a partially-applied sequence of
self-emissions where some made it into the queue and others did not.
Authors of actions that emit multiple events should treat
`Err(QueueFull)` as terminal for the current chain.
**Biased select in run loop** (tokio: `codegen.rs:1306–1313`; embassy:
`1392+`). The `select!` is `biased;`, so durings are always polled
before the cross-task channel. For most charts this is the right
priority — a sensor driver should pre-empt a UI event. But for a
`during:` that wraps a poll-based driver (one that returns `Ready`
nearly every poll), the channel can be starved indefinitely. Cure:
make the during yield (`embassy_futures::yield_now`) when there's
nothing real to report.
**Cancel-safety contract on durings** (`hsmc/src/lib.rs:53–60`). Every
`.await` in a `during:` future must be a clean resume point, because
the future will be dropped if the state transitions before it
completes. This is the standard async-cancel-safety contract, but it
is easy to violate by holding a mutable borrow across an await — the
borrow is "valid" when resumed but the surrounding state may have
moved on.
---
## 9. Candidate mitigations (menu, NOT a commitment)
Sized for sequencing in a follow-up plan-mode session. "Effort" is gut
feel: L = afternoon, M = couple of days, H = a week or more.
| ID | Mitigation | Targets | Impact | Effort | Cost |
|-----|-----------------------------------------------------------------------------------------------------------------------------------|-----------|-----------------------------------------|--------|---------------------------------------|
| H0 | Replace `lca()` with an O(H) depth-walk via the `__DEPTH` table. | #2 | **Landed.** | - | - |
| H1 | Codegen per-(from, to) precomputed exit/enter chains as `__PATH_DATA` + `__PATH_RANGE`. | #2 | **Landed.** Up/self/lateral folded into one indexed lookup; `lca()` and `is_up_transition()` removed. | - | ~`6·N²` B index + ~`N²·H` B path data |
| T1 | Inlined `is_empty()` early-out on `pop_expired` / `min_remaining` / `decrement` / `cancel_state` with `#[cold]` non-empty bodies. | #11 | **Landed.** | - | - |
| T2 | Codegen-static elide of all `TimerTable` calls when `ir.duration_triggers.is_empty()`. | #11 | **Landed.** Most charts are timer-free; `step()` returns `None` directly. | - | - |
| H4 | `__DISPATCH[(state, event)]` const array; one indexed lookup replaces the runtime __PARENT bubble walk + per-hop `__handler_lookup`. | #3 | **Landed.** Each (state, event) statically resolved at codegen. | - | ~24 B per (state, event) pair |
| U1 | Shrink `TimerEntry.remaining_ns: u128 → u64` (~584-year ceiling). Halves entry size; replaces u128 saturating-sub with u64. | #12 | **Landed.** | - | - |
| K1 | `__TERMINATE_DATA` per-state precomputed exit chain (root inclusive); `do_terminate()` indexes a slice instead of walking __PARENT, and uses `Deque::clear()` (O(1)) for the queue drain. | #13 | **Landed.** terminate_event -22.5% instr / -23.4% cycles. | - | ~`(H+1)·N` B path data |
| H3 | Provide a `dispatch_sync()` API that bypasses `.await` when the chart's actions are fully synchronous. | #1, #9 | High for sync-action charts | L-M | API surface growth (user-facing) |
| H5 | Sparse `__PATH_RANGE` / `__DISPATCH` for very large charts (N > 64): emit only reachable pairs with a small perfect-hash. Latency-neutral; cuts O(N²) static memory growth. | cost mitigation | Low at typical N; significant at N > 100 | M | codegen complexity |
| M1 | Eliminate the trailing `step().await` in `dispatch()` when queue empties mid-step. | #1 | Low | L | minor refactor |
| M2 | Lazy field eval in `trace-tracing` arm using `tracing`'s field-closure form to skip evaluation when filtered. | #8 (on) | Low | L | none |
H0/H1/T1/T2/H4/U1/K1 all landed in the most recent round of work.
Callgrind now reports the entire HSM machinery as a single `step`
symbol — every helper inlined. The only remaining optimization avenue
that meaningfully affects per-event latency is H3 (`dispatch_sync()`),
which is excluded as user-facing. Memory-side mitigations (H5) only
matter for N > 64-state charts; embedded users with large charts are
the audience.
---
## 10. Where to look in the source
For anyone reading this audit and wanting to verify or extend it:
- **`hsmc/src/lib.rs`**
- `:94` — the `#![cfg_attr(... no_std)]` boundary
- `:158-712` — `__chart_observe!` macro, all observation arms
- `:737-772` — `JournalSink` ZST/Vec duality
- **`hsmc/src/journal.rs`**
- `:26-32` — `extern crate alloc` and `Vec` import (only here)
- `:65-150+` — `TraceEvent` enum (the journal vocabulary)
- **`hsmc-macros/src/codegen.rs`**
- `compute_transition_paths()` (top of file, after `compute_chart_hash`) — builds `__PATH_DATA` + `__PATH_RANGE` at codegen time
- `__PARENT`, `__DEPTH`, `__DEFAULT_CHILD`, `__PATH_DATA`, `__PATH_RANGE` static-table emission (search "const __PARENT")
- `step()` (search "fn step" / "async fn step") — sync vs. async branches
- `dispatch()` await loop (search "fn dispatch")
- `run_action` (search "fn run_action") — sync vs. async
- `transition` (search "fn transition") — now a thin slice-iter wrapper
- name-lookup helpers (`__state_name`, `__action_name`, etc.) at `:631-667`
- **Test reference shape**: `hsmc/tests/state_chart_const.rs` is the
smallest sync-only chart in the tree; `hsmc/tests/det_lateral.rs` is
the reference async + journal shape used for the asm spot-check.
---
## 11. Reproducing the spot-checks
```sh
# Zero-cost observation verification (expand → no observation residue)
cargo expand --release --test state_chart_const \
> /tmp/expand_no_features.rs --manifest-path hsmc/Cargo.toml
grep -E "TraceEvent::|::log::|::tracing::|::defmt::" /tmp/expand_no_features.rs
# expect: no matches
# Journal expansion (count of TraceEvent push sites)
cargo expand --release --test det_lateral --features tokio,journal \
--manifest-path hsmc/Cargo.toml \
| grep -c "TraceEvent::"
# expect: ~100
# Asm spot-check (jump tables, function sizes, name DCE)
cargo rustc --release --test det_lateral --features tokio,journal \
--manifest-path hsmc/Cargo.toml -- --emit=asm
ASM=$(find target -name 'det_lateral*.s' | head -1)
grep -E "LJTI|jmpq?[[:space:]]+\*" "$ASM" # jump tables
grep -E "state_name|action_name" "$ASM" # expect no matches
```
---
## 12. Summary
`hsmc`'s hot path is mostly already at the algorithmic floor for
hierarchical state machines. The intrinsic per-event cost (parent walk,
handler lookup, action dispatch, queue ops) is small and well-monomorphized.
Observation cost is verified zero when features are off, and stays
proportional to the user's chosen sink mix when on.
**Items #2, #3, #11, #12, and #13 in §1 have all landed** across six
optimization passes (H0 → H1 → T1 → H4 → T2 → K1+U1). Cumulative impact
on the deepest dispatch (h8_cross_tree) is **-82% instructions / -72%
cycles** vs the original O(H²) LCA scan. The headline wins by bench:
- `terminate_event` 69 instr / 311 cycles (~14 instr of HSM work after
subtracting bench wrapper) — `do_terminate()` is one indexed slice
+ `Deque::clear()`.
- `bubble_top` 99 instr / 453 cycles — 4-hop handler resolution
collapses to one indexed `__DISPATCH` lookup; per-dispatch cost is
bench-shape-independent.
- `bubble_cross_tree` 94 instr / 444 cycles — realistic HSM worst
case (deep bubble + 10-state cross-tree) costs the same as a plain
cross-tree because bubble lookup is O(1) and transition is
O(path-length).
- `h8_cross_tree` 93 instr / 443 cycles — 7-deep cross-tree dispatch.
Callgrind reports the entire HSM machinery as a single `step` symbol —
`run_handlers`, `run_action`, `transition`, `do_terminate`,
`exit_state`, `enter_state_no_descent`, `EmitProxy::push`, all four
`TimerTable` methods, the dispatch-table lookup, and the
terminate-path lookup are all inlined.
Cost remaining, in priority order:
1. **Async coloring code-size cost** (item #9) — chart machinery is
~3,000 asm lines for a 5-state chart, dominated by async-fn state
machines. Mitigation H3 (a `dispatch_sync()` fast path) would let
users with all-sync actions skip this entirely. **User-facing API
change** — out of scope for the current optimization pass.
2. **Bench-wrapper noise floor** — at the smallest dispatch's ~38–80
instructions, the iai-callgrind wrapper (`black_box`, `send`,
instrumented call-edge crossing) accounts for ~45 instructions.
Sub-bench-wrapper improvements are below the measurement floor.
3. **Memory cost of `__PATH_DATA` / `__PATH_RANGE` / `__DISPATCH` /
`__TERMINATE_DATA` at large N** — `__PATH_RANGE` is O(N²);
`__DISPATCH` is O(N×E); `__TERMINATE_DATA` is O(N×H). For N ≤ 32 /
E ≤ 16 / H ≤ 8 the total static footprint is a few KiB. A
64-state × 32-event chart would push past ~80 KiB. Mitigation H5
sketches a sparse encoding.
All addressable purely in `hsmc-macros/src/codegen.rs` and `hsmc/src/lib.rs`
without breaking runtime semantics or feature gates. The bench harness
in `hsmc/benches/dispatch.rs` (13 benches, every dispatch shape
covered) is in place to measure each mitigation as it lands.
+652 -223
View File
File diff suppressed because it is too large. Load diff
+35 -6
View File
@@ -79,7 +79,17 @@ pub struct StateIr {
pub name: Ident,
pub parent: Option<u16>,
pub depth: u8,
/// Target of this state's `default(...)` declaration, if any. Resolved
/// in `resolve_transitions`. The target may be any state in the chart
/// (not just a direct child) — `default(...)` is a transition that
/// fires immediately after the declaring state's entries, using the
/// same LCA-aware algorithm as event-driven transitions. The
/// default-graph cycle check in `validate.rs` ensures the chain
/// terminates.
pub default_child: Option<u16>,
/// Original ident from the parse tree, kept until resolution so we can
/// emit good error spans on unknown targets / cycles.
pub default_target_ident: Option<(Ident, Span)>,
pub entries: Vec<u16>, // action ids
pub exits: Vec<u16>,
pub handlers: Vec<HandlerIr>,
@@ -192,6 +202,7 @@ fn alloc_state(ir: &mut Ir, name: Ident, parent: Option<u16>, depth: u8, span: S
parent,
depth,
default_child: None,
default_target_ident: None,
entries: Vec::new(),
exits: Vec::new(),
handlers: Vec::new(),
@@ -451,12 +462,11 @@ fn lower_body(
}
ir.states[state_id as usize].children = child_ids.clone();
if let Some((dc_ident, _)) = &body.default_child {
let resolved = child_ids
.iter()
.find(|&&cid| ir.states[cid as usize].name == *dc_ident)
.copied();
ir.states[state_id as usize].default_child = resolved;
// Stash the unresolved default target. The actual id is filled in by
// `resolve_transitions`, which has the global state-name → id map and
// can resolve targets that aren't direct children.
if let Some((dc_ident, dc_span)) = &body.default_child {
ir.states[state_id as usize].default_target_ident = Some((dc_ident.clone(), *dc_span));
}
Ok(())
@@ -547,5 +557,24 @@ pub fn resolve_transitions(ir: &mut Ir) -> syn::Result<()> {
}
}
}
// Resolve default-transition targets against the same global map.
for i in 0..n {
if let Some((ident, _span)) = ir.states[i].default_target_ident.clone() {
let tname = ident.to_string();
match name_map.get(&tname).copied() {
Some(id) => {
ir.states[i].default_child = Some(id);
}
None => {
return Err(syn::Error::new(
ident.span(),
format!("unknown state `{}` in `default(...)`", ident),
));
}
}
}
}
Ok(())
}
+76 -34
View File
@@ -8,6 +8,7 @@ use crate::ir::{HandlerKindIr, Ir, TriggerIr};
use crate::parse::{StateBody, StatechartInput};
pub fn validate(ir: &Ir) -> syn::Result<()> {
validate_default_graph(ir)?;
// Duplicate state names
let mut seen = HashMap::new();
for s in &ir.states {
@@ -94,16 +95,11 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
}
}
// Children require default; default must be a direct child; no default allowed if no children.
if !s.children.is_empty() && s.default_child.is_none() {
return Err(syn::Error::new(
s.span,
format!(
"state `{}` has children but no `default` declaration",
s.name
),
));
}
// `default` is optional: a composite without a `default(...)` is a
// valid resting state — transitions targeting it land on the composite
// itself, with no descent. Substates of such a composite are reachable
// only via explicit transitions. (`default` must still be a direct
// child when declared; that's checked in `validate_parse_tree`.)
// duplicate transitions per trigger in this state, AND transition
// target (if present) must be declared after every action handler
@@ -184,9 +180,70 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
Ok(())
}
/// Walk the default-edge graph and reject cycles. Each state has at most one
/// outgoing default edge, so the graph has out-degree ≤ 1 and any cycle is a
/// simple chain that loops. Detection: 3-color DFS via an iterative chain walk.
///
/// Cycles are rejected at compile time because at runtime, a default chain
/// fires as a sequence of immediate transitions on entry — a cycle would loop
/// forever, never returning control to the dispatcher.
fn validate_default_graph(ir: &Ir) -> syn::Result<()> {
let n = ir.states.len();
// 0 = white (unvisited), 1 = gray (on current chain), 2 = black (done).
let mut color = vec![0u8; n];
for start in 0..n {
if color[start] != 0 {
continue;
}
let mut chain: Vec<usize> = Vec::new();
let mut cur = start;
loop {
if color[cur] == 2 {
// Joined a previously-cleared subgraph. Chain so far is fine.
break;
}
if color[cur] == 1 {
// Back-edge into the current chain — cycle.
let cycle_start = chain
.iter()
.position(|&x| x == cur)
.expect("gray node must be on current chain");
let cycle: Vec<String> = chain[cycle_start..]
.iter()
.map(|&id| ir.states[id].name.to_string())
.collect();
let pretty = format!("{} -> {}", cycle.join(" -> "), cycle[0]);
let err_span = ir.states[chain[cycle_start]]
.default_target_ident
.as_ref()
.map(|(_, s)| *s)
.unwrap_or(ir.states[chain[cycle_start]].span);
return Err(syn::Error::new(
err_span,
format!(
"default-transition cycle detected: {} \
(entering any state in this cycle would loop forever \
before reaching user code)",
pretty
),
));
}
color[cur] = 1;
chain.push(cur);
match ir.states[cur].default_child {
Some(next) => cur = next as usize,
None => break,
}
}
for &id in &chain {
color[id] = 2;
}
}
Ok(())
}
/// Additional checks that require the original parse tree (for counting duplicate
/// `default`/`terminate` with their spans, and the "default in childless state" /
/// "default not a direct child" / "terminate in non-root" errors).
/// `default`/`terminate` with their spans, and the "terminate in non-root" error).
pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> {
let root_name = input.name.to_string();
validate_body(&input.body, true, &root_name)
@@ -217,28 +274,13 @@ fn validate_body(body: &StateBody, is_root: bool, state_name: &str) -> syn::Resu
));
}
}
if body.default_child.is_some() && body.children.is_empty() {
return Err(syn::Error::new(
body.default_span.unwrap(),
format!(
"`default` in state `{}` which has no child states",
state_name
),
));
}
if let (Some((dc, dspan)), false) = (&body.default_child, body.children.is_empty()) {
// default must be a direct child
let is_child = body.children.iter().any(|c| c.name == *dc);
if !is_child {
return Err(syn::Error::new(
*dspan,
format!(
"`default({})` in state `{}`: `{}` is not a direct child of `{}`",
dc, state_name, dc, state_name
),
));
}
}
// default(...) is a transition that fires immediately after the
// declaring state's entries finish. It may be declared on any state
// (composite or leaf) and may target any state in the chart. A leaf
// with `default(T)` just transitions to T on every entry. The
// default-graph cycle check in `validate_default_graph` (run from the
// top-level `validate_parse_tree` over the lowered IR) ensures we
// cannot construct an infinite chain at startup.
for c in &body.children {
let cname = c.name.to_string();
validate_body(&c.body, false, &cname)?;
@@ -1,24 +0,0 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Parent);
state Parent {
state ChildA { entry: e; }
state ChildB { entry: e; }
}
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -1,5 +0,0 @@
error: state `Parent` has children but no `default` declaration
--> tests/ui/t9_2_missing_default.rs:13:11
|
13 | state Parent {
| ^^^^^^
@@ -1,25 +0,0 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Parent);
state Parent {
default(Cousin);
state Child { entry: e; }
}
state Cousin { entry: e; }
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -1,5 +0,0 @@
error: `default(Cousin)` in state `Parent`: `Cousin` is not a direct child of `Parent`
--> tests/ui/t9_4_default_non_child.rs:14:9
|
14 | default(Cousin);
| ^^^^^^^
@@ -1,3 +1,6 @@
// `default(self)` is a 1-cycle: entering A would fire its default
// transition to A, which would fire its default again, ad infinitum.
// Compile-time error.
use hsmc::statechart;
#[derive(Debug, Clone)]
@@ -9,9 +12,9 @@ statechart! {
M {
context: Ctx;
events: Ev;
default(Leaf);
state Leaf {
default(Ghost);
default(A);
state A {
default(A);
entry: e;
}
}
@@ -0,0 +1,5 @@
error: default-transition cycle detected: A -> A (entering any state in this cycle would loop forever before reaching user code)
--> tests/ui/t9_4_default_self_cycle.rs:17:9
|
17 | default(A);
| ^^^^^^^
@@ -1,5 +0,0 @@
error: `default` in state `Leaf` which has no child states
--> tests/ui/t9_5_default_childless.rs:14:9
|
14 | default(Ghost);
| ^^^^^^^
@@ -0,0 +1,30 @@
// A → B → A is a default cycle: entering either state would loop
// forever before reaching user code. Compile-time error.
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(A);
state A {
default(B);
entry: e;
}
state B {
default(A);
entry: e;
}
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -0,0 +1,5 @@
error: default-transition cycle detected: A -> B -> A (entering any state in this cycle would loop forever before reaching user code)
--> tests/ui/t9_5_default_two_cycle.rs:16:9
|
16 | default(B);
| ^^^^^^^
@@ -0,0 +1,50 @@
//! Compile-pass: a composite state may omit `default(...)`.
//!
//! When a composite has children but no `default`, transitions targeting
//! that composite land on the composite itself — the substates are reachable
//! only via explicit transitions. Microwave-style chart: `Standby` is the
//! resting state and `LowPower` is a sub-mode you enter only on demand.
#![allow(unexpected_cfgs)]
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev {
PowerOn,
PowerOff,
EnterLowPower,
LeaveLowPower,
Halt,
}
#[derive(Default)]
pub struct Ctx;
statechart! {
Microwave {
context: Ctx;
events: Ev;
default(Standby);
terminate(Halt);
state Standby {
// No `default(...)` — Standby is itself the resting state.
on(PowerOn) => On;
on(EnterLowPower) => LowPower;
state LowPower {
on(LeaveLowPower) => Standby;
}
}
state On {
on(PowerOff) => Standby;
}
}
}
impl MicrowaveActions for MicrowaveActionContext<'_> {}
fn main() {
let _ = Microwave::new(Ctx);
}
@@ -0,0 +1,43 @@
//! Compile-pass: a state's `default(...)` may target an ancestor. Entering
//! the declaring state would immediately exit back up to the ancestor (the
//! up-transition rule applies — ancestor not re-entered). The state is
//! reached only via an explicit transition; without one, control never
//! enters it. (No cycle here: Outer has no default; Inner targets Outer.)
#![allow(unexpected_cfgs)]
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { GoIn, Halt }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Outer);
terminate(Halt);
state Outer {
entry: outer_in;
on(GoIn) => Inner;
state Inner {
entry: inner_in;
// Default jumps up to Outer. Inner is reachable only via the
// explicit GoIn transition; once entered, this default fires
// and lands the chart back at Outer (no re-entry of Outer).
default(Outer);
}
}
}
}
impl MActions for MActionContext<'_> {
fn outer_in(&mut self) {}
fn inner_in(&mut self) {}
}
fn main() {
let _ = M::new(Ctx);
}
@@ -0,0 +1,38 @@
//! Compile-pass: a state's `default(...)` may target a sibling. Entering
//! the declaring state immediately fires a transition out to the sibling,
//! using the same LCA-aware algorithm as any other transition.
#![allow(unexpected_cfgs)]
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { Halt }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Foyer);
terminate(Halt);
state Foyer {
// Default redirects out of Foyer to its sibling LivingRoom.
default(LivingRoom);
entry: foyer_in;
}
state LivingRoom {
entry: living_in;
}
}
}
impl MActions for MActionContext<'_> {
fn foyer_in(&mut self) {}
fn living_in(&mut self) {}
}
fn main() {
let _ = M::new(Ctx);
}
+8 -1
View File
@@ -40,7 +40,7 @@ trace-tracing = ["dep:tracing"]
journal = []
[dependencies]
hsmc-macros = { path = "../hsmc-macros", version = "0.4.0" }
hsmc-macros = { path = "../hsmc-macros", version = "0.5.0" }
heapless = "0.8"
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
embassy-sync = { version = "0.8", optional = true }
@@ -57,6 +57,9 @@ ratatui = "0.28"
crossterm = { version = "0.28", features = ["event-stream"] }
futures = "0.3"
[target.'cfg(target_os = "linux")'.dev-dependencies]
iai-callgrind = "0.16"
[[example]]
name = "microwave"
required-features = ["tokio"]
@@ -69,3 +72,7 @@ required-features = ["tokio"]
name = "embassy_full"
required-features = ["embassy"]
crate-type = ["lib"]
[[bench]]
name = "dispatch"
harness = false
+176
View File
@@ -0,0 +1,176 @@
# hsmc dispatch baseline — instruction counts from iai-callgrind.
#
# Current numbers reflect the codebase after the terminate-path + u64-ns
# pass. `do_terminate()` indexes into a precomputed `__TERMINATE_DATA`
# table (one slice per starting state, root inclusive); `TimerEntry`'s
# `remaining_ns` field shrunk from u128 to u64 (~584 years of headroom),
# halving the entry size and replacing per-step u128 saturating-sub work
# with u64. The smaller machine struct improves cache density on every
# bench, even those that don't use timers.
#
# Re-run with:
#
# cargo bench --bench dispatch
#
# Named baselines under target/iai/ for diffing past changes:
# pre_lca_fix — original O(H²) LCA contains() scan
# before_path_precompute — post-LCA-fix, pre-precompute
# before_timer_skip — post-precompute, pre-timer-skip (runtime is_empty)
# before_dispatch_precompute — post-timer-skip, pre-dispatch-table
# before_timer_elide — post-dispatch-table, pre-codegen-elide
# before_terminate_and_timer_opts — post-codegen-elide, pre-terminate-path / u64
#
# To diff a future change against the current numbers:
#
# cargo bench --bench dispatch -- --save-baseline=before_<your_change>
# <change>
# cargo bench --bench dispatch -- --baseline=before_<your_change>
#
# Numbers below are from a 6.19.14-zen kernel x86_64 box, valgrind 3.25.1,
# iai-callgrind 0.16.1, --release.
dispatch::dispatch::h2_cold_start cold:setup_h2_cold()
Instructions: 38
Estimated Cycles: 201
dispatch::dispatch::h2_warm_lateral warm:setup_h2_warm()
Instructions: 84
Estimated Cycles: 397
dispatch::dispatch::h4_cross_tree cross:setup_h4_warm()
Instructions: 101
Estimated Cycles: 456
dispatch::dispatch::h4_up up:setup_h4_warm()
Instructions: 98
Estimated Cycles: 384
dispatch::dispatch::h4_self self_:setup_h4_warm()
Instructions: 101
Estimated Cycles: 456
dispatch::dispatch::h8_cross_tree cross:setup_h8_warm()
Instructions: 93
Estimated Cycles: 443
dispatch::dispatch::bubble_leaf leaf:setup_bubble_warm()
Instructions: 99
Estimated Cycles: 453
dispatch::dispatch::bubble_mid mid:setup_bubble_warm()
Instructions: 99
Estimated Cycles: 453
dispatch::dispatch::bubble_top top:setup_bubble_warm()
Instructions: 99
Estimated Cycles: 453
dispatch::dispatch::timer_fire_dispatch fire:setup_timer_fire_armed()
Instructions: 191
Estimated Cycles: 706
dispatch::dispatch::terminate_event quit:setup_term_warm()
Instructions: 69
Estimated Cycles: 311
dispatch::dispatch::bubble_cross_tree switch:setup_bubble_cross_warm()
Instructions: 94
Estimated Cycles: 444
dispatch::dispatch::chain_self_emit chain:setup_chain_warm()
Instructions: 419
Estimated Cycles: 1301
# ─── LCA O(H) fix delta (pre_lca_fix → before_path_precompute) ──────
#
# Bench Instr Δ Cycle Δ
# h2_warm_lateral -22.8% -22.1%
# h4_cross_tree -25.9% -10.6%
# h4_self -21.5% -11.7%
# h8_cross_tree -46.2% -41.0% ← deepest LCA stress
# chain_self_emit -15.4% -10.4%
#
# ─── Precompute-paths delta (before_path_precompute → before_timer_skip) ──
#
# Bench Instr Δ Cycle Δ
# h2_warm_lateral -19.5% -4.8%
# h4_cross_tree -45.0% -28.6%
# h8_cross_tree -48.6% -34.8%
# chain_self_emit -28.1% -14.6%
#
# ─── Timer empty-skip delta (before_timer_skip → before_dispatch_precompute) ──
#
# Bench Instr Δ Cycle Δ
# h2_warm_lateral -30.8% -35.1%
# h4_cross_tree -38.4% -35.4%
# h8_cross_tree -54.4% -52.9%
# chain_self_emit -27.9% -29.2%
#
# ─── Dispatch-table delta (before_dispatch_precompute → before_timer_elide) ──
#
# Bench Instr Δ Cycle Δ
# bubble_mid -19.4% -4.7% 3-hop __PARENT walk → table lookup
# bubble_top -22.3% -5.8% 4-hop walk → table lookup
# h8_cross_tree +4.2% +15.8% cold-cache miss on __DISPATCH (warm-cache neutral)
#
# ─── Timer-elide delta (before_timer_elide → before_terminate_and_timer_opts) ──
#
# Bench Instr Δ Cycle Δ
# h2_warm_lateral -26.9% -9.7%
# h4_cross_tree -22.2% -20.1%
# h8_cross_tree -24.2% -20.8%
# bubble_top -22.1% -23.2%
# chain_self_emit -18.0% -8.4%
#
# ─── Terminate-path + u64-ns delta (before_terminate_and_timer_opts → current) ──
#
# Bench Instr Δ Cycle Δ Notes
# h2_cold_start -2.6% -14.1% smaller machine struct, tighter cache
# h2_warm_lateral -3.4% -21.2% (RAM hits 11→8)
# h4_cross_tree -3.8% -1.7%
# h4_up -3.9% -22.3% (RAM hits 10→7)
# h4_self -3.8% -8.4%
# h8_cross_tree -1.1% -0.7%
# bubble_leaf -6.6% -9.6%
# bubble_mid -6.6% -3.0%
# bubble_top -6.6% -9.6%
# timer_fire_dispatch +2.7% -12.6% smaller TimerEntry → -3 RAM hits
# terminate_event -22.5% -23.4% ← headline: precomputed exit path + queue.clear()
# bubble_cross_tree -2.1% -0.9%
# chain_self_emit -0.9% -3.1%
#
# Two changes in this pass:
#
# (1) `__TERMINATE_DATA` / `__TERMINATE_RANGE` — per-state precomputed
# exit chain up to and including __Root. `do_terminate()` indexes the
# table once and iterates the slice, replacing the runtime `__PARENT`
# walk + per-iteration match. Also swapped the queue-drain loop
# (`while self.queue.pop_front().is_some() {}`) for `self.queue.clear()`,
# which heapless 0.8 implements as O(1) (resets indices, no per-element
# pop). Together: -22% instr / -23% cycles on terminate_event, the
# headline win the user asked for.
#
# (2) `TimerEntry.remaining_ns: u128 → u64`. Internal-only field type
# change. u64 nanoseconds bounds at ~584 years — past any realistic
# Duration. Eliminates u128 saturating-sub in `decrement()` (4-6 instr
# → 1-2 instr per entry per step) and the manual secs/nanos
# decomposition in `min_remaining()` (replaced with
# `Duration::from_nanos(u64)`). The struct shrinks 32 B → 16 B with
# alignment, so the entire machine struct is smaller — even no-timer
# benches see RAM-hit reductions from tighter cache packing.
#
# Net across the six optimization passes (pre_lca_fix → current):
# h8_cross_tree dispatch is **-82% instructions / -72% cycles** vs the
# original O(H²) LCA scan. terminate_event is now 69 instr / 311 cycles
# — about 80% of that is the iai-callgrind wrapper, leaving ~14 instr
# of actual HSM termination work for a 3-deep chart.
#
# ─── Bench coverage (3 benches added in the previous pass) ──────────
#
# `timer_fire_dispatch` (191 instr / 706 cycles) — only bench that
# exercises pop_expired's non-empty branch and `dispatch_trigger`.
# `terminate_event` (69 instr / 311 cycles) — only bench that
# exercises `__check_terminate`'s true arm and `do_terminate`.
# `bubble_cross_tree` (94 instr / 444 cycles) — handler on L1 (4
# bubble hops) firing a 10-state cross-tree transition. Realistic
# HSM worst case.
+633
View File
@@ -0,0 +1,633 @@
//! Deterministic instruction-count benches for the dispatch hot path.
//!
//! Run with `cargo bench --bench dispatch` (Linux only — uses
//! iai-callgrind + valgrind).
//!
//! Each bench measures one dispatch kind in isolation:
//! - `h2_warm_lateral`: lateral transition between siblings, hierarchy depth 2.
//! - `h4_cross_tree`: lateral transition crossing two parallel subtrees, depth 4 from root.
//! - `h8_cross_tree`: same shape but depth 8 — exercises LCA worst-case.
//! - `h4_up`: up-transition (target is an ancestor of current).
//! - `h4_self`: self-transition (target == current).
//! - `h4_drop`: send an event with no matching handler (drop path).
//! - `h4_self_emit_chain`: action emits next event 3×; tests the dispatch drain loop.
//! - `h2_cold_start`: first dispatch into a freshly-constructed chart (priming step).
//!
//! Each bench's setup function primes the chart to a known state outside the
//! measured region. The body sends one event and runs `step()` once.
#[cfg(not(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy")))))]
fn main() {
// The benches use sync action impls; under `--features tokio` /
// `--features embassy` the codegen emits async fns and the impls
// would need an `async` keyword. Rather than maintain two parallel
// bench files, we gate the whole suite to the default-features /
// Linux configuration, which is what we measure latency in anyway.
eprintln!(
"hsmc dispatch benches require Linux + default features \
(iai-callgrind + valgrind, sync action impls)."
);
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
use hsmc::{statechart, Duration};
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
use iai_callgrind::{library_benchmark, library_benchmark_group, main};
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
use std::hint::black_box;
// ──────────────────────────────────────────────────────────────────────
// Chart 1: H=2 sibling. LCA(A, B) = P. Two states deep.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod h2 {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Go,
}
statechart! {
H2 {
context: Ctx;
events: Ev;
default(P);
state P {
default(A);
state A { on(Go) => B; }
state B { entry: noop; }
}
}
}
impl H2Actions for H2ActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 2: H=4 cross-tree. LCA(L3, R3) = root. Stresses LCA over 8 ancestors.
// Also has up- and self-transition events for additional benches.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod h4 {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Cross,
Up,
Self_,
}
statechart! {
H4 {
context: Ctx;
events: Ev;
default(L1);
state L1 {
default(L2);
state L2 {
default(L3);
state L3 {
on(Cross) => R3;
on(Up) => L1;
on(Self_) => L3;
}
}
}
state R1 {
default(R2);
state R2 {
default(R3);
state R3 { entry: noop; }
}
}
}
}
impl H4Actions for H4ActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 3: H=8 cross-tree. LCA(L7, R7) = root. Worst-case for the
// pre-fix O(H²) LCA — old code does ~64 compares; new code does ~16.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod h8 {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Cross,
}
statechart! {
H8 {
context: Ctx;
events: Ev;
default(L1);
state L1 { default(L2);
state L2 { default(L3);
state L3 { default(L4);
state L4 { default(L5);
state L5 { default(L6);
state L6 { default(L7);
state L7 { on(Cross) => R7; }
}
}
}
}
}
}
state R1 { default(R2);
state R2 { default(R3);
state R3 { default(R4);
state R4 { default(R5);
state R5 { default(R6);
state R6 { default(R7);
state R7 { entry: noop; }
}
}
}
}
}
}
}
}
impl H8Actions for H8ActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 4: emit-chain. S0 →(Go)→ S1 →(emits Next)→ S2 →(emits Next)→ S3 →(emits Next)→ S4.
// One Send + one Step ⇒ chart drains 4 events through 3 self-emits.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod chain {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Go,
Next,
}
statechart! {
Chain {
context: Ctx;
events: Ev;
default(S0);
state S0 { on(Go) => S1; }
state S1 { entry: emit_next; on(Next) => S2; }
state S2 { entry: emit_next; on(Next) => S3; }
state S3 { entry: emit_next; on(Next) => S4; }
state S4 { entry: settle; }
}
}
impl ChainActions for ChainActionContext<'_> {
fn emit_next(&mut self) {
let _ = self.emit(Ev::Next);
}
fn settle(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 5: handler bubbling. Active state is L4 (depth 5). The three
// events resolve to handlers at three different ancestor depths so the
// __PARENT walk in step() is exercised at increasing distance:
//
// Leaf — handler on L4 itself → bubble depth 0
// Mid — handler on L1 (3 ancestors) → bubble depth 3
// Top — handler on Outer (4 anc.) → bubble depth 4
//
// `Sibling` is the transition target so each event causes the same
// dispatch shape; the only thing that varies between benches is the
// bubble distance to the handler.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod bubble {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Leaf,
Mid,
Top,
}
statechart! {
Bubble {
context: Ctx;
events: Ev;
default(Outer);
state Outer {
default(L1);
on(Top) => Sibling;
state L1 {
default(L2);
on(Mid) => Sibling;
state L2 {
default(L3);
state L3 {
default(L4);
state L4 {
on(Leaf) => Sibling;
}
}
}
}
}
state Sibling { entry: noop; }
}
}
impl BubbleActions for BubbleActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 6: timer firing. A single timer armed for 1 ns; the bench body
// advances time past expiry so `pop_expired` returns Some and dispatches
// the timer-driven transition. This is the only bench that exercises
// `step()`'s pop_expired non-empty branch — every other chart in this
// suite declares no `Duration` triggers and (post timer-elide pass) has
// the timer-table machinery removed at codegen.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod timer_fire {
use super::*;
#[derive(Default)]
pub struct Ctx;
statechart! {
TimerFire {
context: Ctx;
default(Armed);
state Armed {
on(after Duration::from_nanos(1)) => Fired;
}
state Fired { entry: noop; }
}
}
impl TimerFireActions for TimerFireActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 7: terminate event. `Quit` is declared as the terminate event,
// so dispatching it runs `do_terminate()` — exits every active state up
// to the root, sets `terminated`, and short-circuits future `step()`s.
// Only bench that exercises `__check_terminate`'s true arm and the
// terminate-on-event path.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod term {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Quit,
}
statechart! {
Term {
context: Ctx;
events: Ev;
default(L1);
terminate(Quit);
state L1 {
default(L2);
state L2 {
default(L3);
state L3 { entry: noop; }
}
}
}
}
impl TermActions for TermActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Chart 8: bubble + cross-tree. Handler declared on L1 (4 ancestors up
// from current=L5) targets a deep leaf in a sibling subtree (R5). Two
// dispatch costs at once: the (now-O(1)) handler resolution and a
// 10-state-op cross-tree transition (5 exits + 5 enters). The
// realistic worst case for HSM dispatch — a parent's "back to default"
// handler that descends deep on the other side.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
mod bubble_cross {
use super::*;
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Switch,
}
statechart! {
BubbleCross {
context: Ctx;
events: Ev;
default(L1);
state L1 {
default(L2);
on(Switch) => R5;
state L2 {
default(L3);
state L3 {
default(L4);
state L4 {
default(L5);
state L5 { entry: noop; }
}
}
}
}
state R1 {
default(R2);
state R2 {
default(R3);
state R3 {
default(R4);
state R4 {
default(R5);
state R5 { entry: noop; }
}
}
}
}
}
}
impl BubbleCrossActions for BubbleCrossActionContext<'_> {
fn noop(&mut self) {}
}
}
// ──────────────────────────────────────────────────────────────────────
// Setup helpers. Prime each chart to its measurement starting state
// OUTSIDE the measured region — iai-callgrind only counts work inside
// the bench fn body.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_h2_warm() -> h2::H2<8> {
let mut m = h2::H2::new(h2::Ctx);
let _ = m.step(Duration::ZERO); // priming step → enters P → A
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_h2_cold() -> h2::H2<8> {
h2::H2::new(h2::Ctx)
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_h4_warm() -> h4::H4<8> {
let mut m = h4::H4::new(h4::Ctx);
let _ = m.step(Duration::ZERO); // priming step → L1 → L2 → L3
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_h8_warm() -> h8::H8<8> {
let mut m = h8::H8::new(h8::Ctx);
let _ = m.step(Duration::ZERO); // priming step → L1..L7
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_chain_warm() -> chain::Chain<8> {
let mut m = chain::Chain::new(chain::Ctx);
let _ = m.step(Duration::ZERO); // priming step → S0
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_bubble_warm() -> bubble::Bubble<8> {
let mut m = bubble::Bubble::new(bubble::Ctx);
let _ = m.step(Duration::ZERO); // priming step → Outer → L1 → L2 → L3 → L4
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_timer_fire_armed() -> timer_fire::TimerFire<8> {
let mut m = timer_fire::TimerFire::new(timer_fire::Ctx);
let _ = m.step(Duration::ZERO); // priming step → enters Armed, arms 1ns timer
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_term_warm() -> term::Term<8> {
let mut m = term::Term::new(term::Ctx);
let _ = m.step(Duration::ZERO); // priming step → L1 → L2 → L3
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
fn setup_bubble_cross_warm() -> bubble_cross::BubbleCross<8> {
let mut m = bubble_cross::BubbleCross::new(bubble_cross::Ctx);
let _ = m.step(Duration::ZERO); // priming step → L1 → L2 → L3 → L4 → L5
m
}
// ──────────────────────────────────────────────────────────────────────
// Benches. One library_benchmark per scenario. Each takes its primed
// chart by value, sends one event, and runs one step.
// ──────────────────────────────────────────────────────────────────────
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::cold(setup = setup_h2_cold)]
fn h2_cold_start(mut m: h2::H2<8>) -> h2::H2<8> {
// first .step() primes the chart by entering the default state path
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::warm(setup = setup_h2_warm)]
fn h2_warm_lateral(mut m: h2::H2<8>) -> h2::H2<8> {
let _ = black_box(m.send(black_box(h2::Ev::Go)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::cross(setup = setup_h4_warm)]
fn h4_cross_tree(mut m: h4::H4<8>) -> h4::H4<8> {
let _ = black_box(m.send(black_box(h4::Ev::Cross)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::up(setup = setup_h4_warm)]
fn h4_up(mut m: h4::H4<8>) -> h4::H4<8> {
let _ = black_box(m.send(black_box(h4::Ev::Up)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::self_(setup = setup_h4_warm)]
fn h4_self(mut m: h4::H4<8>) -> h4::H4<8> {
let _ = black_box(m.send(black_box(h4::Ev::Self_)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::cross(setup = setup_h8_warm)]
fn h8_cross_tree(mut m: h8::H8<8>) -> h8::H8<8> {
let _ = black_box(m.send(black_box(h8::Ev::Cross)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::leaf(setup = setup_bubble_warm)]
fn bubble_leaf(mut m: bubble::Bubble<8>) -> bubble::Bubble<8> {
// Handler on L4 (current state). Bubble distance = 0.
let _ = black_box(m.send(black_box(bubble::Ev::Leaf)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::mid(setup = setup_bubble_warm)]
fn bubble_mid(mut m: bubble::Bubble<8>) -> bubble::Bubble<8> {
// Handler on L1. Bubble walks L4 → L3 → L2 → L1 (3 __PARENT hops).
let _ = black_box(m.send(black_box(bubble::Ev::Mid)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::top(setup = setup_bubble_warm)]
fn bubble_top(mut m: bubble::Bubble<8>) -> bubble::Bubble<8> {
// Handler on Outer. Bubble walks L4 → L3 → L2 → L1 → Outer (4 hops).
let _ = black_box(m.send(black_box(bubble::Ev::Top)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::fire(setup = setup_timer_fire_armed)]
fn timer_fire_dispatch(mut m: timer_fire::TimerFire<8>) -> timer_fire::TimerFire<8> {
// Advance time by 2ns — past the 1ns arm — so pop_expired returns
// Some and dispatches the timer-driven Armed → Fired transition.
let _ = black_box(m.step(black_box(Duration::from_nanos(2))));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::quit(setup = setup_term_warm)]
fn terminate_event(mut m: term::Term<8>) -> term::Term<8> {
// Send Quit (declared as terminate event); step runs check_terminate's
// true arm and do_terminate, which exits L3 → L2 → L1 → root.
let _ = black_box(m.send(black_box(term::Ev::Quit)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::switch(setup = setup_bubble_cross_warm)]
fn bubble_cross_tree(mut m: bubble_cross::BubbleCross<8>) -> bubble_cross::BubbleCross<8> {
// Switch handler is on L1 (4 bubble hops from current=L5). Target is
// R5 in a sibling subtree, so the transition is exit L5..L1 + enter
// R1..R5 = 10 state operations.
let _ = black_box(m.send(black_box(bubble_cross::Ev::Switch)));
let _ = black_box(m.step(black_box(Duration::ZERO)));
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
#[library_benchmark]
#[bench::chain(setup = setup_chain_warm)]
fn chain_self_emit(mut m: chain::Chain<8>) -> chain::Chain<8> {
let _ = black_box(m.send(black_box(chain::Ev::Go)));
// Drain the chain: 1 step per event. Go → S1 (entry emits Next) → S2
// (entry emits Next) → S3 (entry emits Next) → S4. 4 events total.
for _ in 0..4 {
let _ = black_box(m.step(black_box(Duration::ZERO)));
}
m
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
library_benchmark_group!(
name = dispatch;
benchmarks =
h2_cold_start,
h2_warm_lateral,
h4_cross_tree,
h4_up,
h4_self,
h8_cross_tree,
bubble_leaf,
bubble_mid,
bubble_top,
timer_fire_dispatch,
terminate_event,
bubble_cross_tree,
chain_self_emit,
);
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
main!(library_benchmark_groups = dispatch);
+4 -2
View File
@@ -56,8 +56,10 @@ pub enum TransitionReason {
/// fired this transition.
Timer { state: u16, timer: u16 },
/// Driven from inside the runtime without an originating event or
/// timer (e.g. default-descent into a child after entering a
/// composite state). Reserved; not currently emitted.
/// timer. Emitted for `default(...)` firings: after a state with a
/// declared default is entered, the default fires as a transition
/// with this reason. The default target may be any state in the
/// chart — sibling, ancestor, anywhere.
Internal,
}
+62 -9
View File
@@ -812,7 +812,12 @@ pub mod __private {
pub struct TimerEntry {
pub state: u16,
pub trigger: u16,
pub remaining_ns: u128,
// u64 nanoseconds bounds at ~584 years of countdown — comfortably
// beyond any practical Duration. Storing the original u128 from
// `Duration::as_nanos()` would force u128 saturating_sub on every
// `decrement()` call; on 64-bit it's 4-6 instructions per entry vs
// 1-2 for u64. The ceiling is enforced by saturating-cast at insert.
pub remaining_ns: u64,
}
impl<const N: usize> Default for TimerTable<N> {
@@ -828,7 +833,12 @@ pub mod __private {
}
}
pub fn start(&mut self, state: u16, trigger: u16, duration: core::time::Duration) {
let ns = duration.as_nanos();
// Truncating u128→u64. u64 nanoseconds bounds at ~584 years —
// past that the cast wraps, but no realistic timer duration
// gets near it (Duration::from_secs(u64::MAX) overflows long
// before, and from_millis/from_nanos take u64 directly so
// round-trip exactly).
let ns = duration.as_nanos() as u64;
// Replace existing entry for same (state, trigger) — resets on re-entry.
for e in self.entries.iter_mut() {
if e.state == state && e.trigger == trigger {
@@ -842,22 +852,51 @@ pub mod __private {
remaining_ns: ns,
});
}
/// Cancel every timer owned by `state`. Inlined empty-check so an
/// `exit_state` on a chart with no live timers compiles to a single
/// branch — the `retain` call (and its closure) stays out-of-line.
#[inline]
pub fn cancel_state(&mut self, state: u16) {
self.entries.retain(|e| e.state != state);
if !self.entries.is_empty() {
self.entries.retain(|e| e.state != state);
}
}
pub fn cancel_one(&mut self, state: u16, trigger: u16) {
self.entries
.retain(|e| !(e.state == state && e.trigger == trigger));
}
/// Subtract `elapsed` from every live timer (saturating at 0). Inlined
/// empty-check so the per-`step()` call costs one branch when no
/// timers are armed.
#[inline]
pub fn decrement(&mut self, elapsed: core::time::Duration) {
let ns = elapsed.as_nanos();
if self.entries.is_empty() {
return;
}
let ns = elapsed.as_nanos() as u64;
for e in self.entries.iter_mut() {
e.remaining_ns = e.remaining_ns.saturating_sub(ns);
}
}
/// Returns (state, trigger) of first expired timer, picking the innermost
/// (deepest) state, ties broken by declaration order.
///
/// The empty-table case is split out and `#[inline]`d so charts with no
/// live timers pay only a one-instruction `is_empty` branch per
/// `step()`, not the full function-call + iterator-setup overhead. The
/// non-empty body stays out-of-line to keep the hot path's icache
/// footprint small.
#[inline]
pub fn pop_expired(&mut self, depth: &[u8]) -> Option<(u16, u16)> {
if self.entries.is_empty() {
None
} else {
self.pop_expired_nonempty(depth)
}
}
#[cold]
fn pop_expired_nonempty(&mut self, depth: &[u8]) -> Option<(u16, u16)> {
let mut best: Option<usize> = None;
for (i, e) in self.entries.iter().enumerate() {
if e.remaining_ns == 0 {
@@ -880,12 +919,26 @@ pub mod __private {
None
}
}
/// Smallest `remaining_ns` across live timers, or `None` if there are
/// none. Same inline-the-empty-case treatment as `pop_expired` so
/// timer-free charts pay only a branch per `step()`.
#[inline]
pub fn min_remaining(&self) -> Option<core::time::Duration> {
self.entries.iter().map(|e| e.remaining_ns).min().map(|ns| {
let secs = (ns / 1_000_000_000) as u64;
let rem = (ns % 1_000_000_000) as u32;
core::time::Duration::new(secs, rem)
})
if self.entries.is_empty() {
None
} else {
self.min_remaining_nonempty()
}
}
#[cold]
fn min_remaining_nonempty(&self) -> Option<core::time::Duration> {
self.entries
.iter()
.map(|e| e.remaining_ns)
.min()
.map(core::time::Duration::from_nanos)
}
}
+136
View File
@@ -0,0 +1,136 @@
#![allow(dead_code)]
//! Deterministic-flow tests: `default(...)` targeting an ANCESTOR.
//! Inner's `default(Outer)` is a valid up-default. Inner is reachable
//! only via an explicit transition. When entered, its default fires
//! immediately, exiting Inner back to Outer (up-transition rule:
//! Outer is not re-entered).
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
GoIn,
Halt,
}
statechart! {
AncestorDefault {
context: Ctx;
events: Ev;
default(Outer);
terminate(Halt);
state Outer {
entry: outer_in;
on(GoIn) => Inner;
state Inner {
entry: inner_in;
exit: inner_out;
// Inner's default redirects up to Outer. After entering
// Inner, this default fires as an up-transition that
// exits Inner. Outer is not re-entered.
default(Outer);
}
}
}
}
impl AncestorDefaultActions for AncestorDefaultActionContext<'_> {
async fn outer_in(&mut self) {}
async fn inner_in(&mut self) {}
async fn inner_out(&mut self) {}
}
const AD_ROOT: u16 = 0;
const AD_OUTER: u16 = 1;
const AD_INNER: u16 = 2;
#[tokio::test(flavor = "current_thread")]
async fn det_initial_lands_at_outer_no_default_descent() {
// Outer has no default of its own — initial entry stops at Outer.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = AncestorDefault::new(Ctx);
let _ = m.step(hsmc::Duration::ZERO).await;
assert_eq!(m.current_state(), AncestorDefaultState::Outer);
let j = m.take_journal();
let entered: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect();
assert_eq!(entered, vec![AD_ROOT, AD_OUTER]);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_explicit_into_inner_bounces_back_via_up_default() {
// `=> Inner` enters Inner (entries fire), then Inner's default fires
// as an up-transition Inner → Outer. Outer is NOT re-entered (it
// was already on the active path).
tokio::task::LocalSet::new()
.run_until(async {
let mut m = AncestorDefault::new(Ctx);
let _ = m.dispatch(Ev::GoIn).await;
assert_eq!(
m.current_state(),
AncestorDefaultState::Outer,
"Inner's default bounces us back to Outer"
);
let j = m.take_journal();
// Inner was entered (its entries fired) and then exited
// (its default fired an up-transition).
let entered_states: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect();
// Across the full journal: Root, Outer, Inner. After Inner's
// up-default to Outer, Outer is NOT re-entered.
assert_eq!(entered_states, vec![AD_ROOT, AD_OUTER, AD_INNER]);
let exited_states: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect();
assert_eq!(
exited_states,
vec![AD_INNER],
"only Inner exits; Outer remains on the active path"
);
// Outer's entry actions ran exactly once (initial entry only).
let outer_entries = j
.iter()
.filter(|e| {
matches!(
e,
TraceEvent::ActionInvoked {
state: AD_OUTER,
kind: hsmc::ActionKind::Entry,
..
}
)
})
.count();
assert_eq!(outer_entries, 1, "Outer is entered once and stays put");
})
.await;
}
+90
View File
@@ -0,0 +1,90 @@
#![allow(dead_code)]
//! Deterministic-flow tests: `default(...)` chains. Hub → Spoke1 →
//! Spoke2. Each link fires as a real LCA-aware transition. The
//! compile-time default-graph cycle check guarantees this terminates.
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Halt,
}
statechart! {
ChainedDefault {
context: Ctx;
events: Ev;
default(Hub);
terminate(Halt);
state Hub {
entry: hub_in;
default(Spoke1);
}
state Spoke1 {
entry: s1_in;
exit: s1_out;
default(Spoke2);
}
state Spoke2 {
entry: s2_in;
}
}
}
impl ChainedDefaultActions for ChainedDefaultActionContext<'_> {
async fn hub_in(&mut self) {}
async fn s1_in(&mut self) {}
async fn s1_out(&mut self) {}
async fn s2_in(&mut self) {}
}
const CD_ROOT: u16 = 0;
const CD_HUB: u16 = 1;
const CD_SPOKE1: u16 = 2;
const CD_SPOKE2: u16 = 3;
#[tokio::test(flavor = "current_thread")]
async fn det_chained_defaults_terminate_at_chain_end() {
tokio::task::LocalSet::new()
.run_until(async {
let mut m = ChainedDefault::new(Ctx);
let _ = m.step(hsmc::Duration::ZERO).await;
assert_eq!(m.current_state(), ChainedDefaultState::Spoke2);
let j = m.take_journal();
let entered: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect();
assert_eq!(entered, vec![CD_ROOT, CD_HUB, CD_SPOKE1, CD_SPOKE2]);
let exited: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect();
// Each default fires as a sibling-transition: Hub → Spoke1
// (exits Hub), then Spoke1 → Spoke2 (exits Spoke1).
assert_eq!(exited, vec![CD_HUB, CD_SPOKE1]);
// At least 2 TransitionFired events for the chained defaults.
let txn_count = j
.iter()
.filter(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.count();
assert!(txn_count >= 2);
})
.await;
}
+133
View File
@@ -0,0 +1,133 @@
#![allow(dead_code)]
//! Deterministic-flow tests: `default(...)` targeting a SIBLING fires as
//! a real LCA-aware transition immediately after the declaring state's
//! entries finish. Foyer's `default(LivingRoom)` means: enter Foyer,
//! run its entries, then immediately transition Foyer → LivingRoom
//! (LCA = Root, exits Foyer, enters LivingRoom).
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Halt,
}
statechart! {
SiblingDefault {
context: Ctx;
events: Ev;
default(Foyer);
terminate(Halt);
state Foyer {
entry: foyer_in;
exit: foyer_out;
default(LivingRoom);
}
state LivingRoom {
entry: living_in;
exit: living_out;
}
}
}
impl SiblingDefaultActions for SiblingDefaultActionContext<'_> {
async fn foyer_in(&mut self) {}
async fn foyer_out(&mut self) {}
async fn living_in(&mut self) {}
async fn living_out(&mut self) {}
}
const SD_ROOT: u16 = 0;
const SD_FOYER: u16 = 1;
const SD_LIVING: u16 = 2;
fn entered(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect()
}
fn exited(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect()
}
#[tokio::test(flavor = "current_thread")]
async fn det_sibling_default_exits_declarer_lands_at_target() {
tokio::task::LocalSet::new()
.run_until(async {
let mut m = SiblingDefault::new(Ctx);
let _ = m.step(hsmc::Duration::ZERO).await;
assert_eq!(m.current_state(), SiblingDefaultState::LivingRoom);
let j = m.take_journal();
assert_eq!(
entered(&j),
vec![SD_ROOT, SD_FOYER, SD_LIVING],
"Root → Foyer → (default fires) → LivingRoom"
);
assert_eq!(
exited(&j),
vec![SD_FOYER],
"Foyer is exited as the default transition's `from` state"
);
// Foyer's entry must run before its exit (default fires after entries).
let action_seq: Vec<(u16, ActionKind)> = j
.iter()
.filter_map(|e| match e {
TraceEvent::ActionInvoked { state, kind, .. } => Some((*state, *kind)),
_ => None,
})
.collect();
let foyer_entry_idx = action_seq
.iter()
.position(|(s, k)| *s == SD_FOYER && *k == ActionKind::Entry)
.unwrap();
let foyer_exit_idx = action_seq
.iter()
.position(|(s, k)| *s == SD_FOYER && *k == ActionKind::Exit)
.unwrap();
let living_entry_idx = action_seq
.iter()
.position(|(s, k)| *s == SD_LIVING && *k == ActionKind::Entry)
.unwrap();
assert!(foyer_entry_idx < foyer_exit_idx);
assert!(foyer_exit_idx < living_entry_idx);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_sibling_default_emits_transition_observation() {
tokio::task::LocalSet::new()
.run_until(async {
let mut m = SiblingDefault::new(Ctx);
let _ = m.step(hsmc::Duration::ZERO).await;
let j = m.take_journal();
let txn_count = j
.iter()
.filter(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.count();
assert!(
txn_count >= 1,
"expected at least one TransitionFired for the default-as-transition firing"
);
})
.await;
}
+68 -4
View File
@@ -106,7 +106,9 @@ fn exit_(state: u16, action: u16) -> TraceEvent {
#[tokio::test(flavor = "current_thread")]
async fn det_descent_target_with_three_default_levels() {
// Idle → Tower: Tower has default chain L1 → L2 → L3.
// Idle → Tower: Tower's `default(L1)` fires as a transition, L1's
// `default(L2)` fires, L2's `default(L3)` fires. Each link is a
// real transition with its own TransitionFired/Complete pair.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Desc::new(Ctx);
@@ -119,9 +121,19 @@ async fn det_descent_target_with_three_default_levels() {
},
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR },
// Root → Idle fires as Root's default.
TraceEvent::TransitionFired {
from: Some(SR),
to: SI,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SI },
entry(SI, A_IDLE_IN),
TraceEvent::Entered { state: SI },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SI,
},
TraceEvent::EventReceived { event: E_GO },
TraceEvent::EventDelivered {
handler_state: SI,
@@ -138,15 +150,45 @@ async fn det_descent_target_with_three_default_levels() {
TraceEvent::EnterBegan { state: ST },
entry(ST, A_TOWER_IN),
TraceEvent::Entered { state: ST },
// Tower's default(L1) fires.
TraceEvent::TransitionFired {
from: Some(ST),
to: SL1,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SL1 },
entry(SL1, A_L1_IN),
TraceEvent::Entered { state: SL1 },
TraceEvent::TransitionComplete {
from: Some(ST),
to: SL1,
},
// L1's default(L2) fires.
TraceEvent::TransitionFired {
from: Some(SL1),
to: SL2,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SL2 },
entry(SL2, A_L2_IN),
TraceEvent::Entered { state: SL2 },
TraceEvent::TransitionComplete {
from: Some(SL1),
to: SL2,
},
// L2's default(L3) fires.
TraceEvent::TransitionFired {
from: Some(SL2),
to: SL3,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SL3 },
entry(SL3, A_L3_IN),
TraceEvent::Entered { state: SL3 },
TraceEvent::TransitionComplete {
from: Some(SL2),
to: SL3,
},
TraceEvent::TransitionComplete {
from: Some(SI),
to: ST,
@@ -164,11 +206,22 @@ async fn det_descent_entries_fire_outer_to_inner() {
.run_until(async {
let mut m = Desc::new(Ctx);
let _ = m.dispatch(Ev::Go).await;
// After the GO transition: order of Entered events should be Tower → L1 → L2 → L3.
// After the GO event-driven transition: order of Entered
// events should be Tower → L1 → L2 → L3. We split on the
// event-driven TransitionFired specifically (initial-entry
// defaults emit TransitionFired{Internal} too).
let split = m
.journal()
.iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap();
let entered: Vec<u16> = m.journal()[split..]
.iter()
@@ -210,10 +263,21 @@ async fn det_descent_action_kind_sequence() {
.run_until(async {
let mut m = Desc::new(Ctx);
let _ = m.dispatch(Ev::Go).await;
// Split on the event-driven TransitionFired (the initial
// default-fire is also TransitionFired{Internal} but happens
// before GO).
let split = m
.journal()
.iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap();
// After the Idle exit, all subsequent ActionInvoked must be Entry kind.
let kinds_after_exit: Vec<ActionKind> = m.journal()[split + 3..]
+40 -1
View File
@@ -19,7 +19,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)]
pub struct Ctx;
@@ -124,24 +124,63 @@ async fn det_initial_descent_records_full_path() {
let actual = m.take_journal();
let expected = vec![
// Initial enter (driven by dispatch's prime-state branch).
// Each `default(...)` is a real transition, so the journal
// brackets every default-fire with TransitionFired
// (Internal) / TransitionComplete pairs.
TraceEvent::Started {
chart_hash: Init4::<8>::CHART_HASH,
},
TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN),
TraceEvent::Entered { state: SR },
TraceEvent::TransitionFired {
from: Some(SR),
to: SA,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN),
TraceEvent::Entered { state: SA },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SA,
},
TraceEvent::TransitionFired {
from: Some(SA),
to: SB,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
TraceEvent::TransitionFired {
from: Some(SB),
to: SC,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN),
TraceEvent::Entered { state: SC },
TraceEvent::TransitionComplete {
from: Some(SB),
to: SC,
},
TraceEvent::TransitionFired {
from: Some(SC),
to: SD,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN),
TraceEvent::Entered { state: SD },
TraceEvent::TransitionComplete {
from: Some(SC),
to: SD,
},
// Halt.
TraceEvent::EventReceived { event: E_HALT },
TraceEvent::TerminateRequested { event: E_HALT },
+39 -2
View File
@@ -140,7 +140,8 @@ fn handler(state: u16, action: u16) -> TraceEvent {
}
}
// Initial descent: Root → Parent → A.
// Initial descent: Root → (Root's default fires) → Parent → (Parent's
// default fires) → A. Each default-fire is a real transition.
fn initial_descent() -> Vec<TraceEvent> {
vec![
TraceEvent::Started {
@@ -148,12 +149,30 @@ fn initial_descent() -> Vec<TraceEvent> {
},
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR },
TraceEvent::TransitionFired {
from: Some(SR),
to: SP,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SP },
entry(SP, A_P_IN),
TraceEvent::Entered { state: SP },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SP,
},
TraceEvent::TransitionFired {
from: Some(SP),
to: SA,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN),
TraceEvent::Entered { state: SA },
TraceEvent::TransitionComplete {
from: Some(SP),
to: SA,
},
]
}
@@ -285,9 +304,19 @@ async fn det_lateral_target_with_children_default_descends() {
TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN),
TraceEvent::Entered { state: SD },
// D's `default(E)` fires as an internal transition.
TraceEvent::TransitionFired {
from: Some(SD),
to: SE,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SE },
entry(SE, A_E_IN),
TraceEvent::Entered { state: SE },
TraceEvent::TransitionComplete {
from: Some(SD),
to: SE,
},
TraceEvent::TransitionComplete {
from: Some(SA),
to: SD,
@@ -471,7 +500,15 @@ async fn det_lateral_entry_outer_then_inner() {
let entries: Vec<u16> = m
.journal()
.iter()
.skip_while(|e| !matches!(e, TraceEvent::TransitionFired { .. }))
.skip_while(|e| {
!matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
+218
View File
@@ -0,0 +1,218 @@
#![allow(dead_code)]
//! Deterministic-flow tests for OPTIONAL `default(...)` on composite states.
//!
//! Spec: a composite state may declare `default(...)` or omit it. When
//! omitted, transitions targeting the composite land on the composite itself
//! — no default-descent. Substates are reachable only via explicit
//! transitions. This is the "microwave" pattern: a `Standby` state with
//! sub-modes (e.g. `LowPower`) that the chart enters only on demand.
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
PowerOn,
PowerOff,
EnterLowPower,
LeaveLowPower,
Halt,
}
statechart! {
Microwave {
context: Ctx;
events: Ev;
default(Standby);
terminate(Halt);
state Standby {
// No `default(...)` — Standby itself is the resting state.
entry: standby_in;
exit: standby_out;
on(PowerOn) => On;
on(EnterLowPower) => LowPower;
state LowPower {
entry: low_in;
exit: low_out;
on(LeaveLowPower) => Standby;
}
}
state On {
entry: on_in;
exit: on_out;
on(PowerOff) => Standby;
}
}
}
impl MicrowaveActions for MicrowaveActionContext<'_> {
async fn standby_in(&mut self) {}
async fn standby_out(&mut self) {}
async fn low_in(&mut self) {}
async fn low_out(&mut self) {}
async fn on_in(&mut self) {}
async fn on_out(&mut self) {}
}
// State ids: 0=Root, 1=Standby, 2=LowPower, 3=On.
const SR: u16 = 0;
const S_STANDBY: u16 = 1;
const S_LOW: u16 = 2;
const S_ON: u16 = 3;
fn entered_states(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect()
}
fn exited_states(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect()
}
fn entry_action_states(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::ActionInvoked {
state,
kind: ActionKind::Entry,
..
} => Some(*state),
_ => None,
})
.collect()
}
#[tokio::test(flavor = "current_thread")]
async fn det_initial_lands_on_composite_no_descent() {
// Standby has children but no `default` — initial entry stops at Standby.
// LowPower must NOT enter.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
// Drive initial entry without consuming an event.
let _ = m.step(hsmc::Duration::ZERO).await;
assert_eq!(
m.current_state(),
MicrowaveState::Standby,
"innermost active state is the composite Standby"
);
let j = m.take_journal();
let entered = entered_states(&j);
assert_eq!(
entered,
vec![SR, S_STANDBY],
"initial entry stops at Standby; no default-descent into LowPower"
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_transition_into_substate_works() {
// Explicit `=> LowPower` from Standby enters LowPower.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
let _ = m.dispatch(Ev::EnterLowPower).await;
assert_eq!(m.current_state(), MicrowaveState::LowPower);
let j = m.take_journal();
// Standby entered first (initial), then LowPower entered after the event.
let entered = entered_states(&j);
assert_eq!(entered, vec![SR, S_STANDBY, S_LOW]);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_up_from_substate_lands_on_composite() {
// From LowPower, `=> Standby` is an up-transition: exit LowPower, do not
// re-enter Standby.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
let _ = m.dispatch(Ev::EnterLowPower).await;
let _ = m.dispatch(Ev::LeaveLowPower).await;
assert_eq!(m.current_state(), MicrowaveState::Standby);
let j = m.take_journal();
// Across the whole run: enter Standby, enter LowPower, exit
// LowPower. Standby's entry must have run exactly once.
let entry_states = entry_action_states(&j);
let standby_entries = entry_states.iter().filter(|&&s| s == S_STANDBY).count();
assert_eq!(
standby_entries, 1,
"Standby's entry runs once; up-transition does not re-enter it"
);
let exited = exited_states(&j);
assert_eq!(
exited,
vec![S_LOW],
"only LowPower is exited; Standby remains on the active path"
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_lateral_to_composite_does_not_descend() {
// From On, `=> Standby` lands on Standby — NOT on LowPower.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
let _ = m.dispatch(Ev::PowerOn).await;
assert_eq!(m.current_state(), MicrowaveState::On);
let _ = m.dispatch(Ev::PowerOff).await;
assert_eq!(
m.current_state(),
MicrowaveState::Standby,
"PowerOff lands on Standby; absence of `default` skips descent"
);
let j = m.take_journal();
// LowPower must never have been entered.
let entered = entered_states(&j);
assert!(
!entered.contains(&S_LOW),
"no descent into LowPower on entry to Standby"
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_terminate_from_composite_unwinds_path() {
// Halt while at Standby exits Standby → Root only.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
let _ = m.dispatch(Ev::Halt).await;
let j = m.take_journal();
let exits = exited_states(&j);
assert_eq!(
exits,
vec![S_STANDBY, SR],
"terminate from Standby exits Standby then Root"
);
assert!(matches!(j.last(), Some(TraceEvent::Terminated)));
})
.await;
}
+12 -3
View File
@@ -13,7 +13,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)]
pub struct Ctx;
@@ -293,10 +293,19 @@ async fn det_ord_full_transition_canonical_sequence() {
let mut m = Ord::new(Ctx);
let _ = m.dispatch(Ev::Go).await;
let j = m.take_journal();
// Find the TransitionFired and capture everything until A's exit and B's entry are done.
// Find the EVENT-driven TransitionFired (initial default-fires
// are TransitionFired{Internal}; we want the GO transition).
let trans_idx = j
.iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap();
let kinds: Vec<ActionKind> = j[trans_idx..]
.iter()
+33 -2
View File
@@ -104,12 +104,32 @@ async fn det_root_targetable_by_chart_name() {
TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN),
TraceEvent::Entered { state: SR },
// Root's `default(A)` fires.
TraceEvent::TransitionFired {
from: Some(SR),
to: SA,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN),
TraceEvent::Entered { state: SA },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SA,
},
// A's `default(B)` fires.
TraceEvent::TransitionFired {
from: Some(SA),
to: SB,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
TraceEvent::EventReceived { event: E_UPTOROOT },
TraceEvent::EventDelivered {
handler_state: SB,
@@ -148,10 +168,21 @@ async fn det_root_target_does_not_re_enter_root() {
let mut m = Sub::new(Ctx);
let _ = m.dispatch(Ev::UpToRoot).await;
let j = m.take_journal();
// After the TransitionFired, no Entered event should fire.
// After the EVENT-driven TransitionFired (the UpToRoot one),
// no Entered event should fire — the up-transition exits B
// and A but never re-enters root, and root has no default
// (so no internal default-fire follows either).
let split = j
.iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap();
let entered_after = j[split..]
.iter()
+18
View File
@@ -88,12 +88,30 @@ fn initial_descent() -> Vec<TraceEvent> {
},
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR },
TraceEvent::TransitionFired {
from: Some(SR),
to: SM,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SM },
entry(SM, A_M_IN),
TraceEvent::Entered { state: SM },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SM,
},
TraceEvent::TransitionFired {
from: Some(SM),
to: SL,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SL },
entry(SL, A_L_IN),
TraceEvent::Entered { state: SL },
TraceEvent::TransitionComplete {
from: Some(SM),
to: SL,
},
]
}
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
+49 -1
View File
@@ -113,6 +113,8 @@ fn exit_(state: u16, action: u16) -> TraceEvent {
}
}
fn initial_descent() -> Vec<TraceEvent> {
// Each `default(...)` fires as a TransitionFired{Internal} /
// TransitionComplete pair around the entered state.
vec![
TraceEvent::Started {
chart_hash: Up::<8>::CHART_HASH,
@@ -120,18 +122,54 @@ fn initial_descent() -> Vec<TraceEvent> {
TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN),
TraceEvent::Entered { state: SR },
TraceEvent::TransitionFired {
from: Some(SR),
to: SA,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SA },
entry(SA, A_A_IN),
TraceEvent::Entered { state: SA },
TraceEvent::TransitionComplete {
from: Some(SR),
to: SA,
},
TraceEvent::TransitionFired {
from: Some(SA),
to: SB,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
TraceEvent::TransitionFired {
from: Some(SB),
to: SC,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN),
TraceEvent::Entered { state: SC },
TraceEvent::TransitionComplete {
from: Some(SB),
to: SC,
},
TraceEvent::TransitionFired {
from: Some(SC),
to: SD,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN),
TraceEvent::Entered { state: SD },
TraceEvent::TransitionComplete {
from: Some(SC),
to: SD,
},
]
}
fn assert_journal(actual: &[TraceEvent], expected: &[TraceEvent]) {
@@ -361,9 +399,19 @@ async fn det_up_current_state_is_target_after() {
.rev()
.find(|(_, e)| matches!(e, TraceEvent::Entered { .. }))
.map(|(i, _)| i);
// Find the EVENT-driven UpToB transition (initial defaults
// emit TransitionFired{Internal} too).
let split = j
.iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap();
// The last Entered event must be BEFORE the transition.
assert!(
+21
View File
@@ -104,6 +104,12 @@ async fn replay_full_lifecycle_matches_expected() {
kind: ActionKind::Entry,
},
TraceEvent::Entered { state: ST_ROOT },
// Root's `default(Idle)` fires as an internal transition.
TraceEvent::TransitionFired {
from: Some(ST_ROOT),
to: ST_IDLE,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: ST_IDLE },
TraceEvent::ActionInvoked {
state: ST_IDLE,
@@ -111,6 +117,10 @@ async fn replay_full_lifecycle_matches_expected() {
kind: ActionKind::Entry,
},
TraceEvent::Entered { state: ST_IDLE },
TraceEvent::TransitionComplete {
from: Some(ST_ROOT),
to: ST_IDLE,
},
// ── Go event delivered to Idle, transition to Active → Sub ──
TraceEvent::EventReceived { event: E_GO },
TraceEvent::EventDelivered {
@@ -136,6 +146,13 @@ async fn replay_full_lifecycle_matches_expected() {
kind: ActionKind::Entry,
},
TraceEvent::Entered { state: ST_ACTIVE },
// Active's `default(Sub)` fires as an internal transition,
// bracketed by its own TransitionFired/Complete.
TraceEvent::TransitionFired {
from: Some(ST_ACTIVE),
to: ST_SUB,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: ST_SUB },
TraceEvent::ActionInvoked {
state: ST_SUB,
@@ -143,6 +160,10 @@ async fn replay_full_lifecycle_matches_expected() {
kind: ActionKind::Entry,
},
TraceEvent::Entered { state: ST_SUB },
TraceEvent::TransitionComplete {
from: Some(ST_ACTIVE),
to: ST_SUB,
},
TraceEvent::TransitionComplete {
from: Some(ST_IDLE),
to: ST_ACTIVE,