Guide

Testing, Simulation, and Conformance

Test pure folds, virtual time, injected outcomes, and the boundary to live-engine contracts.

Testing is how you put a boundary around a claim. The pure simulator tests the chart decision itself. A live engine tests the operational machinery around that decision. A conformance fixture tests whether those meanings stay aligned across implementations.

A green simulated trace is evidence about the pure fold and the instructions that drove it. It is not evidence that storage wrote anything, that an owner held a lease, that a message was redelivered, that recovery resumed an instance, or that an effect executed. Keep those claims on the side of the boundary that can actually witness them.

Test one pure fold directly

The smallest useful simulator test gives the fold a complete input: a chart, its bindings, a snapshot, and one event. The macrostep page teaches the selection and drain pipeline. Here, the question is narrower: can the same pure input produce the same result, and can the test inspect that result without starting a runtime?

Statechart.make promotes an authored chart. Simulator.make starts an immutable simulator value around that chart. Each stimulus returns a Result containing a new value. The old value remains a useful starting point for another assertion, so a test can compare alternative instructions without sharing mutable runtime state.

import { Result, Schema } from "effect";
import { Simulator, State, Statechart, States, Transitions } from "@motive/motive";

class Begin extends Schema.TaggedClass<Begin>()("Begin", {}) {}

class Workflow extends State.Compound<Workflow>()("Workflow") {
  static states = States.make(() => [Waiting, Started]);
}

class Waiting extends State.Atomic<Waiting>()("Waiting") {
  static transitions = Transitions.make(this, ({ on }) => [
    on(Begin, Started),
  ]);
}

class Started extends State.Atomic<Started>()("Started") {}

const chart = Statechart.make(Workflow);
const initial = Result.getOrThrow(Simulator.make(chart, { context: {} }));
const next = Result.getOrThrow(initial.send(new Begin({})));

if (initial.snapshot.configuration[1] !== "Waiting") {
  throw new Error("a simulator value is an immutable starting point");
}
if (next.snapshot.configuration[1] !== "Started") {
  throw new Error("the new value contains the folded result");
}

For a direct fold witness, construct two simulators with equal inputs, apply the same event, and compare the returned snapshot, plan, and trace data. Do not compare only the final state label. A fold result includes the next snapshot, the serializable plan, and the microsteps that explain the decision.

import { Result, Schema } from "effect";
import { Chart, Simulator } from "@motive/motive";

type Context = { readonly count: number };
type Event = { readonly _tag: string };

const chart: Chart.Chart = {
  formatVersion: 1,
  id: "Counter",
  kind: "Compound",
  refs: { assigns: ["bump"] },
  initial: ["Idle"],
  children: [
    {
      id: "Idle",
      kind: "Atomic",
      transitions: [
        { event: "Increment", target: ["Ready"], actions: [{ kind: "Assign", ref: "bump" }] },
      ],
    },
    { id: "Ready", kind: "Atomic" },
  ],
};

const bindings = {
  assigns: {
    bump: ({ context }: { readonly context: Context }) => ({ count: context.count + 1 }),
  },
};

const validated = Result.getOrThrow(
  Schema.decodeResult(Chart.Chart, { onExcessProperty: "error" })(chart),
);

const start = () =>
  Result.getOrThrow(
    Simulator.fromKernel<Context, Event>(
      { chart: validated, bindings },
      { context: { count: 0 }, name: "counter" },
    ),
  );

const first = Result.getOrThrow(start().send({ _tag: "Increment" }));
const second = Result.getOrThrow(start().send({ _tag: "Increment" }));

const traceBytes = (value: unknown): string => JSON.stringify(value);
if (traceBytes(first.trace) !== traceBytes(second.trace)) {
  throw new Error("equal fold inputs must produce equal trace bytes");
}
if (first.snapshot.data.count !== 1) throw new Error("the fold must apply its assignment");

const refused = Result.getOrThrow(first.send({ _tag: "NoTransition" }));
const last = refused.trace[refused.trace.length - 1];
if (last?.outcome !== "Unhandled")
  throw new Error("unhandled input must stay successful trace data");

