Phase 1 — semantics doc
docs/002. hsmc-semantics-formal.md
Phase 2 — deterministic execution journal
hsmc/src/journal.rs (TraceEvent, JournalSink, ActionKind)
hsmc-macros: __chart_journal! emission at every observable site
(entry/exit/action/transition/timer arm-cancel-fire/during/emit/
event delivery/terminate). Per-state during count helpers, stable
chart-hash via FNV-1a. Action observation moved out of run_action
and into call sites so ActionKind is accurate.
Public API on the generated machine: journal(), take_journal(),
clear_journal(), CHART_HASH.
Phase 3 — verification crate (Creusot scaffolding)
verification/{Cargo.toml,mise.toml,requirements.txt,INVARIANTS.md}
verification/src/{event_queue.rs,timer_table.rs} — Pearlite
contracts on simplified mirrors of the runtime types. cargo
creusot generates Coma IR cleanly; VCs not yet discharged.
Correspondence tests drive the verified mirror and the runtime
type with the same op sequences and assert observable parity.
Tests — 113 deterministic-flow tests across 22 files. Each compares a
hand-built expected journal byte-for-byte against actual, anchored
to a spec section: initial entry, lateral/self/up transitions,
bubbling, timers, emit, ordering, durings, step/dispatch
equivalence, root-targeting, current_state, termination.
Tooling — mise.toml (root): stable Rust + cargo subcommands.
verification/mise.toml: pinned nightly-2025-11-13 + opam + python
venv + uv. just verify is fully self-installing.
Bug fix — root state targetable by the chart's own name (e.g.
`on(Trig) => ChartName;`) per spec §"How transitions work".
CHART_HASH — stable FNV-1a fingerprint over the chart's structural
definition; mismatch on replay = different chart.
127 lines
4.1 KiB
Rust
127 lines
4.1 KiB
Rust
#![allow(dead_code)]
|
|
//! Tests that an `emit()` from inside an entry/exit action gets queued
|
|
//! BEFORE the transition completes, but is dispatched AFTER.
|
|
|
|
#![cfg(all(feature = "tokio", feature = "journal"))]
|
|
#![allow(unexpected_cfgs)]
|
|
|
|
use hsmc::{statechart, TraceEvent};
|
|
|
|
#[derive(Default)]
|
|
pub struct Ctx;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Ev {
|
|
Go,
|
|
Cleanup, // emitted from A's exit action
|
|
Wakeup, // emitted from B's entry action
|
|
Halt,
|
|
}
|
|
|
|
statechart! {
|
|
Et {
|
|
context: Ctx;
|
|
events: Ev;
|
|
default(A);
|
|
terminate(Halt);
|
|
|
|
on(Cleanup) => log_cleanup;
|
|
on(Wakeup) => log_wakeup;
|
|
|
|
state A {
|
|
entry: a_in;
|
|
exit: emit_cleanup;
|
|
on(Go) => B;
|
|
}
|
|
state B {
|
|
entry: emit_wakeup;
|
|
exit: b_out;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl EtActions for EtActionContext<'_> {
|
|
async fn a_in(&mut self) {}
|
|
async fn b_out(&mut self) {}
|
|
async fn emit_cleanup(&mut self) {
|
|
let _ = self.emit(Ev::Cleanup);
|
|
}
|
|
async fn emit_wakeup(&mut self) {
|
|
let _ = self.emit(Ev::Wakeup);
|
|
}
|
|
async fn log_cleanup(&mut self) {}
|
|
async fn log_wakeup(&mut self) {}
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn det_emit_in_exit_action_runs_after_transition() {
|
|
// Spec: emitted events are processed only after the current event has
|
|
// fully finished — including all entry/exit actions.
|
|
tokio::task::LocalSet::new().run_until(async {
|
|
let mut m = Et::new(Ctx);
|
|
let _ = m.dispatch(Ev::Go).await;
|
|
let j = m.take_journal();
|
|
|
|
// Find indices of EmitQueued for Cleanup, EmitQueued for Wakeup, and
|
|
// EventDelivered for each.
|
|
// Event ids in interning order: Cleanup=0, Wakeup=1 (root handlers first),
|
|
// Go=2 (A handler), Halt=3 (terminate at end of build).
|
|
let cleanup_queued = j.iter().position(|e| matches!(
|
|
e, TraceEvent::EmitQueued { event: 0 }
|
|
));
|
|
let wakeup_queued = j.iter().position(|e| matches!(
|
|
e, TraceEvent::EmitQueued { event: 1 }
|
|
));
|
|
let cleanup_delivered = j.iter().position(|e| matches!(
|
|
e, TraceEvent::EventDelivered { event: 0, .. }
|
|
));
|
|
let wakeup_delivered = j.iter().position(|e| matches!(
|
|
e, TraceEvent::EventDelivered { event: 1, .. }
|
|
));
|
|
|
|
assert!(cleanup_queued.is_some(), "Cleanup must be queued");
|
|
assert!(wakeup_queued.is_some(), "Wakeup must be queued");
|
|
assert!(cleanup_delivered.is_some(), "Cleanup must be delivered");
|
|
assert!(wakeup_delivered.is_some(), "Wakeup must be delivered");
|
|
|
|
// Both emits queue BEFORE either is delivered (no re-entrant dispatch
|
|
// inside the original Go transition).
|
|
assert!(cleanup_queued.unwrap() < cleanup_delivered.unwrap());
|
|
assert!(wakeup_queued.unwrap() < cleanup_delivered.unwrap(),
|
|
"Wakeup queued before Cleanup delivered (transition completes first)");
|
|
}).await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn det_emit_in_exit_action_journals_in_order() {
|
|
tokio::task::LocalSet::new().run_until(async {
|
|
let mut m = Et::new(Ctx);
|
|
let _ = m.dispatch(Ev::Go).await;
|
|
let j = m.take_journal();
|
|
|
|
// Both Cleanup and Wakeup were queued during the Go transition. They
|
|
// are delivered AFTER. Order: Cleanup first (emitted from A's exit
|
|
// which runs first), then Wakeup (emitted from B's entry).
|
|
let queued_events: Vec<u16> = j.iter().filter_map(|e| match e {
|
|
TraceEvent::EmitQueued { event } => Some(*event),
|
|
_ => None,
|
|
}).collect();
|
|
assert_eq!(queued_events, vec![0, 1], "Cleanup (id 0) then Wakeup (id 1)");
|
|
}).await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn det_emit_in_transition_byte_deterministic() {
|
|
let run = || async {
|
|
tokio::task::LocalSet::new().run_until(async {
|
|
let mut m = Et::new(Ctx);
|
|
let _ = m.dispatch(Ev::Go).await;
|
|
m.take_journal()
|
|
}).await
|
|
};
|
|
let first = run().await;
|
|
for i in 1..10 {
|
|
assert_eq!(first, run().await, "diverged on run {i}");
|
|
}
|
|
}
|