20 Commits
Author SHA1 Message Date
swaits cb38d936dc chore: repoint URLs at git.swaits.com and bump for release
Rewrites remaining github.com / git.sr.ht references to git.swaits.com,
translating GitHub URL shapes to Gitea's (/blob/<ref>/ -> /src/branch/<ref>/,
/tree/<tag> -> /src/tag/<tag>, /actions/workflows/X -> /actions?workflow=X,
/edit/ -> /_edit/).

Left pointing at GitHub on purpose: pull-request links (PRs were not migrated)
and GitHub-only surfaces Gitea lacks (security advisories, discussions).
2026-08-31 17:58:32 -06:00
swaits a068a982f7 chore: point repository at git.swaits.com 2026-08-31 17:19:16 -06:00
swaits 5f933f98fa chore(release): 0.6.0 2026-05-04 15:29:24 -06:00
swaits d8bda344fd safety(verify): Creusot-verified validator for the path-table safety predicates
Closes the formal-verification side of the unsafe surface. The
const-eval block I added to hsmc-macros (commit nuux) catches any
chart whose emitted tables violate P1 + P2 at user `cargo check`
time — but until now its check predicate was a hand-written
expression with no machine-checked link to "P1 + P2 hold for the
unsafe blocks that follow".

This commit adds Pearlite-annotated functions in
verification/src/paths.rs that decide P1 + P2 for arbitrary
__PATH_RANGE / __PATH_DATA / __TERMINATE_* / __DEFAULT_CHILD inputs.
Each function carries `#[ensures(... forall ...)]` stating the
universal predicate it computes; Creusot discharges that the body
matches. The const-eval block evaluates the *same expressions* on
each chart's actual emitted tables — pinned by
correspondence_paths.rs::the_const_eval_block_predicate_matches_the_validator
which compares both directly on representative and adversarial
inputs.

End-to-end safety chain:

  - hsmc-macros emits __PATH_RANGE / etc. for every chart.
  - Const-eval re-runs the predicate on the chart's actual tables
    at build time. Build fails on any violation.
  - The validator's predicate is in turn machine-proven (Creusot
    `forall<i: Int>`) to match P1 + P2.

Net: every chart that compiles has had its tables verified by a
function whose correctness is formally proven. Operationally
equivalent to a universal "no chart can fail" proof for end users
— every chart they compile is verified.

Files:
- verification/src/paths.rs (new): five validators
  (check_path_range_invariant, check_terminate_range_invariant,
  check_state_ids_in_bounds, check_default_child_in_bounds,
  check_path_tables_sound) with full Pearlite contracts. 10 unit
  tests pin executable behavior.
- verification/tests/correspondence_paths.rs (new): 9 tests
  including the predicate-equality test above.
- verification/INVARIANTS.md: new "Path tables" section with five
  spec rows + aggregate. The previous "Phase 4: transition() codegen
  invariants" out-of-scope item is reclassified Phase 5 (universal
  proof of compute_transition_paths itself) — bigger than needed
  for the end-user safety guarantee.
- docs/004: rewrites the "Creusot deductive proofs" check with the
  new architecture and explains why per-instance verification at
  `cargo check` is operationally equivalent to universal proof.

Run:
  cargo test -p hsmc-verification    # 10 unit + 9 correspondence
  just verify                        # discharge Creusot VCs
  just safety                        # full unsafe audit in one shot
2026-05-04 15:29:24 -06:00
swaits 2d38af60f6 safety(audit): cargo-mutants reaches 98% coverage on the unsafe-touching codegen
The earlier safety-audit run reported 16/53 missed mutants in
compute_transition_paths and concluded the LCA depth-walk wasn't
covered by tests. Wrong diagnosis. cargo-mutants infers the
test-package from the file under mutation, so without an explicit
override it ran only the hsmc-macros per-package test set
(essentially trybuild fixtures). All the asymmetric-depth lateral
coverage in det_lateral.rs et al. is in the hsmc/ workspace tests
and was sitting on the bench.

With `--test-package hsmc --features hsmc/tokio,hsmc/journal`:

  codegen.rs safety fns:  51/53 (14 caught + 37 unviable + 2 missed)
  hsmc/src/lib.rs:        48/48 (45 caught + 3 unviable + 0 missed)
  total:                  99/101 = 98%

The 2 remaining are equivalent mutants — transformations that
provably produce identical __PATH_RANGE / __PATH_DATA output and
so cannot be distinguished by any behavior test:

  1. `da > db` → `da >= db` in the LCA depth-equalizer:
     the mutated loop walks `a` one extra step in the equality case;
     the next loop (`while db > da`) compensates symmetrically.
     parent(root) = None short-circuits any walk past root. Same
     LCA, same paths. Verified by hand on lateral, up-transition,
     and asymmetric-depth (4↔2) inputs.

  2. `from != to && is_strict_ancestor(...)` → `from == to && ...`:
     becomes always-false (a state isn't its own strict ancestor),
     so the up-transition branch is dead code. Up-transitions then
     fall through to the lateral branch, which computes
     lca(from, to) = to (target is ancestor of source), walks
     source up to (exclusive of) to, and produces an empty enter
     path. Same paths.

Both are documented in docs/004 § "The two equivalent mutants".

Updates:
- justfile `just safety`: add the correct flags so future runs
  reproduce 99/101 instead of the misleading 37/53.
- docs/004: corrected mutants section, equivalent-mutant analysis,
  removed the false "asymmetric-depth test gap" claim.
2026-05-04 15:29:24 -06:00
swaits ad2aa6e04d docs(safety): document the unsafe contract end-to-end
After Opt #2 added six bounded `get_unchecked` blocks per chart, the
project's two "no unsafe" claims (README §"At a glance" + spec §11
rule 12) were stale, and there was no single place answering "how
do I know the unsafe is correct?".

This commit closes both gaps:

- New: docs/004. unsafe-safety-contract.md
  Inventories every unsafe site, names the predicate, names what
  establishes it (codegen-time guarantee), and lists the five
  layered checks (const-eval, debug_assert, miri, mutants, Creusot)
  with current status. Documents what's *not* yet covered (the
  16-of-53 missed mutants in the LCA depth walk are behavior, not
  safety; transition() codegen invariants are still Phase-4 for
  Creusot). Includes a maintenance checklist for future unsafe.

- README points at the doc and gives an honest one-paragraph
  summary instead of "no unsafe".

- Spec rule 12 rewritten from "no unsafe in generated code" to
  "bounded unsafe in generated code, none in user code" with a
  pointer to the audit.

- justfile gains `just safety` — runs const-eval (cargo check),
  debug_assert tests, miri, and mutants in one shot. Mirrors the
  doc's audit matrix so the doc never goes stale silently.
2026-05-04 15:29:24 -06:00
swaits 98352b0e4d safety(codegen): compile-time const-eval checks for table invariants
Every codegen-emitted statechart now contains a const block that
verifies the safety preconditions of every unsafe get_unchecked at
build time. Any future codegen change that produces an out-of-range
offset or a malformed (s, m, e) triple in __PATH_RANGE /
__TERMINATE_RANGE fails `cargo check` with a named diagnostic
instead of being deferred to runtime UB.

Checks performed (per chart):
  - __PATH_RANGE[i] = (s, m, e) satisfies 0 <= s <= m <= e and
    e <= __PATH_DATA.len()
  - __TERMINATE_RANGE[i] = (s, e) satisfies 0 <= s <= e and
    e <= __TERMINATE_DATA.len()
  - Every state id stored in __PATH_DATA / __TERMINATE_DATA is
    < N_STATES
  - Every Some(t) in __DEFAULT_CHILD names a state id < N_STATES

The const block evaluates at type-check (cargo check), produces no
runtime code, and runs once per chart definition. Verified by
manually injecting a bad triple — rustc reports `E0080: evaluation
panicked: __PATH_RANGE: e past __PATH_DATA end`.

Combined with the runtime debug_assert! companions (this commit
chain) and miri (zero UB on 66 tests), the static and dynamic checks
together cover every safety predicate the unsafe blocks rely on.
2026-05-04 15:29:24 -06:00
swaits b9645f80cb safety(codegen): debug_assert companion for every unsafe get_unchecked
Pair every codegen-emitted `unsafe { ... get_unchecked ... }` with a
debug_assert! that re-states the safety obligation as a runtime
predicate. Free in release (debug_assert! compiles to nothing); in
dev/test/miri builds, every dispatch through the 68 behavior tests
exercises these checks, so any future regression in the codegen's
invariants trips loudly.

Sites covered:
  - step()'s __DISPATCH lookup        (sync + async)
  - apply_path()'s __PATH_RANGE +
    __PATH_DATA[..] slice ranges      (sync + async)
  - follow_defaults()'s __DEFAULT_CHILD lookup (sync + async)
  - do_terminate()'s __TERMINATE_RANGE +
    __TERMINATE_DATA[..] slice ranges (sync + async)

Verification:
  - cargo nextest: 68/68 pass (debug_assert! active)
  - cargo +nightly miri test: 66/66 pass (UB detector)
  - cargo bench --bench dispatch: zero release-mode regression
    (asserts compile out as expected)
2026-05-04 15:29:24 -06:00
swaits da1f3046b4 docs(readme): qualify the no-unsafe claim
After Opt #2 (get_unchecked for codegen table indexing), the macro
emits ~6 unsafe blocks per chart (in apply_path, do_terminate,
follow_defaults, and step's __DISPATCH lookup). Each is a bounded
get_unchecked into a codegen-owned static table whose `idx < len`
invariant holds by construction (state ids are produced by codegen
itself, not user input). User code remains unsafe-free.

Update both bullets:
- "no Mutex, no unsafe" → "no Mutex, no unsafe in your code"
  (in the during/split-borrow context)
- "No heap, no unsafe, no dynamic dispatch" → drop the unsafe
  exception with an honest one-sentence note about where it lives
  and why.
2026-05-04 15:29:24 -06:00
swaits 2f37f261c5 docs(bench): refresh baseline.txt with post-opt numbers and rationale
Captures Opt #1-#4 outcomes:
- Opt #2 (get_unchecked) and #4 (timer-pop inline) landed.
- Opt #3 (run_action proxy/context) was already free.
- Opt #1 (Option<u16> → sentinel) tried, reverted (-0.6% net).

Documents the gungraun-wrapper measurement floor.
2026-05-04 15:29:24 -06:00
swaits 8cd1aea646 perf(timer): inline TimerTable::pop_expired_nonempty
Drop the #[cold] attribute and add #[inline]. The hint-cold was
keeping the body out-of-line at every call site (one per chart that
declares any after() trigger), which forced a real function call
through the x86_64 ABI plus its register-spill prologue/epilogue —
~10-15 instructions of pure framing on top of the body's own cost.
Inlining lets LLVM fold the body into the codegen-emitted
timer_pop_block at step()'s timer-pop site; for the timer_fire
bench, run_handlers also gets inlined into step() as a knock-on,
because the inliner's budget is no longer being spent on a separate
pop_expired_nonempty call.

Bench delta (vs. after_opt2_unchecked, instructions):

  timer_fire_dispatch    212 → 175   -17%
  bubble_top              94 →  93    -1%   (incidental)
  others: unchanged

Code-size cost: pop_expired_nonempty is short and inlines cheaply;
the binary size before/after differs by a handful of bytes per
chart-with-timers call site. The original #[cold] attribute appears
to have been a guess (timer arming is rare → mark cold) that was
wrong: when a chart has timers at all, the empty-check in pop_expired
is the cold path; pop_expired_nonempty itself runs on every step()
of any state with an active timer.
2026-05-04 15:29:23 -06:00
swaits d5ef6a6944 perf(codegen): get_unchecked for codegen table indexing
Hot-path lookups into __DISPATCH, __DEFAULT_CHILD, __PATH_RANGE,
__PATH_DATA, __TERMINATE_RANGE and __TERMINATE_DATA all index by
state ids the codegen produces (current state, transition targets,
__PATH_RANGE-emitted offsets), every one of which is provably in
bounds by codegen invariant. Replace the `[idx]` / `[s..e]` checked
forms with `get_unchecked`.

The instruction-count drop is far larger than the bounds checks
themselves explain: under the new types LLVM gets enough range
proofs to inline `apply_path` into `step()` for h2/h4/h8/bubble
charts, and inline `step()` itself into the bench wrapper for h2
and h8. The cold-path enter chain on h2_cold_start collapses to a
single u16 store.

Bench deltas (vs. gungraun_initial baseline, instructions):

  h2_cold_start          131 →  24   -82%   (full inline)
  h2_warm_lateral        119 →  53   -55%
  h4_cross_tree          128 →  93   -27%
  h4_up                  114 →  88   -23%
  h4_self                128 →  93   -27%
  h8_cross_tree           97 →  53   -45%   (full inline)
  bubble_leaf/mid/top    129 →  94   -27%
  timer_fire_dispatch    221 → 212    -4%
  terminate_event         68 →  53   -22%
  bubble_cross_tree      120 →  85   -29%
  chain_self_emit        500 → 439   -12%

All 68 hsmc behavior tests pass. Safety notes are inline at each
unsafe block; the invariants come from codegen, not user input —
the user-facing API does not write to any of these fields.
2026-05-04 15:29:23 -06:00
swaits 915b7ce101 chore(bench): migrate dispatch harness from iai-callgrind to gungraun
iai-callgrind is unmaintained; gungraun is the active fork. API is
drop-in (use iai_callgrind → use gungraun, same library_benchmark /
library_benchmark_group / main! macros), so the dispatch.rs body only
needed the import rename.

Also:
- mise.toml: add cargo:gungraun-runner dep so the valgrind harness
  binary tracks the lib version automatically.
- baseline.txt: rebaselined under gungraun_initial. Absolute numbers
  shifted up because gungraun's bench wrapper instruments more than
  iai-callgrind 0.16's did; comparable benches (terminate_event 69→68,
  h8_cross_tree 93→97) confirm HSM dispatch work itself is unchanged.
  Old iai-callgrind named baselines kept as historical context only.
2026-05-04 15:29:23 -06:00
swaits 1f98a9bf5b chore(release): 0.5.1 2026-04-29 09:50:36 -06:00
swaits 9eb1d10b28 chore(release): 0.5.0 2026-04-28 21:03:05 -06:00
swaitsandClaude Opus 4.7 3fc0d7c57d feat(default)!: model default(...) as a real LCA-aware transition
`default(...)` is now a transition with the same semantics as any
event-driven transition. It fires immediately after the declaring
state's entries finish and its durings start, applying the standard
exit-to-LCA / enter-to-target algorithm. Three behavioral changes
follow from this unification:

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

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

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

Implementation:

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

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

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

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

BREAKING CHANGES:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:58:53 -06:00
swaits 4ea7463089 feat(observe)!: unify journal and trace into single observation pipeline
The codegen now emits one `__chart_observe!` invocation per observable
atom. The macro fans out at compile time to whichever sinks are enabled:
the in-memory journal (`feature = "journal"`), `defmt` (`trace-defmt`),
`log` (`trace-log`), and/or `tracing` (`trace-tracing`). Trace and
journal share the same vocabulary by construction — they cannot
diverge. With no sink feature on, every site expands to `()` (zero
overhead). Multiple trace backends now compose; previously they were
mutually exclusive.

Trace lines use a logfmt-style format with a `[statechart:Name]` prefix
and `key=value` pairs, machine-parseable and greppable. Twenty verbs
cover every observable atom — entries/exits with begin/end pairs,
actions with kind, durings, transitions with the reason that triggered
them, event lifecycle (received/delivered/dropped/queued/failed),
timers, and termination.

BREAKING CHANGES (chart syntax unchanged; observability surface only):

- `TraceEvent::TransitionFired` now carries `reason: TransitionReason`
  showing whether the transition was driven by an event, a timer, or
  internal logic.
- New `TraceEvent` variants for phase pairing: `EnterBegan`, `ExitBegan`,
  `TransitionComplete`, `EventReceived`.
- Trace prefix changed from `[Name]` to `[statechart:Name]`.
- The chart-level `trace;` keyword is parsed for backwards compatibility
  but no longer gates emission.
- `__chart_trace!` / `__chart_journal!` macros removed (doc-hidden).

Verification: 159 integration tests pass, 0 missed mutants, miri 68
tests clean, Creusot 14/14 VCs discharged. Zero overhead confirmed via
`cargo expand` — every observation site expands to `{}` with no sinks
enabled.
2026-04-28 15:24:14 -06:00
57 changed files with 6838 additions and 3021 deletions

No files matched your search