Own virtual time explicitly

The simulator owns a virtual clock and the deadline queue derived from the chart's schedule delta. advance and advanceTo are authored test instructions. They move the simulator's virtual time and resolve due deadlines. They do not wait for wall-clock time, and they do not subtract live time from a simulated deadline.

This makes time part of the test script. A test can say, in order, arm a timer, advance one duration, and inspect the resulting fold. It can also fire a named timer directly when the claim is about manual admission rather than deadline selection. The two paths should be tested separately because the simulator owns both the deadline driver and the manual injection surface.

Inject activity outcomes

Activities do not execute in a simulator. There is no activity handler to run and no effect to await. Use resolve or reject to admit an outcome for a currently armed activity slot. The simulator folds that outcome through the chart and records the resulting decision in its immutable trace.

The activity citizen names the authored activity, not a runtime execution. If a chart has more than one matching live slot, scope the injection with a state citizen or let the typed ambiguity failure stop the test. If the slot is gone or its arming is stale, the simulator records a stale outcome instead of pretending that the old work still owns the chart.

import { Result, Schema } from "effect";
import { Chart, Simulator } from "@motive/motive";

type Context = { readonly count: number };
type Event = { readonly _tag: string };

const chart: Chart.Chart = {
  formatVersion: 1,
  id: "WorkClock",
  kind: "Compound",
  refs: { assigns: ["bump"], timers: ["Deadline"], activities: ["Work"] },
  initial: ["Waiting"],
  children: [
    {
      id: "Waiting",
      kind: "Atomic",
      transitions: [
        { event: "Arm", target: ["Timed"] },
        { event: "Work", target: ["Working"] },
      ],
    },
    {
      id: "Timed",
      kind: "Atomic",
      timers: [{ timer: "Deadline" }],
      transitions: [
        {
          event: "done.timer.Deadline",
          target: ["Waiting"],
          actions: [{ kind: "Assign", ref: "bump" }],
        },
      ],
    },
    {
      id: "Working",
      kind: "Atomic",
      invoke: [{ id: "Job", src: "Work", onDone: { target: ["Waiting"] } }],
    },
  ],
};

const bindings = {
  assigns: {
    bump: ({ context }: { readonly context: Context }) => ({ count: context.count + 1 }),
  },
};

const validated = Result.getOrThrow(
  Schema.decodeResult(Chart.Chart, { onExcessProperty: "error" })(chart),
);

const timer = Simulator.TimerCitizen.make("Deadline");
const activity = Simulator.ActivityCitizen.make("Work");
const timers = { Deadline: "1 second" } satisfies Readonly<
  Record<"Deadline", Simulator.TimerBinding>
>;

const started = Result.getOrThrow(
  Simulator.fromKernel<Context, Event, "Deadline", "Work">(
    { chart: validated, bindings },
    { context: { count: 0 }, timers, name: "clock" },
  ),
);

const armed = Result.getOrThrow(started.send({ _tag: "Arm" }));
const atDeadline = Result.getOrThrow(armed.advance("1 second"));
if (atDeadline.snapshot.data.count !== 1) throw new Error("advance must fire the virtual deadline");

const waiting = Result.getOrThrow(atDeadline.send({ _tag: "Work" }));
const resolved = Result.getOrThrow(waiting.resolve(activity, { accepted: true }));
if (resolved.snapshot.configuration[1] !== "Waiting") {
  throw new Error("the injected activity outcome must be folded by the chart");
}

The timer binding and activity outcome are inputs to the test driver. Neither one turns the simulator into a runtime. If the claim is that an activity performed an external operation, move the witness to a live engine with a real or test-owned activity binding.

Preserve refusal, failure, and staleness

The trace keeps different reasons for non-progress distinct:

| Trace fact | Meaning | What it does not mean | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | | Unhandled | A valid external event selected no transition. The simulator succeeded and recorded the refusal. | It is not malformed input or a terminal state. | | Stale | A manual timer or activity injection no longer names a current slot and arming. The simulator succeeded and recorded the stale delivery. | It is not proof that the old work executed. | | Typed Result failure | The simulator rejected the instruction at an owned boundary, such as a reserved send or an ambiguous injection. | It is not a successful trace outcome. |

