Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Murk is a world simulation engine for reinforcement learning and real-time applications.

It provides a tick-based simulation core with pluggable spatial backends, a modular propagator pipeline, ML-native observation extraction, and Gymnasium-compatible Python bindings — all backed by arena-based generational allocation for deterministic, zero-GC memory management.

Features

  • Spatial backends — Line1D, Ring1D, Square4, Square8, Hex2D, and composable ProductSpace (e.g. Hex2D x Line1D)
  • Propagator pipeline — stateless per-tick operators with automatic write-conflict detection, Euler/Jacobi read modes, and CFL validation
  • Observation extraction — ObsSpec, ObsPlan, flat f32 tensors with validity masks, foveation, pooling, and multi-agent batching
  • Two runtime modesLockstepWorld (synchronous, borrow-checker enforced) and RealtimeAsyncWorld (background tick thread with epoch-based reclamation)
  • Deterministic replay — binary replay format with per-tick snapshot hashing and divergence reports
  • Arena allocation — double-buffered ping-pong arenas with Static/PerTick/Sparse field mutability classes; no GC pauses, no Box<dyn> per cell
  • C FFI — stable ABI with handle tables (slot+generation), safe double-destroy, versioned
  • Python bindings — PyO3/maturin native extension with Gymnasium Env and VecEnv adapters
  • Zero unsafe in simulation logic — only murk-arena and murk-ffi are permitted unsafe; everything else is #![forbid(unsafe_code)]

Architecture

┌─────────────────────────────────────────────────────┐
│  Python (murk)          │  C consumers              │
│  MurkEnv / MurkVecEnv   │  murk_lockstep_step()     │
├────────────┬────────────┴───────────────────────────┤
│ murk-python│           murk-ffi                     │
│ (PyO3)     │        (C ABI, handle tables)          │
├────────────┴────────────────────────────────────────┤
│                    murk-engine                       │
│         LockstepWorld · RealtimeAsyncWorld           │
│        TickEngine · IngressQueue · EgressPool        │
├──────────────┬──────────────┬───────────────────────┤
│ murk-propagator │  murk-obs │   murk-replay         │
│ Propagator trait│  ObsSpec  │   ReplayWriter/Reader  │
│ StepContext     │  ObsPlan  │   determinism verify   │
├──────────────┴──┴──────────┬┴───────────────────────┤
│       murk-arena           │      murk-space         │
│  PingPongArena · Snapshot  │  Space trait · backends │
│  ScratchRegion · Sparse    │  regions · edges        │
├────────────────────────────┴────────────────────────┤
│                     murk-core                        │
│    FieldDef · Command · SnapshotAccess · IDs         │
└─────────────────────────────────────────────────────┘

Getting started

Head to the Getting Started guide for installation instructions and your first simulation.

Getting Started

Prerequisites

Rust (for building from source or using the Rust API):

Python (for the Gymnasium bindings):

  • Python 3.9+
  • maturin (pip install maturin)
  • numpy >= 1.24, gymnasium >= 0.29 (installed automatically)

Installation

Murk is not yet on PyPI or crates.io. Install from source:

git clone https://github.com/tachyon-beep/murk.git
cd murk

# Rust: build and test
cargo build --workspace
cargo test --workspace

# Python: build native extension in development mode
cd crates/murk-python
pip install maturin
maturin develop --release

First Rust simulation

Run the built-in quickstart example:

cargo run --example quickstart -p murk-engine

See crates/murk-engine/examples/quickstart.rs for the full source. The essential pattern:

#![allow(unused)]
fn main() {
let config = WorldConfig { space, fields, propagators, dt: 0.1, seed: 42, .. };
let mut world = LockstepWorld::new(config)?;
let result = world.step_sync(commands)?;
let heat = result.snapshot.read(FieldId(0)).unwrap();
}

First Python simulation

import murk
from murk import Config, FieldMutability, EdgeBehavior, WriteMode, ObsEntry, RegionType

config = Config()
config.set_space_square4(16, 16, EdgeBehavior.Absorb)
config.add_field("heat", mutability=FieldMutability.PerTick)
# ... add propagators ...

env = murk.MurkEnv(config, obs_entries=[ObsEntry(0, region_type=RegionType.All)], n_actions=5)
obs, info = env.reset()

for _ in range(1000):
    action = policy(obs)
    obs, reward, terminated, truncated, info = env.step(action)

Next steps

  • Concepts — understand spaces, fields, propagators, commands, and observations
  • Examples — complete Python RL training examples
  • API Reference — full rustdoc

Murk Concepts Guide

This guide explains the mental model behind Murk. It’s written for someone who has run the heat_seeker example and wants to build something of their own.

Every Murk simulation has five components:

  1. A Space — the topology cells live on
  2. Fields — per-cell data stored in arenas
  3. Propagators — stateless operators that update fields each tick
  4. Commands — how actions from outside enter the simulation
  5. Observations — how state gets extracted for agents or renderers

These components are configured once, compiled into a world, and then ticked forward repeatedly. The rest of this guide explains each one.


Spaces & Topologies

A space defines how many cells exist and which cells are neighbors. Murk ships with seven built-in space backends:

SpaceDimsNeighborsParametersDistance metric
Line1D1D2length, edgeManhattan
Ring1D1D2 (periodic)lengthmin(fwd, bwd)
Square42D4 (N/S/E/W)width, height, edgeManhattan
Square82D8 (+ diagonals)width, height, edgeChebyshev
Hex2D2D6 (pointy-top)cols, rowsCube distance
Fcc123D12 (face-centred cubic)w, h, d, edgeFCC metric
ProductSpaceN-Dvarieslist of component spacesL1 sum

Choosing a space

  • Line1D / Ring1D — 1D cellular automata, queues, pipelines.
  • Square4 — grid worlds, pathfinding, Conway’s Game of Life.
  • Square8 — grid worlds where diagonal movement matters.
  • Hex2D — isotropic 2D movement without diagonal bias.
  • Fcc12 — 3D isotropic lattice (12 equidistant neighbors). Good for volumetric simulations like crystal growth or 3D diffusion.
  • ProductSpace — compose any spaces together (e.g., Hex2D x Line1D for a hex map with a vertical elevation axis).

Edge behaviors

Spaces that have boundaries support three edge behaviors:

BehaviorAt boundaryExample use
AbsorbEdge cells have fewer neighborsBounded arena, finite grid
ClampBeyond-edge maps to edge cellImage processing, extrapolation
WrapWraps to opposite side (torus)Pac-Man map, periodic simulation

Ring1D is always periodic (wrap). Hex2D only supports Absorb.

Coordinates

Every cell has a coordinate — a small vector of i32 values:

  • Line1D / Ring1D: [x]
  • Square4 / Square8: [row, col]
  • Hex2D: [q, r] (axial, pointy-top)
  • Fcc12: [x, y, z] where (x + y + z) % 2 == 0
  • ProductSpace: concatenation of component coordinates

Cells are stored in canonical order (a deterministic traversal of all coordinates). When you read a field as a flat f32 array, element i corresponds to canonical coordinate i. For 2D grids this is row-major order.

Cell count

The number of cells is determined by the space parameters:

  • Line1D(5) → 5 cells
  • Square4(10, 10) → 100 cells
  • Hex2D(8, 8) → 64 cells
  • Fcc12(4, 4, 4) → approximately w*h*d / 2 cells (parity constraint)

This matters because every field allocates cell_count * components floats per generation.


Fields & Mutability

Fields are per-cell data arrays. A 100-cell Square4 world with one Scalar field allocates 100 f32 values for that field.

Field types

TypeStorage per cellUse case
Scalar1 × f32Temperature, density, boolean flags
Vector { dims }dims × f32Velocity, color
Categorical { n_values }1 × f32 (stored as index)Terrain type, cell state

Field mutability

Mutability controls how and when memory is allocated for a field. This is the most important performance decision you’ll make.

MutabilityAllocation patternRead baselineUse when
StaticOnce, never againAlways generation 0Constants (terrain type, wall mask)
PerTickFresh buffer every tickPrevious tick’s valuesFrequently-updated state (heat, positions)
SparseNew buffer only on writeShared until mutatedInfrequently-changed state (terrain HP)

Static fields are allocated once in a shared arena. They’re read-only after initialization — propagators can read them but never write them. Use these for data that never changes (terrain layout, obstacle masks).

PerTick fields get a fresh buffer every tick. If a propagator writes to the field, it fills the new buffer. If nothing writes to the field, the previous tick’s values are copied forward. This is the most common mutability class — use it for anything that changes regularly.

