journal + determinism tests + Creusot verification scaffolding

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.
This commit is contained in:
swaits committed 2026-04-27 20:15:05 -06:00
1 parent 4d7baf7236
commit 081bd28cca
55 files changed
+6476 -151

No files matched your search

+23
View File
@@ -26,6 +26,11 @@ 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`.
pub trace_enabled: bool,
}
pub struct ActionIr {
@@ -122,6 +127,7 @@ pub fn build_ir(input: StatechartInput) -> syn::Result<Ir> {
}
};
let terminate_event = root_body.terminate.as_ref().map(|(i, _)| i.clone());
let trace_enabled = root_body.trace_enabled;
let mut ir = Ir {
name,
@@ -133,6 +139,7 @@ pub fn build_ir(input: StatechartInput) -> syn::Result<Ir> {
actions: Vec::new(),
event_variants: Vec::new(),
duration_triggers: Vec::new(),
trace_enabled,
};
// Two passes: collect the set of declared state names first so
@@ -140,6 +147,9 @@ pub fn build_ir(input: StatechartInput) -> syn::Result<Ir> {
// (transition) or a handler fn (action) based on the declared set.
let mut state_names = std::collections::HashSet::new();
collect_state_names(&root_body, &mut state_names);
// Per spec: the root state is just a state, named after the chart.
// `on(...) => <ChartName>;` must classify as a transition to root.
state_names.insert(ir.name.to_string());
let name_span = ir.name.span();
let root_name = Ident::new("__Root", name_span);
@@ -152,6 +162,13 @@ pub fn build_ir(input: StatechartInput) -> syn::Result<Ir> {
);
lower_body(&mut ir, root_id, &root_body, true, &state_names)?;
// Intern the terminate event variant so the journal can record it
// by id (otherwise `__event_to_id` returns u16::MAX for the very
// event that triggered shutdown, which is useless for replay).
if let Some(t_ident) = ir.terminate_event.clone() {
let _ = intern_event(&mut ir, &t_ident, None)?;
}
Ok(ir)
}
@@ -495,6 +512,12 @@ pub fn resolve_transitions(ir: &mut Ir) -> syn::Result<()> {
name_map.insert(s.name.to_string(), s.id);
}
}
// Per spec: the root state is targetable as a transition destination
// by the chart's own name. Find the root (parent is None) and map
// `<ChartName>` → root_id.
if let Some(root) = ir.states.iter().find(|s| s.parent.is_none()) {
name_map.insert(ir.name.to_string(), root.id);
}
let n = ir.states.len();
for i in 0..n {