The sequence advances only for a committed step. A stale or unhandled entry does not manufacture a new snapshot. An ambiguous timer or activity reference returns Simulator.AmbiguousInjection; keep that failure typed so the test can fix its scope rather than silently choosing one slot.

import { Result, Schema } from "effect";
import { Chart, Simulator } from "@motive/motive";

type Event = { readonly _tag: string };

const chart: Chart.Chart = {
  formatVersion: 1,
  id: "Outcomes",
  kind: "Compound",
  refs: { timers: ["Deadline"], activities: ["Work"] },
  initial: ["Waiting"],
  children: [
    {
      id: "Waiting",
      kind: "Atomic",
      transitions: [{ event: "Start", target: ["Timed"] }],
    },
    {
      id: "Timed",
      kind: "Atomic",
      timers: [{ timer: "Deadline" }],
      transitions: [{ event: "done.timer.Deadline", target: ["Waiting"] }],
    },
    {
      id: "Working",
      kind: "Atomic",
      invoke: [{ id: "Work", src: "Work", onDone: { target: ["Waiting"] } }],
    },
  ],
};

const timer = Simulator.TimerCitizen.make("Deadline");
const activity: Simulator.ActivityCitizen<"Work"> = Simulator.ActivityCitizen.make("Work");
const validated = Result.getOrThrow(
  Schema.decodeResult(Chart.Chart, { onExcessProperty: "error" })(chart),
);
const started = Result.getOrThrow(
  Simulator.fromKernel<{}, Event, "Deadline", "Work">(
    { chart: validated, bindings: {} },
    { context: {}, timers: { Deadline: "1 second" }, name: "outcomes" },
  ),
);

const unhandled = Result.getOrThrow(started.send({ _tag: "NoTransition" }));
if (unhandled.trace[unhandled.trace.length - 1]?.outcome !== "Unhandled") {
  throw new Error("refusal must remain a successful unhandled trace entry");
}

const staleTimer = Result.getOrThrow(started.fireTimer(timer));
if (staleTimer.trace[staleTimer.trace.length - 1]?.outcome !== "Stale") {
  throw new Error("an unarmed timer injection must remain stale trace data");
}

const staleActivity = Result.getOrThrow(started.resolve(activity, { ok: true }));
if (staleActivity.trace[staleActivity.trace.length - 1]?.outcome !== "Stale") {
  throw new Error("an unarmed activity injection must remain stale trace data");
}

const reserved = started.send({ _tag: "done.timer.Deadline" });
if (Result.isSuccess(reserved) || reserved.failure._tag !== "ReservedEvent") {
  throw new Error("a reserved completion send must remain a typed simulator failure");
}

The negative controls matter. A test that turns every non-progress case into false cannot tell the author whether to change the event, scope an injection, or move to a live engine.

State the child limit plainly

Child sites are recorded as part of the simulator snapshot and arming data. No child instance runs inside the simulator. There is also no child-outcome injection operation. A simulated parent trace can therefore prove that the parent admitted a child site, but it cannot prove a child's completion, child effect, child mailbox, cancellation, or ownership behavior.

That limit is deliberate. Test parent geometry and site admission with the simulator. Test child execution, child output, parent-to-child sends, cancellation, and ownership with a live engine. A child completion listener that appears in the authored chart is not reachable through a simulator outcome injection, so do not use its absence from a simulated trace as evidence that the listener is wrong.

Branch from immutable simulator values

An immutable value is a testing instrument. Keep one base value, send one event to create branch A, send another event to create branch B, and compare their snapshots or traces. The base does not need to be cloned by hand, and a branch cannot mutate the evidence used by its sibling.

This is especially useful for negative controls. Change one instruction, keep every other input equal, and assert that the recorded trace changes. If the trace does not change, the test is not actually exercising the instruction it claims to cover.

Draw the live-engine boundary

The simulator and a live engine share chart meaning, but they answer different questions.