Sparse fields share memory across ticks until something writes to them, at which point a new buffer is allocated (copy-on-write). Use these for data that changes rarely — the arena skips allocation on ticks where the field isn’t modified.

Bounds and boundary behavior

Fields can optionally have value bounds (min, max). When a value is written outside those bounds, the BoundaryBehavior determines what happens:

  • Clamp — value is clamped to the nearest bound
  • Reflect — value bounces off the bound
  • Absorb — value is set to the bound
  • Wrap — value wraps to the opposite bound

If you don’t need bounds, just use the defaults.


Propagators

A propagator is a stateless function that runs once per tick. It reads some fields, writes some fields, and that’s it. All simulation logic lives in propagators.

The step signature (Python)

def my_propagator(reads, reads_prev, writes, tick_id, dt, cell_count):
    """
    reads:       list of numpy arrays (fields from current-tick overlay)
    reads_prev:  list of numpy arrays (fields from previous tick, frozen)
    writes:      list of numpy arrays (output buffers to fill)
    tick_id:     int, monotonically increasing tick counter
    dt:          float, simulation timestep in seconds
    cell_count:  int, number of cells in the space
    """
    ...

The step signature (Rust)

#![allow(unused)]
fn main() {
fn step(&self, ctx: &mut StepContext<'_>) -> Result<(), PropagatorError> {
    let prev_heat = ctx.reads_previous().read_field(HEAT_ID)?;
    let space = ctx.space();
    let writer = ctx.writes();
    // ... compute new values, write to output ...
}
}

Read modes: Euler vs Jacobi

Every propagator declares which fields it reads. There are two read modes:

  • reads (Euler mode) — sees the in-tick overlay. If a prior propagator in the same tick already wrote to this field, you see those new values. This creates a dependency chain between propagators.

  • reads_previous (Jacobi mode) — sees the frozen tick-start snapshot. Always reads the base generation, regardless of what other propagators have written this tick.

The choice matters for correctness:

  • Diffusion should use reads_previous (Jacobi). Otherwise the result depends on cell visit order, which is wrong.
  • A reward propagator that reads an agent-position field written by a movement propagator should use reads (Euler) to see the already-updated position.

Write modes

Each written field has a write mode:

  • WriteMode.Full — the propagator fills every cell. The engine gives you a fresh, zeroed buffer. In debug builds, a coverage guard checks that every cell was written.

  • WriteMode.Incremental — the propagator modifies only some cells. The engine pre-seeds the buffer with the previous tick’s values via memcpy. You only update the cells you need.

Pipeline validation

Murk validates the propagator pipeline at startup:

  • Write conflicts — two propagators writing the same field is an error (detected and reported with both propagator names).
  • CFL stability — if a propagator declares a max_dt, Murk checks that the configured dt doesn’t exceed it.
  • Undefined fields — reading a field that doesn’t exist is an error.

Ordering

Propagators run in the order they’re registered. This ordering, combined with the Euler/Jacobi read declarations, defines the dataflow. The engine precomputes a ReadResolutionPlan that maps each (propagator, field) pair to either the base generation or a prior propagator’s staged output — with zero per-tick routing overhead.


Commands & Ingress

Commands are how actions from outside the simulation (agent actions, user input, network messages) enter the tick loop.

Command types

CommandPurpose
SetField(coord, field_id, value)Write a single cell value
Move(entity_id, target_coord)Move an entity
Spawn(coord, field_values)Create a new entity
Despawn(entity_id)Remove an entity
SetParameter(key, value)Change a global simulation parameter
Custom(type_id, data)User-defined command type

In the Python API, the most common command is SetField:

cmd = Command.set_field(field_id=1, coord=[5, 3], value=1.0)
receipts, metrics = world.step([cmd])

Receipts

Every command submitted to step() gets a receipt:

receipts, metrics = world.step([cmd])
for r in receipts:
    print(r.accepted, r.applied_tick_id)

A command can be rejected if the ingress queue is full, the command is stale (refers to an old tick), or the world is shutting down.

Command ordering

Commands are applied in this order: priority_class (lower = higher priority), then source_id, then arrival_seq (monotonic counter). System commands (priority 0) run before user commands (priority 1).


Observations

The observation system extracts field data into flat f32 tensors suitable for neural networks.

The pipeline: ObsSpec → ObsPlan → execute

  1. ObsSpec — a list of ObsEntry objects declaring what to observe.
  2. ObsPlan — a compiled plan (precomputed gather indices). Created once, reused every tick.
  3. execute — runs the plan against the current world snapshot, producing a flat f32 array.
# 1. Specify what to observe
obs_entries = [
    ObsEntry(field_id=0, region_type=RegionType.All),
    ObsEntry(field_id=1, region_type=RegionType.AgentDisk, radius=3),
]

# 2. MurkEnv compiles the plan internally
# 3. Each step(), the plan executes and returns obs as a numpy array
obs, reward, terminated, truncated, info = env.step(action)

Region types

RegionDescriptionWhen to use
AllEvery cell in the spaceFull observability, small grids
AgentDisk(radius)Cells within radius graph-distance of the agentPartial observability, foveation
AgentRect(half_extent)Axis-aligned bounding box around agentRectangular partial observability

All is the simplest — you get cell_count floats per entry. Agent-centered regions give partial observability and scale better on large grids.

Transforms

Transforms are applied to field values during extraction:

  • Identity — raw values, no change
  • Normalize(min, max) — linearly maps [min, max] to [0, 1], clamping values outside the range

Pooling

For large observations, pooling reduces dimensionality:

  • PoolKernel.Mean — average of each window
  • PoolKernel.Max — maximum of each window
  • PoolKernel.Min — minimum of each window
  • PoolKernel.Sum — sum of each window

Pooling is configured per-entry with kernel_size and stride.

Observation layout

Entries are concatenated in order. If you observe two fields on a 100-cell grid with region_type=All, you get a 200-element f32 array: the first 100 elements are field 0, the next 100 are field 1.


Runtime Modes

Murk has two runtime modes that share the same tick engine but differ in how you interact with it.

LockstepWorld (synchronous)

The standard mode for RL training:

# Python (via MurkEnv)
obs, reward, terminated, truncated, info = env.step(action)

# Rust
let result = world.step_sync(commands)?;
let snapshot = result.snapshot;  // borrows world

Properties:

  • Blocking step() call — you wait for the tick to complete
  • In Rust, &mut self enforces single-threaded access at compile time
  • The snapshot borrows the world, preventing a new step until you’re done reading
  • Deterministic: same seed + same commands = same result, always

This is what MurkEnv and MurkVecEnv use internally.

RealtimeAsyncWorld (asynchronous)

For real-time applications (game servers, live visualizations):

#![allow(unused)]
fn main() {
// Commands are submitted without blocking
world.submit_commands(commands)?;

// Observations can be taken concurrently
let result = world.observe(&mut plan)?;
}

Properties:

  • Background tick thread runs at a configurable rate
  • Multiple observation requests can be served concurrently via a worker pool
  • Epoch-based reclamation ensures snapshots aren’t freed while being read
  • Command channel provides back-pressure when the queue is full

The Python bindings currently only expose LockstepWorld.


Arena & Memory

Murk uses arena-based generational allocation instead of per-object heap allocation. This is what makes it fast and GC-free.

The ping-pong buffer

The engine maintains two segment pools (A and B). On each tick:

  1. One pool is staging (being written by propagators)
  2. The other is published (readable as a snapshot)
  3. After the tick, they swap roles

This means the previous tick’s data is always available for reading while the current tick is being computed.

How mutability maps to memory

  • Static fields live in a separate shared arena. They’re allocated once and never touched again. No per-tick cost.

  • PerTick fields get a fresh allocation in the staging pool every tick. After publish, the old staging pool (now published) still holds the previous tick’s values — so snapshots and reads_previous work without copying.

  • Sparse fields use a dedicated copy-on-write slab. They share memory across ticks until a propagator writes to them, at which point a new allocation is made. On ticks where nothing writes to a sparse field, there’s zero allocation cost.

Why this matters

  • No garbage collection pauses — arena memory is bulk-freed, not per-object
  • Deterministic memory lifetime — you know exactly when memory is allocated and freed
  • Zero-copy snapshots — reading the previous tick’s data is just a pointer into the published pool

For most users, you don’t need to think about arenas directly. The practical takeaway is: choose the right FieldMutability for your data, and the arena system handles the rest efficiently.