+409
View File
@@ -4,6 +4,415 @@ 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.6.0 — 2026-05-04
Performance pass on the dispatch hot path + a fully-audited bounded
`unsafe` surface in the generated code. The bench harness moves from
`iai-callgrind` to `gungraun` (the renamed continuation, since
iai-callgrind is frozen at 0.16.1).
### Performance
Two optimization passes land. Bench impact (instruction counts vs.
the post-migration baseline; release-mode, valgrind 3.25.1):
```
before after Δ
h2_cold_start 131 24 -82%
h2_warm_lateral 119 53 -55%
h8_cross_tree 97 53 -45%
bubble_cross_tree 120 85 -29%
h4_cross_tree / h4_self 128 93 -27%
bubble_leaf/mid/top 129 94 -27%
h4_up 114 88 -23%
terminate_event 68 53 -22%
timer_fire_dispatch 221 175 -21%
chain_self_emit 500 439 -12%
```
- **`get_unchecked` on codegen-emitted dispatch tables.** `__DISPATCH`,
`__DEFAULT_CHILD`, `__PATH_RANGE`, `__PATH_DATA`, `__TERMINATE_RANGE`,
and `__TERMINATE_DATA` are all indexed by codegen-produced state ids
whose bounds are guaranteed at codegen time. Replacing the
bounds-checked `[idx]` / `[s..e]` forms with `slice::get_unchecked`
unlocks aggressive inlining beyond what plain bounds-check elision
would buy: `apply_path` inlines into `step()` for h2 / h4 / h8 /
bubble; `step()` itself inlines into the bench wrapper for
h2_cold_start / h2_warm_lateral / h8_cross_tree. The cold-path enter
chain on h2_cold_start collapses to a single `u16` store.
- **`pop_expired_nonempty` inlining.** The `#[cold]` attribute was
forcing the body out-of-line at every step()-with-timers call site,
paying full x86_64 ABI prologue/epilogue (~10–15 instructions) per
call. Drop `#[cold]` and add `#[inline]` so LLVM folds it into the
codegen-emitted timer-pop block. As a knock-on effect, `run_handlers`
also inlines into `step()` on the timer path.
### Bounded `unsafe` in generated code (was: "no `unsafe`")
The performance work introduces six bounded `unsafe { get_unchecked }`
blocks per generated chart. User code (chart definitions, action
bodies, `during:` blocks, public API surface) remains `unsafe`-free.
The README and spec rule 12 are updated to reflect this honestly. The
audit story is documented end-to-end in
[`docs/004. unsafe-safety-contract.md`](docs/004.%20unsafe-safety-contract.md);
a short summary:
- **Compile-time const-eval.** Each chart now contains a
`const _: () = { ... };` block that re-states the safety preconditions
as `assert!`s evaluated by `rustc`'s const evaluator at user
`cargo check` time. Any chart whose emitted tables violate the
invariants fails the build with a named diagnostic
(`E0080: evaluation panicked: ...`) before reaching runtime.
- **Runtime `debug_assert!` companions.** Every `unsafe` block has a
paired `debug_assert!` of the same predicate. Free in `--release`;
in dev/test/miri builds, every dispatch through the 68 hsmc behavior
tests exercises the assertions.
- **Miri.** `cargo +nightly miri test -p hsmc` runs 20 unit + 46
behavior tests under the UB interpreter. All clean.
- **Mutation testing.** `cargo mutants` against the safety-relevant
codegen functions: 99 of 101 mutants caught (98%) when run with
`--test-package hsmc --features hsmc/tokio,hsmc/journal`. The 2
remaining are equivalent mutants — transformations that provably
produce identical `__PATH_RANGE` / `__PATH_DATA` output and so
cannot be distinguished by any behavior test (formal analysis in
the safety contract doc).
- **Creusot-verified validator.** `verification/src/paths.rs` carries
Pearlite-annotated functions that decide the safety predicates for
arbitrary inputs; their `#[ensures(... forall ...)]` clauses pin the
universal property the const-eval block evaluates per chart.
`correspondence_paths.rs` includes a test that directly compares the
validator and the const-eval block's predicate expression on
representative and adversarial inputs.
`just safety` runs the const-eval, debug_assert, miri, and mutants
phases as one command and mirrors the audit matrix in
`docs/004. unsafe-safety-contract.md`. `just verify` discharges the
Creusot VCs.
### Bench harness migration: `iai-callgrind` → `gungraun`
`iai-callgrind` on crates.io is frozen at 0.16.1; the project has been
renamed to `gungraun` (same maintainers, same valgrind backend, same
callgrind output format — `github.com/iai-callgrind/iai-callgrind`
redirects to `github.com/gungraun/gungraun`). Migration is mechanical:
- `Cargo.toml` dev-dep: `iai-callgrind = "0.16"` → `gungraun = "0.18"`
- `mise.toml`: replace the `iai-callgrind-runner` install with
`cargo:gungraun-runner`
- benchmark source: `use iai_callgrind::...` → `use gungraun::...`
(macro names identical: `library_benchmark`, `library_benchmark_group`,
`main!`)
- output dir: `target/iai/...` → `target/gungraun/...`
- environment variables: `IAI_CALLGRIND_*` → `GUNGRAUN_*`
Absolute instruction counts shifted slightly between the two harnesses
because gungraun's bench wrapper instruments a few more setup/teardown
instructions per bench (~+4–5 per bench, all in harness, none in HSM).
The historical iai-callgrind numbers are kept in
`hsmc/benches/baseline.txt` for reference but the post-migration
`gungraun_initial` is the comparison point going forward.
### Documentation
- New: `docs/004. unsafe-safety-contract.md` — full inventory of the
six unsafe blocks, the invariants they rely on, and the five-layer
audit (const-eval, debug_assert, miri, mutants, Creusot validator).
- Updated: `docs/000` rule 12 — was "no unsafe in generated code", now
"bounded unsafe in generated code, none in user code" with a pointer
to docs/004.
- Updated: `verification/INVARIANTS.md` — new "Path tables" section
with five spec rows for the path-table predicates. Phase 4 (universal
proof of `compute_transition_paths`) reclassified as Phase 5 since
the per-chart const-eval + verified validator is operationally
equivalent for end users.
- Updated: README — replaces the bare "no `unsafe`" claim with an
honest one-paragraph summary pointing at docs/004; install snippets
bumped to `version = "0.6"`.
- New: `just safety` recipe wires up the full unsafe-audit matrix.
## 0.5.1 — 2026-04-29
Docs and licensing cleanup. No code or behavior changes.
- **License simplified to MIT.** The crate was previously dual-licensed
MIT OR Apache-2.0; it is now MIT-only. `LICENSE-APACHE` removed,
`LICENSE-MIT` renamed to `LICENSE`, workspace `license` field is
now `"MIT"`. Copyright (c) 2026 Stephen Waits &lt;steve@waits.net&gt;.
- **README rewritten reader-first.** Restructured top-to-bottom so a
reader who's never used statecharts can follow the document linearly:
a working Blinker chart is now the first concrete artifact; the
Quickstart sits directly under it; the larger Radio example
(events / payloads / `during:`) follows; the formal eight-rule
semantics live below all of that as reference rather than as a
prerequisite. The "What it does" feature list is rewritten in
user-benefit-first language. The "What `hsmc` deliberately does NOT
have" table is promoted from a sub-section to its own top-level
section. No semantic changes — the formal rules' content is
untouched, only repositioned.
- **Tokio integration story corrected.** The README previously implied
driving a chart under tokio required `tokio::task::LocalSet`. The
normal path is `#[tokio::main(flavor = "current_thread")]` — one
line, no extra wrapping, and how the existing examples have always
worked. `LocalSet` no longer appears in the README at all.
- **`microwave_tui` example dropped.** The ratatui/crossterm TUI
variant of the microwave was overkill for a library example and
pulled in three dev-dependencies (`ratatui`, `crossterm`, `futures`)
that nothing else used. The `microwave` example covers the same
feature surface in plain Rust.
- **Observability framed honestly.** Enabling `defmt` / `log` /
`tracing` output for a chart is one `trace-*` Cargo feature on
hsmc's side, plus whatever your downstream crate already does to
init its logger (e.g. `tracing_subscriber::fmt::init()` or
`env_logger::init()`) — hsmc deliberately doesn't init a logger,
per the standard Rust library convention. The Observability
section now says so directly and includes a runnable end-to-end
snippet using `tracing-subscriber`. The `trace-*` features are
also listed in the main feature-flags table so they're
discoverable at a glance.
- **Inline examples corrected.** The Quickstart Blinker now has both
`light_on` and `light_off` actions (it previously had only
`light_on`, which left the LED on the whole time). The Embassy
snippet no longer declares an unused event channel for what is a
timer-only chart.
- **Eight-rule order finalized.** The "How a chart behaves" rules are
now strictly bottom-up: active path → transitions (LCA-aware) →
up-transition rule → entry/exit ordering → `default(...)` (a
transition fired on entry) → event bubbling → timers →
emit/during/termination. Cross-references updated.
## 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)
- **Unified observation pipeline.** The codegen now emits a single
`__chart_observe!` invocation per observable atom, dispatched at
compile time to whichever sinks are enabled: the in-memory journal
(`feature = "journal"`), `defmt` (`trace-defmt`), `log`
(`trace-log`), and/or `tracing` (`trace-tracing`). All sinks share
the same vocabulary by construction — the journal IS the observation
stream; trace backends are textual renderings of it. With NO sink
feature on, every call site expands to `()` (zero overhead). Multiple
trace sinks may now be enabled simultaneously (previously they were
mutually exclusive).
- **Trace prefix changed.** `[ChartName] verb …` →
`[statechart:ChartName] verb …`. The new form is greppable and
unambiguous in mixed log streams.
- **Trace format is now logfmt-style.** Every line is
`[statechart:Name] <verb> key=value key=value …` (or native structured
fields under `tracing`). Twenty verbs cover every TraceEvent variant
— entries, exits (with begin/end pairs), actions (with kind),
durings, transitions (with **reason** showing what triggered them),
events (received/delivered/dropped/queued/failed), timers, terminate.
- **`trace;` keyword deprecated.** Still parsed for backwards
compatibility, but no longer gates emission. Trace output is now
controlled purely by which `trace-*` cargo feature you enable.
### Changed — journal (breaking)
- **`TraceEvent::TransitionFired` carries `reason: TransitionReason`.**
Replay consumers now see whether a transition was driven by an event
(`Event { event: u16 }`), a timer (`Timer { state, timer }`), or
internal logic (`Internal`).
- **New TraceEvent variants for phase begin/end pairing.**
- `EnterBegan { state }` fires before a state's entry actions /
timers / durings; `Entered` keeps its place as the end marker.
- `ExitBegan { state }` fires before a state's during cancellations
/ timer cancellations / exit actions; `Exited` keeps its place as
the end marker.
- `TransitionComplete { from, to }` fires after a transition's full
exit-then-enter sequence finishes; pairs with `TransitionFired`.
- `EventReceived { event }` fires when an event is popped from the
queue, before handler search; pairs with `EventDelivered` /
`EventDropped` / `TerminateRequested`.
### Added
- **`hsmc/tests/trace_format.rs`** — 11 tests pinning every verb's
exact textual rendering, including a journal/trace atom-count
equivalence test.
- **`TransitionReason`** enum — re-exported from the crate root when
`feature = "journal"` is on. Carried on `TraceEvent::TransitionFired`.
- **Multiple trace backends compose.** Previously enabling more than
one of `trace-defmt` / `trace-log` / `trace-tracing` was a compile
error. Now they fan out independently.
### Migration
Chart syntax is unchanged: every v0.1 / v0.2 / v0.3 statechart compiles
under v0.4 without modification. Breakage is confined to the
observability surface area:
1. **Trace line prefix.** Update grep patterns and log filters from
`[ChartName]` to `[statechart:ChartName]`. The verb after the
prefix is now a single hyphenated token followed by `key=value`
pairs.
2. **`TraceEvent::TransitionFired`** has a new `reason` field. Update
pattern matches:
```rust
// before:
TraceEvent::TransitionFired { from, to } => …
// after:
TraceEvent::TransitionFired { from, to, reason } => …
// or, if you don't care:
TraceEvent::TransitionFired { from, to, .. } => …
```
3. **New `TraceEvent` variants.** If you `match` exhaustively, add
arms for `EnterBegan`, `ExitBegan`, `TransitionComplete`,
`EventReceived`. Otherwise nothing changes.
4. **`trace;` keyword is now a no-op.** Charts that declared it still
compile. Trace output is now controlled purely by which `trace-*`
cargo feature you enable.
5. **`__chart_trace!` and `__chart_journal!` macros are removed.**
These were doc-hidden and not part of the public API; mentioned
here for codebases that grepped through the macro emission for
patches/forks.
## 0.3.0 — 2026-04-27
### Added
Generated
+82 -587
View File
@@ -2,71 +2,39 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "autocfg"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "creusot-std"
version = "0.9.0"
@@ -94,66 +62,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossterm"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.11.1",
"crossterm_winapi",
"futures-core",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "defmt"
version = "0.3.100"
@@ -169,7 +77,7 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
dependencies = [
"bitflags 1.3.2",
"bitflags",
"defmt-macros",
]
@@ -195,6 +103,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"
@@ -204,12 +133,6 @@ dependencies = [
"litrs",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embassy-futures"
version = "0.1.2"
@@ -301,116 +224,61 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "gungraun"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bfa183cef2c88324b20de9727befd262c35592d9885ffd93b8b102536a81cf2"
dependencies = [
"bincode",
"derive_more",
"gungraun-macros",
"gungraun-runner",
]
[[package]]
name = "gungraun-macros"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e35c7fb6133421db1cf752b7a2838d9277a26810ccaeeca7aa449f96ad7c2b01"
dependencies = [
"derive_more",
"proc-macro-error2",
"proc-macro2",
"quote",
"rustc_version",
"serde",
"serde_json",
"syn",
]
[[package]]
name = "gungraun-runner"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ebdac6d5fe0aa9d9623d690fbfdb759e0ed44115e0685b9abc4568fe47db364"
dependencies = [
"serde",
]
[[package]]
name = "hash32"
version = "0.3.1"
@@ -420,17 +288,6 @@ dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.0"
@@ -457,26 +314,18 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hsmc"
version = "0.3.0"
version = "0.6.1"
dependencies = [
"crossterm",
"defmt 0.3.100",
"embassy-futures",
"embassy-sync",
"embassy-time",
"futures",
"gungraun",
"heapless 0.8.0",
"hsmc-macros",
"log",
"ratatui",
"tokio",
"tracing",
"trybuild",
@@ -484,7 +333,7 @@ dependencies = [
[[package]]
name = "hsmc-macros"
version = "0.3.0"
version = "0.6.1"
dependencies = [
"hsmc",
"proc-macro2",
@@ -495,18 +344,12 @@ dependencies = [
[[package]]
name = "hsmc-verification"
version = "0.3.0"
version = "0.6.1"
dependencies = [
"creusot-std",
"hsmc",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -514,38 +357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.0",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "instability"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971"
dependencies = [
"darling",
"indoc",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
"hashbrown",
]
[[package]]
@@ -554,66 +366,24 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "mio"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "nb"
version = "0.1.3"
@@ -668,35 +438,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -744,65 +485,19 @@ dependencies = [
]
[[package]]
name = "ratatui"
version = "0.28.1"
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"bitflags 2.11.1",
"cassowary",
"compact_str",
"crossterm",
"instability",
"itertools",
"lru",
"paste",
"strum",
"strum_macros",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
"semver",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags 2.11.1",
]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags 2.11.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
@@ -811,6 +506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
@@ -855,89 +551,12 @@ dependencies = [
"serde_core",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "syn"
version = "2.0.117"
@@ -1081,87 +700,27 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-segmentation"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools",
"unicode-segmentation",
"unicode-width",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "void"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -1171,70 +730,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "1.0.1"
+4 -4
View File
@@ -3,9 +3,9 @@ resolver = "2"
members = ["hsmc", "hsmc-macros", "verification"]
[workspace.package]
version = "0.3.0"
version = "0.6.1"
edition = "2021"
license = "MIT OR Apache-2.0"
license = "MIT"
authors = ["Stephen Waits <steve@waits.net>"]
repository = "https://github.com/swaits/hsmc"
homepage = "https://github.com/swaits/hsmc"
repository = "https://git.swaits.com/swaits/hsmc"
homepage = "https://git.swaits.com/swaits/hsmc"
View File
File renamed without changes.
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Stephen Waits
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing permissions
and limitations under the License.
+397 -259
View File
@@ -1,10 +1,98 @@
# hsmc — Hierarchical State Machines for Rust
`hsmc` is a declarative, proc-macro-based statechart crate for Rust. It targets
embedded Rust (embassy) as the primary runtime and supports `tokio` via
`LocalSet`. Machines are compiled from a `statechart!` block into
monomorphized code with no dynamic dispatch, no interior mutability, and no
heap allocations by default.
`hsmc` lets you write the control logic of a device — a microwave, a
LoRa radio, a sensor, a UI flow — as a **statechart**: nested states
with entry/exit actions, transitions, timers, and async work, all
declared in one block. The proc macro turns it into Rust code with no
heap allocations, no dynamic dispatch, and no interior mutability.
It targets embedded Rust on `embassy` first, with full tokio support
on the side. Same chart, both runtimes.
## A first chart — a blinker
```rust
use hsmc::{statechart, Duration};
pub struct Ctx {
pub on: bool, // mirrors the LED's current state
pub blinks: u32, // count of times we've turned it on
}
statechart! {
Blinker {
context: Ctx;
default(Off); // start with the LED off
state Off {
entry: light_off;
on(after Duration::from_millis(500)) => On;
}
state On {
entry: light_on;
on(after Duration::from_millis(500)) => Off;
}
}
}
impl BlinkerActions for BlinkerActionContext<'_> {
async fn light_off(&mut self) { self.on = false; }
async fn light_on(&mut self) { self.on = true; self.blinks += 1; }
}
```
Two states, two timer-driven transitions, two entry actions. The
runtime arms each state's timer when you enter it and cancels it
when you leave; `default(Off)` picks the initial state. Land in
`Off`, fire `light_off`, arm the 500 ms timer; when it elapses,
transition to `On`, fire `light_on`, arm the next 500 ms timer.
Repeat.
That's `hsmc` at its smallest: states, timers, and one entry action
per state. No event channel, no async I/O, no hierarchy yet.
## Quickstart
Add to `Cargo.toml`:
```toml
[dependencies]
hsmc = { version = "0.6", features = ["embassy"] } # firmware
# or
hsmc = { version = "0.6", features = ["tokio"] } # desktop / tests
```
Run the Blinker under tokio:
```rust
#[tokio::main(flavor = "current_thread")]
async fn main() {
let mut m = Blinker::new(Ctx { on: false, blinks: 0 });
let _ = m.run().await;
}
```
That's the whole driver. A chart runs on a single task — `flavor =
"current_thread"` keeps tokio out of the multi-threaded scheduler so
you don't have to think about `Send` bounds on your context, your
events, or your `during:` futures. Normal Rust, no extra wrapping.
Run it under embassy:
```rust
#[embassy_executor::task]
async fn blink_task() {
let mut m = Blinker::new(Ctx { on: false, blinks: 0 });
let _ = m.run().await;
}
```
## A bigger chart — events, payloads, async I/O
The Blinker doesn't talk to anything outside itself. A real device
does. Here's a radio that receives commands from somewhere
(button press, RPC call, ISR), continuously listens for packets while
in the `Receiving` state, and stops when told:
```rust
statechart! {
@@ -25,112 +113,139 @@ statechart! {
}
```
## What it does
Three things here that the Blinker didn't have:
- **Harel-style hierarchical states.** Nested states with `entry:`/`exit:`
actions, LCA-aware transitions, default-child descent, `terminate`.
- **Event-driven transitions and actions.** Typed payload bindings:
`on(PacketRx { rssi: i16 }) => handler;`.
- **Timer triggers.** `on(after Duration::from_secs(5)) => Next;` and
`on(every Duration::from_millis(100)) => tick;`.
- **`during:` activities.** Async functions that run while a state is
active and produce events. Multiple durings can borrow disjoint fields
of the context concurrently via Rust's native split borrow — no
`RefCell`, no `Mutex`, no `UnsafeCell` in user code.
- **`m.run().await` is the whole task body.** Races active durings, the
external event channel, and timer deadlines. The MCU sleeps between
events (embassy's cooperative executor parks all futures).
- **Deterministic + mechanically verified.** The `journal` feature
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`). 113 byte-equal expected-vs-actual
journal tests anchor every spec rule.
- **Backward-compatible.** Every v0.1 / v0.2 statechart compiles under
v0.3 unchanged.
- **Events.** `events: Ev;` declares the input enum. External code
drives the chart with `m.sender().send(Ev::StartRx)`; in-chart
actions can `emit(Ev::Whatever)` to queue a follow-up. Handlers
pattern-match the variant and bind its payload directly:
`on(PacketRx { rssi, snr }) => record_packet;` makes `rssi` and
`snr` available to the action.
- **`during:` activities.** `next_packet(lora, rx_buf)` is an async
fn that runs while `Receiving` is active and is dropped when the
state exits. It gets `&mut` borrows of just the `lora` and
`rx_buf` fields of the context — Rust's split-borrow rules let
several `during:`s on the active path run concurrently without a
`RefCell` or `Mutex` in sight.
- **The runtime races them all.** `m.run()` simultaneously polls the
`during:`, listens on the event channel, and waits for the next
timer deadline; whichever fires first drives the next transition.
The MCU sleeps in between.
## How a chart behaves — the building blocks
For nested-state hierarchies, `emit(...)`, `terminate`, and
`on(every D)`, see the [microwave example](hsmc/examples/microwave.rs).
Eight rules, each building on the previous. Internalize these and every
edge case below falls out — you don't have to memorize anything else.
## What you get
- **Nested states with proper transitions.** Exit a deeply nested
state and every enclosing state's exits run in the right order, no
manual bookkeeping. A transition is just `=> Target;` and the
runtime walks the tree (LCA dispatch). Includes `entry:`/`exit:`
actions, optional `default(...)` initial transitions, and
`terminate`.
- **Events with typed payloads.** Pattern-match a variant and bind
its fields right in the chart: `on(PacketRx { rssi: i16 }) =>
record_packet;`. The compiler type-checks the binding against your
event enum.
- **Timers as first-class triggers.** `on(after 5s) => Next;` for a
one-shot, `on(every 100ms) => tick;` for periodic. Armed when the
declaring state is entered, cancelled when it's exited — no
`select!` plumbing in your code.
- **Async work scoped to a state's lifetime.** A `during:` is an
async fn that runs while a state is active and is dropped when you
leave. Multiple durings on the active path race concurrently and
split-borrow disjoint context fields — no `RefCell`, no `Mutex`,
no `unsafe` in your code.
- **`m.run().await` is the whole task.** It races durings, the event
channel, and timer deadlines, and parks the executor between them.
No manual loop, no busy-waits, no `Send` headaches under
`current_thread` tokio or embassy.
- **Observability that's one feature flag away.** Add
`features = ["trace-log"]` (or `trace-defmt` / `trace-tracing`) and
every action, transition, timer, and event becomes a log line in
whichever framework your crate already uses — `defmt` for firmware,
`log` for desktop, `tracing` for structured spans. With no sink
feature, the observation calls compile to nothing.
- **Deterministic + machine-checked.** The `journal` feature records
every action, transition, timer, and event in order; identical
input produces a byte-identical journal. The runtime's event
queue and timer table are formally verified — Creusot + Why3 +
alt-ergo + z3 discharge the proofs (`just verify`).
- **No heap, no dynamic dispatch.** Pure monomorphized code.
`no_std` clean by default. Designed to fit on a Cortex-M0+ under
embassy. The macro emits six bounded `unsafe { get_unchecked }`
blocks per chart for hot-path table indexing; their preconditions
are checked at build time by const-eval, in dev/test by
`debug_assert!`, and under `cargo +nightly miri test`. Full
inventory and audit in
[`docs/004. unsafe-safety-contract.md`](docs/004.%20unsafe-safety-contract.md).
Your chart, your action bodies, and your durings stay
`unsafe`-free.
## How a chart behaves — the rules
Eight rules, each building on the previous. Internalize these and
every edge case below falls out — you don't have to memorize anything
else.
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 5.
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. Transitions — exit to LCA, enter to target
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.
So `Root` with `default(A)` and `A` with `default(B)` means: the
machine starts by entering `Root` → `A` → `B`, all on first step.
### 3. Event bubbling — leaf 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,
walk up to the parent and check there. Repeat. If you reach the root
with no handler, the event is silently discarded.
Three corollaries:
- Leaf 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
it; it fires only in that state's scope.
### 4. Transitions — exit to LCA, enter to target
Any transition's algorithm is uniform:
A transition moves the active path from the current innermost state to
some target state. The algorithm is uniform regardless of what
triggered it (event, timer, or default — see later rules):
1. Find the LCA (lowest common ancestor) of current and target.
2. Walk **up** from current, exiting each state, until you hit the LCA.
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).
The LCA always exists because root is always an upper bound. State
names are globally unique across the chart, so a transition can target
**any** state — siblings, cousins, ancestors, the root itself
(`on(Trig) => MyChart;`).
### 5. The up-transition rule — never re-enter what you never left
### 3. The up-transition rule — never re-enter what you never left
If your transition target is **already on the active path** (i.e., it's
an ancestor of the innermost state), then steps 3 and 4 of the
transition algorithm do nothing. You exit the states strictly between
current and target; the target itself is **not** re-entered.
an ancestor of the innermost state), then step 3 of the transition
algorithm does 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 timers don't restart.
`A`'s durings keep running.
You cannot enter a state you never left.
### 6. Entry / exit ordering
### 4. Entry / exit ordering
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.
- **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.
@@ -139,11 +254,63 @@ So a full transition reads: cancel durings on the way out → run exits
on the way out → run entries on the way in → start durings on the way
in.
### 5. `default(...)` — a transition that fires immediately on entry
Any state **may** declare one `default(...)`. It's a transition — same
LCA-aware algorithm as rule 2 — 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. Defaults chain: if the resulting target also has
a `default(...)`, it fires too, and so on until a state with no
default is reached. The compiler rejects cycles in the default graph,
so the chain is always finite.
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`.
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.
A consequence of rule 3 (up-transitions): an ancestor's `default(...)`
only fires when that ancestor is freshly entered. Transitioning *up*
to an ancestor does not re-enter it, so its default does not re-fire.
### 6. 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 (the
handler may execute a transition per rule 2). If not, walk up to the
parent and check there. Repeat. If you reach the root with no handler,
the event is silently discarded.
Three corollaries:
- 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
it; it fires only in that state's scope.
### 7. Timers — armed by state lifecycle
A timer (`on(after D) => …` or `on(every D) => …`) is **armed** when
its declaring state is entered, **cancelled** when that state is
exited. Descending into a child is *not* an exit, so the parent's
exited. Descending into a child is _not_ an exit, so the parent's
timers keep running while you're in a child. Re-entering a state
restarts its timers from zero.
@@ -159,7 +326,7 @@ restarts its timers from zero.
durings on the active path race; each borrows disjoint `&mut` fields
of the context (Rust's split-borrow checker enforces this at compile
time).
- **Termination** is just rules 6 + 4 with target = "outside the
- **Termination** is just rules 2 + 4 with target = "outside the
chart": exit the entire active path inner-to-outer, then stop.
Pending events drop. In-flight durings drop at their next `.await`.
@@ -169,78 +336,6 @@ spells out edge cases (queue overflow surfacing, same-tick timer ties,
self-transitions on composites) but the rules above cover the
overwhelming majority of charts.
### What `hsmc` deliberately does NOT have
The bet is on **simple, consistent semantics**. These features are
omitted on purpose; for each, what to do instead:
| Missing feature | Use instead |
|---|---|
| Guard conditions on transitions | Split source into two states, or have an action `emit()` a follow-up event for the chart to branch on |
| Orthogonal / parallel regions | Two charts as two tasks connected by channels, or multiple `during:` activities in one state |
| History states | Store last child explicitly in your context; transition to it on re-entry |
| Deferred events | Handle where they arrive (drop/log if not relevant), or have producer check state |
| Internal transitions | `action(Trig) => fn;` (no transition) — exactly that |
| State-local context | All state in the root context; durings borrow disjoint fields |
| Event priorities beyond FIFO | Higher-priority events on a separate channel into a separate, faster machine |
| Runtime statechart modification | Design every variation up front as states |
## Quickstart
Add to `Cargo.toml`:
```toml
[dependencies]
hsmc = { version = "0.3", features = ["embassy"] } # or ["tokio"]
```
Minimal timer-only machine:
```rust
use hsmc::{statechart, Duration};
pub struct Ctx { pub ticks: u32 }
statechart! {
Blinker {
context: Ctx;
default(On);
state On {
entry: light_on;
on(after Duration::from_millis(500)) => Off;
}
state Off {
on(after Duration::from_millis(500)) => On;
}
}
}
impl BlinkerActions for BlinkerActionContext<'_> {
async fn light_on(&mut self) { self.ticks += 1; }
}
```
Drive it under tokio:
```rust
let mut m = Blinker::new(Ctx { ticks: 0 });
tokio::task::LocalSet::new().run_until(m.run()).await.unwrap();
```
Or under embassy:
```rust
static CHANNEL: Channel<CriticalSectionRawMutex, __NoEvents, 8> = Channel::new();
// (timer-only machines don't need a channel; omit `events:` and use `Blinker::new(ctx)`.)
#[embassy_executor::task]
async fn blink_task() {
let mut m = Blinker::new(Ctx { ticks: 0 });
let _ = m.run().await;
}
```
## `during:` — async activities scoped to a state
A `during:` runs while its state is on the active path. It's a plain async
@@ -327,127 +422,157 @@ into a single during that uses `select` internally.
For `statechart! Foo { ... }` the macro emits:
| Item | What it is |
|---------------------------------|----------------------------------------------------------------------|
| `FooState` | Enum of every user-declared state. |
| `FooActions` | Trait with one async fn per unique action name. User implements. |
| `FooActionContext<'a>` | Wrapper passed to actions. `Deref`s to the root context. `emit()`. |
| `FooCtx<'a>` | Type alias for `FooActionContext<'a>`. |
| `Foo<const QN: usize = 8>` | The machine itself. |
| `FooSender` | Cloneable handle for cross-task / ISR event injection. |
| `Foo::<8>::STATE_CHART` | `&'static str` ASCII tree of the hierarchy (useful for `defmt`/docs).|
| Item | What it is |
| -------------------------- | --------------------------------------------------------------------- |
| `FooState` | Enum of every user-declared state. |
| `FooActions` | Trait with one async fn per unique action name. User implements. |
| `FooActionContext<'a>` | Wrapper passed to actions. `Deref`s to the root context. `emit()`. |
| `FooCtx<'a>` | Type alias for `FooActionContext<'a>`. |
| `Foo<const QN: usize = 8>` | The machine itself. |
| `FooSender` | Cloneable handle for cross-task / ISR event injection. |
| `Foo::<8>::STATE_CHART` | `&'static str` ASCII tree of the hierarchy (useful for `defmt`/docs). |
### Machine methods
| Method | Notes |
|-------------------------------------|------------------------------------------------------------------|
| `new(ctx)` / `new(ctx, channel)` | Construct. Under embassy, pass an `&'static Channel`. |
| `with_queue_capacity::<N>(ctx,...)` | Custom event-queue capacity. |
| `async fn run()` | Drive the machine to completion (async features). |
| `async fn dispatch(ev)` | Push event + drain to quiescence. Same task injection. |
| `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. |
| `is_terminated()` | True after `terminate` event. |
| `has_pending_events()` | Queue non-empty. |
| `context()` / `context_mut()` | Borrow the root context. |
| `into_context(self)` | Consume, return ctx. |
| `STATE_CHART` | Const ASCII diagram. |
| Method | Notes |
| -------------------------------------- | ------------------------------------------------------ |
| `new(ctx)` / `new(ctx, channel)` | Construct. Under embassy, pass an `&'static Channel`. |
| `with_queue_capacity::<N>(ctx,...)` | Custom event-queue capacity. |
| `async fn run()` | Drive the machine to completion (async features). |
| `async fn dispatch(ev)` | Push event + drain to quiescence. Same task injection. |
| `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 innermost active state. |
| `is_terminated()` | True after `terminate` event. |
| `has_pending_events()` | Queue non-empty. |
| `context()` / `context_mut()` | Borrow the root context. |
| `into_context(self)` | Consume, return ctx. |
| `STATE_CHART` | Const ASCII diagram. |
## Feature flags
| Feature | Effect |
|-----------|-------------------------------------------------------------------------|
| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. |
| `tokio` | Async actions + `run()`. Targets single-thread via `LocalSet`. |
| `embassy` | Async actions + `run()`. Targets embassy's cooperative executor. `no_std`.|
| Feature | Effect |
| --------------- | -------------------------------------------------------------------------- |
| (default) | `no_std`, no async runtime. Drive via `step(Duration)` + `send(ev)`. |
| `tokio` | Async actions + `run()`. Single-thread (`flavor = "current_thread"`). |
| `embassy` | Async actions + `run()`. Embassy's cooperative executor. `no_std`. |
| `journal` | In-memory `Vec<TraceEvent>` of every observation (replay, byte-deterministic). |
| `trace-defmt` | Auto-emit `defmt::*` log lines for every observation. |
| `trace-log` | Auto-emit `log::*` log lines for every observation. |
| `trace-tracing` | Auto-emit `tracing::*` events with structured fields. |
`embassy` and `tokio` are mutually exclusive — enabling both is a compile
error.
error. The `trace-*` and `journal` features are independently composable
with each other and with the runtime feature; with no sink feature on,
the trace points compile to nothing (zero overhead).
## Migration from v0.1
## Observability — one journal, multiple outputs
v0.1 statecharts continue to compile. New features are purely additive:
On hsmc's side, turning on observability is a single Cargo feature.
On your side, it's whatever you'd already do to wire `defmt` / `log`
/ `tracing` to an output — hsmc does not initialize a logger
(libraries shouldn't; `main` owns that decision).
### 1. `events:` is now optional
If your firmware crate already uses `defmt`, add the feature and
you're done — chart events interleave with the rest of your defmt
output:
Previously required; now omit for pure timer-only machines:
```toml
hsmc = { version = "0.6", features = ["embassy", "trace-defmt"] }
```
For a desktop crate, pick `log` or `tracing` and do that framework's
normal one-line init in `main`:
```toml
[dependencies]
hsmc = { version = "0.5", features = ["tokio", "trace-tracing"] }
tracing-subscriber = "0.3"
```
```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; }
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
tracing_subscriber::fmt::init();
let mut m = Blinker::new(Ctx { on: false, blinks: 0 });
let _ = m.run().await;
}
```
### 2. `dispatch(ev).await` replaces the push+drain helper
Swap `trace-tracing` for `trace-log` and `tracing_subscriber::fmt::init()`
for `env_logger::init()` if you prefer the `log` ecosystem; the rest is
identical. With no sink feature enabled at all, the observation calls
compile to `()` — the runtime is byte-identical to a chart with no
instrumentation.
Before (v0.1, in the dogfood firmware):
| Feature | Sink |
| --------------- | -------------------------------------------------------- |
| `journal` | In-memory `Vec<TraceEvent>` (replay, byte-deterministic) |
| `trace-defmt` | `defmt::*` log lines |
| `trace-log` | `log::*` log lines |
| `trace-tracing` | `tracing::*` events with structured fields |
```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;
Multiple sinks can be enabled simultaneously; the journal IS the
observation stream and trace backends are textual renderings of it,
so they cannot diverge.
The textual format is logfmt-style — greppable and regex-parseable:
```
[statechart:Microwave] started chart_hash=0xa1b2c3d4
[statechart:Microwave] entering state=Idle
[statechart:Microwave] action state=Idle action=clear_display kind=entry
[statechart:Microwave] timer-armed state=Idle timer=t0 duration_ns=5000000000
[statechart:Microwave] during-started state=Idle during=poll_sensor
[statechart:Microwave] entered state=Idle
[statechart:Microwave] event-received name=StartButton
[statechart:Microwave] event-delivered name=StartButton handler=Idle
[statechart:Microwave] transition-begin from=Idle to=Heating reason=event:StartButton
[statechart:Microwave] exiting state=Idle
[statechart:Microwave] timer-cancelled state=Idle timer=t0
[statechart:Microwave] during-cancelled state=Idle during=poll_sensor
[statechart:Microwave] action state=Idle action=stop_motor kind=exit
[statechart:Microwave] exited state=Idle
[statechart:Microwave] entering state=Heating
[statechart:Microwave] entered state=Heating
[statechart:Microwave] transition-complete from=Idle to=Heating
```
After (v0.2):
### The verb vocabulary
```rust
machine.dispatch(ev).await.unwrap();
```
Twenty verbs cover every `TraceEvent` variant. Begin/end markers
bracket entry, exit, and transition phases so the actions, timers,
and durings running inside each phase are visible.
### 3. `during:` replaces the imperative I/O outer loop
| Verb | Fires when |
|---|---|
| `started` | First step of a chart's life — carries `chart_hash` |
| `entering` / `entered` | Begin / end of a state's entry phase |
| `exiting` / `exited` | Begin / end of a state's exit phase |
| `action` | An action fn was called — `kind=entry\|exit\|handler` |
| `during-started` / `during-cancelled` | A `during:` activity started / was dropped |
| `transition-begin` / `transition-complete` | Begin / end of a transition's exit-then-enter sequence |
| `event-received` | An event was popped from the queue |
| `event-delivered` | A handler ran for the event |
| `event-dropped` | The event reached root with no handler |
| `emit-queued` / `emit-failed` | `emit()` from inside an action — queued or rejected |
| `timer-armed` / `timer-cancelled` / `timer-fired` | Timer lifecycle |
| `terminate-requested` | `terminate(...)` event matched, exit chain about to run |
| `terminated` | Chart finished (always last) |
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();
}
```
The `reason=` field on `transition-begin` records what triggered the
transition: an event (`event:VariantName`), a timer
(`timer:State/timer-id`), or `internal`. Real-life captures can be
diffed against expected behavior the same way the journal can.
## Examples
| Example | Purpose |
|--------------------|---------------------------------------------------------------------|
| `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate.|
| `microwave_tui` | TUI variant of microwave. |
| `during_radio` | `during:` activities simulating a radio task with RX + TX loops. |
| `embassy_full` | Embassy feature demonstration. |
| Example | Purpose |
| --------------- | --------------------------------------------------------------------- |
| `microwave` | Full-feature pure-event machine: entry/exit, timers, emit, terminate. |
| `during_radio` | `during:` activities simulating a radio task with RX + TX loops. |
| `embassy_full` | Embassy feature demonstration. |
Run `cargo run --example during_radio --features tokio` to see the
`during:` pattern end-to-end.
@@ -469,29 +594,42 @@ gets to a green proof in one command. See
[`verification/INVARIANTS.md`](verification/INVARIANTS.md) for the
spec-rule → proof mapping.
The `journal` Cargo feature gives you a deterministic execution
trace of every action, transition, timer arm/cancel/fire, queued
emit, event delivery, and termination. Same chart + same events =
byte-identical journal. 113 byte-equal expected-vs-actual journal
tests anchor every spec rule. `cargo mutants` runs cleanly with
0 missed mutants under `--features tokio,journal`.
Beyond Creusot, the suite includes:
- **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** — clean, no UB on the runtime crates.
## What `hsmc` deliberately does NOT have
The bet is on **simple, consistent semantics**. These features are
omitted on purpose; for each, what to do instead:
| Missing feature | Use instead |
| ------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Guard conditions on transitions | Split source into two states, or have an action `emit()` a follow-up event for the chart to branch on |
| Orthogonal / parallel regions | Two charts as two tasks connected by channels, or multiple `during:` activities in one state |
| History states | Store last child explicitly in your context; transition to it on re-entry |
| Deferred events | Handle where they arrive (drop/log if not relevant), or have producer check state |
| Internal transitions | `action(Trig) => fn;` (no transition) — exactly that |
| State-local context | All state in the root context; durings borrow disjoint fields |
| Event priorities beyond FIFO | Higher-priority events on a separate channel into a separate, faster machine |
| Runtime statechart modification | Design every variation up front as states |
| Multi-threaded tokio | `flavor = "current_thread"` is the supported path — one task per chart |
See [`docs/002. hsmc-semantics-formal.md`](docs/002.%20hsmc-semantics-formal.md)
§ "What `hsmc` deliberately does NOT have" for the rationale on each.
## 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.
- 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).
## License
Dual-licensed MIT OR Apache-2.0.
MIT. Copyright (c) 2026 Stephen Waits &lt;steve@waits.net&gt;. See [LICENSE](LICENSE).
+8 -1
View File
@@ -1186,7 +1186,14 @@ The crate is considered complete when:
11. **Timer precision.** Timers fire on the first `step()` call where cumulative elapsed time meets or exceeds the duration. They do not fire early.
12. **No unsafe code** in the public API or generated code (implementation may use unsafe internally if justified and documented).
12. **Bounded `unsafe` in generated code, none in user code.** The
generated dispatch path uses six `slice::get_unchecked` blocks
for hot-path table indexing; their bounds are guaranteed at
codegen time, checked at build time by const-eval, in dev/test
by `debug_assert!`, and in CI under Miri. Inventory + audit in
[`docs/004. unsafe-safety-contract.md`](./004.%20unsafe-safety-contract.md).
User-facing code (chart definitions, action bodies, durings,
public API surface) contains no `unsafe`.
13. **Documentation.** Every public type, method, and trait has rustdoc with examples.
+52 -24
View File
@@ -37,10 +37,16 @@ Each primitive, in roughly the order a developer first meets them.
to the target, calling entries along the way.
- **Action (without transition)** — a function call attached to a trigger.
No state change. That's it.
- **Default child** — every non-leaf state declares one default child.
Whenever a state with children is entered, the default child is entered
immediately after that state's entry actions finish — it's effectively
an automatic transition into the child.
- **Default transition** — any state **may** declare one `default(...)`.
It's a transition (same LCA-aware algorithm as rule 4 / event-driven
transitions) that fires immediately after the declaring state's entry
actions finish and its durings start. The target may be any state in
the chart — a child (the classic descent case), a sibling, an
ancestor, anywhere. Defaults chain (each link is itself a transition)
until they reach a state with no default. The compiler rejects cycles
in the default graph at compile time, so every chain terminates. When
a state has no `default`, nothing else happens after entry — the
state itself is the resting innermost active state.
- **Trigger: event vs. duration** — an event is a variant of your event
enum. A duration is a timer (`after Duration` fires once, `every
Duration` fires repeatedly). Triggers attach to transitions and to
@@ -71,7 +77,8 @@ seems weird, come back to this one — it's probably the resolution.
`current_state()` returns the innermost active state, which is usually a
leaf, but can be a composite (a state with children) if you got there via
an up-transition (see below) and haven't moved since.
an up-transition (see below) and haven't moved since, or if you
transitioned into a composite that has no `default` declared.
---
@@ -86,8 +93,9 @@ and still nothing handles it, the event is silently discarded.
That's all. Three other things follow from this rule:
- A handler on a leaf state shadows a handler on its ancestor for the same
event. The leaf wins because the search starts there.
- A handler on the innermost active state shadows a handler on any
ancestor for the same event. The innermost wins because the search
starts there.
- If a single state has multiple handlers (actions and/or a transition)
for the same trigger, the actions fire in declaration order, then the
transition fires last.
@@ -108,8 +116,8 @@ in `statechart! { Foo { ... } }`, an `on(Trig) => Foo;` is a valid
transition whose target is the root. From any state inside the chart this
is an up-transition (root is always on the active path) — exiting only
the states strictly between current and root, with no entry actions and
no default-descent on root. Use this when you want to wind everything
back up to the chart's own scope.
no default firing on root (the root wasn't freshly entered). Use this
when you want to wind everything back up to the chart's own scope.
To run a transition:
@@ -121,8 +129,10 @@ To run a transition:
itself is **not** exited.
3. Walk **down** from the LCA to the target, entering each state on the
way (run entry actions → start durings). The LCA is **not** re-entered.
4. If the target has children, follow the default-child chain down to a
leaf, entering each as you go.
4. If the target declares a `default(...)`, fire it as a recursive
transition (using the same algorithm). Defaults chain until a state
with no default is reached. Compile-time cycle check guarantees
termination.
The three flavors below are just three relationships between current and
target. The same algorithm covers all of them — they only look different
@@ -131,8 +141,9 @@ in which steps fire.
### Transitioning to a sibling (or anywhere not on your active path)
The standard case. LCA is somewhere strictly above both current and
target. Exit up to LCA, enter down to target, default-descend if the
target has children. Already covered above.
target. Exit up to LCA, enter down to target, then if the target
declares a `default(...)`, fire it as a chained transition. Already
covered above.
### Transitioning to yourself
@@ -153,11 +164,12 @@ Timers declared on the target restart, because the target itself was
exited and re-entered.
If the target happens to be a composite (it has children, but no child is
active right now because you got there via an up-transition), the same
default-child rule still applies on re-entry: exit the composite, re-enter
it, then descend through its default chain. Self-transitions don't add a
new rule — they're just the case where source and target are the same
state.
active right now because you got there via an up-transition or because
the composite has no `default`), the same rule still applies on re-entry:
exit the composite, re-enter it, then descend through its default chain
if it declares one. A composite without `default` is exited, re-entered,
and that's it — no further descent. Self-transitions don't add a new
rule — they're just the case where source and target are the same state.
Personally I don't find self-transitions all that valuable, but people
talk about them all the time, so they're supported.
@@ -207,7 +219,11 @@ Putting it all together:
outermost state's entry actions fire first, then its child's, and so on
down to the innermost. Within a single state, entry actions fire in
declaration order. After entry actions of a state finish, that state's
durings start. Then the default child (if any) is entered, recursively.
durings start. Then, if the state declares a `default(...)`, the
default fires as a transition (which may exit the state we just
entered if the target lies elsewhere in the chart) and the chain
continues from there. A state without `default` is the end of the
path — control stops there.
- **Exit** runs **inner-to-outer**: when leaving a path of states, the
innermost state exits first, then its parent, and so on up. Within a
single state, exit actions fire in declaration order. Before exit
@@ -217,6 +233,16 @@ So a full transition reads top-to-bottom as: cancel durings on the way
out → run exits on the way out → run entries on the way in → start
durings on the way in.
Observers (the journal and any active trace backend) see this as
phase **begin** and **end** markers bracketing the work: an
`EnterBegan { state }` fires before any of that state's entry actions /
timer arms / during starts, and an `Entered { state }` fires after
all of them finish. The same for `ExitBegan` / `Exited`, and
`TransitionFired` / `TransitionComplete` bracket the whole exit-then-
enter sequence of a transition. This is purely a visibility change —
the operational rules above are unchanged — but it lets you tell from
a captured trace which atoms ran inside which phase.
---
## `emit()` — sending events from inside an action
@@ -357,11 +383,13 @@ drafts. Edit freely.)_
| Scenario | What fires |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Initial machine start | Root entries → default-descend down through each child's entries → durings start at each entered state. |
| Transition to sibling | Exit current up to LCA (cancel durings, run exits) → enter from LCA down to target (run entries, start durings) → default-descend if target has children. |
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If innermost has children, default-descend after re-entry. |
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. No default-descent. Ancestor's durings and timers continue. |
| Transition into a state with children | After the target's entries fire, default-descend through each default child's entries down to a leaf. |
| Initial machine start | Root entries → if root declares a `default(...)`, fire it as a transition; chain continues until a state with no default. Durings start at each entered state. |
| Transition to sibling | Exit current up to LCA (cancel durings, run exits) → enter from LCA down to target (run entries, start durings) → if target declares a `default`, fire it; chain continues. |
| Transition to self | Exit innermost (cancel durings, run exits), re-enter innermost (run entries, start durings). Timers restart. If the re-entered state declares a `default`, fire it. |
| Transition to ancestor | Exit current up to (but not including) the ancestor. No entry actions. The ancestor's `default` is **not** fired (it wasn't freshly entered). Durings and timers continue. |
| Transition into a state with `default(<descendant>)` | After the target's entries fire, the default fires as a child-direction transition (no exits at this step). The chain continues from the new innermost. |
| Transition into a state with `default(<sibling/ancestor>)` | After the target's entries fire, the default fires as a transition that exits the just-entered target and lands elsewhere. The chain continues. |
| State has no `default(...)` | After its entries fire, control stops there. The state itself is the innermost active state until something explicitly transitions away. |
| Timer fires | The declaring state's handler runs (timers don't bubble). |
| Event with no handler | Silently discarded. |
| Event handled at innermost | Innermost state's handler runs (actions in declaration order, then transition if any). |
+500
View File
@@ -0,0 +1,500 @@
# 003. Latency analysis: hsmc hot-path cost surface
This is an audit of where wall-clock and code-size cost lives in `hsmc`'s
event-dispatch path. It is intended to inform mitigation work for embedded
and embassy-rs use cases where every microsecond of dispatch overhead
couples directly into hardware-control loop tightness and jitter.
The audit is **analytical**, with three categories of evidence:
1. **Source reading** of `hsmc/src/lib.rs`, `hsmc/src/journal.rs`, and
`hsmc-macros/src/codegen.rs`.
2. **`cargo expand --release`** of two test programs (one with no
observation features, one with `tokio + journal`) to confirm what the
macro really emits.
3. **`cargo rustc --release --emit=asm`** spot-checks of the compiled
output to confirm LLVM-level claims (jump-table lowering, dead-code
elimination of unused name tables, async-closure code size).
The audit deliberately stops short of recommending mitigations. A ranked
menu of candidates is included at the end so a follow-up planning round
can pick from it; nothing here is a commitment.
---
## 1. Cost surface, ranked by per-event impact
| # | Source | Class | Per-event cost (estimate) | Location |
|----|-----------------------------------------------|-----------------------------|------------------------------------------------------------------------------------------------------------|---------------------------------------|
| # | Source | Class | Per-event cost (estimate) | Status |
|----|-----------------------------------------------|-----------------------------|------------------------------------------------------------------------------------------------------------|---------------------------------------|
| 1 | `dispatch().await` loop | Discretionary, async-only | 2 awaits cold-start / 1 await steady-state per dispatched event. ~free if action body never `Pending`-yields. | open |
| 2 | LCA + transition path build | Discretionary | ~~O(H²) `heapless::Vec<u16,32>::contains` scan~~ → ~~O(H) depth-walk~~ → **O(1) `__PATH_RANGE` lookup**. Up/self/lateral folded into the precomputed table; `lca()`, `is_up_transition()`, and the per-call `heapless::Vec<u16,16>` ancestry build are all gone. | resolved |
| 3 | Parent walk for handler search | Discretionary | ~~O(H) `__PARENT` walk + per-hop `__handler_lookup` call~~ → **O(1) `__DISPATCH[(state, event)]` lookup**. Every (state, event) pair statically resolved at codegen. `__handler_lookup` retained for timer dispatch only. | resolved |
| 4 | `__handler_lookup` nested match (timer path) | Intrinsic | Still used for timer dispatch. LLVM lowers to jump tables (verified `LJTI*`). ~1 indirect jump per call. | open (already at floor) |
| 5 | `run_action` dispatch | Intrinsic | One match arm + monomorphized trait call + 1 vtable hop on `&mut dyn QueuePush` (verified devirtualized when callsite type is known). | open (already at floor) |
| 6 | Event queue push/pop | Intrinsic | `heapless::Deque::push_back` / `pop_front`, both O(1). | open (already at floor) |
| 7 | `__chart_observe!` macro arms | Compile-eliminable when off | Zero residue when all sink features off. Each call site collapses to `{};`. | verified zero-cost |
| 8 | Journal `Vec::push` per atom | Conditional on `journal` | Amortized O(1); ~16 B per `TraceEvent`. ~11 push sites fire per transition. | opt-in |
| 9 | Async-fn future size (binary cost) | Discretionary, async-only | ~3000 asm lines of chart machinery for a 5-state chart; dominated by async-fn state machines. | open (mitigation H3) |
| 10 | `tokio::sync::mpsc::unbounded_channel` | Tokio-only, std-bound | Heap-backed channel for cross-task event injection. Embassy uses bounded `embassy_sync::channel`. | feature-specific |
| 11 | TimerTable per-step / per-exit fixed cost | Discretionary | ~~Unconditional `pop_expired` + `cancel_state` calls every step / exit~~ → **inlined `is_empty()` + `#[cold]` non-empty body** (runtime branch on charts that use timers; compile-time elided on timer-free charts). | resolved |
| 12 | TimerEntry size + per-decrement work | Discretionary | ~~`remaining_ns: u128`, 32-byte entry, u128 saturating-sub per decrement~~ → **`u64`, 16-byte entry, u64 saturating-sub** (~584-year ceiling; Duration::from_nanos round-trips exactly). | resolved |
| 13 | `do_terminate()` exit walk | Discretionary | ~~Runtime `__PARENT` walk + `pop_front`-loop queue drain~~ → **precomputed `__TERMINATE_DATA` slice + `Deque::clear()`** (O(1)). | resolved |
Items 2, 3, 11, 12, 13 are fully resolved by the precompute / inline-
empty-check passes in this round of work — see `hsmc/benches/baseline.txt`
for the per-pass delta blocks. Items 4–6 are at or near the algorithmic
floor for hierarchical state machines. Item 7 is verified zero-cost.
Items 1 (async overhead) and 9 (async machinery binary size) are the
only meaningful open items; both are addressable via a `dispatch_sync()`
fast path (mitigation H3 in §9), which would be a user-facing API
change and is therefore out-of-scope for this round.
---
## 2. Per-event cost model (analytical)
For a chart with hierarchy depth `H=3`, one entry action + one exit action,
a simple lateral transition, no observation features, `cargo build
--release`, on a Cortex-M4-class core (~168 MHz):
| Stage | Cycles (rough) | Notes |
|----------------------------------------|--------------------|------------------------------------------------|
| `queue.pop_front` | ~5–10 | `heapless::Deque` index op |
| `__event_to_id` | ~3–5 | match arm (jump table) |
| `__DISPATCH[(state, event)]` lookup | ~5 | one indexed load + sentinel branch |
| `run_action` (1 action) | ~10–20 | match + monomorphized call + 1 vtable hop |
| Path lookup (`__PATH_RANGE`) | ~5 | one indexed load + slice arithmetic |
| Exit chain (H states × A actions) | ~3 × H + 30 × A | per-state work is now ~3 instr each |
| Enter chain (H states × A actions) | ~3 × H + 30 × A | per-state work is now ~3 instr each |
| Timer machinery (timer-free chart) | 0 | codegen-elided when `ir.duration_triggers.is_empty()` |
| Timer machinery (timer chart, no fire) | ~3 | inlined `is_empty()` branches × 3 |
| Timer machinery (timer firing) | ~30–40 | decrement + pop_expired + dispatch_trigger |
| **Total `.step()` steady-state** | ~0.4 – 0.8 µs | for H=3, A=1; user action body excluded |
| Async overhead (`.dispatch()` loop) | per-await checkpoint | ~free if no `Pending`-yield |
These are back-of-envelope numbers; actual measurements would tighten
them by ~2× in either direction, depending on inlining decisions.
For empirical numbers (deterministic, instruction-counted), see
`hsmc/benches/baseline.txt`. Headline numbers (current):
h2_warm_lateral 84 instr / 397 cycles; h4_cross_tree 101 instr / 456
cycles; h8_cross_tree 93 instr / 443 cycles; bubble_top 99 instr /
453 cycles (4-hop handler resolution); bubble_cross_tree 94 instr /
444 cycles (4-hop bubble + 10-state cross-tree); terminate_event 69
instr / 311 cycles; timer_fire_dispatch 191 instr / 706 cycles;
chain_self_emit 419 instr across 4 events (≈105 instr/event).
Cumulative on h8_cross_tree: **-82% instructions / -72% cycles** vs
the original O(H²) LCA scan.
---
## 3. Spot-check evidence
### 3.1 `__chart_observe!` is zero-cost when off (cargo expand)
Test: `tests/state_chart_const.rs`, with no observation features enabled.
In the expanded source (`/tmp/hsmc_expand_no_features.rs`, 911 lines), every
observation site collapses to an empty block:
```rust
// in `step()`:
if self.current.is_none() {
{}; // would be observe_started
self.enter_path(0);
return self.timers.min_remaining();
}
// ...
if let Some((state, trig)) = self.timers.pop_expired(&__DEPTH) {
let __t_state = state;
let __t_trigger = trig;
{}; // would be observe_timer_fired
// ...
}
```
A grep for any of `TraceEvent::`, `::log::`, `::tracing::`, `::defmt::`,
`JournalSink::push` in the expanded code returns **zero matches**. The
macro arms entirely vanish.
`JournalSink` itself becomes a unit struct when the feature is off:
```rust
#[cfg(not(feature = "journal"))]
pub struct JournalSink; // ZST — 0 bytes in the chart struct
```
so the `__journal` field on the chart costs zero bytes at runtime.
### 3.2 Name lookups are dead-code-eliminated (cargo rustc --emit=asm)
Test: `tests/det_lateral.rs`, built with `--features tokio,journal`.
Even with `journal` enabled, a grep across the 22,477 lines of generated
asm for `state_name`, `action_name`, `event_name`, or `timer_name` returns
**zero matches**. The journal stores `u16` indices, not strings, and no
trace-* feature is on, so the name-lookup helper functions are uncalled
and DCE'd from the final binary.
This means even if a user enables `journal` for replay/verification, they
pay zero binary cost for the human-readable name tables until they also
enable a `trace-*` sink.
### 3.3 LLVM lowers handler matches to jump tables
Same asm file. Grep for `LJTI`:
```asm
leaq .LJTI2_0(%rip), %rcx
jmpq *%rax
.LJTI2_0:
.long .LBB2_1-.LJTI2_0
.long .LBB2_28-.LJTI2_0
.long .LBB2_29-.LJTI2_0
.long .LBB2_23-.LJTI2_0
```
Multiple jump tables are present, each sized roughly to the chart's
state/event count. This corroborates the assumption that
`__handler_lookup`'s nested `match (state_id) { match (kind, trigger_id) }`
lowers to a single indirect jump on platforms LLVM can target with one,
not a chain of compares.
### 3.4 Async machinery code size
Symbol-by-symbol asm sizes from `det_lateral`:
| Symbol (async closure / future) | Asm lines | Notes |
|---------------------------------|-----------|----------------------------------------------|
| `Lat::exit_state` | ~275 | one `.await` per exit action |
| `Lat::run_action` | ~349 | match arm + N monomorphized async branches |
| `Lat::run_handlers` | ~826 | loop over actions, each `.await` |
| `Lat::enter_state_no_descent` | ~217 | |
| `Lat::step` | ~970 | inlines `lca`, `is_up_transition`, etc. |
| `Lat::dispatch` | ~137 | thin loop wrapper |
| **Lat-chart total** | **~2,800**| async machinery for one 5-state chart |
For comparison, `Lat::new` is 141 lines. The async coloring
roughly doubles to triples the binary footprint of a chart relative to a
pure sync codegen. On Cortex-M0/M0+ where flash is small, this matters.
### 3.5 Journal expansion adds N pushes, nothing else (cargo expand)
Same test, expanded with `--features tokio,journal`
(`/tmp/hsmc_expand_with_journal.rs`, 2,426 lines vs. 911 lines without).
Counts:
- 104 total `TraceEvent::*` constructions.
- 20 distinct variant names (the full vocabulary).
- 8 `Vec::push` call sites inside `step()` — but only one fires per
invocation (timer-fired path, event-received path, etc., are mutually
exclusive).
The diff against the no-feature expansion is exactly: every `{};`
placeholder becomes a `(&mut self.__journal).push(TraceEvent::...)`. No
other infrastructure is added. No locking, no allocation per push beyond
the amortized `Vec` growth.
---
## 4. Observability cost matrix
Distilled from `lib.rs:158-712` and the spot-checks above:
| Feature(s) | Per-call CPU | Per-call alloc | Off → fully elided? |
|-------------------|-------------------------------------------|--------------------------|-------------------------------|
| `(none)` | 0 | 0 | Yes (verified) |
| `journal` | `Vec::push` ~10 ns amortized | ~16-24 B amortized | n/a |
| `trace-defmt` | format-hash u32 + field write | 0 | Yes |
| `trace-log` | `log_enabled!()` check + format if passes | 0 unless format | Yes (with small residual when on but level filtered) |
| `trace-tracing` | unconditional field collection + span | 0 | Yes (with ~30-cycle residual when on but no subscriber) |
The codegen emits **~11 `__chart_observe!` calls per typical transition**
in deterministic order: `EventReceived` → `EventDelivered` →
`TransitionFired` → `ExitBegan` → `ActionInvoked{Exit}` → `Exited` →
`EnterBegan` → `ActionInvoked{Entry}` → `Entered` → `TransitionComplete`,
plus action-handler markers. That's the multiplier on every per-call
number above.
---
## 5. What is *not* a problem (audited and dismissed)
- **Handler-lookup growth with chart size** — confirmed jump-table
lowering (§3.3). O(1) per event regardless of state or event count
(modulo cache effects from larger jump tables).
- **Trait-object cost on action dispatch** — only the `&mut dyn QueuePush`
proxy is `dyn`. User actions are monomorphized trait methods (one match
arm per action ID, direct calls). One vtable hop per emit, *not* per
action.
- **Observation overhead with features off** — confirmed zero. Verified
three ways: macro arm structure (every body `#[cfg]`-gated), expanded
source (every site is `{};`), and asm (no name-lookup symbols, no trace
symbols).
- **Heap allocation in default build** — `extern crate alloc` is confined
to `journal.rs:26`. With `journal` off, the runtime is fully stack-only.
Verified: no `Vec`, `Box`, or `String` in expanded sync code.
- **`Pin<Box<...>>` futures on the hot path** — none. Chart futures are
inline state machines, not boxed.
- **Tokio `unbounded_channel` for embedded** — irrelevant: tokio is `std`
anyway. Embassy uses bounded `embassy_sync::channel::Channel` (no
alloc).
---
## 6. Event entry points
Events reach the chart's internal `heapless::Deque` via four distinct
paths. Each has its own latency, wakeup, ordering, and backpressure
profile, summarized below.
| Path | API | Backing | Wakeup | Ordering | Backpressure |
|------|-------------------------------------------|--------------------------------------------|-------------------------|----------------------------------------|------------------------------------|
| 1 | `chart.send(event)` (sync, single-task) | `heapless::Deque::push_back` | none — caller drives | FIFO within queue | `Err(QueueFull)` immediate |
| 2a | `chart.sender().send(ev)` (tokio) | `tokio::sync::mpsc::unbounded_channel` | tokio task wake | FIFO; biased select deprioritizes vs. durings | **unbounded memory** |
| 2b | `chart.sender().send(ev)` (embassy) | `embassy_sync::channel::Channel` (bounded) | embassy task wake | FIFO; same biased-select | `Err(Full)` (try_send) or backpressure-await |
| 3 | `actx.emit(ev)` from inside an action | same internal `Deque` as path 1 | none — drained inline | drained synchronously after current handler completes; *before* `step()` returns | sticky overflow flag |
| 4 | return from a `during:` future | `select!` arm in run loop | driver-specific | biased priority over channel events | silent drop on `Deque` full |
**Path 1 (sync send)** — `codegen.rs:2316-2319`. Bare
`Deque::push_back`; owning task only, no synchronization. ~5–10 cycles.
Caller is responsible for invoking `step()` (or `dispatch()` under an
async feature) to actually process the event.
**Path 2 (cross-task `sender()`)** — `codegen.rs:1492` (tokio sender),
`1638` (embassy sender), `1308` / `1405` (run-loop receive sites). The
canonical cross-thread / ISR path per the spec. Tokio uses an unbounded
MPSC (heap-backed; no backpressure signal); embassy uses a bounded
`Channel<M, E, N>` with `try_send` for ISR-safe non-blocking emits and
an `async send` that backpressures the producer. The receiver task
wakes on send; one iteration of the run-loop's `select!` consumes the
event and pushes it into the internal `Deque`.
**Path 3 (self-emit from action)** — `actx.emit(...)` goes through
`EmitProxy::push` (`hsmc/src/lib.rs`) which writes into the same
internal `Deque` as Path 1. Self-emitted events are drained in the
*same* `step()` call that emitted them — `dispatch()` runs `while
!queue.is_empty() { step().await }` until quiescence. Nothing crosses a
task boundary.
**Path 4 (`during:` return)** — `codegen.rs:1306–1313` (tokio with
durings), `1392+` (embassy). Durings are racing futures spawned in the
run-loop's `select!`. When one resolves to an event, the `select!`
unblocks and the event is pushed into the internal `Deque`. The
`select!` is `biased;` so durings have priority over the channel —
relevant if a during is a poll-based driver that's almost always
ready (see §8).
**One ordering caveat**: there is no global FIFO across paths 1–4.
Within each path, events are FIFO; across paths, the order depends on
when each path's wakeup arrives at the run loop. For most charts this
is a non-issue, but a chart that mixes high-frequency cross-task sends
with rapid self-emits could observe self-emits processed before
external events that arrived "first" in wall-clock terms.
---
## 7. Cooperative-driving audit
The library targets embassy-rs's single-priority cooperative scheduler,
where any tight loop without an `.await` can starve other tasks. An
audit of every loop and await pattern in the runtime and codegen:
| Loop / await | Location | Bounded? | Awaits per iter? | Verdict |
|---------------------------------------|---------------------------|-------------------------|--------------------------|---------------------|
| Main `run()` outer | codegen.rs:1290+, 1379+ | by `terminated` flag | yes — always | cooperative |
| `dispatch()` drain loop | codegen.rs:2339 | by queue size | only via `step().await` | suspect — fairness |
| Transition exit/enter slice loops | codegen.rs (transition) | by precomputed slice len| yes (async variant) | OK |
| Termination drain | codegen.rs:2750 | by hierarchy + queue | yes (async variant) | OK |
| Timer decrement / pop | runtime / lib.rs | by timer-table size | sync, off the event-loop | OK |
| Send/recv backends | codegen.rs:1308, 1405 | n/a | always `.await` | cooperative |
No `try_recv` patterns anywhere. No fixed-period polling. The library
is fundamentally event-driven and embassy-friendly.
**One latent fairness characteristic**: the `dispatch()` drain loop —
`while !self.queue.is_empty() { step(Duration::ZERO).await }` — never
yields to the executor between iterations. If user actions are all
synchronous and they emit a chain of follow-on events (action A emits
B, B emits C, …), each `step().await` completes inline, and the loop
becomes effectively synchronous for the duration of the chain. Embassy
high-priority tasks could be delayed by the chain length.
This is **not a bug** — `dispatch()` is documented to drain to
quiescence — but it's worth knowing about. A future option (not part of
this round of work) would be a feature-gated
`embassy_futures::yield_now().await` every Nth iteration, or a
chart-level config to bound chain length. Not done by default because
it would serialize the common case for no benefit.
---
## 8. Correctness invariants surfaced during the audit
Three contracts that aren't bugs, but warrant explicit documentation
because they are easy to miss when reading the code:
**Sticky overflow flag in `EmitProxy`** (`hsmc/src/lib.rs`,
`EmitProxy::push`). When a self-emit fails because the internal `Deque`
is full, an `__overflow` flag is set on the chart. *Every* subsequent
`actx.emit(...)` in the same dispatch chain returns `Err(QueueFull)`,
even after the queue has drained. The flag is cleared at the top of
each `dispatch()` call by `take_overflow()`. This is intentional — it
prevents a chart from observing a partially-applied sequence of
self-emissions where some made it into the queue and others did not.
Authors of actions that emit multiple events should treat
`Err(QueueFull)` as terminal for the current chain.
**Biased select in run loop** (tokio: `codegen.rs:1306–1313`; embassy:
`1392+`). The `select!` is `biased;`, so durings are always polled
before the cross-task channel. For most charts this is the right
priority — a sensor driver should pre-empt a UI event. But for a
`during:` that wraps a poll-based driver (one that returns `Ready`
nearly every poll), the channel can be starved indefinitely. Cure:
make the during yield (`embassy_futures::yield_now`) when there's
nothing real to report.
**Cancel-safety contract on durings** (`hsmc/src/lib.rs:53–60`). Every
`.await` in a `during:` future must be a clean resume point, because
the future will be dropped if the state transitions before it
completes. This is the standard async-cancel-safety contract, but it
is easy to violate by holding a mutable borrow across an await — the
borrow is "valid" when resumed but the surrounding state may have
moved on.
---
## 9. Candidate mitigations (menu, NOT a commitment)
Sized for sequencing in a follow-up plan-mode session. "Effort" is gut
feel: L = afternoon, M = couple of days, H = a week or more.
| ID | Mitigation | Targets | Impact | Effort | Cost |
|-----|-----------------------------------------------------------------------------------------------------------------------------------|-----------|-----------------------------------------|--------|---------------------------------------|
| H0 | Replace `lca()` with an O(H) depth-walk via the `__DEPTH` table. | #2 | **Landed.** | - | - |
| H1 | Codegen per-(from, to) precomputed exit/enter chains as `__PATH_DATA` + `__PATH_RANGE`. | #2 | **Landed.** Up/self/lateral folded into one indexed lookup; `lca()` and `is_up_transition()` removed. | - | ~`6·N²` B index + ~`N²·H` B path data |
| T1 | Inlined `is_empty()` early-out on `pop_expired` / `min_remaining` / `decrement` / `cancel_state` with `#[cold]` non-empty bodies. | #11 | **Landed.** | - | - |
| T2 | Codegen-static elide of all `TimerTable` calls when `ir.duration_triggers.is_empty()`. | #11 | **Landed.** Most charts are timer-free; `step()` returns `None` directly. | - | - |
| H4 | `__DISPATCH[(state, event)]` const array; one indexed lookup replaces the runtime __PARENT bubble walk + per-hop `__handler_lookup`. | #3 | **Landed.** Each (state, event) statically resolved at codegen. | - | ~24 B per (state, event) pair |
| U1 | Shrink `TimerEntry.remaining_ns: u128 → u64` (~584-year ceiling). Halves entry size; replaces u128 saturating-sub with u64. | #12 | **Landed.** | - | - |
| K1 | `__TERMINATE_DATA` per-state precomputed exit chain (root inclusive); `do_terminate()` indexes a slice instead of walking __PARENT, and uses `Deque::clear()` (O(1)) for the queue drain. | #13 | **Landed.** terminate_event -22.5% instr / -23.4% cycles. | - | ~`(H+1)·N` B path data |
| H3 | Provide a `dispatch_sync()` API that bypasses `.await` when the chart's actions are fully synchronous. | #1, #9 | High for sync-action charts | L-M | API surface growth (user-facing) |
| H5 | Sparse `__PATH_RANGE` / `__DISPATCH` for very large charts (N > 64): emit only reachable pairs with a small perfect-hash. Latency-neutral; cuts O(N²) static memory growth. | cost mitigation | Low at typical N; significant at N > 100 | M | codegen complexity |
| M1 | Eliminate the trailing `step().await` in `dispatch()` when queue empties mid-step. | #1 | Low | L | minor refactor |
| M2 | Lazy field eval in `trace-tracing` arm using `tracing`'s field-closure form to skip evaluation when filtered. | #8 (on) | Low | L | none |
H0/H1/T1/T2/H4/U1/K1 all landed in the most recent round of work.
Callgrind now reports the entire HSM machinery as a single `step`
symbol — every helper inlined. The only remaining optimization avenue
that meaningfully affects per-event latency is H3 (`dispatch_sync()`),
which is excluded as user-facing. Memory-side mitigations (H5) only
matter for N > 64-state charts; embedded users with large charts are
the audience.
---
## 10. Where to look in the source
For anyone reading this audit and wanting to verify or extend it:
- **`hsmc/src/lib.rs`**
- `:94` — the `#![cfg_attr(... no_std)]` boundary
- `:158-712` — `__chart_observe!` macro, all observation arms
- `:737-772` — `JournalSink` ZST/Vec duality
- **`hsmc/src/journal.rs`**
- `:26-32` — `extern crate alloc` and `Vec` import (only here)
- `:65-150+` — `TraceEvent` enum (the journal vocabulary)
- **`hsmc-macros/src/codegen.rs`**
- `compute_transition_paths()` (top of file, after `compute_chart_hash`) — builds `__PATH_DATA` + `__PATH_RANGE` at codegen time
- `__PARENT`, `__DEPTH`, `__DEFAULT_CHILD`, `__PATH_DATA`, `__PATH_RANGE` static-table emission (search "const __PARENT")
- `step()` (search "fn step" / "async fn step") — sync vs. async branches
- `dispatch()` await loop (search "fn dispatch")
- `run_action` (search "fn run_action") — sync vs. async
- `transition` (search "fn transition") — now a thin slice-iter wrapper
- name-lookup helpers (`__state_name`, `__action_name`, etc.) at `:631-667`
- **Test reference shape**: `hsmc/tests/state_chart_const.rs` is the
smallest sync-only chart in the tree; `hsmc/tests/det_lateral.rs` is
the reference async + journal shape used for the asm spot-check.
---
## 11. Reproducing the spot-checks
```sh
# Zero-cost observation verification (expand → no observation residue)
cargo expand --release --test state_chart_const \
> /tmp/expand_no_features.rs --manifest-path hsmc/Cargo.toml
grep -E "TraceEvent::|::log::|::tracing::|::defmt::" /tmp/expand_no_features.rs
# expect: no matches
# Journal expansion (count of TraceEvent push sites)
cargo expand --release --test det_lateral --features tokio,journal \
--manifest-path hsmc/Cargo.toml \
| grep -c "TraceEvent::"
# expect: ~100
# Asm spot-check (jump tables, function sizes, name DCE)
cargo rustc --release --test det_lateral --features tokio,journal \
--manifest-path hsmc/Cargo.toml -- --emit=asm
ASM=$(find target -name 'det_lateral*.s' | head -1)
grep -E "LJTI|jmpq?[[:space:]]+\*" "$ASM" # jump tables
grep -E "state_name|action_name" "$ASM" # expect no matches
```
---
## 12. Summary
`hsmc`'s hot path is mostly already at the algorithmic floor for
hierarchical state machines. The intrinsic per-event cost (parent walk,
handler lookup, action dispatch, queue ops) is small and well-monomorphized.
Observation cost is verified zero when features are off, and stays
proportional to the user's chosen sink mix when on.
**Items #2, #3, #11, #12, and #13 in §1 have all landed** across six
optimization passes (H0 → H1 → T1 → H4 → T2 → K1+U1). Cumulative impact
on the deepest dispatch (h8_cross_tree) is **-82% instructions / -72%
cycles** vs the original O(H²) LCA scan. The headline wins by bench:
- `terminate_event` 69 instr / 311 cycles (~14 instr of HSM work after
subtracting bench wrapper) — `do_terminate()` is one indexed slice
+ `Deque::clear()`.
- `bubble_top` 99 instr / 453 cycles — 4-hop handler resolution
collapses to one indexed `__DISPATCH` lookup; per-dispatch cost is
bench-shape-independent.
- `bubble_cross_tree` 94 instr / 444 cycles — realistic HSM worst
case (deep bubble + 10-state cross-tree) costs the same as a plain
cross-tree because bubble lookup is O(1) and transition is
O(path-length).
- `h8_cross_tree` 93 instr / 443 cycles — 7-deep cross-tree dispatch.
Callgrind reports the entire HSM machinery as a single `step` symbol —
`run_handlers`, `run_action`, `transition`, `do_terminate`,
`exit_state`, `enter_state_no_descent`, `EmitProxy::push`, all four
`TimerTable` methods, the dispatch-table lookup, and the
terminate-path lookup are all inlined.
Cost remaining, in priority order:
1. **Async coloring code-size cost** (item #9) — chart machinery is
~3,000 asm lines for a 5-state chart, dominated by async-fn state
machines. Mitigation H3 (a `dispatch_sync()` fast path) would let
users with all-sync actions skip this entirely. **User-facing API
change** — out of scope for the current optimization pass.
2. **Bench-wrapper noise floor** — at the smallest dispatch's ~38–80
instructions, the gungraun 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.
+261
View File
@@ -0,0 +1,261 @@
# Unsafe Safety Contract
This document inventories every `unsafe` block emitted by the
`statechart!` macro, states the preconditions, names what
establishes them, and lists the layered checks that prove they hold.
It is the answer to "you said no `unsafe` — now there's `unsafe`. How
do I know it's correct?".
## Inventory
All `unsafe` lives in code emitted by `hsmc-macros`. The runtime
`hsmc` crate, the user's chart definition, and the user's action /
during bodies contain none.
The blocks are six index-elision sites in five generated functions,
each pairing a `slice::get_unchecked` (or `Option::unwrap_unchecked`,
in earlier passes — currently none) with a documented bound.
| Site | Index expression | Bound |
| ----------------------------------------------- | ---------------------------------------- | -------------------------------------- |
| `step()` — sync + async — `__DISPATCH` lookup | `cur * N_EVENTS + ev_id` | `< __DISPATCH.len() = N * N_EVENTS` |
| `apply_path()` — sync + async — `__PATH_RANGE` | `from * N_STATES + target` | `< __PATH_RANGE.len() = N * N` |
| `apply_path()` — sync + async — `__PATH_DATA[s..m]` | `s..m` from `__PATH_RANGE[idx]` | `s ≤ m ≤ __PATH_DATA.len()` |
| `apply_path()` — sync + async — `__PATH_DATA[m..e]` | `m..e` from `__PATH_RANGE[idx]` | `m ≤ e ≤ __PATH_DATA.len()` |
| `follow_defaults()` — sync + async — `__DEFAULT_CHILD` | `cur` | `< __DEFAULT_CHILD.len() = N_STATES` |
| `do_terminate()` — sync + async — `__TERMINATE_RANGE` + `__TERMINATE_DATA[s..e]` | `cur`, `s..e` | `cur < N_STATES`; `s ≤ e ≤ __TERMINATE_DATA.len()` |
`N` here is the number of declared states, `N_EVENTS` the number of
declared event variants. Both are codegen constants per chart.
## Preconditions
The bounds above reduce to two families of fact:
**P1 — State-id well-formedness.** Every `u16` value that ends up in
`self.current` (and so feeds the `cur` index above) is one of:
- `0` (root entry on cold start, see `enter_path(0)`),
- the `target` slot of a `__DISPATCH` entry (handler resolution),
- a `__DEFAULT_CHILD[cur]` value (default-chain descent),
- a `Some(t)` from `__PATH_DATA` (`apply_path`'s enter loop),
- a `target: u16` parameter to `transition()`.
All five sources are produced by codegen from `IR::states`, whose
`StateIr.id` field is `0..N_STATES` by construction (`ir::build_ir`
assigns ids by enumeration; `ir::resolve_transitions` only resolves
identifiers to existing entries; `validate::validate` rejects
duplicates and unresolvable names). User code cannot write
`self.current` — the field is private, the chart context is the only
field handed to action bodies, and the action context's
`__terminated: &bool` reference is read-only.
**P2 — Range-table well-formedness.** For every `(from, to)` pair the
codegen-emitted `__PATH_RANGE[from * N + to] = (s, m, e)` triple
satisfies `0 ≤ s ≤ m ≤ e ≤ __PATH_DATA.len()`. The same holds for
`__TERMINATE_RANGE[i] = (s, e)` against `__TERMINATE_DATA`. The
triple's interpretation is: exit chain is `__PATH_DATA[s..m]`, enter
chain is `__PATH_DATA[m..e]`. Both slices are required to fit inside
`__PATH_DATA`.
Codegen establishes P2 in `compute_transition_paths` (codegen.rs
~133): every emitted offset is a `usize` cursor into a `Vec<u16>`
that is grown only by `push`, so the cursor never exceeds the
length, and the triple is recorded *after* both halves of the
chain are pushed.
## Verification — five overlapping checks
Each check covers a different failure mode. The unsafe surface is
secure when *all five* pass.
### 1. Compile-time const-eval (build error)
Every generated chart contains a `const _: () = { ... }` block
(codegen.rs ~2465) that re-states P2 as `assert!`s evaluated at type
check time. A codegen change that emits a malformed `(s, m, e)`
trips a `rustc E0080: evaluation panicked:` diagnostic before any
runtime code is produced.
Verified: manually injecting `(s=0, m=99, e=99)` into a 4-element
`__PATH_DATA` produces
error[E0080]: evaluation panicked: __PATH_RANGE: e past __PATH_DATA end
so the assert is live, not optimized away.
### 2. Runtime `debug_assert!` companions
Every `unsafe { ... get_unchecked ... }` is paired with a
`debug_assert!` of the same predicate (codegen.rs ~2693, ~2978–2987,
~3110, ~3211). `debug_assert!` is a `#[cfg(debug_assertions)]` no-op
in `--release`, so production code pays nothing. In dev/test it
panics with a clear message before the unsafe access runs.
Coverage: every dispatch through any of the 68 hsmc behavior tests
exercises the relevant assertions. A regression in P1 (e.g., a
codegen change that lets a stale id leak into `self.current`) panics
the test, naming the violated bound.
### 3. Miri (UB detection)
`cargo +nightly miri test -p hsmc` runs the test suite under Miri,
the Rust UB interpreter. Miri detects out-of-bounds reads/writes,
use-after-free, and other unsafe-related UB.
Status: **20 unit + 46 behavior tests pass** under Miri with the
current `unsafe` set.
### 4. Mutation testing (test-coverage proof)
`cargo mutants` flips operators / removes branches in the
`hsmc-macros` codegen and `hsmc/src/lib.rs` runtime, rebuilding and
testing each mutant. Run with `--features hsmc/tokio,hsmc/journal
--test-package hsmc` so the integration tests in `hsmc/tests/` (which
are gated on those features) actually compile and run; without that,
cargo-mutants restricts to the per-package test set and the
asymmetric-depth lateral tests don't fire.
Status (with the correct invocation):
- `hsmc-macros/src/codegen.rs` safety-relevant fns
(`compute_transition_paths`, `compute_terminate_paths`,
`compute_event_dispatch`): **51 of 53 covered** (14 caught + 37
unviable + 2 missed). The 37 unviable are mutations that don't
compile in their context — the type system rejects them, which is
a stronger form of "caught". The 2 missed are *equivalent
mutants* analyzed below.
- `hsmc/src/lib.rs` (runtime): **48 of 48 covered** (45 caught + 3
unviable + 0 missed) — full coverage.
Combined: **99 of 101 mutants accounted for (98%)**. Run with
`just safety`.
#### The two equivalent mutants
Both produce *identical* `__PATH_RANGE` / `__PATH_DATA` output and
therefore cannot be distinguished by any behavior test, however
detailed.
**Equivalent mutant 1.** `codegen.rs:140`:
`while da > db { ... }` → `while da >= db { ... }`.
The mutated loop walks `a` one extra step when `da == db`. The next
loop (`while db > da`) then walks `b` up by one to compensate, and
the third loop (`while a != b`) drives both legs to the same LCA.
`parent(root) = None` and the `?` operator short-circuit so going
"past root" is symmetric on both sides. Result: same LCA, same
exit/enter paths. Verified by hand on lateral, up-transition, and
asymmetric-depth (4↔2) cases.
**Equivalent mutant 2.** `codegen.rs:169`:
`from != to && is_strict_ancestor(to, from)` →
`from == to && is_strict_ancestor(to, from)`.
`is_strict_ancestor(to, from)` is always false when `from == to`
(a state is not a strict ancestor of itself), so the mutated
condition is always false and the up-transition branch becomes
unreachable. Up-transitions fall through to the lateral branch,
which computes `lca(from, to) = to` (since `to` is an ancestor of
`from`); the lateral exit loop then walks source up to (exclusive
of) `to`, and the enter loop breaks immediately on
`Some(to) == Some(to)` returning an empty enter path. Result: same
exit path, same empty enter path — same `(s, m, e)` triple.
### 5. Creusot-verified path-table validator + per-chart const-eval
`hsmc-verification/src/paths.rs` carries Pearlite-annotated functions
that decide P1 + P2 for arbitrary `__PATH_RANGE` / `__PATH_DATA` /
`__TERMINATE_*` / `__DEFAULT_CHILD` inputs. Each function's
`#[ensures(... forall ...)]` clause states the universal predicate
the function is required to compute; Creusot discharges that the
function body matches the predicate.
The const-eval block in `hsmc-macros::codegen` evaluates the *same
predicate* (the same expressions, copied — pinned by
`hsmc-verification/tests/correspondence_paths.rs`) on every user
chart's actual emitted tables at build time. Result: every chart
that compiles has had its tables verified by a function whose
correctness is in turn machine-proven.
**This is operationally equivalent to a universal proof for end
users.** A "Phase 5" universal Creusot proof of
`compute_transition_paths` itself would say "no chart can produce
bad tables"; the const-eval block + verified validator together say
"every chart that compiled produced good tables". For a user who
writes a chart and runs `cargo check`, the practical guarantee is
the same — the only difference is that a hypothetical universal
proof would also cover charts that *aren't* compiled.
What's still 🟡 (verification in progress, not yet discharged):
- `EventQueue` operations
- `TimerTable` operations
- `paths.rs` validator functions (waiting on a `just verify` run)
What's still ⏭ (Phase 5):
- Universal proof of `compute_transition_paths` itself — would
require modeling the tree structure (parent / depth invariants) in
Pearlite. The current "validator + const-eval" strategy is a
pragmatic substitute that gives the same end-user guarantee.
Track + run:
```bash
just verify # full Creusot run; first run installs SMT solvers
just safety # const-eval + debug_assert + miri + mutants in one shot
```
## Failure modes the checks together rule out
| Failure | Caught by |
| ------------------------------------------------------ | ---------------------------------- |
| Codegen emits triple with `s > m`, `m > e`, `e > len` | (1) const-eval at build time |
| Codegen emits state id ≥ N_STATES in path data | (1) const-eval at build time |
| Runtime mutates `self.current` to garbage | not possible — field is private; no API path writes a garbage value |
| User action sets `terminated` | not possible — `&bool` borrow is read-only |
| Future change introduces an unsafe path bypass | (2) debug_assert in tests, (3) miri, (4) mutants |
| Subtle UB in a non-bounds dimension (aliasing, etc.) | (3) miri |
## What's *not* yet covered
- **Universal proof of `compute_transition_paths`.** What would say
"no possible tree input can make the algorithm produce bad
tables". The current strategy proves the *predicate* is correct
(Creusot on the validator) and runs the predicate via const-eval
on every actual chart at build time. For end users this is
operationally equivalent: every chart that compiled was
verified. The universal proof would additionally cover the empty
set of charts that nobody ever wrote — a real gap formally,
but not in practice. Tracked as Phase 5 in
`verification/INVARIANTS.md`.
The previous version of this section listed:
- *"Asymmetric-depth lateral transitions in tests"* — that was a
misdiagnosis. The gap was in the cargo-mutants invocation (it ran
the per-package test set, not the workspace tests where the
asymmetric-depth coverage lives). Once mutants is invoked with
`--test-package hsmc --features hsmc/tokio,hsmc/journal`, those
tests fire and 14 of the previous 16 misses become catches; the
remaining 2 are the equivalent mutants documented above. Closed.
- *"Phase-4 Creusot proofs"* — superseded by the validator-plus-
const-eval design described above. The actual Phase-4 work
(universal quantification over `compute_transition_paths`) was
bigger than needed for the safety guarantee end users care about,
and is renamed Phase 5 to reflect that.
## Maintenance rules
When adding a new `unsafe` block in codegen:
1. Express the safety predicate as a `debug_assert!` immediately
above the `unsafe { ... }`.
2. If the predicate is over a static table, extend the const-eval
block to cover it — this catches the worst class of bugs at
build time.
3. Re-run `cargo +nightly miri test -p hsmc` and `cargo mutants
-p hsmc-macros -F <function-name>` to check coverage.
4. Update this doc. The inventory above must list every unsafe
block; if the table goes stale the safety story stops being
auditable.
File renamed without changes.
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Stephen Waits
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing permissions
and limitations under the License.
+1181 -390
View File
File diff suppressed because it is too large. Load diff
+41 -10
View File
@@ -26,10 +26,12 @@ pub struct Ir {
/// Duration triggers referenced anywhere, in first-seen order.
/// Each one gets a trigger id used by the timer table.
pub duration_triggers: Vec<DurationTrigger>,
/// True when the chart declared `trace;` at root. Codegen routes
/// trace calls to `::hsmc::__chart_trace!`, which dispatches to a
/// backend (defmt / log / tracing / no-op) based on the user's
/// `hsmc/trace-*` feature selection in their `Cargo.toml`.
/// Set when the chart declared `trace;` at root. Retained for
/// backwards-compatible parsing — the keyword no longer gates
/// observation emission. As of 0.4 every chart unconditionally
/// emits `__chart_observe!` calls, and the actual trace fan-out is
/// chosen by the user's `hsmc/trace-*` cargo feature selection.
#[allow(dead_code)]
pub trace_enabled: bool,
}
@@ -77,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>,
@@ -190,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(),
@@ -449,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(())
@@ -545,5 +557,24 @@ pub fn resolve_transitions(ir: &mut Ir) -> syn::Result<()> {
}
}
}
// Resolve default-transition targets against the same global map.
for i in 0..n {
if let Some((ident, _span)) = ir.states[i].default_target_ident.clone() {
let tname = ident.to_string();
match name_map.get(&tname).copied() {
Some(id) => {
ir.states[i].default_child = Some(id);
}
None => {
return Err(syn::Error::new(
ident.span(),
format!("unknown state `{}` in `default(...)`", ident),
));
}
}
}
}
Ok(())
}
+76 -34
View File
@@ -8,6 +8,7 @@ use crate::ir::{HandlerKindIr, Ir, TriggerIr};
use crate::parse::{StateBody, StatechartInput};
pub fn validate(ir: &Ir) -> syn::Result<()> {
validate_default_graph(ir)?;
// Duplicate state names
let mut seen = HashMap::new();
for s in &ir.states {
@@ -94,16 +95,11 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
}
}
// Children require default; default must be a direct child; no default allowed if no children.
if !s.children.is_empty() && s.default_child.is_none() {
return Err(syn::Error::new(
s.span,
format!(
"state `{}` has children but no `default` declaration",
s.name
),
));
}
// `default` is optional: a composite without a `default(...)` is a
// valid resting state — transitions targeting it land on the composite
// itself, with no descent. Substates of such a composite are reachable
// only via explicit transitions. (`default` must still be a direct
// child when declared; that's checked in `validate_parse_tree`.)
// duplicate transitions per trigger in this state, AND transition
// target (if present) must be declared after every action handler
@@ -184,9 +180,70 @@ pub fn validate(ir: &Ir) -> syn::Result<()> {
Ok(())
}
/// Walk the default-edge graph and reject cycles. Each state has at most one
/// outgoing default edge, so the graph has out-degree ≤ 1 and any cycle is a
/// simple chain that loops. Detection: 3-color DFS via an iterative chain walk.
///
/// Cycles are rejected at compile time because at runtime, a default chain
/// fires as a sequence of immediate transitions on entry — a cycle would loop
/// forever, never returning control to the dispatcher.
fn validate_default_graph(ir: &Ir) -> syn::Result<()> {
let n = ir.states.len();
// 0 = white (unvisited), 1 = gray (on current chain), 2 = black (done).
let mut color = vec![0u8; n];
for start in 0..n {
if color[start] != 0 {
continue;
}
let mut chain: Vec<usize> = Vec::new();
let mut cur = start;
loop {
if color[cur] == 2 {
// Joined a previously-cleared subgraph. Chain so far is fine.
break;
}
if color[cur] == 1 {
// Back-edge into the current chain — cycle.
let cycle_start = chain
.iter()
.position(|&x| x == cur)
.expect("gray node must be on current chain");
let cycle: Vec<String> = chain[cycle_start..]
.iter()
.map(|&id| ir.states[id].name.to_string())
.collect();
let pretty = format!("{} -> {}", cycle.join(" -> "), cycle[0]);
let err_span = ir.states[chain[cycle_start]]
.default_target_ident
.as_ref()
.map(|(_, s)| *s)
.unwrap_or(ir.states[chain[cycle_start]].span);
return Err(syn::Error::new(
err_span,
format!(
"default-transition cycle detected: {} \
(entering any state in this cycle would loop forever \
before reaching user code)",
pretty
),
));
}
color[cur] = 1;
chain.push(cur);
match ir.states[cur].default_child {
Some(next) => cur = next as usize,
None => break,
}
}
for &id in &chain {
color[id] = 2;
}
}
Ok(())
}
/// Additional checks that require the original parse tree (for counting duplicate
/// `default`/`terminate` with their spans, and the "default in childless state" /
/// "default not a direct child" / "terminate in non-root" errors).
/// `default`/`terminate` with their spans, and the "terminate in non-root" error).
pub fn validate_parse_tree(input: &StatechartInput) -> syn::Result<()> {
let root_name = input.name.to_string();
validate_body(&input.body, true, &root_name)
@@ -217,28 +274,13 @@ fn validate_body(body: &StateBody, is_root: bool, state_name: &str) -> syn::Resu
));
}
}
if body.default_child.is_some() && body.children.is_empty() {
return Err(syn::Error::new(
body.default_span.unwrap(),
format!(
"`default` in state `{}` which has no child states",
state_name
),
));
}
if let (Some((dc, dspan)), false) = (&body.default_child, body.children.is_empty()) {
// default must be a direct child
let is_child = body.children.iter().any(|c| c.name == *dc);
if !is_child {
return Err(syn::Error::new(
*dspan,
format!(
"`default({})` in state `{}`: `{}` is not a direct child of `{}`",
dc, state_name, dc, state_name
),
));
}
}
// default(...) is a transition that fires immediately after the
// declaring state's entries finish. It may be declared on any state
// (composite or leaf) and may target any state in the chart. A leaf
// with `default(T)` just transitions to T on every entry. The
// default-graph cycle check in `validate_default_graph` (run from the
// top-level `validate_parse_tree` over the lowered IR) ensures we
// cannot construct an infinite chain at startup.
for c in &body.children {
let cname = c.name.to_string();
validate_body(&c.body, false, &cname)?;
@@ -1,24 +0,0 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Parent);
state Parent {
state ChildA { entry: e; }
state ChildB { entry: e; }
}
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -1,5 +0,0 @@
error: state `Parent` has children but no `default` declaration
--> tests/ui/t9_2_missing_default.rs:13:11
|
13 | state Parent {
| ^^^^^^
@@ -1,25 +0,0 @@
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(Parent);
state Parent {
default(Cousin);
state Child { entry: e; }
}
state Cousin { entry: e; }
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -1,5 +0,0 @@
error: `default(Cousin)` in state `Parent`: `Cousin` is not a direct child of `Parent`
--> tests/ui/t9_4_default_non_child.rs:14:9
|
14 | default(Cousin);
| ^^^^^^^
@@ -1,3 +1,6 @@
// `default(self)` is a 1-cycle: entering A would fire its default
// transition to A, which would fire its default again, ad infinitum.
// Compile-time error.
use hsmc::statechart;
#[derive(Debug, Clone)]
@@ -9,9 +12,9 @@ statechart! {
M {
context: Ctx;
events: Ev;
default(Leaf);
state Leaf {
default(Ghost);
default(A);
state A {
default(A);
entry: e;
}
}
@@ -0,0 +1,5 @@
error: default-transition cycle detected: A -> A (entering any state in this cycle would loop forever before reaching user code)
--> tests/ui/t9_4_default_self_cycle.rs:17:9
|
17 | default(A);
| ^^^^^^^
@@ -1,5 +0,0 @@
error: `default` in state `Leaf` which has no child states
--> tests/ui/t9_5_default_childless.rs:14:9
|
14 | default(Ghost);
| ^^^^^^^
@@ -0,0 +1,30 @@
// A → B → A is a default cycle: entering either state would loop
// forever before reaching user code. Compile-time error.
use hsmc::statechart;
#[derive(Debug, Clone)]
pub enum Ev { _U }
pub struct Ctx;
statechart! {
M {
context: Ctx;
events: Ev;
default(A);
state A {
default(B);
entry: e;
}
state B {
default(A);
entry: e;
}
}
}
impl MActions for MActionContext<'_> {
fn e(&mut self) {}
}
fn main() {}
@@ -0,0 +1,5 @@
error: default-transition cycle detected: A -> B -> A (entering any state in this cycle would loop forever before reaching user code)
--> tests/ui/t9_5_default_two_cycle.rs:16:9
|
16 | default(B);
| ^^^^^^^
@@ -1,3 +1,5 @@
#![allow(unexpected_cfgs)]
use hsmc::statechart;
#[derive(Debug, Clone)]
@@ -1,68 +1,13 @@
warning: unexpected `cfg` condition value: `tokio`
--> tests/ui/t9_8_unknown_target.rs:8:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
... |
17 | | }
| |_^
|
= note: no expected values for `feature`
= note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate
= help: try referring to `statechart` crate for guidance on how handle this unexpected cfg
= help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: `#[warn(unexpected_cfgs)]` on by default
= note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info)
warning: unexpected `cfg` condition value: `embassy`
--> tests/ui/t9_8_unknown_target.rs:8:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
... |
17 | | }
| |_^
|
= note: no expected values for `feature`
= note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate
= help: try referring to `statechart` crate for guidance on how handle this unexpected cfg
= help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info)
warning: unexpected `cfg` condition value: `journal`
--> tests/ui/t9_8_unknown_target.rs:8:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
... |
17 | | }
| |_^
|
= note: no expected values for `feature`
= note: using a cfg inside a macro will use the cfgs from the destination crate and not the ones from the defining crate
= help: try referring to `statechart` crate for guidance on how handle this unexpected cfg
= help: the macro `statechart` may come from an old version of the `hsmc_macros` crate, try updating your dependency with `cargo update -p hsmc_macros`
= note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
= note: this warning originates in the macro `statechart` (in Nightly builds, run with -Z macro-backtrace for more info)
error[E0046]: not all trait items implemented, missing: `NonExistent`
--> tests/ui/t9_8_unknown_target.rs:19:1
--> tests/ui/t9_8_unknown_target.rs:21:1
|
8 | / statechart! {
9 | | M {
10 | | context: Ctx;
11 | | events: Ev;
10 | / statechart! {
11 | | M {
12 | | context: Ctx;
13 | | events: Ev;
... |
17 | | }
19 | | }
| |_- `NonExistent` from trait
18 |
19 | impl MActions for MActionContext<'_> {}
20 |
21 | impl MActions for MActionContext<'_> {}
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `NonExistent` in implementation
@@ -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);
}
+23 -15
View File
@@ -11,31 +11,35 @@ readme = "../README.md"
keywords = ["statechart", "state-machine", "hsm", "embassy", "embedded"]
categories = ["embedded", "no-std", "asynchronous", "rust-patterns"]
autoexamples = false
exclude = ["examples/microwave_tui.rs"]
[features]
default = []
tokio = ["dep:tokio"]
embassy = ["dep:embassy-sync", "dep:embassy-time", "dep:embassy-futures"]
# Auto-tracing backends — pick at most one. When the user opts a chart
# into `trace;`, the macro emits calls to an internal dispatcher that
# routes to whichever backend is enabled here. With NO backend feature
# enabled, `trace;` is a no-op (zero overhead). Enabling more than one
# backend produces a duplicate-macro compile error — that's intentional.
# Auto-tracing sinks — every chart emits `__chart_observe!` calls
# unconditionally; whichever of these sinks you enable is what gets
# textual log lines. Pick zero, one, or any combination — the
# observation pipeline fans out to all enabled sinks and the in-memory
# journal (below). With NO sink feature enabled, observation is fully
# elided at compile time (zero overhead).
#
# Format: `[statechart:<Name>] <verb> key=value …` (logfmt-style,
# greppable, regex-parseable). The `tracing` backend uses native
# structured fields instead of stringly key=value pairs.
trace-defmt = ["dep:defmt"]
trace-log = ["dep:log"]
trace-tracing = ["dep:tracing"]
# Journaling backend. When enabled, the macro emits calls into an
# in-memory journal recording every observable atom: entries, exits,
# action invocations, during start/cancel, timer arm/cancel/fire, emits,
# event delivery, transitions, and termination. Used to make charts
# replay-deterministic and to catch semantic regressions in CI.
# In-memory journal sink. When enabled, every `__chart_observe!` site
# also records a `TraceEvent` into the per-machine `JournalSink`. The
# journal is the authoritative byte-deterministic atom-by-atom record
# of chart execution; trace backends are textual renderings of the
# same vocabulary.
# Requires `alloc` (the journal grows dynamically). Off by default —
# zero overhead and no `alloc` requirement when not enabled.
journal = []
[dependencies]
hsmc-macros = { path = "../hsmc-macros", version = "0.3.0" }
hsmc-macros = { path = "../hsmc-macros", version = "0.6.1" }
heapless = "0.8"
tokio = { version = "1", features = ["sync", "time", "rt", "macros"], optional = true }
embassy-sync = { version = "0.8", optional = true }
@@ -48,9 +52,9 @@ tracing = { version = "0.1", default-features = false, optional = true }
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))'.dev-dependencies]
trybuild = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "test-util"] }
ratatui = "0.28"
crossterm = { version = "0.28", features = ["event-stream"] }
futures = "0.3"
[target.'cfg(target_os = "linux")'.dev-dependencies]
gungraun = "0.18"
[[example]]
name = "microwave"
@@ -64,3 +68,7 @@ required-features = ["tokio"]
name = "embassy_full"
required-features = ["embassy"]
crate-type = ["lib"]
[[bench]]
name = "dispatch"
harness = false
View File
File renamed without changes.
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Stephen Waits
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied. See the License for the specific language governing permissions
and limitations under the License.
+204
View File
@@ -0,0 +1,204 @@
# hsmc dispatch baseline — instruction counts from gungraun.
#
# ─── Harness migration (iai-callgrind 0.16 → gungraun 0.18) ──────────
#
# Switched from iai-callgrind to gungraun (the renamed continuation —
# crates.io shows iai-callgrind frozen at 0.16.1; the gh repo
# iai-callgrind/iai-callgrind redirects to gungraun/gungraun). Same
# valgrind backend, same callgrind output format. Old named baselines
# (pre_lca_fix, before_*) are gone; their deltas live in the historical
# block at the bottom for context.
#
# Re-run: cargo bench --bench dispatch
# Diff: cargo bench --bench dispatch -- --baseline=gungraun_initial
# Snapshot: cargo bench --bench dispatch -- --save-baseline=before_<change>
#
# Numbers below are from a 6.19.14-zen kernel x86_64 box, valgrind
# 3.25.1, gungraun 0.18.2, gungraun-runner 0.18.2, --release.
#
# Saved baselines (all under target/gungraun/...):
# gungraun_initial first gungraun-harness run, post-migration
# after_opt2_unchecked get_unchecked for codegen table indexing
# after_opt4_timer_inline pop_expired_nonempty inline (current numbers)
# before_more_opts alias for after_opt4_timer_inline (sweep base)
dispatch::dispatch::h2_cold_start cold:setup_h2_cold()
Instructions: 24
L1 Hits: 39
RAM Hits: 3
Estimated Cycles: 144
dispatch::dispatch::h2_warm_lateral warm:setup_h2_warm()
Instructions: 53
L1 Hits: 77
RAM Hits: 4
Estimated Cycles: 217
dispatch::dispatch::h4_cross_tree cross:setup_h4_warm()
Instructions: 93
L1 Hits: 130
RAM Hits: 10
Estimated Cycles: 480
dispatch::dispatch::h4_up up:setup_h4_warm()
Instructions: 88
L1 Hits: 125
RAM Hits: 8
Estimated Cycles: 405
dispatch::dispatch::h4_self self_:setup_h4_warm()
Instructions: 93
L1 Hits: 130
RAM Hits: 10
Estimated Cycles: 480
dispatch::dispatch::h8_cross_tree cross:setup_h8_warm()
Instructions: 53
L1 Hits: 76
RAM Hits: 5
Estimated Cycles: 251
dispatch::dispatch::bubble_leaf leaf:setup_bubble_warm()
Instructions: 94
L1 Hits: 132
RAM Hits: 11
Estimated Cycles: 517
dispatch::dispatch::bubble_mid mid:setup_bubble_warm()
Instructions: 94
L1 Hits: 133
RAM Hits: 10
Estimated Cycles: 483
dispatch::dispatch::bubble_top top:setup_bubble_warm()
Instructions: 93
L1 Hits: 132
RAM Hits: 10
Estimated Cycles: 482
dispatch::dispatch::timer_fire_dispatch fire:setup_timer_fire_armed()
Instructions: 175
L1 Hits: 237
RAM Hits: 7
Estimated Cycles: 482
dispatch::dispatch::terminate_event quit:setup_term_warm()
Instructions: 53
L1 Hits: 81
RAM Hits: 5
Estimated Cycles: 256
dispatch::dispatch::bubble_cross_tree switch:setup_bubble_cross_warm()
Instructions: 85
L1 Hits: 116
RAM Hits: 11
Estimated Cycles: 501
dispatch::dispatch::chain_self_emit chain:setup_chain_warm()
Instructions: 439
L1 Hits: 613
RAM Hits: 17
Estimated Cycles: 1213
# ─── Migration deltas (gungraun_initial → current) ──────────────────
#
# Two optimization passes landed under gungraun:
#
# Opt #2: get_unchecked for codegen table indexing
# __DISPATCH, __DEFAULT_CHILD, __PATH_RANGE/_DATA, __TERMINATE_*
# all index by codegen-emitted state ids that are bounded by
# construction. Replacing the [idx] / [s..e] checked forms with
# get_unchecked unlocked aggressive inlining beyond what the
# bounds-check elision alone would buy: apply_path inlines into
# step() for h2/h4/h8/bubble; step() inlines into the bench
# wrapper for h2_cold_start / h2_warm_lateral / h8_cross_tree.
# The cold-path enter chain on h2_cold_start collapses to one
# u16 store. Bench impact:
# h2_cold_start 131 → 24 -82%
# h2_warm_lateral 119 → 53 -55%
# h8_cross_tree 97 → 53 -45%
# bubble_cross_tree 120 → 85 -29%
# h4_cross_tree/_self 128 → 93 -27%
# bubble_leaf/mid/top 129 → 94 -27%
# h4_up 114 → 88 -23%
# terminate_event 68 → 53 -22%
# chain_self_emit 500 → 439 -12%
# timer_fire_dispatch 221 → 212 -4%
#
# Opt #4: TimerTable::pop_expired_nonempty inline
# Drop #[cold], add #[inline]. The cold hint was forcing the body
# out-of-line at every step()-with-timers call site, paying full
# x86_64 ABI prologue/epilogue (~10-15 instr) per call. Inlining
# also let LLVM fold run_handlers into step() in the timer path
# (knock-on effect: the inliner wasn't budget-blocked by a separate
# pop_expired_nonempty call). Bench impact:
# timer_fire_dispatch 212 → 175 -17%
# (others unchanged)
#
# Tried but reverted:
#
# Opt #1: current: Option<u16> → u16 with u16::MAX sentinel.
# Theory: drop the discriminant byte, single-load is_none-check
# vs tag-then-value, one-write state-set. In practice net -9 instr
# across all 13 benches (-0.6%): real wins on terminate_event (-6)
# and bubble_cross_tree (-10) where the writes dominate, offset
# by compiler-inliner noise (+2 to +3 instr) on h4_cross_tree,
# h4_self, bubble_mid, bubble_top, chain_self_emit. Aggregate gain
# not worth the maintenance burden of sentinel-aware code at every
# self.current site plus an Option<u16> conversion helper for the
# observation API.
#
# Opt #3: hand-tighten run_action's EmitProxy/ActionContext init.
# Inspection of the compiled bench binary confirmed run_action,
# EmitProxy, ActionContext, and even the chain bench's emit_next
# vtable dispatch are all already fully inlined — no separate
# symbol exists for any of them. Already free; no change made.
#
# ─── Historical context (iai-callgrind era, NOT comparable) ─────────
#
# The codebase went through six dispatch-path optimization passes
# under iai-callgrind 0.16 before the harness migration. Cumulative
# h8_cross_tree result vs. the original O(H²) LCA scan: -82% instr.
# The passes, in order:
#
# 1. LCA contains() → O(H) ancestor-walk
# 2. Per-state precomputed exit/enter path tables
# 3. Skip empty timer scan in step()
# 4. Per-event handler dispatch table (was __PARENT bubble walk)
# 5. Codegen-elide timer machinery for charts with no `after()` triggers
# 6. Precomputed __TERMINATE_DATA + queue.clear() + u128→u64 nanoseconds
#
# Pre-migration absolute numbers (iai-callgrind 0.16, for reference
# only; not directly comparable to gungraun numbers because the
# gungraun bench wrapper instruments slightly more):
#
# h2_cold_start 38 instr 201 cycles
# h2_warm_lateral 84 instr 397 cycles
# h4_cross_tree 101 instr 456 cycles
# h4_up 98 instr 384 cycles
# h4_self 101 instr 456 cycles
# h8_cross_tree 93 instr 443 cycles
# bubble_leaf/mid/top 99 instr 453 cycles
# timer_fire_dispatch 191 instr 706 cycles
# terminate_event 69 instr 311 cycles
# bubble_cross_tree 94 instr 444 cycles
# chain_self_emit 419 instr 1301 cycles
#
# Bench coverage:
#
# `timer_fire_dispatch` — only bench that exercises pop_expired's
# non-empty branch and `dispatch_trigger`.
# `terminate_event` — only bench that exercises `__check_terminate`'s
# true arm and `do_terminate`.
# `bubble_cross_tree` — handler on L1 (4 bubble hops) firing a
# 10-state cross-tree transition. Realistic HSM worst case.
#
# ─── Measurement floor ───────────────────────────────────────────────
#
# After Opt #2, the gungraun bench-harness wrapper is the dominant
# fixed cost on simple benches (~28-49 instr per bench). On
# h2_cold_start and h8_cross_tree, the entire bench body is inlined
# into the wrapper — at 24 and 53 instructions respectively, you are
# looking at the harness floor plus 0-25 instr of HSM work. Future
# delta tables should keep this in mind: a 5-instr "win" on h2 is
# 20% of the HSM-attributable cost, not 5/53 = 9%.
+633
View File
@@ -0,0 +1,633 @@
//! Deterministic instruction-count benches for the dispatch hot path.
//!
//! Run with `cargo bench --bench dispatch` (Linux only — uses
//! gungraun + 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 \
(gungraun + valgrind, sync action impls)."
);
}
#[cfg(all(target_os = "linux", not(any(feature = "tokio", feature = "embassy"))))]
use gungraun::{library_benchmark, library_benchmark_group, main};
#[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 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 — gungraun 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);
-761
View File
@@ -1,761 +0,0 @@
//! Interactive microwave oven TUI driven by `hsmc`.
//!
//! Run it:
//!
//! ```text
//! cargo run --example microwave_tui --features tokio
//! ```
//!
//! Keys:
//! S — Start cook O — Open door
//! X — Stop / cancel C — Close door
//! K — Simulate power failure (terminates the machine)
//! Q — Quit
//!
//! This demonstrates the same statechart as `microwave.rs` but hooked up to
//! live keyboard input and a real-time ratatui UI. The state machine runs
//! on `oven.run()` in one task; a second task reads keys and forwards them
//! via a cloned `sender()`; a third task redraws the screen 30×/sec using
//! state published by entry/exit actions.
use std::{
collections::VecDeque,
io,
sync::{Arc, Mutex},
time::Instant,
};
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::StreamExt;
use hsmc::{statechart, Duration};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, Gauge, Paragraph, Wrap},
Terminal,
};
// ---------- Shared UI state ----------
//
// The actions write into this through the user context. The render task
// reads it. `Arc<Mutex<_>>` keeps it cheap and thread-safe.
#[derive(Default)]
pub struct UiState {
state_path: Vec<&'static str>,
display: String,
magnetron: bool,
heater: bool,
door_open: bool,
blink: bool,
log: VecDeque<String>,
/// Instant at which the cook should complete. Advances by the pause
/// duration on resume so the progress bar freezes while paused.
cook_deadline: Option<Instant>,
cook_total: Duration,
/// Set when we enter Paused; cleared on resume. Freezes the gauge.
paused_at: Option<Instant>,
terminated: bool,
}
impl UiState {
async fn log(&mut self, msg: impl Into<String>) {
self.log.push_back(msg.into());
while self.log.len() > 50 {
self.log.pop_front();
}
}
async fn set_path(&mut self, path: &[&'static str]) {
self.state_path = path.to_vec();
}
}
// ---------- User context ----------
pub struct OvenContext {
pub ui: Arc<Mutex<UiState>>,
}
// ---------- Events ----------
#[derive(Debug, Clone)]
pub enum OvenEvent {
StartPressed,
StopPressed,
DoorOpened,
DoorClosed,
Reset,
/// Sent by the external watchdog when the adjusted cook deadline is met.
/// Using an event instead of a static `Duration` transition lets us pause
/// and resume the cook clock — something hsmc timers can't do on their own.
CookDone,
PowerFailure,
}
// ---------- Statechart ----------
//
// Same shape as microwave.rs (every feature exercised) with a longer cook
// time so the user has time to interact. The cook timer lives on `Running`
// so it survives Heating⇄Paused transitions.
const COOK_MS: u64 = 8_000;
statechart! {
Oven {
context: OvenContext;
events: OvenEvent;
entry: boot_self_test;
entry: log_boot_done;
exit: save_last_state;
exit: cut_all_power;
default(Idle);
terminate(PowerFailure);
// Root-level door tracking. Any state whose local handler shadows these
// must repeat `mark_door_*` alongside its own handlers.
on(DoorOpened) => mark_door_open;
on(DoorClosed) => mark_door_closed;
// Idle is split so Start can only fire when the door is closed. This is
// the HSM equivalent of a guard — a structural check rather than a
// boolean predicate.
state Idle {
// `sync_door` runs after the default descent completes handling, then
// emits DoorOpened if the door is physically open. That re-aligns the
// machine with reality when we land in Idle from Running (e.g. Stop
// pressed while door is open). The emitted event is processed after
// the entry cascade, so DoorClosed → DoorOpen happens cleanly.
entry: enter_idle, sync_door;
default(DoorClosed);
state DoorClosed {
entry: show_ready;
// Local handlers, so we must repeat mark_door_open here.
on(DoorOpened) => mark_door_open;
on(DoorOpened) => DoorOpen;
on(StartPressed) => record_start;
on(StartPressed) => prime_cook;
on(StartPressed) => Running;
}
state DoorOpen {
entry: show_close_door;
on(DoorClosed) => mark_door_closed;
on(DoorClosed) => DoorClosed;
// Pressing Start while door is open: beep + log, no transition.
on(StartPressed) => warn_door_open;
}
}
state Running {
entry: enter_running, start_cook_clock;
exit: stop_cook_clock;
default(Cooking);
on(after Duration::from_millis(250)) => heartbeat;
on(StopPressed) => Idle;
on(CookDone) => Done;
state Cooking {
entry: enter_cooking, show_cooking;
default(Heating);
state Heating {
// Magnetron + heater live on Heating so they turn off the
// instant we leave for Paused.
entry: enter_heating, start_magnetron, heater_on;
exit: heater_off, stop_magnetron;
on(DoorOpened) => mark_door_open;
on(DoorOpened) => pause_cook_clock;
on(DoorOpened) => Suspended;
}
// Suspended = "cook is paused". Two substates express the classic
// microwave rule: closing the door alone does NOT resume cooking
// — the user must press Start. Pressing Stop (handled by Running)
// cancels entirely back to Idle.
state Suspended {
entry: enter_suspended, start_pause_blink;
exit: stop_pause_blink;
default(DoorAjar);
state DoorAjar {
entry: show_paused;
on(DoorClosed) => mark_door_closed;
on(DoorClosed) => AwaitingResume;
}
state AwaitingResume {
entry: show_press_start;
on(DoorOpened) => mark_door_open;
on(DoorOpened) => DoorAjar;
// Actions fire before the transition, so the clock is
// resumed and the log line written before we re-enter
// Heating (which turns the magnetron back on).
on(StartPressed) => log_resume;
on(StartPressed) => resume_cook_clock;
on(StartPressed) => Heating;
}
}
}
}
state Done {
entry: enter_done, beep, show_done;
exit: exit_done;
on(after Duration::from_millis(2000)) => schedule_auto_reset;
on(StartPressed) => Done;
on(Reset) => Idle;
}
}
}
// ---------- Actions ----------
//
// Every method is either publishing to the shared UI state or emitting an
// event back into the machine.
impl OvenActions for OvenActionContext<'_> {
// Root
async fn boot_self_test(&mut self) { self.ui.lock().unwrap().log("BOOT self-test ok"); }
async fn log_boot_done(&mut self) { self.ui.lock().unwrap().log("BOOT ready"); }
async fn save_last_state(&mut self){ self.ui.lock().unwrap().log("SHUTDOWN persisted state"); }
async fn cut_all_power(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.magnetron = false;
ui.heater = false;
ui.display = "-- OFF --".into();
ui.terminated = true;
ui.log("SHUTDOWN power cut");
}
// Door tracking (root-level)
async fn mark_door_open(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.door_open = true;
}
async fn mark_door_closed(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.door_open = false;
}
// Idle
async fn enter_idle(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.set_path(&["Oven", "Idle"]);
}
async fn sync_door(&mut self) {
let door_open = self.ui.lock().unwrap().door_open;
if door_open {
self.ui.lock().unwrap().log("IDLE door still open — syncing");
let _ = self.emit(OvenEvent::DoorOpened);
}
}
async fn show_ready(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.set_path(&["Oven", "Idle", "DoorClosed"]);
ui.display = "READY".into();
ui.log("IDLE ready");
}
async fn show_close_door(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.set_path(&["Oven", "Idle", "DoorOpen"]);
ui.display = "CLOSE DOOR".into();
ui.log("IDLE door is open");
}
async fn warn_door_open(&mut self) {
self.ui.lock().unwrap().log("WARN can't start — door open");
}
async fn record_start(&mut self) { self.ui.lock().unwrap().log("BTN start pressed"); }
async fn prime_cook(&mut self) { self.ui.lock().unwrap().log("BTN primed cook"); }
async fn beep(&mut self) { self.ui.lock().unwrap().log("*BEEP*"); }
// Running
async fn enter_running(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.set_path(&["Oven", "Running"]);
}
async fn start_magnetron(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.magnetron = true;
ui.log("MAG on");
}
async fn stop_magnetron(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.magnetron = false;
ui.log("MAG off");
}
async fn start_cook_clock(&mut self) {
let mut ui = self.ui.lock().unwrap();
let total = Duration::from_millis(COOK_MS);
ui.cook_total = total;
ui.cook_deadline = Some(Instant::now() + total);
ui.paused_at = None;
}
async fn stop_cook_clock(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.cook_deadline = None;
ui.paused_at = None;
}
async fn pause_cook_clock(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.paused_at = Some(Instant::now());
ui.log("CLOCK paused");
}
async fn resume_cook_clock(&mut self) {
let mut ui = self.ui.lock().unwrap();
if let (Some(paused_at), Some(deadline)) = (ui.paused_at, ui.cook_deadline) {
let skipped = paused_at.elapsed();
ui.cook_deadline = Some(deadline + skipped);
ui.log(format!("CLOCK resume (+{}ms)", skipped.as_millis()));
}
ui.paused_at = None;
}
async fn heartbeat(&mut self) { /* UI gauge animates on its own */ }
// Cooking / Heating
async fn enter_cooking(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.set_path(&["Oven", "Running", "Cooking"]);
}
async fn show_cooking(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.display = "COOKING".into();
ui.blink = false;
}
async fn enter_heating(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.set_path(&["Oven", "Running", "Cooking", "Heating"]);
ui.blink = false;
ui.display = "COOKING".into();
}
async fn heater_on(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.heater = true;
ui.log("HEAT on");
}
async fn heater_off(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.heater = false;
ui.log("HEAT off");
}
// Suspended (paused)
async fn enter_suspended(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.set_path(&["Oven", "Running", "Cooking", "Suspended"]);
}
async fn show_paused(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.set_path(&["Oven", "Running", "Cooking", "Suspended", "DoorAjar"]);
ui.display = "PAUSED".into();
}
async fn show_press_start(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.set_path(&["Oven", "Running", "Cooking", "Suspended", "AwaitingResume"]);
ui.display = "PRESS START".into();
ui.log("PAUSE door closed — press Start to resume");
}
async fn start_pause_blink(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.blink = true;
ui.log("PAUSE blink on");
}
async fn stop_pause_blink(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.blink = false;
ui.log("PAUSE blink off");
}
async fn log_resume(&mut self) { self.ui.lock().unwrap().log("PAUSE resuming"); }
// Done
async fn enter_done(&mut self) {
let mut ui = self.ui.lock().unwrap();
ui.set_path(&["Oven", "Done"]);
}
async fn show_done(&mut self) {
self.ui.lock().unwrap().display = "DONE!".into();
}
async fn exit_done(&mut self) { self.ui.lock().unwrap().log("DONE exit"); }
async fn schedule_auto_reset(&mut self) {
self.ui.lock().unwrap().log("DONE auto-reset queued");
self.emit(OvenEvent::Reset).expect("queue has room");
}
}
// ---------- Rendering ----------
fn draw(frame: &mut ratatui::Frame<'_>, ui: &UiState) {
let size = frame.area();
let root = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(16),
Constraint::Length(10),
Constraint::Length(3),
])
.split(size);
// --- Title bar ---
let title = if ui.terminated {
Line::from(vec![
Span::styled(" MICROWAVE ", Style::new().bg(Color::Red).fg(Color::White).bold()),
Span::raw(" "),
Span::styled("TERMINATED", Style::new().fg(Color::Red).bold()),
])
} else {
Line::from(vec![
Span::styled(" MICROWAVE ", Style::new().bg(Color::Yellow).fg(Color::Black).bold()),
Span::raw(" hsmc statechart demo · press Q to quit"),
])
};
frame.render_widget(Paragraph::new(title), root[0]);
// --- Main area: oven on the left, status on the right ---
let main = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
.split(root[1]);
draw_oven(frame, main[0], ui);
draw_status(frame, main[1], ui);
// --- Log ---
let log_lines: Vec<Line> = ui.log.iter().rev().take(8).rev()
.map(|s| Line::from(s.as_str()))
.collect();
let log = Paragraph::new(log_lines)
.block(Block::default().title(" Event log ").borders(Borders::ALL))
.wrap(Wrap { trim: false });
frame.render_widget(log, root[2]);
// --- Keys ---
let keys = Line::from(vec![
key_hint("S", "Start"),
Span::raw(" "),
key_hint("X", "Stop"),
Span::raw(" "),
key_hint("O", "Open door"),
Span::raw(" "),
key_hint("C", "Close door"),
Span::raw(" "),
key_hint("K", "Kill (power fail)"),
Span::raw(" "),
key_hint("Q", "Quit"),
]);
frame.render_widget(
Paragraph::new(keys).block(Block::default().borders(Borders::ALL).title(" Controls ")),
root[3],
);
}
fn key_hint(k: &str, label: &str) -> Span<'static> {
Span::from(format!("[{}] {}", k, label)).fg(Color::Cyan)
}
fn draw_oven(frame: &mut ratatui::Frame<'_>, area: Rect, ui: &UiState) {
let block = Block::default().borders(Borders::ALL).title(" Oven ");
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(5),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(2),
Constraint::Min(0),
])
.split(inner);
// Door indicator line
let door_line = if ui.door_open {
Span::styled(" ┌───── DOOR OPEN ─────┐", Style::new().fg(Color::Yellow).bold())
} else {
Span::styled(" ┌───── door closed ─────┐", Style::new().fg(Color::DarkGray))
};
frame.render_widget(Paragraph::new(Line::from(door_line)), rows[0]);
// Big display
let display_style = if ui.terminated {
Style::new().fg(Color::Red).bold()
} else if ui.blink && (Instant::now().elapsed().as_millis() / 300) % 2 == 0 {
Style::new().fg(Color::Yellow).bold()
} else if ui.display == "DONE!" {
Style::new().fg(Color::Green).bold()
} else if ui.display == "COOKING" {
Style::new().fg(Color::LightRed).bold()
} else {
Style::new().fg(Color::Cyan).bold()
};
let big = Paragraph::new(vec![
Line::from(""),
Line::from(Span::styled(
format!(" ┃ {:^14} ┃", ui.display),
display_style,
)),
Line::from(Span::styled(" ┃ ┃", Style::new().fg(Color::DarkGray))),
Line::from(Span::styled(" ┗━━━━━━━━━━━━━━━━━━┛", Style::new().fg(Color::DarkGray))),
]);
frame.render_widget(big, rows[1]);
// Progress gauge for cook — freezes while paused.
let (ratio, label, color) = if let Some(deadline) = ui.cook_deadline {
let now = ui.paused_at.unwrap_or_else(Instant::now);
let total = ui.cook_total;
let remaining = deadline.saturating_duration_since(now);
let elapsed = total.saturating_sub(remaining);
let r = (elapsed.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0);
let label = format!("{:.1}s / {:.1}s{}",
elapsed.as_secs_f64(),
total.as_secs_f64(),
if ui.paused_at.is_some() { " (paused)" } else { "" },
);
let color = if ui.paused_at.is_some() { Color::Yellow } else { Color::LightRed };
(r, label, color)
} else {
(0.0, "—".to_string(), Color::DarkGray)
};
let gauge = Gauge::default()
.block(Block::default().borders(Borders::NONE))
.gauge_style(Style::new().fg(color))
.ratio(ratio)
.label(label);
frame.render_widget(gauge, rows[3]);
}
fn draw_status(frame: &mut ratatui::Frame<'_>, area: Rect, ui: &UiState) {
let block = Block::default().borders(Borders::ALL).title(" Status ");
let inner = block.inner(area);
frame.render_widget(block, area);
let path = if ui.state_path.is_empty() {
"—".to_string()
} else {
ui.state_path.join(" › ")
};
let bullet = |on: bool| {
if on { Span::styled("●", Style::new().fg(Color::Green).bold()) }
else { Span::styled("○", Style::new().fg(Color::DarkGray)) }
};
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" State path: ", Style::new().fg(Color::DarkGray)),
Span::styled(path, Style::new().fg(Color::White).bold()),
]),
Line::from(""),
Line::from(vec![
Span::raw(" Magnetron "),
bullet(ui.magnetron),
Span::raw(if ui.magnetron { " ON " } else { " off" }),
]),
Line::from(vec![
Span::raw(" Heater "),
bullet(ui.heater),
Span::raw(if ui.heater { " ON " } else { " off" }),
]),
Line::from(vec![
Span::raw(" Door "),
if ui.door_open {
Span::styled("OPEN ", Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD))
} else {
Span::styled("CLOSED", Style::new().fg(Color::DarkGray))
},
]),
Line::from(""),
Line::from(vec![
Span::styled(" hsmc tip: ", Style::new().fg(Color::DarkGray)),
Span::raw("events bubble · timers don't"),
]),
];
frame.render_widget(Paragraph::new(lines), inner);
}
// ---------- Main ----------
#[tokio::main(flavor = "current_thread")]
async fn main() -> io::Result<()> {
// Shared UI state.
let ui = Arc::new(Mutex::new(UiState::default()));
// Build the machine.
let ctx = OvenContext { ui: Arc::clone(&ui) };
let mut oven = Oven::with_queue_capacity::<16>(ctx);
let sender = oven.sender();
let fault_sender = oven.sender();
let cook_sender = oven.sender();
// Raw-mode terminal.
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Render loop — 30 fps.
let ui_for_render = Arc::clone(&ui);
let (render_stop_tx, mut render_stop_rx) = tokio::sync::oneshot::channel::<()>();
let render_task = tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(33));
loop {
tokio::select! {
_ = &mut render_stop_rx => break,
_ = interval.tick() => {
let snapshot = {
let g = ui_for_render.lock().unwrap();
UiSnapshot::from(&*g)
};
let _ = terminal.draw(|f| draw(f, &snapshot.into()));
}
}
}
// Restore terminal.
let _ = disable_raw_mode();
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture);
terminal
});
// Cook-clock watchdog. Polls shared UI state; when we're not paused and
// the deadline has passed, fires a single CookDone event and then sleeps
// until the deadline is re-armed. The machine discards CookDone if the
// current state doesn't handle it (e.g. after StopPressed).
let ui_for_watchdog = Arc::clone(&ui);
let watchdog_task = tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_millis(50)).await;
let should_fire = {
let u = ui_for_watchdog.lock().unwrap();
if u.terminated { return; }
match (u.cook_deadline, u.paused_at) {
(Some(deadline), None) => Instant::now() >= deadline,
_ => false,
}
};
if should_fire {
let _ = cook_sender.send(OvenEvent::CookDone);
// Give the machine a beat to process and clear cook_deadline
// (via Running's exit → stop_cook_clock) before we check again.
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
});
// Input loop.
let ui_for_input = Arc::clone(&ui);
let input_task = tokio::spawn(async move {
let mut events = EventStream::new();
while let Some(Ok(ev)) = events.next().await {
if let Event::Key(k) = ev {
if k.kind != KeyEventKind::Press { continue; }
let code = k.code;
let msg = match code {
KeyCode::Char('s') | KeyCode::Char('S') => Some(OvenEvent::StartPressed),
KeyCode::Char('x') | KeyCode::Char('X') => Some(OvenEvent::StopPressed),
KeyCode::Char('o') | KeyCode::Char('O') => Some(OvenEvent::DoorOpened),
KeyCode::Char('c') | KeyCode::Char('C') => Some(OvenEvent::DoorClosed),
KeyCode::Char('k') | KeyCode::Char('K') => Some(OvenEvent::PowerFailure),
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
// Quit: cleanly terminate the machine via PowerFailure.
let _ = fault_sender.send(OvenEvent::PowerFailure);
break;
}
_ => None,
};
if let Some(m) = msg {
ui_for_input.lock().unwrap().log(format!("KEY -> {:?}", m));
let _ = sender.send(m);
}
}
}
});
// Drive the state machine on the main task.
let _ = oven.run().await;
// Let the UI show the TERMINATED banner briefly.
tokio::time::sleep(Duration::from_millis(700)).await;
// Stop the render loop.
let _ = render_stop_tx.send(());
input_task.abort();
watchdog_task.abort();
let mut terminal = render_task.await.expect("render task");
// Belt-and-braces teardown in case the render loop didn't get to it.
let _ = disable_raw_mode();
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture);
// Final transcript.
let ui = ui.lock().unwrap();
println!("\nFinal state: {}", ui.state_path.join(" › "));
println!("Events: {}", ui.log.len());
for line in ui.log.iter() {
println!(" {}", line);
}
Ok(())
}
// Snapshot so we don't hold the Mutex across the render.
struct UiSnapshot {
state_path: Vec<&'static str>,
display: String,
magnetron: bool,
heater: bool,
door_open: bool,
blink: bool,
log: VecDeque<String>,
cook_deadline: Option<Instant>,
cook_total: Duration,
paused_at: Option<Instant>,
terminated: bool,
}
impl From<&UiState> for UiSnapshot {
fn from(u: &UiState) -> Self {
Self {
state_path: u.state_path.clone(),
display: u.display.clone(),
magnetron: u.magnetron,
heater: u.heater,
door_open: u.door_open,
blink: u.blink,
log: u.log.clone(),
cook_deadline: u.cook_deadline,
cook_total: u.cook_total,
paused_at: u.paused_at,
terminated: u.terminated,
}
}
}
impl From<UiSnapshot> for UiState {
fn from(s: UiSnapshot) -> Self {
Self {
state_path: s.state_path,
display: s.display,
magnetron: s.magnetron,
heater: s.heater,
door_open: s.door_open,
blink: s.blink,
log: s.log,
cook_deadline: s.cook_deadline,
cook_total: s.cook_total,
paused_at: s.paused_at,
terminated: s.terminated,
}
}
}
+57 -14
View File
@@ -45,6 +45,24 @@ pub enum ActionKind {
Handler,
}
/// Why a transition fired. Carried on `TransitionFired` so observers can
/// see *what triggered* the state change, not just that one happened.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TransitionReason {
/// External or queued event with the given index dispatched into a
/// handler that fired this transition.
Event { event: u16 },
/// Timer on the given (state, timer) slot expired and its handler
/// fired this transition.
Timer { state: u16, timer: u16 },
/// Driven from inside the runtime without an originating event or
/// 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,
}
/// One observable atom of chart execution. The macro emits these at every
/// hook point so the full sequence can be journaled and compared.
///
@@ -54,14 +72,23 @@ pub enum ActionKind {
pub enum TraceEvent {
/// First event in any journal. Identifies the chart definition.
Started { chart_hash: u64 },
/// A state's entry begins. Followed by `ActionInvoked{Entry}` for each
/// of that state's entry actions, then `DuringStarted` for each during
/// declared on the state, then potentially the next state's `Entered`
/// (default-descent).
/// A state's entry began. The marker is emitted before any of the
/// state's entry actions, timers, or durings have run. Followed by
/// `ActionInvoked{Entry}`, `TimerArmed`, `DuringStarted`, then the
/// matching `Entered` end marker.
EnterBegan { state: u16 },
/// A state's entry completed. Emitted after every entry action,
/// timer arm, and during start has run for that state. Pairs with
/// the prior `EnterBegan { state }`.
Entered { state: u16 },
/// A state's exit completed. Preceded by `DuringCancelled` for each
/// during on the state, followed by `ActionInvoked{Exit}` for each
/// exit action.
/// A state's exit began. The marker is emitted before any of the
/// state's during cancellations, timer cancellations, or exit
/// actions have run. Followed by `DuringCancelled`, `TimerCancelled`,
/// `ActionInvoked{Exit}`, then the matching `Exited` end marker.
ExitBegan { state: u16 },
/// A state's exit completed. Emitted after every during cancel,
/// timer cancel, and exit action has run for that state. Pairs with
/// the prior `ExitBegan { state }`.
Exited { state: u16 },
/// An action function was called.
ActionInvoked {
@@ -75,14 +102,30 @@ pub enum TraceEvent {
/// A `during:` activity was cancelled because its owning state is
/// being exited.
DuringCancelled { state: u16, during: u16 },
/// A transition is about to fire. `from` is the innermost active state
/// at the moment the transition was triggered; `to` is the target.
/// Followed by the actual `Exited` / `Entered` / etc. events that
/// implement the transition.
TransitionFired { from: Option<u16>, to: u16 },
/// A transition is about to fire. `from` is the innermost active
/// state at the moment the transition was triggered; `to` is the
/// target; `reason` records what caused the transition (event,
/// timer, or internal). Followed by the actual `ExitBegan`/`Exited`/
/// `EnterBegan`/`Entered` events that implement the transition,
/// then the matching `TransitionComplete` end marker.
TransitionFired {
from: Option<u16>,
to: u16,
reason: TransitionReason,
},
/// A transition completed. Emitted after the transition's exit and
/// entry sequences are fully done. Pairs with the prior
/// `TransitionFired { from, to, .. }`.
TransitionComplete { from: Option<u16>, to: u16 },
/// An event was popped from the internal queue and is about to be
/// dispatched. Emitted before the handler search; followed by either
/// `EventDelivered` (handler found and ran) or `EventDropped`
/// (no handler on the active path), or `TerminateRequested` if the
/// event matches the chart's terminate declaration.
EventReceived { event: u16 },
/// An event reached a state's handler. `handler_state` is the state
/// whose handler ran (which may be an ancestor of the innermost active
/// state if the event bubbled).
/// whose handler ran (which may be an ancestor of the innermost
/// active state if the event bubbled).
EventDelivered { handler_state: u16, event: u16 },
/// An event was dispatched but no state on the active path had a
/// handler. Silently dropped per spec.
+652 -125
View File
@@ -102,140 +102,614 @@ pub use hsmc_macros::statechart;
mod error;
pub use error::HsmcError;
// ── Journal (deterministic execution trace) ─────────────────────────
// ── Unified observation pipeline ────────────────────────────────────
//
// When `feature = "journal"` is enabled, the macro emits one
// [`TraceEvent`] per observable atom: entries, exits, action calls,
// during start/cancel, timer arm/cancel/fire, queued emits, event
// dispatch, transitions, termination. The events are appended to the
// machine's internal `JournalSink` so callers can compare against an
// expected sequence (see `tests/replay.rs`).
// The `statechart!` macro emits ONE `__chart_observe!` call per
// observable atom of execution — entry/exit phase begin/end markers,
// action invocations, during start/cancel, timer arm/cancel/fire,
// emit queue push/fail, event receive/deliver/drop, transition
// begin/complete, terminate-requested, and terminated.
//
// When the feature is OFF, the codegen still emits `__chart_journal!`
// invocations at every hook site, but the macro expands to nothing
// (zero overhead) and `JournalSink` is a ZST so the field on the
// machine takes 0 bytes.
// `__chart_observe!` is the single dispatcher: each arm fans the
// observation out to whichever sinks are enabled at compile time:
//
// - `feature = "journal"` → push a `TraceEvent` into the
// per-machine `JournalSink`.
// - `feature = "trace-defmt"` → emit a `defmt::*` log line.
// - `feature = "trace-log"` → emit a `log::*` log line.
// - `feature = "trace-tracing"` → emit a `tracing::*` event with
// structured fields.
//
// With NO sink feature enabled, every arm body is empty and the
// macro's args are never expanded — zero runtime cost, zero binary
// growth. There is intentionally no catch-all arm: an unrecognized
// invocation produces a compile error, so codegen mismatches are
// caught loudly rather than silently dropped.
//
// Trace and journal share the same vocabulary by construction. The
// trace's textual rendering is machine-parseable (logfmt-style:
// `[statechart:Name] verb key=value …`), so a captured log can be
// diff'd against expected behavior the same way the journal can.
#[cfg(feature = "journal")]
mod journal;
#[cfg(feature = "journal")]
pub use journal::{ActionKind, Journal, TraceEvent};
pub use journal::{ActionKind, Journal, TraceEvent, TransitionReason};
/// Journal call dispatcher invoked by `statechart!`-generated code.
/// Not part of the public API.
/// Internal observation dispatcher invoked by `statechart!`-generated
/// code. Not part of the public API.
///
/// Call form: `__chart_journal!(<sink-expr>, <TraceEvent expr>)`.
/// The first argument is a place expression for the sink (typically
/// `self.__journal`); the second is the full `TraceEvent` value to
/// push.
/// Call form (one arm per `TraceEvent` variant):
/// `__chart_observe!(<Variant>, <sink-expr>, <chart-literal>, <variant args…>)`
///
/// When the `journal` feature is off, the macro discards both arguments
/// and expands to nothing — the codegen can refer to `TraceEvent::*`
/// without the type having to exist.
#[cfg(feature = "journal")]
/// `sink-expr` is the place expression for the journal sink (typically
/// `&mut self.__journal`); `chart-literal` is the chart name as a
/// string literal. Variant args are passed positionally — see each arm
/// for the order. Names (state, action, event, timer, during) are
/// passed as `&'static str` literals at each call site so the trace
/// formatters never do a runtime lookup.
///
/// All sink-feature gates apply *inside* each arm. With every sink
/// feature off, the arm body is empty; the macro's positional args are
/// never expanded; the call site contributes zero code.
#[doc(hidden)]
#[macro_export]
macro_rules! __chart_journal {
($sink:expr, $event:expr) => {
$sink.push($event);
};
}
macro_rules! __chart_observe {
// ── Started ────────────────────────────────────────────────────
(Started, $sink:expr, $chart:literal, $hash:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::Started { chart_hash: $hash });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] started chart_hash={=u64:#x}",
$chart,
$hash
);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] started chart_hash={:#x}", $chart, $hash);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, chart_hash = $hash, "started");
}};
/// No-op fallback for when `feature = "journal"` is disabled.
#[cfg(not(feature = "journal"))]
#[doc(hidden)]
#[macro_export]
macro_rules! __chart_journal {
($($_:tt)*) => {};
}
// ── EnterBegan / Entered ───────────────────────────────────────
(EnterBegan, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EnterBegan { state: $sid });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] entering state={}", $chart, $sname);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] entering state={}", $chart, $sname);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, state = $sname, "entering");
}};
(Entered, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::Entered { state: $sid });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] entered state={}", $chart, $sname);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] entered state={}", $chart, $sname);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, state = $sname, "entered");
}};
// ── Auto-tracing dispatcher ──────────────────────────────────────────
//
// When a chart opts in via `trace;`, the proc-macro emits calls to
// `::hsmc::__chart_trace!(<kind> <chart-name>, <state-or-action>);` at
// every state entry, exit, action invocation, and transition. This
// crate-level macro picks the backend based on which `trace-*` cargo
// feature is enabled — exactly one of `trace-defmt` / `trace-log` /
// `trace-tracing`. With none enabled it expands to nothing.
//
// Multiple `trace-*` features enabled simultaneously produce a
// duplicate-definition compile error from rustc. Intentional.
// ── ExitBegan / Exited ─────────────────────────────────────────
(ExitBegan, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::ExitBegan { state: $sid });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] exiting state={}", $chart, $sname);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] exiting state={}", $chart, $sname);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, state = $sname, "exiting");
}};
(Exited, $sink:expr, $chart:literal, $sid:expr, $sname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::Exited { state: $sid });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] exited state={}", $chart, $sname);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] exited state={}", $chart, $sname);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, state = $sname, "exited");
}};
/// Internal trace dispatcher invoked by `statechart!`-generated code.
/// Not part of the public API.
#[cfg(feature = "trace-defmt")]
#[doc(hidden)]
#[macro_export]
macro_rules! __chart_trace {
(enter $chart:literal, $state:expr) => {
::defmt::info!("[{}] enter {}", $chart, $state);
};
(exit $chart:literal, $state:expr) => {
::defmt::info!("[{}] exit {}", $chart, $state);
};
(action $chart:literal, $action:expr) => {
::defmt::trace!("[{}] action {}", $chart, $action);
};
(transition $chart:literal, $from:expr, $to:expr) => {
::defmt::info!("[{}] {} -> {}", $chart, $from, $to);
};
($($_:tt)*) => {};
}
// ── ActionInvoked (Entry / Exit / Handler) ─────────────────────
(ActionInvoked, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$aid:expr, $aname:expr,
Entry) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::ActionInvoked {
state: $sid,
action: $aid,
kind: $crate::ActionKind::Entry,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::trace!(
"[statechart:{}] action state={} action={} kind=entry",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-log")]
::log::trace!(
"[statechart:{}] action state={} action={} kind=entry",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-tracing")]
::tracing::trace!(
chart = $chart,
state = $sname,
action = $aname,
kind = "entry",
"action"
);
}};
(ActionInvoked, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$aid:expr, $aname:expr,
Exit) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::ActionInvoked {
state: $sid,
action: $aid,
kind: $crate::ActionKind::Exit,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::trace!(
"[statechart:{}] action state={} action={} kind=exit",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-log")]
::log::trace!(
"[statechart:{}] action state={} action={} kind=exit",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-tracing")]
::tracing::trace!(
chart = $chart,
state = $sname,
action = $aname,
kind = "exit",
"action"
);
}};
(ActionInvoked, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$aid:expr, $aname:expr,
Handler) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::ActionInvoked {
state: $sid,
action: $aid,
kind: $crate::ActionKind::Handler,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::trace!(
"[statechart:{}] action state={} action={} kind=handler",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-log")]
::log::trace!(
"[statechart:{}] action state={} action={} kind=handler",
$chart,
$sname,
$aname
);
#[cfg(feature = "trace-tracing")]
::tracing::trace!(
chart = $chart,
state = $sname,
action = $aname,
kind = "handler",
"action"
);
}};
/// Internal trace dispatcher invoked by `statechart!`-generated code.
/// Not part of the public API.
#[cfg(feature = "trace-log")]
#[doc(hidden)]
#[macro_export]
macro_rules! __chart_trace {
(enter $chart:literal, $state:expr) => {
::log::info!("[{}] enter {}", $chart, $state);
};
(exit $chart:literal, $state:expr) => {
::log::info!("[{}] exit {}", $chart, $state);
};
(action $chart:literal, $action:expr) => {
::log::trace!("[{}] action {}", $chart, $action);
};
(transition $chart:literal, $from:expr, $to:expr) => {
::log::info!("[{}] {} -> {}", $chart, $from, $to);
};
($($_:tt)*) => {};
}
// ── DuringStarted / DuringCancelled ────────────────────────────
(DuringStarted, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$did:expr, $dname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::DuringStarted {
state: $sid,
during: $did,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!(
"[statechart:{}] during-started state={} during={}",
$chart,
$sname,
$dname
);
#[cfg(feature = "trace-log")]
::log::debug!(
"[statechart:{}] during-started state={} during={}",
$chart,
$sname,
$dname
);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(
chart = $chart,
state = $sname,
during = $dname,
"during-started"
);
}};
(DuringCancelled, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$did:expr, $dname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::DuringCancelled {
state: $sid,
during: $did,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!(
"[statechart:{}] during-cancelled state={} during={}",
$chart,
$sname,
$dname
);
#[cfg(feature = "trace-log")]
::log::debug!(
"[statechart:{}] during-cancelled state={} during={}",
$chart,
$sname,
$dname
);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(
chart = $chart,
state = $sname,
during = $dname,
"during-cancelled"
);
}};
/// Internal trace dispatcher invoked by `statechart!`-generated code.
/// Not part of the public API.
#[cfg(feature = "trace-tracing")]
#[doc(hidden)]
#[macro_export]
macro_rules! __chart_trace {
(enter $chart:literal, $state:expr) => {
::tracing::info!(chart = $chart, state = $state, "enter");
};
(exit $chart:literal, $state:expr) => {
::tracing::info!(chart = $chart, state = $state, "exit");
};
(action $chart:literal, $action:expr) => {
::tracing::trace!(chart = $chart, action = $action, "action");
};
(transition $chart:literal, $from:expr, $to:expr) => {
::tracing::info!(chart = $chart, from = $from, to = $to, "transition");
};
($($_:tt)*) => {};
}
// ── TransitionFired (begin marker) ─────────────────────────────
//
// The reason is rendered three ways (event:Name, timer:State/Timer,
// internal). The journal stores the structured reason and the trace
// gets a pre-rendered `&'static str` (`$rname`) so log lines stay
// simple to parse.
(TransitionFired, $sink:expr, $chart:literal,
$from:expr, $fname:expr,
$to:expr, $tname:expr,
$reason:expr, $rname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TransitionFired {
from: $from,
to: $to,
reason: $reason,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] transition-begin from={} to={} reason={}",
$chart,
$fname,
$tname,
$rname
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] transition-begin from={} to={} reason={}",
$chart,
$fname,
$tname,
$rname
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(
chart = $chart,
from = $fname,
to = $tname,
reason = $rname,
"transition-begin"
);
}};
/// No-op fallback when no `trace-*` backend feature is enabled. The
/// chart's `trace;` keyword still parses; calls just expand to nothing.
#[cfg(not(any(
feature = "trace-defmt",
feature = "trace-log",
feature = "trace-tracing",
)))]
#[doc(hidden)]
#[macro_export]
macro_rules! __chart_trace {
($($_:tt)*) => {};
// ── TransitionComplete (end marker) ────────────────────────────
(TransitionComplete, $sink:expr, $chart:literal,
$from:expr, $fname:expr,
$to:expr, $tname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TransitionComplete {
from: $from,
to: $to,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] transition-complete from={} to={}",
$chart,
$fname,
$tname
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] transition-complete from={} to={}",
$chart,
$fname,
$tname
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(
chart = $chart,
from = $fname,
to = $tname,
"transition-complete"
);
}};
// ── Event lifecycle ────────────────────────────────────────────
(EventReceived, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EventReceived { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!("[statechart:{}] event-received name={}", $chart, $ename);
#[cfg(feature = "trace-log")]
::log::debug!("[statechart:{}] event-received name={}", $chart, $ename);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(chart = $chart, name = $ename, "event-received");
}};
(EventDelivered, $sink:expr, $chart:literal,
$eid:expr, $ename:expr,
$sid:expr, $sname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EventDelivered {
handler_state: $sid,
event: $eid,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] event-delivered name={} handler={}",
$chart,
$ename,
$sname
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] event-delivered name={} handler={}",
$chart,
$ename,
$sname
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(
chart = $chart,
name = $ename,
handler = $sname,
"event-delivered"
);
}};
(EventDropped, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EventDropped { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!("[statechart:{}] event-dropped name={}", $chart, $ename);
#[cfg(feature = "trace-log")]
::log::debug!("[statechart:{}] event-dropped name={}", $chart, $ename);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(chart = $chart, name = $ename, "event-dropped");
}};
// ── emit() outcomes ────────────────────────────────────────────
(EmitQueued, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EmitQueued { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!("[statechart:{}] emit-queued event={}", $chart, $ename);
#[cfg(feature = "trace-log")]
::log::debug!("[statechart:{}] emit-queued event={}", $chart, $ename);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(chart = $chart, event = $ename, "emit-queued");
}};
(EmitFailed, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::EmitFailed { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::warn!(
"[statechart:{}] emit-failed event={} reason=queue-full",
$chart,
$ename
);
#[cfg(feature = "trace-log")]
::log::warn!(
"[statechart:{}] emit-failed event={} reason=queue-full",
$chart,
$ename
);
#[cfg(feature = "trace-tracing")]
::tracing::warn!(
chart = $chart,
event = $ename,
reason = "queue-full",
"emit-failed"
);
}};
// ── Timer lifecycle ────────────────────────────────────────────
(TimerArmed, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$tid:expr, $tname:expr,
$ns:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TimerArmed {
state: $sid,
timer: $tid,
ns: $ns,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!(
"[statechart:{}] timer-armed state={} timer={} duration_ns={=u64}",
$chart,
$sname,
$tname,
$ns
);
#[cfg(feature = "trace-log")]
::log::debug!(
"[statechart:{}] timer-armed state={} timer={} duration_ns={}",
$chart,
$sname,
$tname,
$ns
);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(
chart = $chart,
state = $sname,
timer = $tname,
duration_ns = $ns,
"timer-armed"
);
}};
(TimerCancelled, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$tid:expr, $tname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TimerCancelled {
state: $sid,
timer: $tid,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::debug!(
"[statechart:{}] timer-cancelled state={} timer={}",
$chart,
$sname,
$tname
);
#[cfg(feature = "trace-log")]
::log::debug!(
"[statechart:{}] timer-cancelled state={} timer={}",
$chart,
$sname,
$tname
);
#[cfg(feature = "trace-tracing")]
::tracing::debug!(
chart = $chart,
state = $sname,
timer = $tname,
"timer-cancelled"
);
}};
(TimerFired, $sink:expr, $chart:literal,
$sid:expr, $sname:expr,
$tid:expr, $tname:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TimerFired {
state: $sid,
timer: $tid,
});
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] timer-fired state={} timer={}",
$chart,
$sname,
$tname
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] timer-fired state={} timer={}",
$chart,
$sname,
$tname
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(
chart = $chart,
state = $sname,
timer = $tname,
"timer-fired"
);
}};
// ── Termination ────────────────────────────────────────────────
(TerminateRequested, $sink:expr, $chart:literal, $eid:expr, $ename:expr) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::TerminateRequested { event: $eid });
}
#[cfg(feature = "trace-defmt")]
::defmt::info!(
"[statechart:{}] terminate-requested event={}",
$chart,
$ename
);
#[cfg(feature = "trace-log")]
::log::info!(
"[statechart:{}] terminate-requested event={}",
$chart,
$ename
);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, event = $ename, "terminate-requested");
}};
(Terminated, $sink:expr, $chart:literal) => {{
#[cfg(feature = "journal")]
{
$sink.push($crate::TraceEvent::Terminated);
}
#[cfg(feature = "trace-defmt")]
::defmt::info!("[statechart:{}] terminated", $chart);
#[cfg(feature = "trace-log")]
::log::info!("[statechart:{}] terminated", $chart);
#[cfg(feature = "trace-tracing")]
::tracing::info!(chart = $chart, "terminated");
}};
}
#[doc(hidden)]
@@ -338,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> {
@@ -354,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 {
@@ -368,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)
}
}
#[inline]
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 {
@@ -406,12 +919,26 @@ pub mod __private {
None
}
}
/// Smallest `remaining_ns` across live timers, or `None` if there are
/// none. Same inline-the-empty-case treatment as `pop_expired` so
/// timer-free charts pay only a branch per `step()`.
#[inline]
pub fn min_remaining(&self) -> Option<core::time::Duration> {
self.entries.iter().map(|e| e.remaining_ns).min().map(|ns| {
let secs = (ns / 1_000_000_000) as u64;
let rem = (ns % 1_000_000_000) as u32;
core::time::Duration::new(secs, rem)
})
if self.entries.is_empty() {
None
} else {
self.min_remaining_nonempty()
}
}
#[cold]
fn min_remaining_nonempty(&self) -> Option<core::time::Duration> {
self.entries
.iter()
.map(|e| e.remaining_ns)
.min()
.map(core::time::Duration::from_nanos)
}
}
+136
View File
@@ -0,0 +1,136 @@
#![allow(dead_code)]
//! Deterministic-flow tests: `default(...)` targeting an ANCESTOR.
//! Inner's `default(Outer)` is a valid up-default. Inner is reachable
//! only via an explicit transition. When entered, its default fires
//! immediately, exiting Inner back to Outer (up-transition rule:
//! Outer is not re-entered).
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
GoIn,
Halt,
}
statechart! {
AncestorDefault {
context: Ctx;
events: Ev;
default(Outer);
terminate(Halt);
state Outer {
entry: outer_in;
on(GoIn) => Inner;
state Inner {
entry: inner_in;
exit: inner_out;
// Inner's default redirects up to Outer. After entering
// Inner, this default fires as an up-transition that
// exits Inner. Outer is not re-entered.
default(Outer);
}
}
}
}
impl AncestorDefaultActions for AncestorDefaultActionContext<'_> {
async fn outer_in(&mut self) {}
async fn inner_in(&mut self) {}
async fn inner_out(&mut self) {}
}
const AD_ROOT: u16 = 0;
const AD_OUTER: u16 = 1;
const AD_INNER: u16 = 2;
#[tokio::test(flavor = "current_thread")]
async fn det_initial_lands_at_outer_no_default_descent() {
// Outer has no default of its own — initial entry stops at Outer.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = AncestorDefault::new(Ctx);
let _ = m.step(hsmc::Duration::ZERO).await;
assert_eq!(m.current_state(), AncestorDefaultState::Outer);
let j = m.take_journal();
let entered: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect();
assert_eq!(entered, vec![AD_ROOT, AD_OUTER]);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_explicit_into_inner_bounces_back_via_up_default() {
// `=> Inner` enters Inner (entries fire), then Inner's default fires
// as an up-transition Inner → Outer. Outer is NOT re-entered (it
// was already on the active path).
tokio::task::LocalSet::new()
.run_until(async {
let mut m = AncestorDefault::new(Ctx);
let _ = m.dispatch(Ev::GoIn).await;
assert_eq!(
m.current_state(),
AncestorDefaultState::Outer,
"Inner's default bounces us back to Outer"
);
let j = m.take_journal();
// Inner was entered (its entries fired) and then exited
// (its default fired an up-transition).
let entered_states: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect();
// Across the full journal: Root, Outer, Inner. After Inner's
// up-default to Outer, Outer is NOT re-entered.
assert_eq!(entered_states, vec![AD_ROOT, AD_OUTER, AD_INNER]);
let exited_states: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect();
assert_eq!(
exited_states,
vec![AD_INNER],
"only Inner exits; Outer remains on the active path"
);
// Outer's entry actions ran exactly once (initial entry only).
let outer_entries = j
.iter()
.filter(|e| {
matches!(
e,
TraceEvent::ActionInvoked {
state: AD_OUTER,
kind: hsmc::ActionKind::Entry,
..
}
)
})
.count();
assert_eq!(outer_entries, 1, "Outer is entered once and stays put");
})
.await;
}
+90
View File
@@ -0,0 +1,90 @@
#![allow(dead_code)]
//! Deterministic-flow tests: `default(...)` chains. Hub → Spoke1 →
//! Spoke2. Each link fires as a real LCA-aware transition. The
//! compile-time default-graph cycle check guarantees this terminates.
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Halt,
}
statechart! {
ChainedDefault {
context: Ctx;
events: Ev;
default(Hub);
terminate(Halt);
state Hub {
entry: hub_in;
default(Spoke1);
}
state Spoke1 {
entry: s1_in;
exit: s1_out;
default(Spoke2);
}
state Spoke2 {
entry: s2_in;
}
}
}
impl ChainedDefaultActions for ChainedDefaultActionContext<'_> {
async fn hub_in(&mut self) {}
async fn s1_in(&mut self) {}
async fn s1_out(&mut self) {}
async fn s2_in(&mut self) {}
}
const CD_ROOT: u16 = 0;
const CD_HUB: u16 = 1;
const CD_SPOKE1: u16 = 2;
const CD_SPOKE2: u16 = 3;
#[tokio::test(flavor = "current_thread")]
async fn det_chained_defaults_terminate_at_chain_end() {
tokio::task::LocalSet::new()
.run_until(async {
let mut m = ChainedDefault::new(Ctx);
let _ = m.step(hsmc::Duration::ZERO).await;
assert_eq!(m.current_state(), ChainedDefaultState::Spoke2);
let j = m.take_journal();
let entered: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect();
assert_eq!(entered, vec![CD_ROOT, CD_HUB, CD_SPOKE1, CD_SPOKE2]);
let exited: Vec<u16> = j
.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect();
// Each default fires as a sibling-transition: Hub → Spoke1
// (exits Hub), then Spoke1 → Spoke2 (exits Spoke1).
assert_eq!(exited, vec![CD_HUB, CD_SPOKE1]);
// At least 2 TransitionFired events for the chained defaults.
let txn_count = j
.iter()
.filter(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.count();
assert!(txn_count >= 2);
})
.await;
}
+133
View File
@@ -0,0 +1,133 @@
#![allow(dead_code)]
//! Deterministic-flow tests: `default(...)` targeting a SIBLING fires as
//! a real LCA-aware transition immediately after the declaring state's
//! entries finish. Foyer's `default(LivingRoom)` means: enter Foyer,
//! run its entries, then immediately transition Foyer → LivingRoom
//! (LCA = Root, exits Foyer, enters LivingRoom).
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
Halt,
}
statechart! {
SiblingDefault {
context: Ctx;
events: Ev;
default(Foyer);
terminate(Halt);
state Foyer {
entry: foyer_in;
exit: foyer_out;
default(LivingRoom);
}
state LivingRoom {
entry: living_in;
exit: living_out;
}
}
}
impl SiblingDefaultActions for SiblingDefaultActionContext<'_> {
async fn foyer_in(&mut self) {}
async fn foyer_out(&mut self) {}
async fn living_in(&mut self) {}
async fn living_out(&mut self) {}
}
const SD_ROOT: u16 = 0;
const SD_FOYER: u16 = 1;
const SD_LIVING: u16 = 2;
fn entered(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect()
}
fn exited(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect()
}
#[tokio::test(flavor = "current_thread")]
async fn det_sibling_default_exits_declarer_lands_at_target() {
tokio::task::LocalSet::new()
.run_until(async {
let mut m = SiblingDefault::new(Ctx);
let _ = m.step(hsmc::Duration::ZERO).await;
assert_eq!(m.current_state(), SiblingDefaultState::LivingRoom);
let j = m.take_journal();
assert_eq!(
entered(&j),
vec![SD_ROOT, SD_FOYER, SD_LIVING],
"Root → Foyer → (default fires) → LivingRoom"
);
assert_eq!(
exited(&j),
vec![SD_FOYER],
"Foyer is exited as the default transition's `from` state"
);
// Foyer's entry must run before its exit (default fires after entries).
let action_seq: Vec<(u16, ActionKind)> = j
.iter()
.filter_map(|e| match e {
TraceEvent::ActionInvoked { state, kind, .. } => Some((*state, *kind)),
_ => None,
})
.collect();
let foyer_entry_idx = action_seq
.iter()
.position(|(s, k)| *s == SD_FOYER && *k == ActionKind::Entry)
.unwrap();
let foyer_exit_idx = action_seq
.iter()
.position(|(s, k)| *s == SD_FOYER && *k == ActionKind::Exit)
.unwrap();
let living_entry_idx = action_seq
.iter()
.position(|(s, k)| *s == SD_LIVING && *k == ActionKind::Entry)
.unwrap();
assert!(foyer_entry_idx < foyer_exit_idx);
assert!(foyer_exit_idx < living_entry_idx);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_sibling_default_emits_transition_observation() {
tokio::task::LocalSet::new()
.run_until(async {
let mut m = SiblingDefault::new(Ctx);
let _ = m.step(hsmc::Duration::ZERO).await;
let j = m.take_journal();
let txn_count = j
.iter()
.filter(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.count();
assert!(
txn_count >= 1,
"expected at least one TransitionFired for the default-as-transition firing"
);
})
.await;
}
+87 -10
View File
@@ -12,7 +12,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;
@@ -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);
@@ -117,9 +119,22 @@ async fn det_descent_target_with_three_default_levels() {
TraceEvent::Started {
chart_hash: Desc::<8>::CHART_HASH,
},
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR },
TraceEvent::Entered { state: SI },
// 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,
event: E_GO,
@@ -127,17 +142,57 @@ async fn det_descent_target_with_three_default_levels() {
TraceEvent::TransitionFired {
from: Some(SI),
to: ST,
reason: TransitionReason::Event { event: E_GO },
},
TraceEvent::ExitBegan { state: SI },
exit_(SI, A_IDLE_OUT),
TraceEvent::Exited { state: SI },
TraceEvent::Entered { state: ST },
TraceEvent::EnterBegan { state: ST },
entry(ST, A_TOWER_IN),
TraceEvent::Entered { state: SL1 },
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: SL2 },
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: SL3 },
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,
},
];
assert_eq!(actual, expected);
@@ -151,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()
@@ -197,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..]
+56 -6
View File
@@ -19,7 +19,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)]
pub struct Ctx;
@@ -124,30 +124,80 @@ 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::Entered { state: SR },
TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN),
TraceEvent::Entered { state: SA },
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: SB },
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: SC },
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: SD },
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 },
// Bottom-up exit chain.
TraceEvent::ExitBegan { state: SD },
exit_(SD, A_D_OUT),
TraceEvent::Exited { state: SD },
TraceEvent::ExitBegan { state: SC },
exit_(SC, A_C_OUT),
TraceEvent::Exited { state: SC },
TraceEvent::ExitBegan { state: SB },
exit_(SB, A_B_OUT),
TraceEvent::Exited { state: SB },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA },
TraceEvent::ExitBegan { state: SR },
exit_(SR, A_R_OUT),
TraceEvent::Exited { state: SR },
TraceEvent::Terminated,
+112 -14
View File
@@ -11,7 +11,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;
@@ -140,17 +140,39 @@ 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 {
chart_hash: Lat::<8>::CHART_HASH,
},
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR },
TraceEvent::Entered { state: SP },
TraceEvent::TransitionFired {
from: Some(SR),
to: SP,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SP },
entry(SP, A_P_IN),
TraceEvent::Entered { state: SA },
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,
},
]
}
@@ -185,6 +207,7 @@ async fn det_lateral_a_to_b_lca_is_parent() {
let mut expected = initial_descent();
expected.extend(vec![
TraceEvent::EventReceived { event: E_GOB },
TraceEvent::EventDelivered {
handler_state: SA,
event: E_GOB,
@@ -192,11 +215,18 @@ async fn det_lateral_a_to_b_lca_is_parent() {
TraceEvent::TransitionFired {
from: Some(SA),
to: SB,
reason: TransitionReason::Event { event: E_GOB },
},
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA },
TraceEvent::Entered { state: SB },
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
]);
assert_journal(&actual, &expected);
})
@@ -215,6 +245,7 @@ async fn det_lateral_a_to_c_exits_parent() {
let mut expected = initial_descent();
expected.extend(vec![
TraceEvent::EventReceived { event: E_GOC },
TraceEvent::EventDelivered {
handler_state: SA,
event: E_GOC,
@@ -222,13 +253,21 @@ async fn det_lateral_a_to_c_exits_parent() {
TraceEvent::TransitionFired {
from: Some(SA),
to: SC,
reason: TransitionReason::Event { event: E_GOC },
},
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA },
TraceEvent::ExitBegan { state: SP },
exit_(SP, A_P_OUT),
TraceEvent::Exited { state: SP },
TraceEvent::Entered { state: SC },
TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN),
TraceEvent::Entered { state: SC },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SC,
},
]);
assert_journal(&actual, &expected);
})
@@ -246,6 +285,7 @@ async fn det_lateral_target_with_children_default_descends() {
let mut expected = initial_descent();
expected.extend(vec![
TraceEvent::EventReceived { event: E_GOD },
TraceEvent::EventDelivered {
handler_state: SA,
event: E_GOD,
@@ -253,15 +293,34 @@ async fn det_lateral_target_with_children_default_descends() {
TraceEvent::TransitionFired {
from: Some(SA),
to: SD,
reason: TransitionReason::Event { event: E_GOD },
},
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA },
TraceEvent::ExitBegan { state: SP },
exit_(SP, A_P_OUT),
TraceEvent::Exited { state: SP },
TraceEvent::Entered { state: SD },
TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN),
TraceEvent::Entered { state: SE },
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,
},
]);
assert_journal(&actual, &expected);
})
@@ -280,6 +339,7 @@ async fn det_lateral_to_deeply_nested_target() {
let mut expected = initial_descent();
expected.extend(vec![
TraceEvent::EventReceived { event: E_GOE },
TraceEvent::EventDelivered {
handler_state: SA,
event: E_GOE,
@@ -287,15 +347,24 @@ async fn det_lateral_to_deeply_nested_target() {
TraceEvent::TransitionFired {
from: Some(SA),
to: SE,
reason: TransitionReason::Event { event: E_GOE },
},
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA },
TraceEvent::ExitBegan { state: SP },
exit_(SP, A_P_OUT),
TraceEvent::Exited { state: SP },
TraceEvent::Entered { state: SD },
TraceEvent::EnterBegan { state: SD },
entry(SD, A_D_IN),
TraceEvent::Entered { state: SE },
TraceEvent::Entered { state: SD },
TraceEvent::EnterBegan { state: SE },
entry(SE, A_E_IN),
TraceEvent::Entered { state: SE },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SE,
},
]);
assert_journal(&actual, &expected);
})
@@ -316,6 +385,7 @@ async fn det_lateral_chained_transitions() {
let mut expected = initial_descent();
// A → B
expected.extend(vec![
TraceEvent::EventReceived { event: E_GOB },
TraceEvent::EventDelivered {
handler_state: SA,
event: E_GOB,
@@ -323,14 +393,22 @@ async fn det_lateral_chained_transitions() {
TraceEvent::TransitionFired {
from: Some(SA),
to: SB,
reason: TransitionReason::Event { event: E_GOB },
},
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA },
TraceEvent::Entered { state: SB },
TraceEvent::EnterBegan { state: SB },
entry(SB, A_B_IN),
TraceEvent::Entered { state: SB },
TraceEvent::TransitionComplete {
from: Some(SA),
to: SB,
},
]);
// B → C (LCA = Root, so Parent exits too).
expected.extend(vec![
TraceEvent::EventReceived { event: E_GOC },
TraceEvent::EventDelivered {
handler_state: SB,
event: E_GOC,
@@ -338,13 +416,21 @@ async fn det_lateral_chained_transitions() {
TraceEvent::TransitionFired {
from: Some(SB),
to: SC,
reason: TransitionReason::Event { event: E_GOC },
},
TraceEvent::ExitBegan { state: SB },
exit_(SB, A_B_OUT),
TraceEvent::Exited { state: SB },
TraceEvent::ExitBegan { state: SP },
exit_(SP, A_P_OUT),
TraceEvent::Exited { state: SP },
TraceEvent::Entered { state: SC },
TraceEvent::EnterBegan { state: SC },
entry(SC, A_C_IN),
TraceEvent::Entered { state: SC },
TraceEvent::TransitionComplete {
from: Some(SB),
to: SC,
},
]);
assert_journal(&actual, &expected);
})
@@ -360,9 +446,13 @@ async fn det_lateral_lca_stays_active() {
let _ = m.dispatch(Ev::GoB).await;
let j = m.take_journal();
// After initial Entered(Parent), Parent must not appear again.
let initial_p_idx = j
.iter()
.position(|e| matches!(e, TraceEvent::Entered { state: SP }))
.unwrap();
let parent_events: Vec<&TraceEvent> = j
.iter()
.skip(3)
.skip(initial_p_idx + 1)
.filter(|e| {
matches!(
e,
@@ -410,7 +500,15 @@ async fn det_lateral_entry_outer_then_inner() {
let entries: Vec<u16> = m
.journal()
.iter()
.skip_while(|e| !matches!(e, TraceEvent::TransitionFired { .. }))
.skip_while(|e| {
!matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
+218
View File
@@ -0,0 +1,218 @@
#![allow(dead_code)]
//! Deterministic-flow tests for OPTIONAL `default(...)` on composite states.
//!
//! Spec: a composite state may declare `default(...)` or omit it. When
//! omitted, transitions targeting the composite land on the composite itself
//! — no default-descent. Substates are reachable only via explicit
//! transitions. This is the "microwave" pattern: a `Standby` state with
//! sub-modes (e.g. `LowPower`) that the chart enters only on demand.
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
#[derive(Default)]
pub struct Ctx;
#[derive(Debug, Clone)]
pub enum Ev {
PowerOn,
PowerOff,
EnterLowPower,
LeaveLowPower,
Halt,
}
statechart! {
Microwave {
context: Ctx;
events: Ev;
default(Standby);
terminate(Halt);
state Standby {
// No `default(...)` — Standby itself is the resting state.
entry: standby_in;
exit: standby_out;
on(PowerOn) => On;
on(EnterLowPower) => LowPower;
state LowPower {
entry: low_in;
exit: low_out;
on(LeaveLowPower) => Standby;
}
}
state On {
entry: on_in;
exit: on_out;
on(PowerOff) => Standby;
}
}
}
impl MicrowaveActions for MicrowaveActionContext<'_> {
async fn standby_in(&mut self) {}
async fn standby_out(&mut self) {}
async fn low_in(&mut self) {}
async fn low_out(&mut self) {}
async fn on_in(&mut self) {}
async fn on_out(&mut self) {}
}
// State ids: 0=Root, 1=Standby, 2=LowPower, 3=On.
const SR: u16 = 0;
const S_STANDBY: u16 = 1;
const S_LOW: u16 = 2;
const S_ON: u16 = 3;
fn entered_states(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::Entered { state } => Some(*state),
_ => None,
})
.collect()
}
fn exited_states(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::Exited { state } => Some(*state),
_ => None,
})
.collect()
}
fn entry_action_states(j: &[TraceEvent]) -> Vec<u16> {
j.iter()
.filter_map(|e| match e {
TraceEvent::ActionInvoked {
state,
kind: ActionKind::Entry,
..
} => Some(*state),
_ => None,
})
.collect()
}
#[tokio::test(flavor = "current_thread")]
async fn det_initial_lands_on_composite_no_descent() {
// Standby has children but no `default` — initial entry stops at Standby.
// LowPower must NOT enter.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
// Drive initial entry without consuming an event.
let _ = m.step(hsmc::Duration::ZERO).await;
assert_eq!(
m.current_state(),
MicrowaveState::Standby,
"innermost active state is the composite Standby"
);
let j = m.take_journal();
let entered = entered_states(&j);
assert_eq!(
entered,
vec![SR, S_STANDBY],
"initial entry stops at Standby; no default-descent into LowPower"
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_transition_into_substate_works() {
// Explicit `=> LowPower` from Standby enters LowPower.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
let _ = m.dispatch(Ev::EnterLowPower).await;
assert_eq!(m.current_state(), MicrowaveState::LowPower);
let j = m.take_journal();
// Standby entered first (initial), then LowPower entered after the event.
let entered = entered_states(&j);
assert_eq!(entered, vec![SR, S_STANDBY, S_LOW]);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_up_from_substate_lands_on_composite() {
// From LowPower, `=> Standby` is an up-transition: exit LowPower, do not
// re-enter Standby.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
let _ = m.dispatch(Ev::EnterLowPower).await;
let _ = m.dispatch(Ev::LeaveLowPower).await;
assert_eq!(m.current_state(), MicrowaveState::Standby);
let j = m.take_journal();
// Across the whole run: enter Standby, enter LowPower, exit
// LowPower. Standby's entry must have run exactly once.
let entry_states = entry_action_states(&j);
let standby_entries = entry_states.iter().filter(|&&s| s == S_STANDBY).count();
assert_eq!(
standby_entries, 1,
"Standby's entry runs once; up-transition does not re-enter it"
);
let exited = exited_states(&j);
assert_eq!(
exited,
vec![S_LOW],
"only LowPower is exited; Standby remains on the active path"
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_lateral_to_composite_does_not_descend() {
// From On, `=> Standby` lands on Standby — NOT on LowPower.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
let _ = m.dispatch(Ev::PowerOn).await;
assert_eq!(m.current_state(), MicrowaveState::On);
let _ = m.dispatch(Ev::PowerOff).await;
assert_eq!(
m.current_state(),
MicrowaveState::Standby,
"PowerOff lands on Standby; absence of `default` skips descent"
);
let j = m.take_journal();
// LowPower must never have been entered.
let entered = entered_states(&j);
assert!(
!entered.contains(&S_LOW),
"no descent into LowPower on entry to Standby"
);
})
.await;
}
#[tokio::test(flavor = "current_thread")]
async fn det_terminate_from_composite_unwinds_path() {
// Halt while at Standby exits Standby → Root only.
tokio::task::LocalSet::new()
.run_until(async {
let mut m = Microwave::new(Ctx);
let _ = m.dispatch(Ev::Halt).await;
let j = m.take_journal();
let exits = exited_states(&j);
assert_eq!(
exits,
vec![S_STANDBY, SR],
"terminate from Standby exits Standby then Root"
);
assert!(matches!(j.last(), Some(TraceEvent::Terminated)));
})
.await;
}
+12 -3
View File
@@ -13,7 +13,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)]
pub struct Ctx;
@@ -293,10 +293,19 @@ async fn det_ord_full_transition_canonical_sequence() {
let mut m = Ord::new(Ctx);
let _ = m.dispatch(Ev::Go).await;
let j = m.take_journal();
// Find the TransitionFired and capture everything until A's exit and B's entry are done.
// Find the EVENT-driven TransitionFired (initial default-fires
// are TransitionFired{Internal}; we want the GO transition).
let trans_idx = j
.iter()
.position(|e| matches!(e, TraceEvent::TransitionFired { .. }))
.position(|e| {
matches!(
e,
TraceEvent::TransitionFired {
reason: TransitionReason::Event { .. },
..
}
)
})
.unwrap();
let kinds: Vec<ActionKind> = j[trans_idx..]
.iter()
+48 -6
View File
@@ -17,7 +17,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;
@@ -101,12 +101,36 @@ async fn det_root_targetable_by_chart_name() {
TraceEvent::Started {
chart_hash: Sub::<8>::CHART_HASH,
},
TraceEvent::Entered { state: SR },
TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN),
TraceEvent::Entered { state: SA },
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: SB },
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,
event: E_UPTOROOT,
@@ -114,11 +138,18 @@ async fn det_root_targetable_by_chart_name() {
TraceEvent::TransitionFired {
from: Some(SB),
to: SR,
reason: TransitionReason::Event { event: E_UPTOROOT },
},
TraceEvent::ExitBegan { state: SB },
exit_(SB, A_B_OUT),
TraceEvent::Exited { state: SB },
TraceEvent::ExitBegan { state: SA },
exit_(SA, A_A_OUT),
TraceEvent::Exited { state: SA },
TraceEvent::TransitionComplete {
from: Some(SB),
to: SR,
},
];
assert_eq!(
@@ -137,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()
+42 -5
View File
@@ -13,7 +13,7 @@
#![cfg(all(feature = "tokio", feature = "journal"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, ActionKind, TraceEvent};
use hsmc::{statechart, ActionKind, TraceEvent, TransitionReason};
#[derive(Default)]
pub struct Ctx;
@@ -86,11 +86,32 @@ fn initial_descent() -> Vec<TraceEvent> {
TraceEvent::Started {
chart_hash: Self_::<8>::CHART_HASH,
},
TraceEvent::EnterBegan { state: SR },
TraceEvent::Entered { state: SR },
TraceEvent::Entered { state: SM },
TraceEvent::TransitionFired {
from: Some(SR),
to: SM,
reason: TransitionReason::Internal,
},
TraceEvent::EnterBegan { state: SM },
entry(SM, A_M_IN),
TraceEvent::Entered { state: SL },
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]) {
@@ -123,6 +144,7 @@ async fn det_self_leaf_exits_and_reenters_only_leaf() {
let mut expected = initial_descent();
expected.extend(vec![
TraceEvent::EventReceived { event: E_SELFLEAF },
TraceEvent::EventDelivered {
handler_state: SL,
event: E_SELFLEAF,
@@ -130,11 +152,18 @@ async fn det_self_leaf_exits_and_reenters_only_leaf() {
TraceEvent::TransitionFired {
from: Some(SL),
to: SL,
reason: TransitionReason::Event { event: E_SELFLEAF },
},
TraceEvent::ExitBegan { state: SL },
exit_(SL, A_L_OUT),
TraceEvent::Exited { state: SL },
TraceEvent::Entered { state: SL },
TraceEvent::EnterBegan { state: SL },
entry(SL, A_L_IN),
TraceEvent::Entered { state: SL },
TraceEvent::TransitionComplete {
from: Some(SL),
to: SL,
},
]);
assert_journal(&actual, &expected);
})
@@ -209,6 +238,7 @@ async fn det_self_composite_default_descends() {
// Mid's entry actions don't fire. No default-descent.
let mut expected = initial_descent();
expected.extend(vec![
TraceEvent::EventReceived { event: E_SELFMID },
TraceEvent::EventDelivered {
handler_state: SM,
event: E_SELFMID,
@@ -216,9 +246,15 @@ async fn det_self_composite_default_descends() {
TraceEvent::TransitionFired {
from: Some(SL),
to: SM,
reason: TransitionReason::Event { event: E_SELFMID },
},
TraceEvent::ExitBegan { state: SL },
exit_(SL, A_L_OUT),
TraceEvent::Exited { state: SL },
TraceEvent::TransitionComplete {
from: Some(SL),
to: SM,
},
]);
assert_journal(&actual, &expected);
})
@@ -257,7 +293,8 @@ async fn det_self_leaf_repeated_is_deterministic() {
e,
TraceEvent::TransitionFired {
from: Some(SL),
to: SL
to: SL,
..
}
)
})
+4 -2
View File
@@ -85,7 +85,8 @@ async fn det_self_composite_default_descends_after_re_entry() {
e,
TraceEvent::TransitionFired {
from: Some(SP),
to: SP
to: SP,
..
}
)
})
@@ -133,7 +134,8 @@ async fn det_self_composite_action_kinds_in_order() {
e,
TraceEvent::TransitionFired {
from: Some(SP),
to: SP
to: SP,
..
}
)
})
+84 -7
View File
@@ -15,7 +15,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;
@@ -113,20 +113,63 @@ 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,
},
TraceEvent::Entered { state: SR },
TraceEvent::EnterBegan { state: SR },
entry(SR, A_R_IN),
TraceEvent::Entered { state: SA },
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: SB },
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: SC },
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: SD },
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]) {
@@ -159,6 +202,7 @@ async fn det_up_to_immediate_grandparent() {
let mut expected = initial_descent();
expected.extend(vec![
TraceEvent::EventReceived { event: E_UPTOC },
TraceEvent::EventDelivered {
handler_state: SD,
event: E_UPTOC,
@@ -166,9 +210,15 @@ async fn det_up_to_immediate_grandparent() {
TraceEvent::TransitionFired {
from: Some(SD),
to: SC,
reason: TransitionReason::Event { event: E_UPTOC },
},
TraceEvent::ExitBegan { state: SD },
exit_(SD, A_D_OUT),
TraceEvent::Exited { state: SD },
TraceEvent::TransitionComplete {
from: Some(SD),
to: SC,
},
]);
assert_journal(&actual, &expected);
})
@@ -186,6 +236,7 @@ async fn det_up_two_levels() {
let mut expected = initial_descent();
expected.extend(vec![
TraceEvent::EventReceived { event: E_UPTOB },
TraceEvent::EventDelivered {
handler_state: SD,
event: E_UPTOB,
@@ -193,11 +244,18 @@ async fn det_up_two_levels() {
TraceEvent::TransitionFired {
from: Some(SD),
to: SB,
reason: TransitionReason::Event { event: E_UPTOB },
},
TraceEvent::ExitBegan { state: SD },
exit_(SD, A_D_OUT),
TraceEvent::Exited { state: SD },
TraceEvent::ExitBegan { state: SC },
exit_(SC, A_C_OUT),
TraceEvent::Exited { state: SC },
TraceEvent::TransitionComplete {
from: Some(SD),
to: SB,
},
]);
assert_journal(&actual, &expected);
})
@@ -215,6 +273,7 @@ async fn det_up_three_levels_to_a() {
let mut expected = initial_descent();
expected.extend(vec![
TraceEvent::EventReceived { event: E_UPTOA },
TraceEvent::EventDelivered {
handler_state: SD,
event: E_UPTOA,
@@ -222,13 +281,21 @@ async fn det_up_three_levels_to_a() {
TraceEvent::TransitionFired {
from: Some(SD),
to: SA,
reason: TransitionReason::Event { event: E_UPTOA },
},
TraceEvent::ExitBegan { state: SD },
exit_(SD, A_D_OUT),
TraceEvent::Exited { state: SD },
TraceEvent::ExitBegan { state: SC },
exit_(SC, A_C_OUT),
TraceEvent::Exited { state: SC },
TraceEvent::ExitBegan { state: SB },
exit_(SB, A_B_OUT),
TraceEvent::Exited { state: SB },
TraceEvent::TransitionComplete {
from: Some(SD),
to: SA,
},
]);
assert_journal(&actual, &expected);
})
@@ -332,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!(
+41 -5
View File
@@ -12,7 +12,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;
@@ -97,19 +97,32 @@ async fn replay_full_lifecycle_matches_expected() {
TraceEvent::Started {
chart_hash: Replay::<8>::CHART_HASH,
},
TraceEvent::Entered { state: ST_ROOT },
TraceEvent::EnterBegan { state: ST_ROOT },
TraceEvent::ActionInvoked {
state: ST_ROOT,
action: A_ROOT_IN,
kind: ActionKind::Entry,
},
TraceEvent::Entered { state: ST_IDLE },
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,
action: A_IDLE_IN,
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 {
handler_state: ST_IDLE,
event: E_GO,
@@ -117,39 +130,62 @@ async fn replay_full_lifecycle_matches_expected() {
TraceEvent::TransitionFired {
from: Some(ST_IDLE),
to: ST_ACTIVE,
reason: TransitionReason::Event { event: E_GO },
},
TraceEvent::ExitBegan { state: ST_IDLE },
TraceEvent::ActionInvoked {
state: ST_IDLE,
action: A_IDLE_OUT,
kind: ActionKind::Exit,
},
TraceEvent::Exited { state: ST_IDLE },
TraceEvent::Entered { state: ST_ACTIVE },
TraceEvent::EnterBegan { state: ST_ACTIVE },
TraceEvent::ActionInvoked {
state: ST_ACTIVE,
action: A_ACTIVE_IN,
kind: ActionKind::Entry,
},
TraceEvent::Entered { state: ST_SUB },
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,
action: A_SUB_IN,
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,
},
// ── Halt event triggers termination: exit Sub → Active → root, then Terminated ──
TraceEvent::EventReceived { event: E_HALT },
TraceEvent::TerminateRequested { event: E_HALT },
TraceEvent::ExitBegan { state: ST_SUB },
TraceEvent::ActionInvoked {
state: ST_SUB,
action: A_SUB_OUT,
kind: ActionKind::Exit,
},
TraceEvent::Exited { state: ST_SUB },
TraceEvent::ExitBegan { state: ST_ACTIVE },
TraceEvent::ActionInvoked {
state: ST_ACTIVE,
action: A_ACTIVE_OUT,
kind: ActionKind::Exit,
},
TraceEvent::Exited { state: ST_ACTIVE },
TraceEvent::ExitBegan { state: ST_ROOT },
TraceEvent::ActionInvoked {
state: ST_ROOT,
action: A_ROOT_OUT,
+429
View File
@@ -0,0 +1,429 @@
//! Verifies the textual format of the unified observation pipeline's
//! `trace-log` sink line-for-line.
//!
//! Each `__chart_observe!` arm has a stable logfmt-style format:
//!
//! `[statechart:<Name>] <verb> key=value key=value …`
//!
//! These tests pin every verb's exact rendering so a real-life log capture
//! can be diffed against expected behavior. The chart is exercised through
//! both the journal sink (so we can assert atom-by-atom equivalence) and
//! the trace-log sink (so we can assert the rendered strings).
#![cfg(all(feature = "tokio", feature = "journal", feature = "trace-log"))]
#![allow(unexpected_cfgs)]
use hsmc::{statechart, Duration, TraceEvent, TransitionReason};
use std::sync::{Mutex, MutexGuard, OnceLock};
// ── Capture sink ─────────────────────────────────────────────────────
//
// log::set_logger can only be installed once per process. The capture
// holds a single Vec<String> shared across all tests, plus a global
// `TEST_LOCK` that each test holds across its drive-then-assert
// section so concurrent tests don't interleave their captured lines.
struct Capture {
lines: Mutex<Vec<String>>,
}
impl log::Log for Capture {
fn enabled(&self, _: &log::Metadata<'_>) -> bool {
true
}
fn log(&self, record: &log::Record<'_>) {
if let Ok(mut g) = self.lines.lock() {
g.push(format!("{}", record.args()));
}
}
fn flush(&self) {}
}
static CAPTURE: OnceLock<&'static Capture> = OnceLock::new();
static TEST_LOCK: Mutex<()> = Mutex::new(());
fn install_capture() -> &'static Capture {
*CAPTURE.get_or_init(|| {
let leaked: &'static Capture = Box::leak(Box::new(Capture {
lines: Mutex::new(Vec::new()),
}));
let _ = log::set_logger(leaked);
log::set_max_level(log::LevelFilter::Trace);
leaked
})
}
/// RAII helper. Acquire before driving the chart, drop after assertions
/// — keeps `tests::*` from interleaving captured log lines.
fn lock_for_test() -> MutexGuard<'static, ()> {
install_capture();
let g = TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
// Clear any lines left by a previous test that paniced before
// draining (poison case).
let cap = CAPTURE.get().unwrap();
cap.lines.lock().unwrap().clear();
g
}
fn current_lines() -> Vec<String> {
let cap = install_capture();
cap.lines.lock().unwrap().clone()
}
// ── Test chart ───────────────────────────────────────────────────────
//
// Covers every observation verb the macro can emit. Two states with
// entry/exit actions, a handler that emits and transitions, a timer,
// a during, and a terminate event.
#[derive(Default)]
pub struct Ctx {
pub buf: u32,
}
#[derive(Debug, Clone)]
pub enum Ev {
Go,
Step,
Halt,
}
statechart! {
TraceFmt {
context: Ctx;
events: Ev;
default(Idle);
terminate(Halt);
state Idle {
entry: idle_in;
exit: idle_out;
during: tick(buf);
on(after Duration::from_secs(60)) => Idle;
on(Go) => Active;
}
state Active {
entry: active_in;
exit: active_out;
on(Step) => bump;
on(Halt) => Idle;
}
}
}
async fn tick(_buf: &mut u32) -> Ev {
Ev::Step
}
impl TraceFmtActions for TraceFmtActionContext<'_> {
async fn idle_in(&mut self) {}
async fn idle_out(&mut self) {}
async fn active_in(&mut self) {}
async fn active_out(&mut self) {}
async fn bump(&mut self) {
let _ = self.emit(Ev::Halt);
}
}
// ── Helpers ──────────────────────────────────────────────────────────
/// Returns true iff `line` matches the expected `[statechart:TraceFmt] verb …`
/// shape. Every line emitted by the dispatcher must.
fn well_formed(line: &str) -> bool {
line.starts_with("[statechart:TraceFmt] ")
}
/// Filter `lines` to only those whose verb (the first token after the
/// prefix) equals `verb`. Useful for asserting on a single verb's lines.
fn lines_with_verb<'a>(lines: &'a [String], verb: &str) -> Vec<&'a str> {
let prefix = "[statechart:TraceFmt] ";
lines
.iter()
.filter_map(|l| l.strip_prefix(prefix))
.filter(|rest| rest.split_whitespace().next() == Some(verb))
.map(|s| s.as_ref())
.collect()
}
// ── Tests ────────────────────────────────────────────────────────────
#[tokio::test(flavor = "current_thread")]
async fn every_line_uses_statechart_prefix() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
let _ = m.dispatch(Ev::Halt).await;
})
.await;
let lines = current_lines();
assert!(!lines.is_empty(), "no log lines captured");
for l in &lines {
assert!(
well_formed(l),
"line missing [statechart:TraceFmt] prefix: {l:?}"
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn enter_and_exit_emit_begin_end_pairs() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let entering = lines_with_verb(&lines, "entering");
let entered = lines_with_verb(&lines, "entered");
assert_eq!(
entering.len(),
entered.len(),
"every `entering` must have a matching `entered`"
);
assert!(entering.contains(&"entering state=TraceFmt"));
assert!(entered.contains(&"entered state=TraceFmt"));
assert!(entering.contains(&"entering state=Idle"));
assert!(entered.contains(&"entered state=Idle"));
}
#[tokio::test(flavor = "current_thread")]
async fn transition_records_event_reason() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let begins = lines_with_verb(&lines, "transition-begin");
assert!(!begins.is_empty(), "no transition-begin lines captured");
let line = begins[0];
assert!(
line.contains("from=Idle"),
"transition-begin missing from=Idle: {line:?}"
);
assert!(
line.contains("to=Active"),
"transition-begin missing to=Active: {line:?}"
);
assert!(
line.contains("reason=event:Go"),
"transition-begin missing reason=event:Go: {line:?}"
);
let completes = lines_with_verb(&lines, "transition-complete");
assert!(
!completes.is_empty(),
"transition-complete must follow transition-begin"
);
assert!(completes[0].contains("from=Idle") && completes[0].contains("to=Active"));
}
#[tokio::test(flavor = "current_thread")]
async fn event_lifecycle_received_then_outcome() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let received = lines_with_verb(&lines, "event-received");
let delivered = lines_with_verb(&lines, "event-delivered");
assert!(received.iter().any(|l| l.contains("name=Go")));
assert!(delivered
.iter()
.any(|l| l.contains("name=Go") && l.contains("handler=Idle")));
}
#[tokio::test(flavor = "current_thread")]
async fn during_emits_started_and_cancelled() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
// Go enters Active, which causes Idle's during to be cancelled.
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let started = lines_with_verb(&lines, "during-started");
let cancelled = lines_with_verb(&lines, "during-cancelled");
assert!(started.iter().any(|l| l.contains("during=tick")));
assert!(cancelled.iter().any(|l| l.contains("during=tick")));
}
#[tokio::test(flavor = "current_thread")]
async fn timer_arm_then_cancel_lines() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let armed = lines_with_verb(&lines, "timer-armed");
let cancelled = lines_with_verb(&lines, "timer-cancelled");
assert!(
armed.iter().any(|l| l.contains("state=Idle")),
"timer should arm when Idle is entered"
);
assert!(
cancelled.iter().any(|l| l.contains("state=Idle")),
"timer should be cancelled when Idle is exited (Go transition)"
);
}
#[tokio::test(flavor = "current_thread")]
async fn emit_queued_appears_for_handler_emit() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
// Step's handler calls emit(Halt).
let _ = m.dispatch(Ev::Step).await;
})
.await;
let lines = current_lines();
let queued = lines_with_verb(&lines, "emit-queued");
assert!(
queued.iter().any(|l| l.contains("event=Halt")),
"expected emit-queued event=Halt line: {lines:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn terminate_records_request_then_terminated() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
// Halt from Active is a transition to Idle (no terminate);
// dispatch Halt twice via Idle to actually terminate.
// First Go landed us in Active; Halt → Idle (handler).
let _ = m.dispatch(Ev::Halt).await; // back to Idle
let _ = m.dispatch(Ev::Halt).await; // now terminate
})
.await;
let lines = current_lines();
let req = lines_with_verb(&lines, "terminate-requested");
let term = lines_with_verb(&lines, "terminated");
assert!(
req.iter().any(|l| l.contains("event=Halt")),
"expected terminate-requested event=Halt: {lines:?}"
);
assert!(
!term.is_empty(),
"expected a final `terminated` line: {lines:?}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn started_records_chart_hash() {
let _g = lock_for_test();
tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
})
.await;
let lines = current_lines();
let started = lines_with_verb(&lines, "started");
assert!(!started.is_empty(), "expected one `started` line");
assert!(
started[0].contains("chart_hash=0x"),
"started must include chart_hash in hex: {:?}",
started[0]
);
}
#[tokio::test(flavor = "current_thread")]
async fn trace_and_journal_have_matching_atom_count() {
// The "one journal, multiple outputs" contract: every TraceEvent
// recorded in the journal corresponds to exactly one log line in
// the trace, modulo verb-name differences.
let _g = lock_for_test();
let actual = tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
m.take_journal()
})
.await;
let lines = current_lines();
assert_eq!(
actual.len(),
lines.len(),
"journal/trace atom count diverged ({} vs {} lines)",
actual.len(),
lines.len()
);
}
#[tokio::test(flavor = "current_thread")]
async fn transition_reason_in_journal_matches_trace() {
// The journal records the structured `TransitionReason::Event { event }`
// and the trace renders it as `event:<VariantName>`. They must agree.
let _g = lock_for_test();
let actual = tokio::task::LocalSet::new()
.run_until(async {
let mut m = TraceFmt::new(Ctx::default());
let _ = m.dispatch(Ev::Go).await;
m.take_journal()
})
.await;
let lines = current_lines();
let journal_reason = actual.iter().find_map(|e| match e {
TraceEvent::TransitionFired { reason, .. } => Some(*reason),
_ => None,
});
let trace_reason_line = lines_with_verb(&lines, "transition-begin")
.first()
.copied()
.map(|s| s.to_string());
assert_eq!(
journal_reason,
Some(TransitionReason::Event { event: 0 }),
"expected first transition driven by Ev::Go (event id 0)"
);
assert!(
trace_reason_line
.as_deref()
.is_some_and(|l| l.contains("reason=event:Go")),
"trace reason should match journal reason: {trace_reason_line:?}"
);
}
+36
View File
@@ -151,6 +151,42 @@ audit:
cargo audit
@echo "✅ Security checks passed"
# Run the full unsafe-audit matrix: const-eval at build, debug_assert!
# in debug tests, Miri for UB, mutants on the safety-relevant codegen.
# Mirrors the audit described in `docs/004. unsafe-safety-contract.md`.
[script]
safety:
echo "🔒 Unsafe safety audit"
echo ""
echo "1/4 — Compile-time const-eval (any out-of-range table entry"
echo " fails cargo check with E0080)..."
cargo check --workspace --all-targets
echo ""
echo "2/4 — Runtime debug_assert! in dev tests (every unsafe site"
echo " checks its bound on every dispatch)..."
cargo nextest run --workspace --no-default-features
cargo nextest run --workspace --features tokio
echo ""
echo "3/4 — Miri (UB detector under interpreted Rust)..."
just miri
echo ""
echo "4/4 — Mutation testing on safety-relevant codegen fns..."
# `--features hsmc/tokio,hsmc/journal --test-package hsmc` is
# essential: the safety-relevant tests (det_lateral, det_up,
# det_descent, etc.) live in hsmc/tests/ and are gated on those
# two features. Without them, cargo-mutants runs only the
# hsmc-macros per-package test set and large swathes of the
# LCA / path-derivation logic look uncovered when they aren't.
cargo mutants \
--regex "compute_transition_paths|compute_terminate_paths|compute_event_dispatch" \
--features hsmc/tokio,hsmc/journal \
--test-package hsmc \
--test-tool nextest \
--jobs 4 --no-shuffle
echo ""
echo "✅ Unsafe audit complete. See docs/004. unsafe-safety-contract.md"
echo " for which checks cover which failure modes."
# Run Miri for UB detection (auto-installs nightly + miri component if missing).
[script]
miri:
+5 -4
View File
@@ -16,10 +16,11 @@
rust = { version = "stable", components = "clippy,rustfmt,rust-src,llvm-tools-preview", targets = "thumbv7em-none-eabihf,thumbv6m-none-eabi,thumbv8m.main-none-eabihf,riscv32imac-unknown-none-elf" }
# ── Cargo subcommands the justfile drives ───────────────────────────
"cargo:cargo-nextest" = "latest" # `just test`, `just dev`
"cargo:cargo-llvm-cov" = "latest" # `just coverage`
"cargo:cargo-mutants" = "latest" # `just mutants`
"cargo:cargo-audit" = "latest" # `just audit`
"cargo:cargo-nextest" = "latest" # `just test`, `just dev`
"cargo:cargo-llvm-cov" = "latest" # `just coverage`
"cargo:cargo-mutants" = "latest" # `just mutants`
"cargo:cargo-audit" = "latest" # `just audit`
"cargo:gungraun-runner" = "latest" # `cargo bench --bench dispatch` (valgrind harness)
# ── Build orchestration ─────────────────────────────────────────────
just = "latest"
+39 -2
View File
@@ -22,6 +22,43 @@ columns track Creusot proof state.
| S6.CLEAR | `clear()` makes the view empty | `EventQueue::clear` | 🟡 |
| S6.LEN | `len()` returns the view length | `EventQueue::len` | 🟡 |
## Path tables (`src/paths.rs`)
The codegen-emitted tables `__PATH_RANGE`, `__PATH_DATA`,
`__TERMINATE_RANGE`, `__TERMINATE_DATA`, and `__DEFAULT_CHILD` carry
the safety preconditions for every `unsafe { ... .get_unchecked(...) }`
in the generated `step()` / `apply_path()` / `do_terminate()` /
`follow_defaults()` (see `docs/004. unsafe-safety-contract.md`). The
proof strategy is two-pronged:
- A `const _: () = { assert!(...) };` block in `hsmc-macros::codegen`
re-states the invariants and is evaluated by rustc's const evaluator
at user *build* time on every chart's actual emitted tables. Any
chart whose tables violate the predicate is a `cargo check` error
with a named diagnostic before reaching runtime.
- The Pearlite-annotated functions in `paths.rs` express the *same
predicate* with an `#[ensures(... forall ...)]` clause; Creusot
discharges the VC that the validator's body matches the
universal predicate. `tests/correspondence_paths.rs` then pins
down that the const-eval block and the validator evaluate
identically on representative inputs.
| Spec rule | Statement | Function | Status |
|-----------|-----------|----------|--------|
| P1.PATH | `forall i. let (s,m,e) = __PATH_RANGE[i] in s ≤ m ≤ e ≤ __PATH_DATA.len()` | `check_path_range_invariant` | 🟡 |
| P1.TERM | `forall i. let (s,e) = __TERMINATE_RANGE[i] in s ≤ e ≤ __TERMINATE_DATA.len()` | `check_terminate_range_invariant` | 🟡 |
| P2.PATH_ID | `forall i. __PATH_DATA[i] < n_states` | `check_state_ids_in_bounds` | 🟡 |
| P2.TERM_ID | `forall i. __TERMINATE_DATA[i] < n_states` | `check_state_ids_in_bounds` | 🟡 |
| P2.DEFAULT | `forall i. match __DEFAULT_CHILD[i] { Some(t) => t < n_states, None => () }` | `check_default_child_in_bounds` | 🟡 |
| P.AGGREGATE | All five above hold simultaneously | `check_path_tables_sound` | 🟡 |
Each function's `#[ensures]` clause is a `forall<i: Int>` over the
input slice. Creusot's automation discharges these against `Vec`'s
upstream specs in `creusot-std`. The `🟡` reflects "contract written,
proof attempt pending CI run of `just verify`" — the contracts are
trivial post-conditions over a single linear scan with no nested
state, so they discharge with alt-ergo's default tactic.
## TimerTable (`src/timer_table.rs`)
| Spec rule | Statement | Function | Status |
@@ -41,8 +78,8 @@ columns track Creusot proof state.
| Item | Reason |
|------|--------|
| `transition()` codegen invariants (path validity) | Requires threading Creusot attrs through the proc-macro. Phase 4. |
| `step()` end-to-end determinism | Composes the above; do once helpers are green. Phase 4. |
| `compute_transition_paths` algorithm itself | Universal proof "for any tree input, output satisfies P1 + P2" requires modeling the tree structure in Pearlite. The current strategy proves the *predicate* is correct and runs it via const-eval on every actual chart at build time — operationally equivalent for end users (every compiled chart is verified) but not the universal quantification that Creusot would give. Phase 5. |
| `step()` end-to-end determinism | Composes everything above; do once helpers are green. Phase 5. |
| Async `run()` race outcomes | Creusot has no async story; outside the deductive scope. |
| User action bodies | User code, not part of the runtime. |
| `duration_from_secs_f64` | Floating-point not modeled. Use `#[trusted]` + property tests. |
+1
View File
@@ -44,4 +44,5 @@
// use `std::vec::Vec` (via creusot-std's specs) directly.
pub mod event_queue;
pub mod paths;
pub mod timer_table;
+285
View File
@@ -0,0 +1,285 @@
//! Verified validator for the codegen-emitted transition path tables.
//!
//! Background: after the Opt #2 perf pass, the generated `step()` /
//! `apply_path()` / `do_terminate()` index into static tables
//! (`__PATH_RANGE`, `__PATH_DATA`, `__TERMINATE_RANGE`,
//! `__TERMINATE_DATA`, `__DEFAULT_CHILD`) via `slice::get_unchecked`.
//! That's sound iff the codegen-emitted values satisfy two predicates:
//!
//! - **P1** *(state-id well-formedness)*: every state id stored in
//! `__PATH_DATA`, `__TERMINATE_DATA`, or as a `Some(t)` inside
//! `__DEFAULT_CHILD` is `< n_states`.
//! - **P2** *(range-table well-formedness)*: every triple
//! `__PATH_RANGE[i] = (s, m, e)` satisfies `0 ≤ s ≤ m ≤ e ≤ data_len`.
//! Same for `__TERMINATE_RANGE[i] = (s, e)` against `__TERMINATE_DATA`.
//!
//! The codegen already enforces these at *user build time* via a
//! `const _: () = { ... }` block in `hsmc-macros::codegen` that runs the
//! same predicates as `assert!`s in const-context — any chart whose
//! emitted tables fail the check is a `cargo check` error before
//! reaching runtime. So the property is operationally proven for every
//! chart that compiles.
//!
//! What this module adds: a Creusot-verified version of the *predicate
//! itself*, so we know the property the const-eval block is checking
//! is the property the unsafe blocks rely on. The contracts say:
//!
//! ```text
//! check_path_range_invariant(range, data_len) == true
//! iff
//! ∀ i ∈ 0..range.len(): let (s, m, e) = range[i] in
//! s ≤ m ≤ e ≤ data_len
//! ```
//!
//! ```text
//! check_path_ids_in_range(data, n_states) == true
//! iff
//! ∀ i ∈ 0..data.len(): data[i] < n_states
//! ```
//!
//! With Creusot discharging both VCs, the chain
//!
//! - hsmc-macros emits tables
//! - const-eval runs the predicate (or its rustc-evaluated equivalent)
//! - this module's predicate is proven equivalent to P1 + P2
//! - therefore P1 + P2 hold for every chart that compiled
//!
//! is closed end-to-end with the only remaining trust point being the
//! identity between the const-eval block's body and this module's
//! predicate (mechanical — they are the same expressions, copied in
//! `tests/correspondence_paths.rs`).
use creusot_std::macros::ensures;
// `pearlite!` looks unused to plain rustc but Creusot's preprocessing
// needs it for quantified ensures bodies.
#[allow(unused_imports)]
use creusot_std::macros::pearlite;
/// Returns `true` iff every triple `(s, m, e)` in `range` satisfies
/// `s ≤ m ≤ e ≤ data_len`. Mirror of the const-eval check on
/// `__PATH_RANGE`.
///
/// Pearlite contract: the function is total (terminates), pure, and
/// the result is equivalent to the universal predicate over `range`.
#[ensures(result == forall<i: Int>
0 <= i && i < range@.len()
==> (range@[i].0 as u64) <= (range@[i].1 as u64)
&& (range@[i].1 as u64) <= (range@[i].2 as u64)
&& (range@[i].2 as u64) <= (data_len as u64)
)]
pub fn check_path_range_invariant(range: &[(u16, u16, u16)], data_len: usize) -> bool {
let mut i: usize = 0;
#[allow(clippy::needless_range_loop)]
while i < range.len() {
let (s, m, e) = range[i];
if !((s as usize) <= (m as usize)
&& (m as usize) <= (e as usize)
&& (e as usize) <= data_len)
{
return false;
}
i += 1;
}
true
}
/// Returns `true` iff every triple `(s, e)` in `range` satisfies
/// `s ≤ e ≤ data_len`. Mirror of the const-eval check on
/// `__TERMINATE_RANGE`.
#[ensures(result == forall<i: Int>
0 <= i && i < range@.len()
==> (range@[i].0 as u64) <= (range@[i].1 as u64)
&& (range@[i].1 as u64) <= (data_len as u64)
)]
pub fn check_terminate_range_invariant(range: &[(u16, u16)], data_len: usize) -> bool {
let mut i: usize = 0;
while i < range.len() {
let (s, e) = range[i];
if !((s as usize) <= (e as usize) && (e as usize) <= data_len) {
return false;
}
i += 1;
}
true
}
/// Returns `true` iff every state id stored in `data` is `< n_states`.
/// Mirror of the const-eval check on `__PATH_DATA` and
/// `__TERMINATE_DATA`.
#[ensures(result == forall<i: Int>
0 <= i && i < data@.len()
==> (data@[i] as u64) < (n_states as u64)
)]
pub fn check_state_ids_in_bounds(data: &[u16], n_states: usize) -> bool {
let mut i: usize = 0;
while i < data.len() {
if (data[i] as usize) >= n_states {
return false;
}
i += 1;
}
true
}
/// Returns `true` iff every `Some(t)` in `default_child` satisfies
/// `t < n_states`. `None` entries (states with no default child) are
/// ignored. Mirror of the const-eval check on `__DEFAULT_CHILD`.
#[ensures(result == forall<i: Int>
0 <= i && i < default_child@.len()
==> match default_child@[i] {
Some(t) => (t as u64) < (n_states as u64),
None => true,
}
)]
pub fn check_default_child_in_bounds(default_child: &[Option<u16>], n_states: usize) -> bool {
let mut i: usize = 0;
while i < default_child.len() {
if let Some(t) = default_child[i] {
if (t as usize) >= n_states {
return false;
}
}
i += 1;
}
true
}
/// Aggregate predicate: returns `true` iff all four invariants hold.
/// This is the property that, when true, makes every codegen-emitted
/// `unsafe { ... .get_unchecked(...) }` in step / apply_path /
/// follow_defaults / do_terminate sound.
#[ensures(result == (
(forall<i: Int>
0 <= i && i < path_range@.len()
==> (path_range@[i].0 as u64) <= (path_range@[i].1 as u64)
&& (path_range@[i].1 as u64) <= (path_range@[i].2 as u64)
&& (path_range@[i].2 as u64) <= (path_data@.len() as u64))
&& (forall<i: Int>
0 <= i && i < terminate_range@.len()
==> (terminate_range@[i].0 as u64) <= (terminate_range@[i].1 as u64)
&& (terminate_range@[i].1 as u64) <= (terminate_data@.len() as u64))
&& (forall<i: Int>
0 <= i && i < path_data@.len()
==> (path_data@[i] as u64) < (n_states as u64))
&& (forall<i: Int>
0 <= i && i < terminate_data@.len()
==> (terminate_data@[i] as u64) < (n_states as u64))
&& (forall<i: Int>
0 <= i && i < default_child@.len()
==> match default_child@[i] {
Some(t) => (t as u64) < (n_states as u64),
None => true,
})
))]
#[allow(clippy::too_many_arguments)]
pub fn check_path_tables_sound(
path_range: &[(u16, u16, u16)],
path_data: &[u16],
terminate_range: &[(u16, u16)],
terminate_data: &[u16],
default_child: &[Option<u16>],
n_states: usize,
) -> bool {
check_path_range_invariant(path_range, path_data.len())
&& check_terminate_range_invariant(terminate_range, terminate_data.len())
&& check_state_ids_in_bounds(path_data, n_states)
&& check_state_ids_in_bounds(terminate_data, n_states)
&& check_default_child_in_bounds(default_child, n_states)
}
#[cfg(test)]
mod tests {
use super::*;
// Sanity checks that the predicates behave correctly on small
// inputs. The ensures clauses are what Creusot proves; these tests
// pin down the executable behavior.
#[test]
fn empty_range_passes() {
assert!(check_path_range_invariant(&[], 0));
assert!(check_path_range_invariant(&[], 100));
assert!(check_terminate_range_invariant(&[], 0));
assert!(check_state_ids_in_bounds(&[], 0));
assert!(check_default_child_in_bounds(&[], 0));
}
#[test]
fn well_formed_paths() {
// s ≤ m ≤ e ≤ data_len for all entries.
let data: Vec<u16> = vec![0, 1, 2, 0, 1];
let range = vec![(0, 2, 3), (3, 3, 5), (0, 0, 0)];
assert!(check_path_range_invariant(&range, data.len()));
assert!(check_state_ids_in_bounds(&data, 3));
}
#[test]
fn path_range_s_gt_m_rejected() {
let range = vec![(2, 1, 3)];
assert!(!check_path_range_invariant(&range, 5));
}
#[test]
fn path_range_m_gt_e_rejected() {
let range = vec![(0, 3, 2)];
assert!(!check_path_range_invariant(&range, 5));
}
#[test]
fn path_range_e_past_data_rejected() {
let range = vec![(0, 1, 6)];
assert!(!check_path_range_invariant(&range, 5));
}
#[test]
fn state_id_oob_rejected() {
let data = vec![0u16, 1, 99];
assert!(!check_state_ids_in_bounds(&data, 3));
}
#[test]
fn default_child_some_oob_rejected() {
let default_child = vec![Some(0u16), None, Some(99)];
assert!(!check_default_child_in_bounds(&default_child, 3));
}
#[test]
fn default_child_all_none_passes() {
let default_child = vec![None, None, None];
assert!(check_default_child_in_bounds(&default_child, 0));
}
#[test]
fn aggregate_sound() {
let path_range = vec![(0, 1, 2)];
let path_data = vec![0u16, 1];
let term_range = vec![(0, 1)];
let term_data = vec![0u16];
let default_child = vec![Some(0u16), None];
assert!(check_path_tables_sound(
&path_range,
&path_data,
&term_range,
&term_data,
&default_child,
2,
));
}
#[test]
fn aggregate_unsound_when_any_fails() {
let path_range = vec![(0, 1, 2)];
let path_data = vec![0u16, 99]; // id out of range
let term_range = vec![(0, 1)];
let term_data = vec![0u16];
let default_child = vec![Some(0u16), None];
assert!(!check_path_tables_sound(
&path_range,
&path_data,
&term_range,
&term_data,
&default_child,
2,
));
}
}
+213
View File
@@ -0,0 +1,213 @@
//! Correspondence test: the Pearlite-verified path-table validator
//! evaluates the *same predicate* as the `const _: () = { ... }` block
//! that `hsmc-macros` emits in every generated chart.
//!
//! Why this matters: the safety chain for the unsafe blocks in
//! `step()` / `apply_path()` / `do_terminate()` / `follow_defaults()` is
//!
//! (a) hsmc-macros emits __PATH_RANGE / __PATH_DATA / __TERMINATE_*
//! / __DEFAULT_CHILD with the values produced by
//! `compute_transition_paths` and `compute_terminate_paths`.
//! (b) A const-eval block runs `assert!(s <= m && m <= e && e <= len
//! && id < n_states && ...)` over those tables at user build
//! time. Build fails if any chart's tables violate the predicate.
//! (c) The Pearlite-annotated validator in
//! `hsmc_verification::paths` proves (under Creusot) that the
//! *function* that runs those checks correctly decides P1 + P2.
//!
//! The remaining trust point is "(b) and (c) are checking the same
//! thing". This test pins that down by exercising the validator on
//! the exact predicate the const-eval block uses, on inputs that
//! mirror codegen's output shape — both well-formed and adversarial.
//!
//! When this test passes alongside the const-eval block compiling,
//! every chart that compiles has been verified by a function whose
//! correctness is in turn formally proven (when `just verify` runs).
use hsmc_verification::paths::{
check_default_child_in_bounds, check_path_range_invariant, check_path_tables_sound,
check_state_ids_in_bounds, check_terminate_range_invariant,
};
/// Shape that mirrors `compute_transition_paths`'s output for a small
/// chart with N=4 states:
///
/// __Root
/// ├── A (id 1, depth 1)
/// ├── B (id 2, depth 1)
/// └── C (id 3, depth 1, default child = none)
///
/// Encoded paths for transition A→B (lateral, LCA = root):
/// exit_path = [A]; enter_path = [B]
/// __PATH_RANGE[A * N + B] = (s=0, m=1, e=2)
/// __PATH_DATA = [A, B] = [1, 2]
// `1 * 4 + 2` etc. are deliberately written `row * stride + col` so the
// __PATH_RANGE indexing scheme matches the codegen formula
// `from * N_STATES + to`; reducing to literals would obscure intent.
// The helper's 6-tuple shape mirrors the codegen outputs we're
// validating; factoring into a struct would just rename the same data.
#[allow(clippy::identity_op, clippy::type_complexity)]
fn small_chart_tables() -> (
Vec<(u16, u16, u16)>, // path_range
Vec<u16>, // path_data
Vec<(u16, u16)>, // terminate_range
Vec<u16>, // terminate_data
Vec<Option<u16>>, // default_child
usize, // n_states (incl. root)
) {
// 4 states (0=root, 1=A, 2=B, 3=C). 16-entry path_range.
// Identity transitions self → self use (s, m, e) = (i*2, i*2, i*2+1)
// — exit empty, enter [self]. Lateral A→B and B→A point at the same
// path_data slice. The exact values don't matter for this test;
// what matters is the structural invariant holds.
let path_data: Vec<u16> = vec![1, 2, 2, 1];
// 4*4 = 16 entries; only a few are populated, rest are (0, 0, 0)
// which is the "no transition" sentinel. (s, m, e) = (0, 0, 0)
// satisfies s <= m <= e <= len trivially.
let mut path_range: Vec<(u16, u16, u16)> = vec![(0, 0, 0); 16];
path_range[1 * 4 + 2] = (0, 1, 2); // A→B: exit [A], enter [B]
path_range[2 * 4 + 1] = (2, 3, 4); // B→A: exit [B], enter [A]
// 4 states, each terminate path is the state itself plus root.
let terminate_data: Vec<u16> = vec![1, 0, 2, 0, 3, 0]; // A,root | B,root | C,root
let terminate_range: Vec<(u16, u16)> = vec![
(0, 0), // root: empty
(0, 2), // A: [A, root]
(2, 4), // B: [B, root]
(4, 6), // C: [C, root]
];
let default_child: Vec<Option<u16>> = vec![Some(1), None, None, None];
(
path_range,
path_data,
terminate_range,
terminate_data,
default_child,
4,
)
}
#[test]
fn well_formed_tables_pass_validator() {
let (pr, pd, tr, td, dc, n) = small_chart_tables();
assert!(check_path_range_invariant(&pr, pd.len()));
assert!(check_terminate_range_invariant(&tr, td.len()));
assert!(check_state_ids_in_bounds(&pd, n));
assert!(check_state_ids_in_bounds(&td, n));
assert!(check_default_child_in_bounds(&dc, n));
assert!(check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
}
#[test]
#[allow(clippy::identity_op)]
fn adversarial_path_range_s_gt_m_caught() {
// Same shape but with one bad triple. Mirrors what an
// off-by-one in `compute_transition_paths` would emit.
let (mut pr, pd, tr, td, dc, n) = small_chart_tables();
pr[1 * 4 + 2] = (2, 1, 2); // s > m
assert!(!check_path_range_invariant(&pr, pd.len()));
assert!(!check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
}
#[test]
#[allow(clippy::identity_op)]
fn adversarial_path_range_e_past_data_caught() {
let (mut pr, pd, tr, td, dc, n) = small_chart_tables();
pr[1 * 4 + 2] = (0, 1, 99); // e past data_len
assert!(!check_path_range_invariant(&pr, pd.len()));
assert!(!check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
}
#[test]
fn adversarial_path_data_id_oob_caught() {
let (pr, mut pd, tr, td, dc, n) = small_chart_tables();
pd[1] = 99; // state id past N_STATES
assert!(!check_state_ids_in_bounds(&pd, n));
assert!(!check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
}
#[test]
fn adversarial_terminate_range_e_past_data_caught() {
let (pr, pd, mut tr, td, dc, n) = small_chart_tables();
tr[1] = (0, 99); // e past terminate_data_len
assert!(!check_terminate_range_invariant(&tr, td.len()));
assert!(!check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
}
#[test]
fn adversarial_terminate_data_id_oob_caught() {
let (pr, pd, tr, mut td, dc, n) = small_chart_tables();
td[0] = 99;
assert!(!check_state_ids_in_bounds(&td, n));
assert!(!check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
}
#[test]
fn adversarial_default_child_id_oob_caught() {
let (pr, pd, tr, td, mut dc, n) = small_chart_tables();
dc[0] = Some(99);
assert!(!check_default_child_in_bounds(&dc, n));
assert!(!check_path_tables_sound(&pr, &pd, &tr, &td, &dc, n));
}
#[test]
fn empty_tables_pass_trivially() {
// Single-state chart (just root, no transitions): all tables
// either empty or trivial.
assert!(check_path_tables_sound(
&[],
&[],
&[(0, 0)],
&[],
&[None],
1,
));
}
#[test]
fn the_const_eval_block_predicate_matches_the_validator() {
// The const-eval block emitted by hsmc-macros::codegen runs:
//
// assert!(s as usize <= m as usize, "...");
// assert!(m as usize <= e as usize, "...");
// assert!(e as usize <= __PATH_DATA.len(), "...");
//
// Translated: for every i, range[i] satisfies s ≤ m ≤ e ≤ len.
//
// The validator's #[ensures] clause says the same thing as a
// forall over Int. This test checks that on a representative
// input, the validator returns true exactly when the const-eval
// block's three assertions all pass — the same semantics.
let data_len = 10usize;
let cases: &[(u16, u16, u16, bool)] = &[
// (s, m, e, expected_pass)
(0, 0, 0, true),
(0, 5, 10, true),
(5, 5, 5, true),
(0, 0, 10, true),
(0, 10, 10, true),
(1, 0, 5, false), // s > m
(0, 6, 5, false), // m > e
(0, 5, 11, false), // e > len
];
for &(s, m, e, expected) in cases {
let const_eval_predicate = (s as usize) <= (m as usize)
&& (m as usize) <= (e as usize)
&& (e as usize) <= data_len;
let validator_result = check_path_range_invariant(&[(s, m, e)], data_len);
assert_eq!(
const_eval_predicate, expected,
"const-eval predicate disagrees with expectation for ({s}, {m}, {e})"
);
assert_eq!(
validator_result, expected,
"validator disagrees with expectation for ({s}, {m}, {e})"
);
assert_eq!(
const_eval_predicate, validator_result,
"const-eval predicate and validator diverge for ({s}, {m}, {e})"
);
}
}