Home · Rust · Kotlin · Swift · Migrating · Compatibility · Cells · Diagnostics · Matrix files · Source
State machines whose declaration is the transition matrix – for Rust, Kotlin and Swift, held to one behaviour by a shared conformance suite.
You write the matrix. Every cell is one word, or a call to code you write. The compiler refuses a machine with a hole in it.
transition_matrix! {
machine Timer;
context Ctx;
state State;
action Action;
effects Effect { StartClock, StopClock { reason: Reason } }
initial Idle;
states { Idle, Running { since: u64 }, Done }
actions { Start, Tick { now: u64 }, Cancel }
// Start Tick Cancel
Idle => [ HANDLE, IGNORE, IGNORE ];
Running => [ IGNORE, HANDLE, GO!(Idle, StopClock { reason: Reason::Cancelled }) ];
Done => [ GO!(Running { since: 0 }, StartClock), IGNORE, IGNORE ];
}
From tabular-center-rust/examples/02-timer/src/machine.tb.rs.
That is the whole behaviour of a three-state timer. Five of its nine cells are
decided right there – IGNORE, or GO! to a state with an effect – and cost
nothing more. Two are HANDLE: those, and only those, become methods you
implement, each receiving its state and action already narrowed to concrete
types. Forget one and the build fails naming it.
Every cell of the state × action matrix is a required member. An incomplete machine does not compile.
Not pattern-match exhaustiveness, which else, default: and _ defeat.
Not purity, which Kotlin and Swift cannot enforce. The oldest mechanism in all
three languages: you declared a required member and did not implement it.
States, actions and effects are generated sum types, so adding an effect
breaks every handler’s build the same way adding a state breaks every matrix.
The Timer is small on purpose. Here is a real order lifecycle, 8 states by 12 actions:
transition_matrix! {
machine Order;
context Ctx;
state State;
action Action;
effects Effect {
RequestPayment, ReleaseHold, NotifyExpired, ChargeCard,
NotifyFailure, SchedulePickup, NotifyShipped, IssueRefund, NotifyDelivered
}
initial Draft;
states {
Draft, AwaitingPayment, PaymentFailed { code: u32 }, Paid,
Preparing, Shipped { tracking: u64 }, Delivered, Cancelled
}
actions {
AddItem, RemoveItem, Submit, PaidOk, PaidFail { code: u32 }, Retry,
Cancel, Prepare, Ship { tracking: u64 }, Deliver, Refund, Expire
}
// AddItem RemoveItem Submit PaidOk PaidFail Retry Cancel Prepare Ship Deliver Refund Expire
Draft => [ HANDLE, HANDLE, GO!(AwaitingPayment, RequestPayment), IGNORE, IGNORE, IGNORE, GO!(Cancelled), IGNORE, IGNORE, IGNORE, IGNORE, GO!(Cancelled, NotifyExpired) ];
AwaitingPayment => [ IGNORE, IGNORE, IGNORE, GO!(Paid, ChargeCard), HANDLE, IGNORE, GO!(Cancelled, ReleaseHold), IGNORE, IGNORE, IGNORE, IGNORE, GO!(Cancelled, ReleaseHold) ];
PaymentFailed => [ IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, HANDLE, GO!(Cancelled, ReleaseHold), IGNORE, IGNORE, IGNORE, IGNORE, GO!(Cancelled, ReleaseHold) ];
Paid => [ IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, GO!(Cancelled, IssueRefund), GO!(Preparing, SchedulePickup), IGNORE, IGNORE, GO!(Cancelled, IssueRefund), IGNORE ];
Preparing => [ IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, HANDLE, IGNORE, HANDLE, IGNORE, HANDLE, IGNORE ];
Shipped => [ IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, GO!(Delivered, NotifyDelivered), HANDLE, IGNORE ];
Delivered => [ IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, HANDLE, IGNORE ];
Cancelled => [ IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE, IGNORE ];
}
From tabular-center-rust/tabular-center/tests/scale.rs.
| cells | 96 |
IGNORE |
75 (78%) |
GO! |
12 |
| methods you write | 9 |
Measured, not estimated: ninety_six_cells_cost_nine_implementations in that
file asserts the 9. Eight lines declare 96 transitions, the grid stays
reviewable as a grid, and the 75 cells that do nothing cost one word each –
which is the only reason a machine this size is pleasant to write at all.
A parent hands cells to a child written knowing nothing about it:
// Credentials StartOver Logout
LoggedOut => [ DELEGATE!(auth), DELEGATE!(auth), IGNORE ];
Active => [ IGNORE, IGNORE, GO!(Banned, Audit) ];
Banned => [ IGNORE, IGNORE, IGNORE ];
From tabular-center-rust/examples/04-login/src/session.tb.rs.
The parent’s cell surface requires the child’s, so a hole in the child is a
build error in the parent – the same error as a hole in the parent itself.
No registration, no runtime check. It holds in all three languages, each with
a compile-fail fixture proving it, and through colors: a plain child composes
into an async or suspend parent; the reverse does not compile.
| Rust | Kotlin | Swift | |
|---|---|---|---|
| Generator | macro_rules! – no build step, no dependencies |
KSP, from annotations | TabularCenterCodegen today; the @Machine macro is designed and pending |
| Runtime dependencies | none, no_std capable |
none | none |
| Colors | async |
suspend, receivers, any modifier |
async, throws, attributes |
| Driver | blocking, non-reentrant mailbox | blocking and suspend |
sync and async, ObservableStore |
| Introspection | TABLE, grid, Mermaid, DOT, lints, coverage |
the same | the same |
The same fixtures in spec/conformance/ are replayed by all three, and each
renders every fixture’s grid, diagram, lints and coverage; a check diffs the
three and fails on a single differing byte. A semantic difference between the
languages is a red build, not a bug report.
*.tb.* convention that keeps
formatters from flattening a matrix.Every sample on these pages is included from the repository when the site is built, so each one is code that CI compiles and runs.