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
f32tensors with validity masks, foveation, pooling, and multi-agent batching - Two runtime modes —
LockstepWorld(synchronous, borrow-checker enforced) andRealtimeAsyncWorld(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
EnvandVecEnvadapters - Zero
unsafein simulation logic — onlymurk-arenaandmurk-ffiare permittedunsafe; 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):
- Rust toolchain (stable, 1.87+): rustup.rs
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:
- A Space — the topology cells live on
- Fields — per-cell data stored in arenas
- Propagators — stateless operators that update fields each tick
- Commands — how actions from outside enter the simulation
- 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:
| Space | Dims | Neighbors | Parameters | Distance metric |
|---|---|---|---|---|
Line1D | 1D | 2 | length, edge | Manhattan |
Ring1D | 1D | 2 (periodic) | length | min(fwd, bwd) |
Square4 | 2D | 4 (N/S/E/W) | width, height, edge | Manhattan |
Square8 | 2D | 8 (+ diagonals) | width, height, edge | Chebyshev |
Hex2D | 2D | 6 (pointy-top) | cols, rows | Cube distance |
Fcc12 | 3D | 12 (face-centred cubic) | w, h, d, edge | FCC metric |
ProductSpace | N-D | varies | list of component spaces | L1 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 Line1Dfor a hex map with a vertical elevation axis).
Edge behaviors
Spaces that have boundaries support three edge behaviors:
| Behavior | At boundary | Example use |
|---|---|---|
Absorb | Edge cells have fewer neighbors | Bounded arena, finite grid |
Clamp | Beyond-edge maps to edge cell | Image processing, extrapolation |
Wrap | Wraps 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 == 0ProductSpace: 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 cellsSquare4(10, 10)→ 100 cellsHex2D(8, 8)→ 64 cellsFcc12(4, 4, 4)→ approximatelyw*h*d / 2cells (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
| Type | Storage per cell | Use case |
|---|---|---|
Scalar | 1 × f32 | Temperature, density, boolean flags |
Vector { dims } | dims × f32 | Velocity, 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.
| Mutability | Allocation pattern | Read baseline | Use when |
|---|---|---|---|
Static | Once, never again | Always generation 0 | Constants (terrain type, wall mask) |
PerTick | Fresh buffer every tick | Previous tick’s values | Frequently-updated state (heat, positions) |
Sparse | New buffer only on write | Shared until mutated | Infrequently-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 boundReflect— value bounces off the boundAbsorb— value is set to the boundWrap— 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 viamemcpy. 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 configureddtdoesn’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
| Command | Purpose |
|---|---|
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
- ObsSpec — a list of
ObsEntryobjects declaring what to observe. - ObsPlan — a compiled plan (precomputed gather indices). Created once, reused every tick.
- execute — runs the plan against the current world snapshot,
producing a flat
f32array.
# 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
| Region | Description | When to use |
|---|---|---|
All | Every cell in the space | Full observability, small grids |
AgentDisk(radius) | Cells within radius graph-distance of the agent | Partial observability, foveation |
AgentRect(half_extent) | Axis-aligned bounding box around agent | Rectangular 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 changeNormalize(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 windowPoolKernel.Max— maximum of each windowPoolKernel.Min— minimum of each windowPoolKernel.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 selfenforces 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:
- One pool is staging (being written by propagators)
- The other is published (readable as a snapshot)
- 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_previouswork 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
| Term | Definition |
|---|---|
| Cell | A single location in the space. Has a coordinate and one value per field. |
| Tick | One simulation timestep. All propagators run, then the arena publishes. |
| Generation | Arena version counter. Incremented on each publish. |
| Canonical order | The deterministic traversal of all coordinates (row-major for 2D grids). |
| Snapshot | Read-only view of the world state at a particular generation. |
| ObsPlan | Compiled observation plan. Precomputes gather indices for fast extraction. |
| Ingress | The command queue that feeds actions into the tick loop. |
| Egress | The observation pathway that extracts state out of the simulation. |
| CFL condition | Courant-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.
| Example | Space | Demonstrates |
|---|---|---|
| heat_seeker | Square4 | PPO RL, diffusion physics, Python propagator |
| hex_pursuit | Hex2D | Multi-agent, AgentDisk foveation |
| crystal_nav | Fcc12 | 3D lattice navigation |
There is also a Rust example:
| Example | Demonstrates |
|---|---|
| quickstart.rs | Rust 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 (murk-core)
- PropagatorError (murk-core)
- IngressError (murk-core)
- ObsError (murk-core)
- ConfigError (murk-engine)
- PipelineError (murk-propagator)
- ArenaError (murk-arena)
- SpaceError (murk-space)
- ReplayError (murk-replay)
- SubmitError (murk-engine)
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.
| Variant | HLD Code | Cause | Remediation |
|---|---|---|---|
PropagatorFailed { name: String, reason: PropagatorError } | MURK_ERROR_PROPAGATOR_FAILED | A 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. |
AllocationFailed | MURK_ERROR_ALLOCATION_FAILED | Arena 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. |
TickRollback | MURK_ERROR_TICK_ROLLBACK | The 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. |
TickDisabled | MURK_ERROR_TICK_DISABLED | Ticking 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. |
DtOutOfRange | MURK_ERROR_DT_OUT_OF_RANGE | The 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. |
ShuttingDown | MURK_ERROR_SHUTTING_DOWN | The 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.
| Variant | HLD Code | Cause | Remediation |
|---|---|---|---|
ExecutionFailed { reason: String } | MURK_ERROR_PROPAGATOR_FAILED | The 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.
| Variant | HLD Code | Cause | Remediation |
|---|---|---|---|
QueueFull | MURK_ERROR_QUEUE_FULL | The 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. |
Stale | MURK_ERROR_STALE | The 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. |
TickRollback | MURK_ERROR_TICK_ROLLBACK | The tick was rolled back; commands submitted during that tick were dropped. | Resubmit the command on the next tick. This is a transient condition. |
TickDisabled | MURK_ERROR_TICK_DISABLED | Ticking 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. |
ShuttingDown | MURK_ERROR_SHUTTING_DOWN | The 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.
| Variant | HLD Code | Cause | Remediation |
|---|---|---|---|
PlanInvalidated { reason: String } | MURK_ERROR_PLAN_INVALIDATED | The 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. |
TimeoutWaitingForTick | MURK_ERROR_TIMEOUT_WAITING_FOR_TICK | An 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. |
NotAvailable | MURK_ERROR_NOT_AVAILABLE | The 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_COMPOSITION | The 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_FAILED | ObsPlan 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_OBSSPEC | Malformed 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. |
WorkerStalled | MURK_ERROR_WORKER_STALLED | An 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.
| Variant | HLD Code | Cause | Remediation |
|---|---|---|---|
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. |
EmptySpace | – | The 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. |
NoFields | – | No 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. |
IngressQueueZero | – | The 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.
| Variant | HLD Code | Cause | Remediation |
|---|---|---|---|
EmptyPipeline | – | No 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:
| Field | Type | Description |
|---|---|---|
field_id | FieldId | The contested field |
first_writer | String | Name of the first writer (earlier in pipeline order) |
second_writer | String | Name 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.
| Variant | HLD Code | Cause | Remediation |
|---|---|---|---|
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.
| Variant | HLD Code | Cause | Remediation |
|---|---|---|---|
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. |
EmptySpace | – | Attempted 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.
| Variant | HLD Code | Cause | Remediation |
|---|---|---|---|
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. |
InvalidMagic | – | The 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.
| Variant | HLD Code | Cause | Remediation |
|---|---|---|---|
Shutdown | – | The 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. |
ChannelFull | – | The 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 value | Meaning | Following bytes |
|---|---|---|
0x00 | Absent (None) | 0 bytes |
0x01 | Present (Some(value)) | 8 bytes (u64 LE) |
| Other | Invalid | Decode 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
| Tag | Constant | CommandPayload Variant |
|---|---|---|
0 | PAYLOAD_MOVE | Move |
1 | PAYLOAD_SPAWN | Spawn |
2 | PAYLOAD_DESPAWN | Despawn |
3 | PAYLOAD_SET_FIELD | SetField |
4 | PAYLOAD_CUSTOM | Custom |
5 | PAYLOAD_SET_PARAMETER | SetParameter |
6 | PAYLOAD_SET_PARAMETER_BATCH | SetParameterBatch |
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:
| Type | Size | Encoding |
|---|---|---|
u8 | 1 byte | Raw byte |
u32 | 4 bytes | Little-endian |
u64 | 8 bytes | Little-endian |
i32 | 4 bytes | Little-endian |
f32 | 4 bytes | IEEE 754, little-endian |
f64 | 8 bytes | IEEE 754, little-endian |
lpstring | 4 + N bytes | u32 LE length prefix + UTF-8 bytes |
lpbytes | 4 + N bytes | u32 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
u8flag (0= absent,1= present) followed by an optionalu64value. - This correctly distinguishes
NonefromSome(0).
Version 1
- source_id and source_seq were encoded as bare
u64values where0meant “not set”. - Bug:
Some(0)was indistinguishable fromNone, causing incorrect replay of commands withsource_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_idbefore 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:
BuildMetadatais 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_tickis NOT serialized in replays. On deserialization, it is set toTickId(u64::MAX)(never expires).arrival_seqis NOT serialized. Set to0on deserialization. The ingress pipeline assigns fresh arrival sequences.- Only
payload,priority_class,source_id,source_seqare 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
| # | Scenario | Ticks | Status |
|---|---|---|---|
| 1 | Sequential-commit vs Jacobi | 1000 | PASS |
| 2 | Multi-source command ordering | 1000 | PASS |
| 3 | WriteMode::Incremental | 1000 | PASS |
| 4 | Arena double-buffer recycling | 1100 | PASS |
| 5 | Sparse field modification | 1000 | PASS |
| 6 | Tick rollback recovery | 100 | PASS |
| 7 | GlobalParameter mid-episode | 1000 | PASS |
| 8 | 10+ propagator pipeline | 1000 | PASS |
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 exceptmurk-arenaandmurk-ffi. If your change needsunsafe, 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:
| Job | What it checks |
|---|---|
cargo check | Compilation across all crates |
cargo test | Full test suite |
clippy | Lint warnings (zero tolerance) |
rustfmt | Formatting |
miri | Memory 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:
- Create
crates/murk-space/src/your_space.rs. - Implement the
Spacetrait:ndim(),cell_count(),neighbours(),distance()compile_region(),iter_region(),map_coord_to_tensor_index()canonical_ordering(),canonical_rank()instance_id()
- Add
pub mod your_space;andpub use your_space::YourSpace;tolib.rs. - 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.
- Add FFI support in
murk-ffiand Python bindings inmurk-pythonif needed.
Adding a new propagator
Propagators implement the Propagator trait in murk-propagator:
- Create your propagator struct (must be
Send + 'static). - Implement:
name()— human-readable namereads()— fields read via in-tick overlay (Euler style)reads_previous()— fields read from frozen tick-start (Jacobi style)writes()— fields written, withWriteMode::FullorIncrementalstep(&self, ctx: &mut StepContext)— the per-tick logic
- 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).&selfonly — 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
- Fork the repository and create a branch.
- Make your changes with tests.
- Ensure all CI checks pass locally (
cargo test --workspace && cargo clippy --workspace -- -D warnings). - Open a PR with a clear description of what changed and why.
Commit messages
This project uses Conventional Commits:
| Prefix | When 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:
- 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. - Merge the release PR when ready to publish.
- On merge: release-plz creates git tags, which trigger the release workflow.
- 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 tokenPYPI_API_TOKEN— PyPI API tokenCODECOV_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
- Crate Structure
- Three-Interface Model
- Arena-Based Generational Allocation
- Runtime Modes
- Threading Model
- Spatial Model
- Field Model
- Propagator Pipeline
- Observation Pipeline
- Command Model
- Error Handling and Recovery
- Determinism
- Language Bindings
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:
- 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.
- Tick-Expressible Time — all engine-internal time references that affect state transitions are expressed in tick counts, never wall clocks. This prevents replay divergence.
- 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:
- Each field is stored as a contiguous
[f32]allocation in a generational arena. - At tick start, propagators write to fresh allocations in the new generation — no copies required.
- Unmodified fields share their allocation across generations (zero-cost structural sharing).
- Snapshot publication swaps a ~1KB descriptor of field handles. Cost: <2us.
- Old generations remain readable until all snapshot references are released.
| Property | Traditional CoW | Arena-Generational |
|---|---|---|
| Copy cost | Fault-driven, unpredictable | Zero (allocate fresh) |
| Snapshot publish | Clone or fork | Descriptor swap, <2us |
| Rollback | Undo log or checkpoint | Free (abandon generation) |
| Memory predictability | Fault-driven | Bump allocation |
Rust type-level enforcement
ReadArena(published snapshots):Send + Sync, safe for concurrent reads.WriteArena(staging, exclusive to TickEngine):&mutaccess, no aliasing possible.- Snapshot descriptors contain
FieldHandlevalues (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) | Role | Owns |
|---|---|---|
| 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_seq | Write 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 cellsneighbours(cell)— ordered neighbour listdistance(a, b)— scalar distance metric- Region planning for observation extraction
Built-in backends
| Space | Dims | Neighbours | Edge handling |
|---|---|---|---|
Line1D | 1D | 2 | Absorb, Wrap |
Ring1D | 1D | 2 (periodic) | Always wraps |
Square4 | 2D | 4 (N/S/E/W) | Absorb, Wrap |
Square8 | 2D | 8 (+ diagonals) | Absorb, Wrap |
Hex2D | 2D | 6 | Absorb, Wrap |
FCC12 | 3D | 12 (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), orCategorical(n)(n classes). - Mutability class: controls arena allocation strategy.
- Boundary behaviour:
Clamp,Reflect,Absorb, orWrap. - Optional units and bounds metadata.
Mutability classes
| Class | Arena behaviour | Use case |
|---|---|---|
Static | Allocated once in generation 0, shared across all snapshots | Terrain, obstacles |
PerTick | Fresh allocation each tick | Temperature, velocity |
Sparse | New allocation only when modified | Rare 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:
&selfsignature — propagators are stateless. All mutable state flows throughStepContext.- Split-borrow reads —
reads()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 checksdt <= max_dtat 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
- ObsSpec declares what to observe: which fields, which spatial region, what transforms (normalisation, pooling, foveation).
- 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.
- Execution fills a caller-allocated buffer with
f32values 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-assignedarrival_seqfor deterministic ordering.
The TickEngine drains and applies commands in deterministic order:
- Resolve
apply_tick_idfor each command. - Group by tick.
- 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()returnsErr(StepError). The caller decides how to recover (typicallyreset()). - 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 usesIndexMap/BTreeMapfor 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 GymnasiumEnvadapter.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.