Putting It Together

Here’s how these concepts compose in a typical simulation:

import murk
from murk import (
    Config, FieldMutability, EdgeBehavior,
    WriteMode, ObsEntry, RegionType,
)

# 1. Space: defines topology
config = Config()
config.set_space_square4(32, 32, EdgeBehavior.Wrap)

# 2. Fields: define per-cell data
config.add_field("temperature", mutability=FieldMutability.PerTick)
config.add_field("terrain", mutability=FieldMutability.Static)
config.add_field("agent_pos", mutability=FieldMutability.PerTick)

# 3. Propagator: defines simulation logic
def diffuse(reads, reads_prev, writes, tick_id, dt, cell_count):
    # reads_prev[0] = previous tick's temperature
    # writes[0] = this tick's temperature output
    ...

murk.add_propagator(
    config,
    name="diffusion",
    step_fn=diffuse,
    reads_previous=[0],              # Jacobi read of field 0
    writes=[(0, WriteMode.Full)],    # Full write to field 0
)

config.set_dt(0.1)
config.set_seed(42)

# 4. Observations: define what the agent sees
obs_entries = [
    ObsEntry(0, region_type=RegionType.All),       # Full temperature grid
    ObsEntry(2, region_type=RegionType.AgentDisk, radius=5),  # Agent's local view
]

# 5. Environment: wraps everything in the Gymnasium interface
env = murk.MurkEnv(config, obs_entries, n_actions=5, seed=42)
obs, info = env.reset()
obs, reward, terminated, truncated, info = env.step(action)

For a complete working example, see heat_seeker.


Glossary

TermDefinition
CellA single location in the space. Has a coordinate and one value per field.
TickOne simulation timestep. All propagators run, then the arena publishes.
GenerationArena version counter. Incremented on each publish.
Canonical orderThe deterministic traversal of all coordinates (row-major for 2D grids).
SnapshotRead-only view of the world state at a particular generation.
ObsPlanCompiled observation plan. Precomputes gather indices for fast extraction.
IngressThe command queue that feeds actions into the tick loop.
EgressThe observation pathway that extracts state out of the simulation.
CFL conditionCourant-Friedrichs-Lewy stability constraint: N * D * dt < 1 where N is neighbor count.

Examples

Murk ships with three Python example projects demonstrating different spatial backends and RL integration patterns.

ExampleSpaceDemonstrates
heat_seekerSquare4PPO RL, diffusion physics, Python propagator
hex_pursuitHex2DMulti-agent, AgentDisk foveation
crystal_navFcc123D lattice navigation

There is also a Rust example:

ExampleDemonstrates
quickstart.rsRust API: config, propagator, commands, snapshots

Running the Python examples

# Install murk first
cd crates/murk-python && maturin develop --release && cd ../..

# Run an example
cd examples/heat_seeker
pip install -r requirements.txt
python heat_seeker.py

Murk Error Code Reference

Complete reference of all error types in the Murk simulation framework, organized by subsystem.


Table of Contents


StepError

Crate: murk-core | File: crates/murk-core/src/error.rs

Errors returned by the tick engine during step(). Corresponds to the TickEngine and Pipeline subsystem codes in HLD section 9.7.

VariantHLD CodeCauseRemediation
PropagatorFailed { name: String, reason: PropagatorError }MURK_ERROR_PROPAGATOR_FAILEDA propagator returned an error during execution. The name field identifies the failing propagator and reason contains the underlying PropagatorError.Inspect the wrapped PropagatorError for details. Check propagator inputs (field values, dt) for validity. The tick engine will roll back the tick.
AllocationFailedMURK_ERROR_ALLOCATION_FAILEDArena allocation failed due to out-of-memory during generation staging.Reduce field count or cell count. Increase arena segment pool capacity. Check for epoch reclamation stalls preventing segment reuse.
TickRollbackMURK_ERROR_TICK_ROLLBACKThe current tick was rolled back due to a propagator failure. All staged writes are discarded and the world state reverts to the previous generation.Transient: retry on the next tick. Persistent: investigate the failing propagator. Commands submitted during a rolled-back tick are dropped.
TickDisabledMURK_ERROR_TICK_DISABLEDTicking has been disabled after consecutive rollbacks (Decision J). The engine enters a fail-stop state to prevent cascading failures.The simulation must be reset or reconstructed. Investigate the root cause of repeated propagator failures before restarting.
DtOutOfRangeMURK_ERROR_DT_OUT_OF_RANGEThe requested dt exceeds a propagator’s max_dt constraint (CFL condition or similar stability limit).Reduce the configured dt to be at or below the tightest max_dt across all propagators. Check PipelineError::DtTooLarge for which propagator constrains it.
ShuttingDownMURK_ERROR_SHUTTING_DOWNThe world is in the shutdown state machine (Decision E). No further ticks will be executed.Expected during graceful shutdown. Do not retry; the world is terminating.

PropagatorError

Crate: murk-core | File: crates/murk-core/src/error.rs

Errors from individual propagator execution. Returned by Propagator::step() and wrapped in StepError::PropagatorFailed by the tick engine.

VariantHLD CodeCauseRemediation
ExecutionFailed { reason: String }MURK_ERROR_PROPAGATOR_FAILEDThe propagator’s step function failed. The reason field contains a human-readable description of the failure.Inspect the reason string. Common causes: invalid field state, numerical instability, domain-specific constraint violations.
NanDetected { field_id: FieldId, cell_index: Option<usize> }NaN detected in propagator output during sentinel checking. field_id identifies the affected field; cell_index pinpoints the first NaN cell if known.Indicates numerical instability. Reduce dt, add clamping or bounds to the propagator, or check for division-by-zero in the propagator logic.
ConstraintViolation { constraint: String }A user-defined constraint was violated during propagator execution.Review the constraint definition and the field state that triggered it. May indicate an out-of-bounds physical quantity or a domain invariant violation.

IngressError

Crate: murk-core | File: crates/murk-core/src/error.rs

Errors from the ingress (command submission) pipeline. Used in Receipt::reason_code to explain why a command was rejected.

VariantHLD CodeCauseRemediation
QueueFullMURK_ERROR_QUEUE_FULLThe command queue is at capacity. The ingress pipeline cannot buffer any more commands until the tick engine drains the queue.Reduce command submission rate. Increase max_ingress_queue in WorldConfig. In RL training, this may indicate the agent is submitting faster than the tick rate.
StaleMURK_ERROR_STALEThe command’s basis_tick_id is too old relative to the current tick. The adaptive backoff mechanism rejected it due to excessive skew.Resubmit the command with a fresh basis tick. If occurring frequently, the agent’s observation-to-action latency is too high relative to the tick rate. Backoff parameters in BackoffConfig control the tolerance.
TickRollbackMURK_ERROR_TICK_ROLLBACKThe tick was rolled back; commands submitted during that tick were dropped.Resubmit the command on the next tick. This is a transient condition.
TickDisabledMURK_ERROR_TICK_DISABLEDTicking is disabled after consecutive rollbacks. No commands will be accepted until the world is reset.Reset the simulation. Investigate the root cause of repeated tick rollbacks.
ShuttingDownMURK_ERROR_SHUTTING_DOWNThe world is shutting down. No further commands are accepted.Expected during graceful shutdown. Do not retry.

ObsError

Crate: murk-core | File: crates/murk-core/src/error.rs

Errors from the observation (egress) pipeline. Covers ObsPlan compilation, execution, and snapshot access failures.

