Launch HASH

Simulation

Running simulations and experiments on Petri nets


Petrinaut ships with a headless simulation engine, @hashintel/petrinaut-core, that powers in-editor simulator but is suitable for using in its own right in other tools.

The simulation engine

createSimulation initializes a simulation against an immutable SDCPN snapshot and resolves a live Simulation handle. The handle exposes readable stores for status and frames, an event stream, and run / pause / reset controls. Frame computation happens off the main thread in a Worker; frames stream back to the consumer as they're computed.

import { createSimulation } from "@hashintel/petrinaut-core";

const simulation = await createSimulation({
  sdcpn,
  initialMarking,
  parameterValues,
  seed: 42,
  dt: 0.01,
  maxTime: null,
  createWorker: () =>
    new Worker(new URL("./simulation.worker.js", import.meta.url)),
});

Key things to know:

  • Lifecycle. A simulation moves through InitializingReadyRunningPausedComplete or Error. The full state diagram lives in the simulation README.
  • Determinism. Given the same seed, dt, and configuration, a simulation will produce byte-identical frames every time — important for reproducible experiments and bug reports.
  • Backpressure. The worker won't run ahead of the consumer indefinitely. backpressure: { maxFramesAhead, batchSize } and simulation.ack(frameNumber) let you tune how far the engine gets ahead of playback.
  • Immutability. A simulation runs against a snapshot. Mutating the underlying document afterwards does not affect any active simulation; reset and re-create to pick up changes.

Single runs

For interactive debugging, a single run is usually what you want. Set the initial marking — token counts for untyped places, individual records for colored places — override any scenario parameters you want to vary, and press Play (or call simulation.run() in headless usage).

Each frame proceeds in two phases:

  1. Continuous dynamics are integrated one step (Euler, step size dt). Every place with a differential equation has its token attributes updated.
  2. Discrete transitions are evaluated in definition order. For each transition, the engine checks structural enablement (input arc weights, inhibitor conditions), evaluates the lambda, and — if the transition fires — removes input tokens immediately so later transitions in the same step see the updated state. All produced tokens are added at the end of the step.

A run stops at the configured maxTime, or when the engine reports deadlock: no transition fired this step and no transition is structurally enabled. Deadlock means there is no possible next step, not just that nothing happened to fire this frame; rate-throttled transitions waiting for their next stochastic firing don't count.

Monte Carlo experiments

For statistical questions — "what's the distribution of throughput?" — a single run isn't enough. createMonteCarloSimulator orchestrates many independent runs against the same SDCPN with different seeds, parameter sweeps, or initial markings:

import { createMonteCarloSimulator } from "@hashintel/petrinaut-core";

const simulator = createMonteCarloSimulator({
  sdcpn,
  runs: [
    /* MonteCarloRunConfig per run */
  ],
  metrics,
});

await simulator.runUntilComplete();

Implementation notes that matter when you're scaling these up:

  • Bounded memory per run. Each run owns two reusable ArrayBuffers — currentFrame and nextFrame — that the engine swaps between each step. The simulator does not retain per-frame history; only the latest frame is observable. That makes it practical to run thousands of long simulations on a single machine without exhausting heap.
  • Deterministic round-robin scheduling. All runs advance together one frame at a time, so a single very long run can't starve the others. See the Monte Carlo README for the full design.
  • Streaming metrics. Because there's no frame history, anything you want to measure has to be computed on-the-fly via a metric (see below).

Custom metrics

A metric is a user-authored TypeScript snippet evaluated against the simulation's state object each frame.

return (
  state.places.Infected.count /
  (state.places.Susceptible.count +
    state.places.Infected.count +
    state.places.Recovered.count)
);

Metrics drive both the in-editor timeline charts (one value per frame, plotted over time) and Monte Carlo aggregates.

Metrics are compiled with compileMetric and are subject to the same diagnostics surface as the rest of the net's code, so authoring errors show up in the editor alongside lambda and kernel diagnostics.

Visualization during simulation

Two visualization surfaces are wired up automatically:

  • Timeline charts in the bottom panel render every place's token count over time.
  • Per-place visualizers render a user-defined JSX/SVG component for any place with a visualizerCode attached. They re-render live as the underlying tokens change, so a place can display anything from a single number to a domain-specific scene (orbits, queues, grids, gauges). See Custom place visualizers in Basic Features for the authoring model.

Previous

Join our community of HASH developers