Guide

Simulation & Trace

Fold one promoted chart as immutable values and retain the semantic route.

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

Simulator provesChart semantics
  • the next Snapshot;
  • selected transitions and microsteps;
  • planned effects and lifecycle changes; and
  • the semantic route retained in trace.
Simulator does not proveRuntime guarantees
  • 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; and
  • trace — 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

  1. Keep initial and opened, then prove their Snapshots and traces diverge without mutation.
  2. Send CloseDoor while already Closed and assert the appended Unhandled trace entry.
  3. Branch twice from opened and compare both routes rather than only their final configurations.
  4. Rehydrate from one decoded Snapshot with fromSnapshot and state the Timer-duration caveat.