Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb1d10b28
|
||
|
|
3fc0d7c57d
|
||
|
|
2e55484080
|
||
|
|
dc97419161
|
||
|
|
e633f5dc55
|
No files matched your search
+141
@@ -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
@@ -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
@@ -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>"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+35
-6
@@ -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
@@ -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);
|
||||
| ^^^^^^^
|
||||
+6
-3
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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..]
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user