VariantHLD CodeCauseRemediation
PlanInvalidated { reason: String }MURK_ERROR_PLAN_INVALIDATEDThe ObsPlan’s generation does not match the current snapshot. This occurs when the world topology or field layout has changed since the plan was compiled.Recompile the ObsPlan via ObsPlan::compile() against the current snapshot. Plans are invalidated by world resets or structural changes.
TimeoutWaitingForTickMURK_ERROR_TIMEOUT_WAITING_FOR_TICKAn exact-tick egress request timed out. Only occurs in RealtimeAsync mode when waiting for a specific tick that has not yet been produced.Increase the timeout budget. Check if the tick thread is stalled or running slower than expected. This error does not occur in Lockstep mode.
NotAvailableMURK_ERROR_NOT_AVAILABLEThe requested tick has been evicted from the snapshot ring buffer. The ring only retains the most recent ring_buffer_size snapshots.Increase ring_buffer_size in WorldConfig. Alternatively, consume observations more promptly so they are not evicted before access.
InvalidComposition { reason: String }MURK_ERROR_INVALID_COMPOSITIONThe ObsPlan’s valid_ratio is below the 0.35 threshold. Too many entries in the observation spec reference invalid or out-of-bounds regions.Review the ObsSpec entries. Ensure field IDs and region specifications are valid for the current world configuration. The 0.35 threshold means at least 35% of entries must be valid.
ExecutionFailed { reason: String }MURK_ERROR_EXECUTION_FAILEDObsPlan execution failed mid-fill. An error occurred while extracting field data into the output buffer.Inspect the reason string. Common causes: snapshot was reclaimed during execution, arena error, or malformed plan.
InvalidObsSpec { reason: String }MURK_ERROR_INVALID_OBSSPECMalformed ObsSpec detected at compilation time. The observation specification contains structural errors.Review the ObsSpec structure: check field IDs, region definitions, transforms, and dtypes. Fix the spec before recompiling.
WorkerStalledMURK_ERROR_WORKER_STALLEDAn egress worker exceeded the max_epoch_hold budget (default 100ms). The epoch reclamation system forcibly unpinned the worker to prevent blocking arena garbage collection.Reduce observation complexity or increase max_epoch_hold_ms in AsyncConfig. A stalled worker prevents epoch advancement, which blocks arena segment reclamation.

ConfigError

Crate: murk-engine | File: crates/murk-engine/src/config.rs

Errors detected during WorldConfig::validate() at startup time. These are structural invariant violations that prevent world construction.

VariantHLD CodeCauseRemediation
Pipeline(PipelineError)Propagator pipeline validation failed. Wraps a PipelineError (see below).Inspect the inner PipelineError for the specific pipeline issue.
Arena(ArenaError)Arena configuration is invalid. Wraps an ArenaError (see below).Inspect the inner ArenaError for the specific arena issue.
EmptySpaceThe configured Space has zero cells. A simulation requires at least one spatial cell.Provide a space with at least one cell. Check the space constructor arguments.
NoFieldsNo fields are registered in the configuration. A simulation requires at least one FieldDef.Add at least one field definition to WorldConfig::fields.
RingBufferTooSmall { configured: usize }The ring_buffer_size is below the minimum of 2. The snapshot ring requires at least 2 slots for double-buffering.Set ring_buffer_size to 2 or greater. Default is 8.
IngressQueueZeroThe max_ingress_queue capacity is zero. The ingress pipeline requires at least one slot.Set max_ingress_queue to 1 or greater. Default is 1024.
InvalidTickRate { value: f64 }tick_rate_hz is NaN, infinite, zero, or negative. Must be a finite positive number.Provide a valid positive finite tick_rate_hz value, or set it to None for no rate limiting.

PipelineError

Crate: murk-propagator | File: crates/murk-propagator/src/pipeline.rs

Errors from pipeline validation at startup. These are checked once by validate_pipeline() and prevent world construction if any are detected.

VariantHLD CodeCauseRemediation
EmptyPipelineNo propagators are registered in the pipeline. At least one propagator is required.Add at least one propagator to WorldConfig::propagators.
WriteConflict(Vec<WriteConflict>)Two or more propagators write the same field. Each WriteConflict contains field_id, first_writer, and second_writer.Ensure each FieldId is written by at most one propagator. Restructure propagators so that field ownership is exclusive.
UndefinedField { propagator: String, field_id: FieldId }A propagator references (reads, reads_previous, or writes) a field that is not defined in the world’s field list.Register the missing FieldId in WorldConfig::fields, or update the propagator to reference only defined fields.
DtTooLarge { configured_dt: f64, max_supported: f64, constraining_propagator: String }The configured dt exceeds a propagator’s max_dt constraint. The constraining_propagator field identifies which propagator has the tightest limit.Reduce WorldConfig::dt to at or below max_supported. The tightest max_dt across all propagators determines the upper bound.
InvalidDt { value: f64 }The configured dt is not a valid timestep: NaN, infinity, zero, or negative.Provide a finite positive dt value in WorldConfig::dt.

The WriteConflict struct contains:

FieldTypeDescription
field_idFieldIdThe contested field
first_writerStringName of the first writer (earlier in pipeline order)
second_writerStringName of the second writer (later in pipeline order)

ArenaError

Crate: murk-arena | File: crates/murk-arena/src/error.rs

Errors from arena operations. The arena manages generational allocation of field data for the snapshot ring buffer.

VariantHLD CodeCauseRemediation
CapacityExceeded { requested: usize, capacity: usize }The segment pool is full and cannot allocate the requested number of bytes. All segments are in use by live generations.Increase arena capacity. Ensure epoch reclamation is running (workers must unpin epochs so old generations can be freed). Reduce ring_buffer_size to decrease the number of live generations.
StaleHandle { handle_generation: u32, oldest_live: u32 }A FieldHandle from a generation that has already been reclaimed was used. The handle’s generation predates the oldest live generation.This indicates a use-after-free bug in handle management. Ensure handles are not cached across generation boundaries. Check that observation plans are recompiled after resets.
UnknownField { field: FieldId }A FieldId that is not registered in the arena was referenced.Ensure the field is registered in WorldConfig::fields. Check that the FieldId index matches the field definition order.
NotWritable { field: FieldId }Attempted to write a field whose FieldMutability does not permit writes in the current context (e.g., writing a Static field after initialization).Check the field’s FieldMutability setting. Static fields can only be set during initialization. Use PerTick or PerCommand mutability for fields that change during simulation.
InvalidConfig { reason: String }The arena configuration is invalid.Inspect the reason string for details. Typically indicates misconfigured segment sizes or field layouts.

SpaceError

Crate: murk-space | File: crates/murk-space/src/error.rs

Errors from space construction or spatial queries.

