Observation tells you what an instance committed. Rehearsal asks what the chart would do from an
explicit starting world. Simulator answers that second question with the same promoted chart and
kernel every engine uses—but without creating a production instance.
Rehearse the fold, not the runtime
- the next Snapshot;
- selected transitions and microsteps;
- planned effects and lifecycle changes; and
- the semantic route retained in trace.
- handler behavior or external delivery;
- storage atomicity or durability;
- distributed ownership or concurrency; or
- wall-clock scheduling accuracy.
Use Simulator to prove authored behavior and construct counterfactuals. Use engine, storage, and integration tests to prove the operational envelope around that behavior.
Fold the chart as a pure value
Test the route, not only the destination.
The promoted chart can be explored as immutable data before any engine, handler, or wall clock enters the test. Each operation returns a new Simulator and keeps the semantic route as evidence.
1 · Start the promoted chart without an engine
Simulator makes one pure observation.
Simulator.make folds the same promoted DoorChart into an immutable value with a Snapshot, virtual clock, and trace. It does not allocate an engine mailbox, storage row, or live handler.
import { Simulator, State, Statechart, States, Transitions } from "@motive/motive";
import { Effect, Schema } from "effect";
export const OpenDoor = Schema.TaggedStruct("OpenDoor", {});
export const CloseDoor = Schema.TaggedStruct("CloseDoor", {});
export class Door extends State.Compound<Door>()("Door") {
static states = States.make(() => [Closed, Open]);
}
class Closed extends State.Atomic<Closed>()("Closed") {
static transitions = Transitions.make(this, ({ on }) => [
on(OpenDoor, Open),
]);
}
class Open extends State.Atomic<Open>()("Open") {
static transitions = Transitions.make(this, ({ on }) => [
on(CloseDoor, Closed),
]);
}
export const DoorChart = Statechart.make(Door);
export const initial = Effect.runSync(Simulator.make(DoorChart, {}));
2 · Fold one event into a new value
Send returns the next simulator in Result.
Sending OpenDoor leaves initial unchanged and returns a Result containing opened. Typed fold failures remain Result failures; a valid but unhandled event instead returns a Simulator with unchanged geometry and an explicit NonStepTrace.
import { Simulator, State, Statechart, States, Transitions } from "@motive/motive";
import { Effect, Result, Schema } from "effect";
export const OpenDoor = Schema.TaggedStruct("OpenDoor", {});
export const CloseDoor = Schema.TaggedStruct("CloseDoor", {});
export class Door extends State.Compound<Door>()("Door") {
static states = States.make(() => [Closed, Open]);
}
class Closed extends State.Atomic<Closed>()("Closed") {
static transitions = Transitions.make(this, ({ on }) => [
on(OpenDoor, Open),
]);
}
class Open extends State.Atomic<Open>()("Open") {
static transitions = Transitions.make(this, ({ on }) => [
on(CloseDoor, Closed),
]);
}
export const DoorChart = Statechart.make(Door);
export const initial = Effect.runSync(Simulator.make(DoorChart, {}));
export const opened = Result.getOrThrow(initial.send(OpenDoor.make({})));
3 · Keep the semantic evidence
Every operation appends an immutable trace.
After closing the door again, trace retains the start and both event folds with their outcomes and macrostep evidence. Assertions can inspect the claimed mechanism instead of checking only a final state.
import { Simulator, State, Statechart, States, Transitions } from "@motive/motive";
import { Effect, Result, Schema } from "effect";
export const OpenDoor = Schema.TaggedStruct("OpenDoor", {});
export const CloseDoor = Schema.TaggedStruct("CloseDoor", {});
export class Door extends State.Compound<Door>()("Door") {
static states = States.make(() => [Closed, Open]);
}
class Closed extends State.Atomic<Closed>()("Closed") {
static transitions = Transitions.make(this, ({ on }) => [
on(OpenDoor, Open),
]);
}
class Open extends State.Atomic<Open>()("Open") {
static transitions = Transitions.make(this, ({ on }) => [
on(CloseDoor, Closed),
]);
}
export const DoorChart = Statechart.make(Door);
export const initial = Effect.runSync(Simulator.make(DoorChart, {}));
export const opened = Result.getOrThrow(initial.send(OpenDoor.make({})));
export const closedAgain = Result.getOrThrow(opened.send(CloseDoor.make({})));
export const trace = closedAgain.trace;
One kernel, two kinds of evidence
Simulator.make(DoorChart, {}) captures the promoted chart's registration and starts the same
chart-specific KernelProgram used by every engine. It does not lower a second model or maintain a
parallel interpretation of statecharts.
Construction is an Effect because chart input and registration may require Schema services. The
result is an immutable value with three primary observations:
snapshot— the complete current runtime Snapshot;now— deterministic virtual UTC time; andtrace— every requested operation and its outcome.
Simulator.fromSnapshot can instead begin from an already-decoded chart-specific Snapshot. A bare
Snapshot retains Timer arming identity and input, not remaining virtual duration, so rehydrated
Timer deadlines restart from the supplied startAt. The next guide owns that time contract.
The Simulator is an immutable world
initial.send(OpenDoor.make({})) returns Result<Simulator, SendError>. On success, opened is a
new world; initial remains Closed with its original Snapshot and trace. Keeping both values makes
branching tests ordinary value composition:
const opened = Result.getOrThrow(initial.send(OpenDoor.make({})));
const branchA = opened.send(CloseDoor.make({}));
const branchB = opened.send(OpenDoor.make({}));
No reset, mailbox drain, hidden process, or replay harness is required. A test can retain the fork point, explore several stimuli, and compare the resulting Snapshots and traces directly.
Result keeps fold failure explicit
Simulator operations validate their input and return typed Result failures when the fold or
injection cannot be performed. Reusable test infrastructure should branch on that result rather
than hiding it behind an unchecked assertion helper.
An unhandled authored event is different. The event was valid, but the current configuration had
no selected transition. send succeeds with another Simulator whose Snapshot is unchanged and
whose trace appends an Unhandled entry. That is evidence of a semantic non-step—not an exception
and not missing telemetry.
The same distinction continues across later operations: advancing time can record Noop, an
injected Activity or child outcome can be Stale or Unhandled, and interrupting an already
terminal chart records AlreadyTerminal. Trace keeps each meaning structural.
Trace records steps and non-steps
A successful macrostep appends a StepTrace with operation, admission metadata, resulting
Snapshot, plan, microsteps, schedule, and family lifecycle changes. A non-step appends the exact
NonStepTrace variant instead.
The final Snapshot proves where the model ended. Trace proves the route that reached it—and whether
a test actually exercised the mechanism it claims to cover. Assert on selected transitions,
entry/exit order, planned work, or an expected Unhandled outcome when those are the law under
test. A final configuration assertion alone can pass for the wrong reason.
The live story mirrors each accepted Door event into a local memory client only so the shared diagram can render the same promoted chart. Its phase, sequence, and trace count are read from the immutable Simulator value. The mirror is presentation, not simulation authority.
Simulation stops at the effect boundary
Simulator never executes an Activity, child chart process, emitted handler, storage transaction, or outbox delivery. Later operations let the test inject the declared outcome—resolve, reject, fire, or interrupt—and fold the chart's reaction to it.
That is a feature. It lets a test say exactly which external fact became available without smuggling a scheduler, network, database, or Effect runtime into the semantic proof. The following guides make virtual time and explicit Activity/child outcomes concrete.
Change the world
- Keep
initialandopened, then prove their Snapshots and traces diverge without mutation. - Send
CloseDoorwhile already Closed and assert the appendedUnhandledtrace entry. - Branch twice from
openedand compare both routes rather than only their final configurations. - Rehydrate from one decoded Snapshot with
fromSnapshotand state the Timer-duration caveat.