| Question | Pure simulator | Live engine or cross-implementation contract | | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | Did selection, guards, updates, completion, refusal, and trace formation follow the chart? | Yes. Drive the pure fold directly. | Recheck through the engine's admission and commit path. | | Did virtual deadlines and injected activity outcomes produce the expected semantic result? | Yes. advance, fireTimer, resolve, and reject are explicit drivers. | Verify scheduling, binding execution, and post-commit observation. | | Did an activity or emit effect execute? | No. Plans are recorded data and activity outcomes are supplied by the test. | Yes. Use an engine binding and observe the execution boundary. | | Did storage, redelivery, recovery, concurrency, ownership, or Scope behavior hold? | No. The simulator owns none of those guarantees. | Yes. Use a live-engine witness that can fail in the relevant mechanism. | | Do multiple implementations agree? | Not by itself. One simulator run is one implementation's fold evidence. | Run the same fixture against each implementation and compare the contract record. |

This is the line to keep visible in reviews: simulation proves semantic input and output for the fold. A live-engine contract proves operational behavior around committed folds. A simulated green trace must never be promoted into evidence for storage, redelivery, recovery, concurrency, ownership, or effect execution.

Replay instruction sequences byte-for-byte

Treat a test script as data. Keep the chart, bindings, initial context, virtual start time, simulator name, and instruction sequence equal when comparing replays. The name matters because trace metadata contains deterministic message information. The sequence itself contains sends, time advances, timer fires, and admitted activity outcomes.

At the recording boundary, encode the trace with canonical JSON: sort object keys, preserve arrays and authored order, reject values that cannot be represented, and include a trailing newline. Compare the resulting bytes. Do not print identity values to make equality look persuasive. Assert equality programmatically, then change one instruction and assert inequality.

The same witness also shows branching. The direct fromKernel construction is useful when a fixture already owns a validated chart and binding record. The promoted Simulator.make construction above is the normal authored-chart path. fromSnapshot is the corresponding way to start a new simulator branch around an observed snapshot.

import { Result, Schema } from "effect";
import { Chart, Simulator } from "@motive/motive";

type Context = { readonly count: number };
type Event = { readonly _tag: string };
type Instruction =
  | { readonly op: "send"; readonly event: Event }
  | { readonly op: "advance"; readonly by: "1 second" }
  | {
      readonly op: "resolve";
      readonly activity: Simulator.ActivityCitizen<"Work">;
      readonly output: unknown;
    };

const chart: Chart.Chart = {
  formatVersion: 1,
  id: "Replay",
  kind: "Compound",
  refs: { assigns: ["bump"], timers: ["Deadline"], activities: ["Work"] },
  initial: ["Waiting"],
  children: [
    {
      id: "Waiting",
      kind: "Atomic",
      transitions: [
        { event: "Arm", target: ["Timed"] },
        { event: "Work", target: ["Working"] },
      ],
    },
    {
      id: "Timed",
      kind: "Atomic",
      timers: [{ timer: "Deadline" }],
      transitions: [
        {
          event: "done.timer.Deadline",
          target: ["Waiting"],
          actions: [{ kind: "Assign", ref: "bump" }],
        },
      ],
    },
    {
      id: "Working",
      kind: "Atomic",
      invoke: [{ id: "Work", src: "Work", onDone: { target: ["Waiting"] } }],
    },
  ],
};

const bindings = {
  assigns: {
    bump: ({ context }: { readonly context: Context }) => ({ count: context.count + 1 }),
  },
};

const validated = Result.getOrThrow(
  Schema.decodeResult(Chart.Chart, { onExcessProperty: "error" })(chart),
);

const deadline = Simulator.TimerCitizen.make("Deadline");
const work: Simulator.ActivityCitizen<"Work"> = Simulator.ActivityCitizen.make("Work");

const start = () =>
  Result.getOrThrow(
    Simulator.fromKernel<Context, Event, "Deadline", "Work">(
      { chart: validated, bindings },
      { context: { count: 0 }, timers: { Deadline: "1 second" }, name: "replay" },
    ),
  );