VariantHLD CodeCauseRemediation
CoordOutOfBounds { coord: Coord, bounds: String }A coordinate is outside the bounds of the space. The bounds string describes the valid coordinate range.Validate coordinates before passing them to space methods. Clamp or reject out-of-bounds coordinates in command processing.
InvalidRegion { reason: String }A region specification is invalid for this space topology.Review the RegionSpec being compiled. Ensure region parameters (center, radius, etc.) are compatible with the space’s dimensionality and bounds.
EmptySpaceAttempted to construct a space with zero cells. All space types require at least one cell.Provide a positive cell count to the space constructor (e.g., Line1D::new(n, ...) with n >= 1).
DimensionTooLarge { name: &'static str, value: u32, max: u32 }A dimension exceeds the representable coordinate range. The name field indicates which dimension (e.g., “len”, “rows”, “cols”).Reduce the dimension to at or below max. The limit exists because coordinates are stored as i32 and the space must be indexable.
InvalidComposition { reason: String }A space composition is invalid (e.g., empty component list, cell count overflow in product spaces).Review the composition parameters. For product spaces, ensure components are non-empty and the total cell count fits in usize.

ReplayError

Crate: murk-replay | File: crates/murk-replay/src/error.rs

Errors during replay recording, playback, or determinism comparison.

VariantHLD CodeCauseRemediation
Io(io::Error)An I/O error occurred during read or write. Wraps a std::io::Error.Check file permissions, disk space, and path validity. Inspect the inner io::Error for the specific OS-level failure.
InvalidMagicThe file does not start with the expected b"MURK" magic bytes. The file is not a valid Murk replay.Verify the file path points to an actual Murk replay file. The file may be corrupt or a different format.
UnsupportedVersion { found: u8 }The format version in the file is not supported by this build. The current build supports version 2.Upgrade or downgrade the Murk library to match the replay file’s format version. Re-record the replay with the current version.
MalformedFrame { detail: String }A frame could not be decoded due to truncated or corrupt data. Includes truncated frame headers (partial tick_id), invalid presence flags, truncated payloads, and invalid UTF-8 strings.The replay file is corrupt or was truncated (e.g., process crash during recording). Re-record the replay. If the truncation is at the end, preceding frames may still be valid.
UnknownPayloadType { tag: u8 }A command payload type tag is not recognized. The tag value does not correspond to any known CommandPayload variant.The replay was recorded with a newer version of Murk that has additional command types. Upgrade the Murk library.
ConfigMismatch { recorded: u64, current: u64 }The replay was recorded with a different configuration hash. The recorded hash (from the file header) does not match the current hash (computed from the live configuration).Reconstruct the world with the same configuration used during recording: same fields, propagators, dt, seed, space, and ring buffer size.
SnapshotMismatch { tick_id: u64, recorded: u64, replayed: u64 }A snapshot hash does not match between the recorded and replayed state at the specified tick_id. This indicates a determinism violation.The simulation is not deterministic under replay. Common causes: floating-point non-determinism across toolchains/platforms, uninitialized memory, non-deterministic iteration order, or external state dependency. Check the BuildMetadata for toolchain/target differences.

SubmitError

Crate: murk-engine | File: crates/murk-engine/src/realtime.rs

Errors from submitting commands to the tick thread in RealtimeAsyncWorld.

VariantHLD CodeCauseRemediation
ShutdownThe tick thread has shut down. The command channel is disconnected.The world has been shut down or dropped. Do not retry. Create a new world or call reset() if the world supports it.
ChannelFullThe command channel is full (back-pressure). The bounded channel (capacity 64) cannot accept more batches until the tick thread drains it.Reduce command submission rate. Wait for the tick thread to process pending batches before submitting more. This indicates the submitter is outpacing the tick rate.

Murk Replay Wire Format Specification

Binary format for deterministic replay recording and playback. All integers are little-endian. Strings and byte arrays are length-prefixed with a u32 length. No compression, no alignment padding, no self-describing schema.

Current version: 2 Magic: b"MURK" (4 bytes) Byte order: Little-endian throughout


Table of Contents


File Structure

[Header] [Frame 0] [Frame 1] ... [Frame N-1] [EOF]

A replay file consists of a single header followed by zero or more frames. EOF is detected by a clean zero-byte read at a frame boundary (no sentinel or frame count in the header).


Header Layout

The header is written once at file creation by ReplayWriter::new() and validated on open by ReplayReader::open().

Offset  Size     Type                Description
──────  ────     ────                ───────────
0       4        [u8; 4]             Magic bytes: b"MURK"
4       1        u8                  Format version (currently 2)

Build Metadata

Immediately follows the format version. All strings are length-prefixed (u32 length + UTF-8 bytes).

Offset  Size     Type                Description
──────  ────     ────                ───────────
5       4+N      lpstring            toolchain (e.g. "1.78.0")
5+a     4+N      lpstring            target_triple (e.g. "x86_64-unknown-linux-gnu")
5+a+b   4+N      lpstring            murk_version (e.g. "0.1.0")
5+a+b+c 4+N      lpstring            compile_flags (e.g. "release")

Where lpstring means u32 length (LE) + N bytes of UTF-8 data, and a, b, c denote the variable sizes of preceding strings (4 + string length each).

Init Descriptor

Immediately follows build metadata. Contains the simulation initialization parameters needed to reconstruct an identical world for replay.

Offset  Size     Type                Description
──────  ────     ────                ───────────
+0      8        u64 LE              seed: RNG seed for deterministic simulation
+8      8        u64 LE              config_hash: hash of the world configuration
+16     4        u32 LE              field_count: number of fields in the world
+20     8        u64 LE              cell_count: total spatial cells
+28     4+N      lpbytes             space_descriptor: opaque serialized space descriptor

Where lpbytes means u32 length (LE) + N bytes of opaque data.

Total header size: 5 + (4 variable-length strings) + 28 + (1 variable-length byte array) = variable.


Frame Layout

Each frame records a single tick’s command inputs and the resulting snapshot hash for determinism verification. Frames are written sequentially with no padding between them.

Offset  Size     Type                Description
──────  ────     ────                ───────────
+0      8        u64 LE              tick_id: the tick number
+8      4        u32 LE              command_count: number of commands in this frame
+12     ...      [Command]           command_count serialized commands (see below)
+N      8        u64 LE              snapshot_hash: FNV-1a hash of the post-tick snapshot

EOF Detection

When reading frames, a clean EOF (zero bytes available at the start of a frame) returns None (no more frames). A partial read of the 8-byte tick_id header (1-7 bytes) is treated as a truncation error (MalformedFrame), not a clean EOF. This distinguishes complete files from files truncated by a crash during recording.


Command Encoding

Each command within a frame is encoded as follows:

Offset  Size     Type                Description
──────  ────     ────                ───────────
+0      1        u8                  payload_type: discriminant tag (see table below)
+1      4        u32 LE              payload_length: byte length of the payload
+5      N        [u8]                payload: serialized command data (N = payload_length)
+5+N    1        u8                  priority_class: lower = higher priority
+6+N    1        u8                  source_id presence flag (0 = absent, 1 = present)
+7+N    0 or 8   u64 LE              source_id value (only if presence flag = 1)
+...    1        u8                  source_seq presence flag (0 = absent, 1 = present)
+...    0 or 8   u64 LE              source_seq value (only if presence flag = 1)

Command size: varies from 8 bytes (minimum: 1 + 4 + 0 + 1 + 1 + 1 = 8 with empty payload, no source fields) to unbounded depending on payload size and source field presence.

Not serialized: expires_after_tick and arrival_seq are not recorded in the replay format. On deserialization, expires_after_tick is set to TickId(u64::MAX) and arrival_seq is set to 0.

Presence Flag Encoding

The source_id and source_seq fields use explicit presence flags to distinguish None from Some(0):

Flag valueMeaningFollowing bytes
0x00Absent (None)0 bytes
0x01Present (Some(value))8 bytes (u64 LE)
OtherInvalidDecode error (MalformedFrame)

This encoding was introduced in format version 2 to fix a bug in v1 where Some(0) was indistinguishable from None.


Payload Type Tags

TagConstantCommandPayload Variant
0PAYLOAD_MOVEMove
1PAYLOAD_SPAWNSpawn
2PAYLOAD_DESPAWNDespawn
3PAYLOAD_SET_FIELDSetField
4PAYLOAD_CUSTOMCustom
5PAYLOAD_SET_PARAMETERSetParameter
6PAYLOAD_SET_PARAMETER_BATCHSetParameterBatch

Unrecognized tags produce ReplayError::UnknownPayloadType.


Payload Serialization

Move (tag 0)

Offset  Size     Type                Description
──────  ────     ────                ───────────
0       8        u64 LE              entity_id
8       4+N*4    coord               target_coord (see Coord encoding below)

Spawn (tag 1)

Offset  Size     Type                Description
──────  ────     ────                ───────────
0       4+N*4    coord               coord: spawn location
+a      4        u32 LE              field_values count
+a+4    M*(4+4)  [(u32, f32)]        field_values: array of (FieldId as u32 LE, value as f32 LE)

Despawn (tag 2)

Offset  Size     Type                Description
──────  ────     ────                ───────────
0       8        u64 LE              entity_id

SetField (tag 3)

Offset  Size     Type                Description
──────  ────     ────                ───────────
0       4+N*4    coord               coord: target cell
+a      4        u32 LE              field_id (FieldId inner value)
+a+4    4        f32 LE              value

Custom (tag 4)

Offset  Size     Type                Description
──────  ────     ────                ───────────
0       4        u32 LE              type_id: user-registered type identifier
4       4        u32 LE              data_length: byte length of opaque data
8       N        [u8]                data: opaque payload (N = data_length)

SetParameter (tag 5)

Offset  Size     Type                Description
──────  ────     ────                ───────────
0       4        u32 LE              key (ParameterKey inner value)
4       8        f64 LE              value

Total payload size: 12 bytes (fixed).

SetParameterBatch (tag 6)

Offset  Size     Type                Description
──────  ────     ────                ───────────
0       4        u32 LE              param_count: number of parameters
4       N*12     [(u32, f64)]        params: array of (ParameterKey as u32 LE, value as f64 LE)

Each entry is 12 bytes (4 bytes key + 8 bytes value).


Coord Encoding

Coordinates (Coord, which is SmallVec<[i32; 4]>) are serialized as a length-prefixed array of i32 values:

Offset  Size     Type                Description
──────  ────     ────                ───────────
0       4        u32 LE              dimension_count: number of coordinate components
4       N*4      [i32 LE]            components: coordinate values (N = dimension_count)

Total size: 4 + (dimension_count * 4) bytes. For a typical 2D coordinate, this is 12 bytes.


Primitive Encoding

All primitive types use little-endian byte order:

TypeSizeEncoding
u81 byteRaw byte
u324 bytesLittle-endian
u648 bytesLittle-endian
i324 bytesLittle-endian
f324 bytesIEEE 754, little-endian
f648 bytesIEEE 754, little-endian
lpstring4 + N bytesu32 LE length prefix + UTF-8 bytes
lpbytes4 + N bytesu32 LE length prefix + raw bytes

Snapshot Hash

The snapshot_hash field in each frame is an FNV-1a hash computed over the post-tick snapshot state. It is used during replay to verify determinism: after replaying all commands for a tick, the replayed simulation’s snapshot hash is compared against the recorded hash. A mismatch produces ReplayError::SnapshotMismatch.

The hash is computed by snapshot_hash() in crates/murk-replay/src/hash.rs and covers all fields up to field_count.


Version History

Version 2 (current)

  • source_id and source_seq use presence-flag encoding: a u8 flag (0 = absent, 1 = present) followed by an optional u64 value.
  • This correctly distinguishes None from Some(0).

Version 1

  • source_id and source_seq were encoded as bare u64 values where 0 meant “not set”.
  • Bug: Some(0) was indistinguishable from None, causing incorrect replay of commands with source_id = Some(0).
  • Superseded by version 2. Files with version 1 are rejected with ReplayError::UnsupportedVersion { found: 1 }.

Determinism Catalogue (R-DET-6)

Living document cataloging known sources of non-determinism and the mitigations applied in the Murk simulation framework.

Sources of Non-Determinism

1. HashMap / HashSet Iteration Order

Risk: HashMap and HashSet use randomized hashing by default. Iterating over them produces different orderings across runs.

Mitigation: Banned project-wide via clippy.toml:

disallowed-types = [
    { path = "std::collections::HashMap", reason = "Use IndexMap for deterministic iteration" },
    { path = "std::collections::HashSet", reason = "Use IndexSet for deterministic iteration" },
]

All code uses IndexMap / BTreeMap instead.

Verification: cargo clippy enforces this at CI time.


2. Floating-Point Reassociation

Risk: Compilers may reorder floating-point operations for performance (e.g., -ffast-math), producing different results across builds.

Mitigation:

  • Rust does not enable fast-math by default.
  • All arithmetic uses explicit operation ordering (no auto-vectorization that could reassociate).
  • Build metadata is recorded in the replay header, enabling detection of toolchain differences.

Verification: Replay header stores BuildMetadata.compile_flags and BuildMetadata.toolchain.


3. Sort Stability

Risk: Unstable sorts may produce different orderings for equal elements across implementations or runs.

Mitigation:

  • Command ordering uses priority_class (primary), source_id (secondary), arrival_seq (final tiebreaker) — all fields are distinct.
  • Agent actions are sorted by agent_id before processing.
  • All sorts use stable sort (sort_by_key / sort_by).

Verification: Scenario 2 (multi-source command ordering) exercises 3 sources with 1000 ticks.


4. Thread Scheduling

Risk: In multi-threaded modes, OS thread scheduling is non-deterministic.

Mitigation:

  • Lockstep mode is single-threaded by design. All propagators execute sequentially in pipeline order.
  • RealtimeAsync mode (future) will use epoch-synchronized snapshots and deterministic command ordering at tick boundaries.

Status: N/A for Lockstep (current scope). Future concern for RealtimeAsync.


5. Arena Recycling

Risk: Memory recycling patterns could theoretically affect state if buffer reuse is order-dependent.

Mitigation:

  • PingPong buffer swap is deterministic: generation N always writes to buffer N % 2, reads from (N-1) % 2.
  • Arena allocations are generation-indexed, not address-indexed.
  • Ring buffer recycling is deterministic (circular index modulo ring size).

Verification: Scenario 4 (arena double-buffer recycling) runs 1100 ticks to exercise multiple full ring buffer cycles.


6. RNG Seed

Risk: Different seeds produce different simulation trajectories.

Mitigation:

  • Seed is stored in the replay header (InitDescriptor.seed).
  • Replay reconstruction uses the same seed.
  • config_hash() includes the seed.

Verification: All scenarios use explicit seeds and verify hash equality.


7. Build Metadata Differences

Risk: Different compilers, optimization levels, or target architectures may produce different floating-point results for the same source code.

Mitigation:

  • BuildMetadata is recorded in every replay file: toolchain, target_triple, murk_version, compile_flags.
  • Replay consumers can warn or reject when metadata doesn’t match.

Status: Detection only. Cross-build determinism is not guaranteed and is explicitly documented as a known limitation.


8. Command Serialization Fidelity

Risk: Fields like expires_after_tick and arrival_seq are runtime-only and should not affect determinism if excluded.

Mitigation:

  • expires_after_tick is NOT serialized in replays. On deserialization, it is set to TickId(u64::MAX) (never expires).
  • arrival_seq is NOT serialized. Set to 0 on deserialization. The ingress pipeline assigns fresh arrival sequences.
  • Only payload, priority_class, source_id, source_seq are recorded.

Verification: Proptest round-trip tests verify command serialization preserves all payload data. Integration tests verify replay produces identical snapshots despite sentinel values.


Verified Scenarios

#ScenarioTicksStatus
1Sequential-commit vs Jacobi1000PASS
2Multi-source command ordering1000PASS
3WriteMode::Incremental1000PASS
4Arena double-buffer recycling1100PASS
5Sparse field modification1000PASS
6Tick rollback recovery100PASS
7GlobalParameter mid-episode1000PASS
810+ propagator pipeline1000PASS

API Reference

The full Rust API documentation is generated by rustdoc and published alongside this book.

Browse the API Reference (rustdoc) →

You can also build the API docs locally:

cargo doc --workspace --no-deps --open

Contributing to Murk

Development environment

Requirements:

  • Rust stable (1.87+) via rustup
  • Rust nightly (for Miri only): rustup toolchain install nightly --component miri
  • Python 3.9+
  • maturin: pip install maturin

Setup:

git clone https://github.com/tachyon-beep/murk.git
cd murk

# Build Rust workspace
cargo build --workspace

# Build Python extension (development mode)
cd crates/murk-python
maturin develop --release
cd ../..

Project structure

murk/
├── crates/
│   ├── murk-core/          # Leaf crate: IDs, field definitions, commands, traits
│   ├── murk-arena/         # Double-buffered ping-pong arena allocator
│   ├── murk-space/         # Space trait + 7 lattice backends
│   ├── murk-propagator/    # Propagator trait, pipeline validation, step context
│   ├── murk-propagators/   # Reference propagators (diffusion, agent movement)
│   ├── murk-obs/           # Observation specification and tensor extraction
│   ├── murk-engine/        # LockstepWorld, RealtimeAsyncWorld, TickEngine
│   ├── murk-replay/        # Deterministic replay recording/verification
│   ├── murk-ffi/           # C ABI with handle tables
│   ├── murk-python/        # Python/PyO3 bindings + Gymnasium adapters
│   ├── murk-bench/         # Benchmark profiles
│   └── murk-test-utils/    # Shared test fixtures
├── examples/               # Python examples (heat_seeker, hex_pursuit, crystal_nav)
└── docs/                   # Design documents and concepts guide

Dependency graph (simplified):

murk-core
  ↑
murk-arena, murk-space
  ↑
murk-propagator, murk-obs
  ↑
murk-engine
  ↑
murk-ffi → murk-python

Running tests

# Full workspace test suite (580+ tests)
cargo test --workspace

# Single crate
cargo test -p murk-space

# Python tests
cd crates/murk-python
pytest tests/ -v

# Memory safety (requires nightly)
cargo +nightly miri test -p murk-arena

# Clippy lints (must pass with zero warnings)
cargo clippy --workspace -- -D warnings

# Format check
cargo fmt --all -- --check

Code style

Rust

  • #![deny(missing_docs)] on all crates — every public item needs a doc comment.
  • #![forbid(unsafe_code)] on all crates except murk-arena and murk-ffi. If your change needs unsafe, it belongs in one of those two crates.
  • Clippy with -D warnings — all clippy suggestions must be resolved.
  • cargo fmt — standard rustfmt formatting, no custom config.

Python

  • Type annotations on all public functions.
  • Docstrings on all public classes and methods.
  • Type stubs (.pyi) must be updated when the Python API changes.

CI expectations

Every push and PR triggers:

JobWhat it checks
cargo checkCompilation across all crates
cargo testFull test suite
clippyLint warnings (zero tolerance)
rustfmtFormatting
miriMemory safety for murk-arena

All five must pass before merging.

Adding a new space backend

Space backends implement the Space trait in murk-space. Follow the pattern of Square4 or Hex2D:

  1. Create crates/murk-space/src/your_space.rs.
  2. Implement the Space trait:
    • ndim(), cell_count(), neighbours(), distance()
    • compile_region(), iter_region(), map_coord_to_tensor_index()
    • canonical_ordering(), canonical_rank()
    • instance_id()
  3. Add pub mod your_space; and pub use your_space::YourSpace; to lib.rs.
  4. Run the compliance test suite — this is critical:
#![allow(unused)]
fn main() {
// In your_space.rs, at the bottom:
#[cfg(test)]
mod tests {
    use super::*;
    use crate::compliance::compliance_tests;

    compliance_tests!(YourSpace, || YourSpace::new(4, 4, EdgeBehavior::Absorb).unwrap());
}
}

The compliance test suite (crates/murk-space/src/compliance.rs) automatically tests all Space trait invariants: canonical ordering consistency, neighbor symmetry, distance triangle inequality, region compilation, and more. If your backend passes compliance tests, it works with the rest of Murk.

  1. Add FFI support in murk-ffi and Python bindings in murk-python if needed.

Adding a new propagator

Propagators implement the Propagator trait in murk-propagator:

  1. Create your propagator struct (must be Send + 'static).
  2. Implement:
    • name() — human-readable name
    • reads() — fields read via in-tick overlay (Euler style)
    • reads_previous() — fields read from frozen tick-start (Jacobi style)
    • writes() — fields written, with WriteMode::Full or Incremental
    • step(&self, ctx: &mut StepContext) — the per-tick logic
  3. Optionally implement max_dt() for CFL stability constraints.

See crates/murk-engine/examples/quickstart.rs for a complete example, or crates/murk-test-utils/src/fixtures.rs for minimal test propagators.

Key rules:

  • step() must be deterministic (same inputs → same outputs).
  • &self only — propagators are stateless. All mutable state goes through fields.
  • Copy read data to a local buffer before grabbing the write handle (split-borrow limitation in StepContext).

Pull request process

  1. Fork the repository and create a branch.
  2. Make your changes with tests.
  3. Ensure all CI checks pass locally (cargo test --workspace && cargo clippy --workspace -- -D warnings).
  4. Open a PR with a clear description of what changed and why.

Commit messages

This project uses Conventional Commits:

PrefixWhen to use
feat:New feature
fix:Bug fix
docs:Documentation only
ci:CI/CD changes
chore:Maintenance (deps, config)
refactor:Code change that neither fixes nor adds
test:Adding or updating tests
perf:Performance improvement

Use a scope when helpful: feat(space):, fix(python):, ci(release):.

Releasing

Releases are managed by release-plz:

  1. Automatic: On every push to main, release-plz opens (or updates) a release PR that bumps versions and updates CHANGELOG.md based on conventional commits since the last release.
  2. Merge the release PR when ready to publish.
  3. On merge: release-plz creates git tags, which trigger the release workflow.
  4. The release workflow publishes Rust crates to crates.io and Python wheels to PyPI, and creates a GitHub Release.

Dry-run a release locally:

cargo publish --dry-run -p murk-core

Secrets required (set in GitHub repo settings > Secrets):

  • CARGO_REGISTRY_TOKEN — crates.io API token
  • PYPI_API_TOKEN — PyPI API token
  • CODECOV_TOKEN — Codecov upload token

Murk Architecture

This document explains Murk’s architecture for developers who want to understand how the engine works internally. For a practical introduction to building simulations, see CONCEPTS.md.


Table of Contents


Design Goals

Murk is a world simulation engine for reinforcement learning and real-time applications. The architecture optimises for:

  • Deterministic replay — identical inputs produce identical outputs across runs on the same platform.
  • Zero-GC memory management — arena allocation with predictable lifetimes, no garbage collection pauses.
  • ML-native observation extraction — pre-compiled observation plans that produce fixed-shape tensors directly, not intermediate representations.
  • Two runtime modes from one codebase — synchronous lockstep for training, asynchronous real-time for live interaction.

Three principles guide every subsystem:

  1. Egress Always Returns — observation extraction never blocks indefinitely, even during tick failures or shutdown. Responses may indicate staleness or degraded coverage via metadata, but always return data.
  2. Tick-Expressible Time — all engine-internal time references that affect state transitions are expressed in tick counts, never wall clocks. This prevents replay divergence.
  3. Asymmetric Mode Dampening — staleness and overload are handled differently in each runtime mode, because Lockstep and RealtimeAsync have fundamentally different dynamics.

Crate Structure

murk/
├── murk              Top-level facade (add this one dependency)
├── murk-core          Leaf crate: IDs, field defs, commands, core traits
├── murk-arena         Arena-based generational allocation
├── murk-space         Spatial backends and region planning
├── murk-propagator    Propagator trait, pipeline validation, StepContext
├── murk-propagators   Reference propagators (diffusion, movement, reward)
├── murk-obs           Observation spec, compilation, tensor extraction
├── murk-engine        Simulation engine: LockstepWorld, RealtimeAsyncWorld
├── murk-replay        Deterministic replay recording and verification
├── murk-ffi           C ABI bindings with handle tables
├── murk-python        Python/PyO3 bindings with Gymnasium adapters
├── murk-bench         Benchmark profiles and utilities
└── murk-test-utils    Shared test fixtures

Dependency flow (arrows point from dependee to dependent):

murk-core ──┬── murk-arena ──┬── murk-engine ──┬── murk-ffi
            ├── murk-space ──┤                 └── murk-python
            ├── murk-propagator ─┤
            └── murk-obs ────────┘
                murk-replay ─────┘

Safety boundary: only murk-arena and murk-ffi are permitted unsafe code. Every other crate uses #![forbid(unsafe_code)].


Three-Interface Model

All interaction with a Murk world flows through three interfaces:

[Producers]                           [Consumers]
    |                                      ^
    v                                      |
 Ingress ──(bounded queue)──> TickEngine ──(publish)──> Egress
                                  \                      |
                                   └──(ring buffer)──────┘
  • Ingress accepts commands (intents to change world state). It implements backpressure via a bounded queue, TTL-based expiry, and deterministic drop policies.
  • TickEngine is the sole authoritative mutator. It drains the ingress queue, executes the propagator pipeline, and publishes an immutable snapshot at each tick boundary.
  • Egress reads published snapshots to produce observations. It never mutates world state. In RealtimeAsync mode, egress workers run on a thread pool for concurrent observation extraction.

This separation enforces the key invariant: only TickEngine holds &mut WorldState. Everything else operates on immutable snapshots.


Arena-Based Generational Allocation

This is Murk’s most load-bearing design decision. It replaces traditional copy-on-write with a generational arena scheme:

  1. Each field is stored as a contiguous [f32] allocation in a generational arena.
  2. At tick start, propagators write to fresh allocations in the new generation — no copies required.
  3. Unmodified fields share their allocation across generations (zero-cost structural sharing).
  4. Snapshot publication swaps a ~1KB descriptor of field handles. Cost: <2us.
  5. Old generations remain readable until all snapshot references are released.
PropertyTraditional CoWArena-Generational
Copy costFault-driven, unpredictableZero (allocate fresh)
Snapshot publishClone or forkDescriptor swap, <2us
RollbackUndo log or checkpointFree (abandon generation)
Memory predictabilityFault-drivenBump allocation

Rust type-level enforcement

  • ReadArena (published snapshots): Send + Sync, safe for concurrent reads.
  • WriteArena (staging, exclusive to TickEngine): &mut access, no aliasing possible.
  • Snapshot descriptors contain FieldHandle values (generation-scoped integers), not raw pointers. ReadArena::resolve(handle) provides &[f32] access.
  • Field access requires &FieldArena — the borrow checker enforces arena liveness.

Lockstep arena recycling

In Lockstep mode, two arena buffers alternate roles each tick (ping-pong). The caller’s &mut self borrow on step_sync() guarantees no outstanding snapshot borrows. Memory usage is bounded at 2x the per-generation field footprint regardless of episode length.

RealtimeAsync reclamation

In RealtimeAsync mode, epoch-based reclamation manages arena lifetimes. Each egress worker pins an epoch while reading a snapshot. The TickEngine reclaims old generations only when no worker holds a reference. Stalled workers are detected and torn down to prevent unbounded memory growth.


Runtime Modes

Murk provides two runtime modes from the same codebase. There is no runtime mode-switching — you choose at construction time.

LockstepWorld

A callable struct with &mut self methods. The caller’s thread executes the full pipeline: command processing, propagators, snapshot publication, and observation extraction.

#![allow(unused)]
fn main() {
let mut world = LockstepWorld::new(config)?;
let result = world.step_sync(commands)?;
let heat = result.snapshot.read(FieldId(0)).unwrap();
}
  • Synchronous, deterministic, throughput-maximised.
  • The borrow checker enforces that snapshots are released before the next step.
  • No background threads, no synchronisation overhead.
  • Primary use case: RL training loops, deterministic replay.

RealtimeAsyncWorld

An autonomous tick thread running at a configurable rate (e.g., 60 Hz).

#![allow(unused)]
fn main() {
let world = RealtimeAsyncWorld::start(config)?;
world.submit_commands(commands)?;
let snapshot = world.latest_snapshot();
let report = world.shutdown(Duration::from_secs(5))?;
}
  • Non-blocking command submission and observation extraction.
  • Egress thread pool for concurrent ObsPlan execution.
  • Epoch-based memory reclamation.
  • Primary use case: live games, interactive tools, dashboards.

Threading Model

Lockstep

No dedicated threads. The caller’s thread runs the full tick pipeline. Thread count equals the number of vectorised environments (typically 16-128 for RL training).

RealtimeAsync

Thread(s)RoleOwns
TickEngine (1)Tick loop: drain ingress, run propagators, publish&mut WorldState, WriteArena
Egress pool (N)Execute ObsPlans against snapshots&ReadArena (shared)
Ingress acceptor (0-M)Accept commands, assign arrival_seqWrite end of bounded queue

Snapshot lifetime is managed by epoch-based reclamation, not reference counting. This avoids cache-line ping-pong from atomic refcount updates under high observation throughput.


Spatial Model

Spaces define how many cells exist and which cells are neighbours. All spaces implement the Space trait, which provides:

  • cell_count() — total cells
  • neighbours(cell) — ordered neighbour list
  • distance(a, b) — scalar distance metric
  • Region planning for observation extraction

Built-in backends

SpaceDimsNeighboursEdge handling
Line1D1D2Absorb, Wrap
Ring1D1D2 (periodic)Always wraps
Square42D4 (N/S/E/W)Absorb, Wrap
Square82D8 (+ diagonals)Absorb, Wrap
Hex2D2D6Absorb, Wrap
FCC123D12 (face-centred cubic)Absorb, Wrap

ProductSpace

Spaces can be composed via ProductSpace to create higher-dimensional topologies. For example, Hex2D x Line1D creates a layered hex map where each layer is a hex grid and vertical neighbours are connected via the Line1D component.

#![allow(unused)]
fn main() {
let space = ProductSpace::new(vec![
    Box::new(Hex2D::new(8, EdgeBehavior::Wrap)?),
    Box::new(Line1D::new(3, EdgeBehavior::Absorb)?),
]);
}

Coordinates are concatenated across components. Neighbours vary one component at a time (no diagonal cross-component adjacency).


Field Model

Fields are per-cell data stored in arenas. Each field has:

  • Type: Scalar (1 float), Vector(n) (n floats), or Categorical(n) (n classes).
  • Mutability class: controls arena allocation strategy.
  • Boundary behaviour: Clamp, Reflect, Absorb, or Wrap.
  • Optional units and bounds metadata.

Mutability classes

ClassArena behaviourUse case
StaticAllocated once in generation 0, shared across all snapshotsTerrain, obstacles
PerTickFresh allocation each tickTemperature, velocity
SparseNew allocation only when modifiedRare events, flags

For vectorised RL (128 envs x 2MB mutable + 8MB shared static): 264MB total vs 1.28GB without Static field sharing.


Propagator Pipeline

Propagators are stateless operators that update fields each tick. They implement the Propagator trait:

#![allow(unused)]
fn main() {
pub trait Propagator: Send + Sync {
    fn name(&self) -> &str;
    fn reads(&self) -> FieldSet;          // current-tick values (Euler)
    fn reads_previous(&self) -> FieldSet; // frozen tick-start values (Jacobi)
    fn writes(&self) -> Vec<(FieldId, WriteMode)>;
    fn max_dt(&self) -> Option<f64>;      // CFL constraint
    fn step(&self, ctx: &mut StepContext<'_>) -> Result<(), PropagatorError>;
}
}

Key properties:

  • &self signature — propagators are stateless. All mutable state flows through StepContext.
  • Split-borrow readsreads() sees current in-tick values (Euler style), reads_previous() sees frozen tick-start values (Jacobi style). This supports both integration approaches.
  • Write-conflict detection — the pipeline validates at startup that no two propagators write the same field in conflicting modes.
  • CFL validation — if a propagator declares max_dt(), the engine checks dt <= max_dt at configuration time.
  • Deterministic execution order — propagators run in the order they are registered. The pipeline is a strict ordered list.

Observation Pipeline

The observation pipeline transforms world state into fixed-shape tensors for RL frameworks:

ObsSpec ──(compile)──> ObsPlan ──(execute against snapshot)──> f32 tensor
  1. ObsSpec declares what to observe: which fields, which spatial region, what transforms (normalisation, pooling, foveation).
  2. ObsPlan is a compiled, bound, executable plan. It pre-resolves field offsets, region iterators, index mappings, and pooling kernels. Compilation is done once; execution is the hot path.
  3. Execution fills a caller-allocated buffer with f32 values and a validity mask for non-rectangular domains (e.g., hex grids).

ObsPlans are bound to a world configuration generation. If the world configuration changes (fields added, space resized), plans are invalidated and must be recompiled.


Command Model

Commands are the way external actions enter the simulation. Each command carries:

  • Payload: SetField, SpawnEntity, RemoveEntity, or custom.
  • TTL: expires_after_tick — tick-based expiry (never wall clock).
  • Priority class: determines application order within a tick.
  • Ordering provenance: source_id, source_seq, and engine-assigned arrival_seq for deterministic ordering.

The TickEngine drains and applies commands in deterministic order:

  1. Resolve apply_tick_id for each command.
  2. Group by tick.
  3. Sort within tick by priority class, then source ordering.

Every command produces a Receipt reporting whether it was accepted, which tick it was applied at, and a reason code if rejected.


Error Handling and Recovery

Tick atomicity

Tick execution is all-or-nothing. If any propagator fails, all staging writes are abandoned (free with the arena model — just drop the staging generation). The world state remains exactly as it was before the tick.

Recovery behaviour

  • Lockstep: step_sync() returns Err(StepError). The caller decides how to recover (typically reset()).
  • RealtimeAsync: after 3 consecutive rollbacks, the TickEngine disables ticking and rejects further commands. Egress continues serving the last good snapshot (Egress Always Returns). Recovery via reset().

See error-reference.md for the complete error type catalogue.


Determinism

Murk targets Tier B determinism: identical results within the same build, ISA, and toolchain, given the same initial state, seed, and command log.

Key mechanisms:

  • No HashMap/HashSet — banned project-wide via clippy. All code uses IndexMap/BTreeMap for deterministic iteration.
  • No fast-math — floating-point reassociation is prohibited in authoritative code paths.
  • Tick-based time — all state-affecting time references use tick counts, not wall clocks.
  • Deterministic command ordering — commands are sorted by priority class and source ordering, not arrival time.
  • Replay support — binary replay format records initial state, seed, and command log with per-tick snapshot hashes for divergence detection.

See determinism-catalogue.md for the full catalogue of non-determinism sources and mitigations.


Language Bindings

C FFI (murk-ffi)

Stable, handle-based C ABI:

  • Opaque handles (MurkWorld, MurkSnapshot, MurkObsPlan) with slot+generation for safe double-destroy.
  • Caller-allocated buffers for tensor output (no allocation on the hot path).
  • Versioned API with explicit error codes.

Python (murk-python)

PyO3/maturin native extension:

  • MurkEnv — single-environment Gymnasium Env adapter.
  • MurkVecEnv — vectorised environment adapter for parallel RL training.
  • Direct NumPy array filling via the C FFI path.
  • Python-defined propagators for prototyping.

Troubleshooting

Build Issues

maturin develop fails with “pyo3 not found”

Ensure you have a compatible Python version (3.9+) and that your virtual environment is activated:

python3 -m venv .venv
source .venv/bin/activate
pip install maturin
cd crates/murk-python
maturin develop --release

cargo build fails with MSRV error

Murk requires Rust 1.87 or later. Update with:

rustup update stable

Miri fails to run

Miri requires the nightly toolchain with the miri component:

rustup toolchain install nightly --component miri
cargo +nightly miri test -p murk-arena

Runtime Issues

Python import error: “No module named murk._murk”

The native extension needs to be built first:

cd crates/murk-python
maturin develop --release

Determinism test failures

Determinism tests are sensitive to floating-point ordering. Ensure you’re running on the same platform and Rust version as CI. See determinism-catalogue.md for details.