quickcheck_statemachine is a MoonBit library for testing stateful programs
with model-based properties. Instead of checking one operation at a time, a test
generates a whole program of commands, executes it against a mutable system, and
checks every response against a pure model.
This library is useful when:
- valid operations depend on earlier operations;
- a bug only appears after a sequence of commands;
- the implementation has handles, ids, files, queues, counters, or other mutable state that is awkward to test with single examples.
As a first example, consider a tiny mutable-reference system. A program can
create a reference, read it, write it, and increment it. The implementation below
also has an optional logic bug: writing a value in 5..=10 stores value + 1
instead.
Generated programs cannot contain real references before execution starts.
Commands therefore use Ref[RefId]: symbolic references during generation, and
concrete references when the program is executed.
///|
struct RefId {
id : Int
} derive(Eq, Debug)
///|
enum Command {
Create
Read(@quickcheck_statemachine.Ref[RefId])
Write(@quickcheck_statemachine.Ref[RefId], Int)
Increment(@quickcheck_statemachine.Ref[RefId])
} derive(Eq, Debug)
///|
enum Response {
Created(@quickcheck_statemachine.Ref[RefId])
ReadValue(Int)
Written
Incremented
} derive(Eq, Debug)Create returns a reference, so its response may introduce a new symbolic
variable. Later commands can mention that variable; during execution it will be
looked up in the environment and replaced by the concrete RefId.
The implementation stores references in a mutable array. The Bug parameter is
there only to demonstrate that the property can find a bad implementation.
///|
enum Bug {
NoBug
LogicBug
} derive(Eq, Debug)
///|
struct SystemEntry {
id : Int
value : Int
} derive(Eq, Debug)
///|
struct ReferenceSystem {
refs : Array[SystemEntry]
mut next_id : Int
} derive(Eq, Debug)The helper functions are ordinary system operations. They only deal with concrete ids.
///|
fn reference_id(reference : @quickcheck_statemachine.Ref[RefId]) -> Int? {
match reference {
Concrete(ref_id) => Some(ref_id.id)
Symbolic(_) => None
}
}
///|
fn system_lookup(system : ReferenceSystem, id : Int) -> Int? {
for entry in system.refs {
if entry.id == id {
return Some(entry.value)
}
}
None
}
///|
fn system_update(system : ReferenceSystem, id : Int, value : Int) -> Unit {
for index = 0; index < system.refs.length(); {
if system.refs[index].id == id {
system.refs[index] = { id, value }
return
}
continue index + 1
}
}semantics is the real command interpreter. In a larger test this is where you
would call a database, file system, service, or mutable data structure.
///|
fn semantics(
bug : Bug,
command : Command,
system : ReferenceSystem,
) -> Response {
match command {
Create => {
let id = system.next_id
system.next_id += 1
system.refs.push({ id, value: 0 })
Created(Concrete({ id, }))
}
Read(reference) =>
match reference_id(reference) {
Some(id) =>
match system_lookup(system, id) {
Some(value) => ReadValue(value)
None => ReadValue(0)
}
None => ReadValue(0)
}
Write(reference, value) =>
match reference_id(reference) {
Some(id) => {
let stored = if bug == LogicBug && 5 <= value && value <= 10 {
value + 1
} else {
value
}
system_update(system, id, stored)
Written
}
None => Written
}
Increment(reference) =>
match reference_id(reference) {
Some(id) => {
match system_lookup(system, id) {
Some(value) => system_update(system, id, value + 1)
None => ()
}
Incremented
}
None => Incremented
}
}
}The model is pure data. It says which references exist and what value each reference should contain.
///|
struct ModelEntry {
reference : @quickcheck_statemachine.Ref[RefId]
value : Int
} derive(Eq, Debug)
///|
struct Model {
refs : Array[ModelEntry]
} derive(Eq, Debug)
///|
fn model_empty() -> Model {
{ refs: [] }
}The model helpers are deliberately simple. They are not the implementation under test; they are the specification used to check it.
///|
fn model_lookup(
model : Model,
reference : @quickcheck_statemachine.Ref[RefId],
) -> Int? {
for entry in model.refs {
if entry.reference == reference {
return Some(entry.value)
}
}
None
}
///|
fn model_member(
model : Model,
reference : @quickcheck_statemachine.Ref[RefId],
) -> Bool {
model_lookup(model, reference) is Some(_)
}
///|
fn model_update(
model : Model,
reference : @quickcheck_statemachine.Ref[RefId],
value : Int,
) -> Model {
let refs = model.refs.copy()
for index = 0; index < refs.length(); {
if refs[index].reference == reference {
refs[index] = { reference, value }
return { refs, }
}
continue index + 1
}
refs.push({ reference, value })
{ refs, }
}The precondition is the client contract. Create is always valid. Reads,
writes, and increments are valid only for references that are already present in
the model.
///|
fn precondition(
model : Model,
command : Command,
) -> @quickcheck_statemachine.Logic {
let ok = match command {
Create => true
Read(reference) | Write(reference, _) | Increment(reference) =>
model_member(model, reference)
}
@quickcheck_statemachine.Logic::predicate(name="known reference", value=ok)
}The transition function advances the pure model. It is used both while generating symbolic programs and while checking concrete executions.
///|
fn transition(model : Model, command : Command, response : Response) -> Model {
match (command, response) {
(Create, Created(reference)) => model_update(model, reference, 0)
(Read(_), ReadValue(_)) => model
(Write(reference, value), Written) => model_update(model, reference, value)
(Increment(reference), Incremented) =>
match model_lookup(model, reference) {
Some(value) => model_update(model, reference, value + 1)
None => model
}
_ => model
}
}Postconditions compare the concrete system response with the model. A Read
must return the current modeled value. A Create must create a reference whose
initial modeled value is zero.
///|
fn postcondition(
model : Model,
command : Command,
response : Response,
) -> @quickcheck_statemachine.Logic {
match (command, response) {
(Create, Created(reference)) => {
let next_model = transition(model, command, response)
@quickcheck_statemachine.Logic::predicate(
name="Create",
value=model_lookup(next_model, reference) == Some(0),
)
}
(Read(reference), ReadValue(value)) =>
@quickcheck_statemachine.Logic::predicate(
name="Read",
value=model_lookup(model, reference) == Some(value),
)
(Write(_, _), Written) => @quickcheck_statemachine.Logic::top()
(Increment(_), Incremented) => @quickcheck_statemachine.Logic::top()
_ => @quickcheck_statemachine.Logic::bot()
}
}The generator creates commands from the current symbolic model. If there are no references yet, it must create one first. Once references exist, it can generate any valid operation over one of them.
///|
fn choose_reference(
model : Model,
rng : @splitmix.RandomState,
) -> @quickcheck_statemachine.Ref[RefId] {
let index = rng.next_positive_int() % model.refs.length()
model.refs[index].reference
}
///|
fn generator(
model : Model,
size : Int,
rng : @splitmix.RandomState,
) -> Command? {
ignore(size)
if model.refs.length() == 0 {
Some(Create)
} else {
match rng.next_positive_int() % 4 {
0 => Some(Create)
1 => Some(Read(choose_reference(model, rng)))
2 =>
Some(Write(choose_reference(model, rng), rng.next_positive_int() % 16))
_ => Some(Increment(choose_reference(model, rng)))
}
}
}The shrinker tries smaller writes. The library also performs dependency-aware command deletion and remaps symbolic variables when possible.
///|
fn shrinker(_model : Model, command : Command) -> Array[Command] {
match command {
Write(reference, value) =>
if value == 0 {
[]
} else {
[Write(reference, 0), Write(reference, value / 2)]
}
_ => []
}
}mock predicts symbolic responses during generation and shrinking. For Create
it allocates a fresh symbolic variable. For Read it returns the value from the
model. For Write and Increment it can return simple acknowledgements.
///|
fn mock(
model : Model,
command : Command,
gen_sym : @quickcheck_statemachine.GenSym,
) -> (Response, @quickcheck_statemachine.GenSym) {
match command {
Create => {
let (fresh_var, next_gen_sym) = gen_sym.fresh()
(Created(Symbolic(fresh_var)), next_gen_sym)
}
Read(reference) =>
match model_lookup(model, reference) {
Some(value) => (ReadValue(value), gen_sym)
None => (ReadValue(0), gen_sym)
}
Write(_, _) => (Written, gen_sym)
Increment(_) => (Incremented, gen_sym)
}
}
///|
fn response_vars(response : Response) -> Array[@quickcheck_statemachine.Var] {
match response {
Created(Symbolic(fresh_var)) => [fresh_var]
_ => []
}
}Before execution, symbolic references must be reified into concrete references using the environment built from earlier responses.
///|
fn reify_ref(
reference : @quickcheck_statemachine.Ref[RefId],
environment : @quickcheck_statemachine.Environment[RefId],
) -> Result[
@quickcheck_statemachine.Ref[RefId],
@quickcheck_statemachine.EnvError,
] {
match reference {
Concrete(ref_id) => Ok(Concrete(ref_id))
Symbolic(fresh_var) =>
match environment.lookup(fresh_var) {
Ok(ref_id) => Ok(Concrete(ref_id))
Err(error) => Err(error)
}
}
}
///|
fn reify_command(
command : Command,
environment : @quickcheck_statemachine.Environment[RefId],
) -> Result[Command, @quickcheck_statemachine.EnvError] {
match command {
Create => Ok(Create)
Read(reference) =>
match reify_ref(reference, environment) {
Ok(concrete) => Ok(Read(concrete))
Err(error) => Err(error)
}
Write(reference, value) =>
match reify_ref(reference, environment) {
Ok(concrete) => Ok(Write(concrete, value))
Err(error) => Err(error)
}
Increment(reference) =>
match reify_ref(reference, environment) {
Ok(concrete) => Ok(Increment(concrete))
Err(error) => Err(error)
}
}
}After execution, a symbolic Created response must be bound to the concrete
reference returned by the system.
///|
fn bind_response(
symbolic : Response,
concrete : Response,
environment : @quickcheck_statemachine.Environment[RefId],
) -> Result[
@quickcheck_statemachine.Environment[RefId],
@quickcheck_statemachine.BindError,
] {
match (symbolic, concrete) {
(Created(Symbolic(fresh_var)), Created(Concrete(ref_id))) =>
Ok(environment.insert(fresh_var, ref_id))
(Created(Symbolic(_)), _) => Err(BindMessage("expected concrete reference"))
_ => Ok(environment)
}
}When shrinking removes commands, references may need to be rewritten. Returning
None means a candidate command is no longer valid because it depends on a
reference that disappeared.
///|
fn remap_ref(
reference : @quickcheck_statemachine.Ref[RefId],
scope : Map[@quickcheck_statemachine.Var, @quickcheck_statemachine.Var],
) -> @quickcheck_statemachine.Ref[RefId]? {
match reference {
Concrete(ref_id) => Some(Concrete(ref_id))
Symbolic(fresh_var) =>
match scope.get(fresh_var) {
Some(next_var) => Some(Symbolic(next_var))
None => None
}
}
}
///|
fn remap_command(
command : Command,
scope : Map[@quickcheck_statemachine.Var, @quickcheck_statemachine.Var],
) -> Command? {
match command {
Create => Some(Create)
Read(reference) =>
match remap_ref(reference, scope) {
Some(next_reference) => Some(Read(next_reference))
None => None
}
Write(reference, value) =>
match remap_ref(reference, scope) {
Some(next_reference) => Some(Write(next_reference, value))
None => None
}
Increment(reference) =>
match remap_ref(reference, scope) {
Some(next_reference) => Some(Increment(next_reference))
None => None
}
}
}All callbacks are packed into a StateMachine. The optional script parameter
below is just a test convenience: it lets the examples run a fixed command
program instead of random generation.
///|
fn command_name(command : Command) -> String {
match command {
Create => "create"
Read(_) => "read"
Write(_, _) => "write"
Increment(_) => "increment"
}
}
///|
fn config(
max_commands~ : Int,
seed? : UInt64 = 11,
cases? : Int = 1,
shrink? : Bool = true,
) -> @quickcheck_statemachine.RunConfig {
{
seed,
cases,
max_commands,
size: max_commands,
max_tries: 20,
shrink,
max_shrinks: 20,
max_shrink_rounds: 10,
required_labels: [],
required_command_names: [],
}
}///|
fn sm(
bug : Bug,
script? : Array[Command]? = None,
) -> @quickcheck_statemachine.StateMachine[
Model,
Model,
Command,
Command,
Response,
Response,
RefId,
ReferenceSystem,
] {
let cursor : Array[Int] = [0]
@quickcheck_statemachine.StateMachine::new(
init_symbolic_model=model_empty,
init_concrete_model=model_empty,
init_system=() => { refs: [], next_id: 0 },
generator=(model, size, rng) => {
match script {
Some(commands) => {
ignore(model)
ignore(size)
ignore(rng)
let index = cursor[0]
if index < commands.length() {
cursor[0] = index + 1
Some(commands[index])
} else {
None
}
}
None => generator(model, size, rng)
}
},
mock~,
transition_symbolic=transition,
transition_concrete=transition,
precondition~,
postcondition~,
response_vars~,
shrinker~,
remap_command~,
reify_command~,
run_command=(command, system) => semantics(bug, command, system),
bind_response~,
command_name~,
)
}With the faithful implementation, the command program below passes.
///|
test "sequential property passes without the bug" {
let reference : @quickcheck_statemachine.Ref[RefId] = Symbolic(@quickcheck_statemachine.Var::{
id: 0,
})
let commands : Array[Command] = [
Create,
Write(reference, 4),
Increment(reference),
Read(reference),
]
let report = @quickcheck_statemachine.assert_check(
sm(NoBug, script=Some(commands)),
config=config(max_commands=commands.length()),
)
assert_eq(report.commands_run, 4)
}With the logic bug enabled, the same machinery finds a small counterexample:
create a reference, write 5, then read it. The implementation returns 6,
while the model expects 5.
///|
test "random generation shrinks the logic bug" {
let result = @quickcheck_statemachine.check(
sm(LogicBug),
config=config(seed=37, cases=100, max_commands=8),
)
guard result
is Err(
PostconditionFailed(
step_index~,
response~,
commands~,
shrinks~,
logic~,
..
)
) else {
fail("expected a shrunk postcondition failure")
}
assert_eq(step_index, 2)
assert_true(response is ReadValue(6))
assert_eq(commands.length(), 3)
assert_true(shrinks > 0)
assert_true(!logic.eval())
}The returned failure carries the minimized symbolic command program, the concrete invocation/response history, and the failed logical predicate. The formatting helpers are intentionally small so a test suite can print or save the parts that are useful for its domain.
///|
test "diagnostics expose commands history and predicate names" {
let result = @quickcheck_statemachine.check(
sm(LogicBug),
config=config(seed=37, cases=100, max_commands=8),
)
guard result is Err(PostconditionFailed(commands~, history~, logic~, ..)) else {
fail("expected a postcondition failure")
}
// symbolic commands
inspect(
@quickcheck_statemachine.format_commands(commands),
content=(
#|0: Create -> Created(Symbolic({ id: 0 }))
#|1: Write(Symbolic({ id: 0 }), 5) -> Written
#|2: Read(Symbolic({ id: 0 })) -> ReadValue(5)
#|
),
)
// concrete history
inspect(
@quickcheck_statemachine.format_history(history),
content=(
#|Invocation(pid={ id: 0 }, command=Create)
#|Response(pid={ id: 0 }, response=Created(Concrete({ id: 0 })))
#|Invocation(pid={ id: 1 }, command=Write(Concrete({ id: 0 }), 5))
#|Response(pid={ id: 1 }, response=Written)
#|Invocation(pid={ id: 2 }, command=Read(Concrete({ id: 0 })))
#|Response(pid={ id: 2 }, response=ReadValue(6))
#|
),
)
// failed predicate
inspect(
@quickcheck_statemachine.format_counterexample(logic.counterexample()),
content="Read",
)
}A state-machine test has these pieces:
Command: operations that can be generated.Response: observable results returned by the system.Model: a pure description of what should be true after each command.precondition: when a generated command is valid.transition: how the model changes after a command and response.postcondition: what must hold after running a command.generatorandshrinker: how command programs are generated and minimized.mock: symbolic responses used while generating and shrinking.reify_commandandbind_response: how symbolic references become concrete values during execution.run_command: the real implementation under test.
The sequential property first builds a complete symbolic command program:
- Start with the initial symbolic model and a fresh symbolic-variable supply.
- Ask the generator for a command and keep retrying until the precondition accepts it, or the retry budget is exhausted.
- Ask
mockfor the symbolic response and record any variables introduced by that response. - Advance the symbolic model with the symbolic transition.
- Repeat until
max_commandsis reached or the generator stops.
The generated program is then replayed against the concrete system:
- Start with the initial concrete model, initial system, empty environment, and empty history.
- Reify symbolic references into concrete references using the environment.
- Execute the command against the system and record invocation/response history.
- Check the postcondition against the pre-state model and observed response.
- Bind any new concrete references returned by the response.
- Advance the concrete model, check the invariant, and collect labels.
- After the program finishes, run cleanup and coverage checks.
When check finds a semantic failure and shrink is enabled, it shrinks the
generated command program and reports a smaller counterexample. Direct
run_commands and replay calls execute the supplied program as-is.
The repository contains smaller focused examples:
- counter shows the smallest counter-style state machine.
- queue checks a mutable circular queue against a pure FIFO model.
- filesystem shows symbolic handles and label coverage diagnostics.
- jug uses a state machine as a search problem for the water-jug puzzle.
Replay is exposed through replay, assert_replay, and run_saved_commands;
the examples in this README use scripted generation with assert_check.
This MoonBit package currently exposes generate_parallel_commands,
ParallelCommands, and run_parallel_commands, but the current runner
deterministically linearises commands as prefix, then left, then right.
It is not a concurrent linearizability checker.
Async parallel checking is available from the native-only
moonbit-community/quickcheck_statemachine/async_parallel package. It provides
AsyncStateMachine, AsyncStateMachine::from_sync,
run_parallel_commands_async, check_parallel_async, and
assert_check_parallel_async.
AsyncStateMachine mirrors StateMachine: pure model, generation, reification,
binding, shrinking, naming, and labelling callbacks keep the same shape, while
init_system, run_command, and cleanup are async. from_sync is useful when
reusing an existing synchronous spec, but commands without an async suspension
point still behave atomically under MoonBit's cooperative async runtime.
run_parallel_commands_async executes the prefix sequentially, runs left and
right as async tasks, records the concrete invocation/response history, and
searches for a sequential linearization that respects real-time order. Async-only
failures use AsyncRunFailure: a linearizability bug is reported as
LinearizationFailed, branch environment conflicts are reported as
BranchEnvironmentMergeFailed, and replay/config/generation failures wrap the
existing synchronous RunFailure inside ReplayFailed.
Async failures keep the original ParallelCommands as the canonical
counterexample. The flattened Commands value exists for reports, coverage, and
compatibility with existing formatting helpers. The first async implementation
does not shrink parallel failures, so shrinks and shrink_rounds are 0.
Manual parallel chunks with more than six branch operations fail before system
initialization with LinearizationBudgetExceeded; this keeps the DFS
linearization search bounded.