26 KiB
Changelog
All notable changes to this workspace are documented here. The format follows Keep a Changelog and this project adheres to Semantic Versioning.
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_uncheckedon codegen-emitted dispatch tables.__DISPATCH,__DEFAULT_CHILD,__PATH_RANGE,__PATH_DATA,__TERMINATE_RANGE, and__TERMINATE_DATAare all indexed by codegen-produced state ids whose bounds are guaranteed at codegen time. Replacing the bounds-checked[idx]/[s..e]forms withslice::get_uncheckedunlocks aggressive inlining beyond what plain bounds-check elision would buy:apply_pathinlines intostep()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 singleu16store.pop_expired_nonemptyinlining. 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_handlersalso inlines intostep()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;
a short summary:
- Compile-time const-eval. Each chart now contains a
const _: () = { ... };block that re-states the safety preconditions asassert!s evaluated byrustc's const evaluator at usercargo checktime. 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. Everyunsafeblock has a paireddebug_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 hsmcruns 20 unit + 46 behavior tests under the UB interpreter. All clean. - Mutation testing.
cargo mutantsagainst 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_DATAoutput and so cannot be distinguished by any behavior test (formal analysis in the safety contract doc). - Creusot-verified validator.
verification/src/paths.rscarries 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.rsincludes 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.tomldev-dep:iai-callgrind = "0.16"→gungraun = "0.18"mise.toml: replace theiai-callgrind-runnerinstall withcargo: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/000rule 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 ofcompute_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 toversion = "0.6". - New:
just safetyrecipe 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-APACHEremoved,LICENSE-MITrenamed toLICENSE, workspacelicensefield is now"MIT". Copyright (c) 2026 Stephen Waits <steve@waits.net>. - 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 "Whathsmcdeliberately 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.LocalSetno longer appears in the README at all. microwave_tuiexample 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. Themicrowaveexample covers the same feature surface in plain Rust.- Observability framed honestly. Enabling
defmt/log/tracingoutput for a chart is onetrace-*Cargo feature on hsmc's side, plus whatever your downstream crate already does to init its logger (e.g.tracing_subscriber::fmt::init()orenv_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 usingtracing-subscriber. Thetrace-*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_onandlight_offactions (it previously had onlylight_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 —Standbyhas substates likeLowPoweryou enter only on demand, and=> Standby;lands atStandbyitself 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(withreason: TransitionReason::Internal) andTransitionCompletebrackets around their entry sequence.Internalwas 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), anddefaulton 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.mdrewritten 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_DATAblob with a__PATH_RANGE: [(u16, u16, u16); N²]index.transition()becomes one indexed range lookup + two slice loops; thelca()runtime helper,is_up_transition(), and the per-callheapless::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__PARENTbubble walk would have found.__DISPATCH: [(u16, &'static [u16], Option<u16>); N×E]stores(handler_state, action_ids, transition_target)per pair;u16::MAXin the handler-state slot is the no-handler sentinel. The runtime walk + per-hop__handler_lookupcall instep()collapses to one indexed lookup. Handler resolution is now O(1) regardless of bubble depth — thebubble_topbench (4-hop walk) is now within 3 instructions ofbubble_leaf(handler on current state).__handler_lookupis retained for timer dispatch (thedispatch_triggerpath doesn't bubble). - Precomputed terminate exit path. Per-state
__TERMINATE_DATAtable holds the full exit chain up to and including__Root(which may carry root-levelentry:/exit:actions).do_terminate()indexes the table once and iterates the slice, replacing the runtime__PARENTwalk + per-iteration match. Queue drain switched fromwhile pop_front().is_some()toDeque::clear()(heapless 0.8 implements this as O(1) — resets indices instead of popping each entry).terminate_eventbench: −22.5% instr / −23.4% cycles. - TimerTable empty-skip.
pop_expired,min_remaining,decrement, andcancel_stateeach get#[inline]+ anis_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
Durationtriggers (the common case for event-driven HSMs), everyTimerTablecall is omitted fromstep()andexit_state()at codegen.step()returnsNonedirectly 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 realisticDuration. Eliminates u128 saturating-sub indecrement()(4–6 instructions per entry → 1–2) and the manual secs/nanos decomposition inmin_remaining()(replaced withDuration::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__DISPATCHtable makes resolution depth-independent.timer_fire_dispatch— only bench that exercisespop_expired's non-empty branch anddispatch_trigger. The most expensive single-step bench in the suite (191 instr / 706 cycles).terminate_event— only bench exercising__check_terminate's true arm anddo_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/ortracing(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 undertracing). 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 whichtrace-*cargo feature you enable.
Changed — journal (breaking)
TraceEvent::TransitionFiredcarriesreason: 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;Enteredkeeps its place as the end marker.ExitBegan { state }fires before a state's during cancellations / timer cancellations / exit actions;Exitedkeeps its place as the end marker.TransitionComplete { from, to }fires after a transition's full exit-then-enter sequence finishes; pairs withTransitionFired.EventReceived { event }fires when an event is popped from the queue, before handler search; pairs withEventDelivered/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.TransitionReasonenum — re-exported from the crate root whenfeature = "journal"is on. Carried onTraceEvent::TransitionFired.- Multiple trace backends compose. Previously enabling more than
one of
trace-defmt/trace-log/trace-tracingwas 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:
-
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 bykey=valuepairs. -
TraceEvent::TransitionFiredhas a newreasonfield. Update pattern matches:// before: TraceEvent::TransitionFired { from, to } => … // after: TraceEvent::TransitionFired { from, to, reason } => … // or, if you don't care: TraceEvent::TransitionFired { from, to, .. } => … -
New
TraceEventvariants. If youmatchexhaustively, add arms forEnterBegan,ExitBegan,TransitionComplete,EventReceived. Otherwise nothing changes. -
trace;keyword is now a no-op. Charts that declared it still compile. Trace output is now controlled purely by whichtrace-*cargo feature you enable. -
__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
journalCargo feature. Records every observable atom of chart execution —Started,Entered,Exited,ActionInvoked(Entry/Exit/Handler),DuringStarted,DuringCancelled,TransitionFired,EventDelivered,EventDropped,EmitQueued,EmitFailed,TimerArmed,TimerCancelled,TimerFired,TerminateRequested,Terminated— into aVec<TraceEvent>on the generated machine. Same chart + same events = byte-identical journal. Public API:journal(),take_journal(),clear_journal(),CHART_HASH(a stable FNV-1a fingerprint of the chart's structural definition).- Root state targetable by chart name.
on(Trig) => MyChart;resolves to the root state in bothclassify_targetandresolve_transitions. From any nested state this is an up-transition (root is always on the active path). - 113 deterministic-flow tests (under
--features tokio,journal) comparing hand-built expected journals byte-for-byte against actual, anchored to spec sections: initial entry, lateral / self / up transitions, default-descent, event bubbling, timer arm / cancel / fire, emit, ordering, durings, step-vs-dispatch equivalence, root-targeting,current_state(), termination. verification/workspace member with Creusot proofs. Verified mirrors of the runtimeEventQueueandTimerTablecarrycreusot-std(Pearlite) contracts that Why3 + alt-ergo + z3 discharge mechanically. 14/14 VCs proven.just verifyis self-installing (mise pulls opam + the pinned nightly + Creusot's canonicalINSTALL).docs/002. hsmc-semantics-formal.md— canonical, unambiguous reference for chart behavior. Numbered S-rules supersede the v0.1 prose spec and v0.2 design-change-request.- README "How a chart behaves" section — eight building-block rules that build on each other, with an explicit "what hsmc deliberately does NOT have" table.
Tooling
mise.toml(root) declares stable Rust + components + embedded targets + cargo subcommands.verification/mise.tomldeclares the pinnednightly-2025-11-13+ opam + python venv + uv.set shellandset script-interpreterroute everyjustrecipe throughmise exec— no shebangs, no per-recipe wrappers.just verifyrunscargo creusot prove --no-cacheso SMT invocations happen on every run;timeshows ~7s CPU on ~1.2s wall = real parallel work.just mutantsruns with--features tokio,journalsojournal_*anddet_*tests execute. 0 missed mutants.just miriexcludeshsmc-macros(trybuild glob hits Miri's isolation) andhsmc-verification(different pinned toolchain). 66 tests pass under Miri, 0 UB.
Backward compatibility
Every v0.1 / v0.2 statechart compiles under v0.3 unchanged.
0.2.0 — 2026-04-22
Fixed — behavior-visible (breaking)
- Up-transitions no longer exit and re-enter the target ancestor.
A transition whose target is already active (i.e., the target is on
the path from root to the innermost active state, and the target is
not itself the innermost) now unwinds only the subtree strictly
below the target. The target's
exit:/entry:actions do NOT fire, and itsdefault(...)does NOT re-descend. You cannot re-enter a state you never left. The old semantics (exit target, re-enter, re-descend to default) was incorrect and could produce infinite re-entry when a child transitioned to its own parent. See spec §2.6, §T2.8, §T2.8b, §T2.8c. - Self-transitions (
target == innermost) are unchanged: the target is still exited and re-entered, timers still restart. - Lateral transitions (target not on the active path) are unchanged.
current_state()may now return a composite state when it became innermost via an up-transition.
0.1.0 — 2026-04-22
Initial release.
Added
statechart!proc macro: declarative Harel-style hierarchical state machines with nested states,entry:/exit:actions, LCA-aware transitions, default-child descent, andterminatesemantics.- Typed event payload bindings:
on(PacketRx { rssi: i16 }) => handler;expands into a typed handler signature, verified at compile time. - Timer triggers:
on(after Duration::from_secs(5)) => Next;andon(every Duration::from_millis(100)) => tick;, tracked in a fixed-capacityTimerTable. during:activities (v0.2 semantics): async functions that run while a state is on the active path and produce events. Multiple concurrent durings on a single state borrow disjoint&mutslices of the context, enforced by Rust's native split-borrow.- Runtime:
machine.run().awaitraces active durings, the external event channel, and timer deadlines — one future, cooperative on embassy, multi-threaded on tokio. - Feature gates:
tokio(std + tokio runtime),embassy(no_std + embassy-time / embassy-sync). The two features are mutually exclusive and enforced viacompile_error!. no_stdby default;stdpulled in only via thetokiofeature.STATE_CHARTconst anddispatch()helper for introspection.- Workspace:
hsmc(library) andhsmc-macros(proc-macro front-end) both at0.1.0.
Docs
- 320-line
README.mdwith quickstart, semantics, and examples. - Runnable examples:
microwaveandduring_radiounder tokio,embassy_fullunder embassy. - Behavior, hierarchy, during, timer, and payload-binding test suites (13 integration tests).