const apply = (script: ReadonlyArray<Instruction>) => {
  let value = start();
  for (const instruction of script) {
    value =
      instruction.op === "send"
        ? Result.getOrThrow(value.send(instruction.event))
        : instruction.op === "advance"
          ? Result.getOrThrow(value.advance(instruction.by))
          : Result.getOrThrow(value.resolve(instruction.activity, instruction.output));
  }
  return value;
};

const bytes = (value: unknown): string => JSON.stringify(value) ?? "";
const script = [
  { op: "send", event: { _tag: "Arm" } },
  { op: "advance", by: "1 second" },
  { op: "send", event: { _tag: "Work" } },
  { op: "resolve", activity: work, output: "accepted" },
] as const satisfies ReadonlyArray<Instruction>;

const first = apply(script);
const second = apply(script);
if (bytes(first.trace) !== bytes(second.trace)) {
  throw new Error("equal instruction sequences must produce byte-equal traces");
}

const changed = apply([...script.slice(0, -1), { op: "send", event: { _tag: "NoTransition" } }]);
if (bytes(first.trace) === bytes(changed.trace)) {
  throw new Error("the changed instruction is a required negative control");
}

const branch = start();
const advanced = Result.getOrThrow(branch.send({ _tag: "Arm" }));
if (branch.snapshot.configuration[1] !== "Waiting") {
  throw new Error("the original simulator value must remain unchanged");
}
if (advanced.snapshot.configuration[1] !== "Timed") {
  throw new Error("the branch must carry its own next snapshot");
}
void deadline;

The bytes function above is only the equality witness. The durable fixture boundary should own the canonical encoder and its rejection rules. This separation keeps replay semantics explicit without making a local page sample look like a second public serialization authority.

Treat fixtures as stable semantics coordinates

A conformance fixture is an executable coordinate system for one semantic claim. Its pieces must agree:

| Coordinate | What it fixes | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | Fixture | A named chart value, binding set, initial context, timer bindings, and ordered script. | | Instruction record | The stable wire form of each send, time advance, timer injection, activity outcome, or interrupt. Durations and instants are normalized before recording. | | Canonical golden | The expected JSON-safe run, encoded with stable keys and values. It is the comparison coordinate, not a hand-edited summary. | | Engine contract | The operational bar for a live engine: registration, sends, reads, streams, completion, refusal, stop, and any explicitly enabled child or timer group. |

The pure simulator run can establish the kernel-side record. A live runtime run can compare its committed record and observe executed plans. A cross-implementation contract then asks whether each engine preserves the same chart meaning while honoring its own operational guarantees. No one of these records should be read as a substitute for the others.

Require positive and negative controls

Every prevention law needs both directions. The positive control shows the claimed mechanism is active. The negative control changes or bypasses that mechanism and must fail the assertion.

| Claim under test | Positive control | Negative control | | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | Equal pure inputs replay equally | Equal chart, bindings, snapshot, event, and trace bytes compare equal. | Change one event or binding and require unequal trace bytes. | | Virtual time is explicit | Advance to a due deadline and observe its fold. | Omit the advance and require that no deadline fold is reported. | | Activity execution is outside simulation | Inject a success or failure and inspect the resulting chart decision. | Provide no injection and require that the activity does not complete by itself. | | Child execution is outside simulation | Observe a child site record in parent snapshot data. | Require live-engine evidence before asserting child output, child completion, or child ownership. | | Refusal, typed failure, and staleness remain distinct | Assert Unhandled, a typed Result failure, and Stale separately. | Collapse them to a boolean and require the test review to reject the lost reason. | | Immutable branching is real | Reuse one value to create two independent branches and compare them. | Mutate or overwrite the base value and require the test to detect the missing branch evidence. | | Operational guarantees belong to engines | Run a live contract that exercises storage, recovery, redelivery, concurrency, or ownership. | Use only a simulator trace and require the operational claim to be rejected. | | Fixture coordinates agree | Compare the recorded run to its canonical golden and compare the live run to the kernel record. | Change an instruction or golden and require a mismatch. |

The determinism page owns canonical form and identity doctrine. This page owns the testing move: choose the witness that owns the claim, preserve the distinctions in its result, and make the failure direction visible.