The Syren Guide
Syren is a parallel Rust framework for agent-based models. It stores agents in an archetype entity-component-system (ECS), runs systems over them through a deterministic stage scheduler on top of Rayon, and adds agent, environment, messaging, and optional GPU layers behind Cargo features.
This guide is the user and contributor manual. The Rust API reference lives on docs.rs; this guide explains how the pieces fit together and how to use them.
Who this is for
Syren is for researchers and engineers who write agent-based models in Rust and care about reproducibility and scale. You should be comfortable with Rust and Cargo. There is no separate model-definition language: a model is a Rust program that uses the library.
What the framework provides
- Archetype-ECS storage — components stored in chunked, columnar arrays, iterated over contiguous memory.
- Deterministic scheduling — systems declare their data access; the scheduler packs non-conflicting systems into parallel stages and preserves a reproducible activation order.
- Query-derived access — a system's read/write set is derived from the queries it runs, so the declared access cannot drift from what it touches.
- Deterministic randomness —
DetRngkeys draws on the run context and a salt, so results do not depend on which worker thread visits which rows. - Model layer (
modelfeature) —ModelBuilder, agent templates, environments, sub-schedulers, and nested models. - Messaging (
messagingfeature) — four message specialisations (brute-force, bucketed, spatial, targeted). - Optional GPU execution (
gpufeature) — mirror component columns to the GPU and dispatch compute systems through wgpu.
How to read this guide
- Getting started takes you from an empty project to a running model.
- Core concepts explains each part of the framework and how it relates to the source.
- How-to gives task-focused recipes.
- Reproducibility covers the reproducibility guarantees, verification, provenance, and citation.
- Reference collects the feature matrix, compatibility policy, error model, performance methodology, and a glossary.
- Contributing documents the engine internals and the development process.
Installation
Syren's minimum supported Rust version (MSRV) is Rust 1.87. Newer toolchains
also work. The development toolchain is pinned in rust-toolchain.toml.
Add the dependency
Syren has no default features. Enable the features your model uses:
[dependencies]
syren = { version = "0.6.0", features = ["model", "messaging"] }
Feature selection
Features are additive. Each enables a module and its dependencies.
| Feature | Enables |
|---|---|
| (none) | The core ECS: components, queries, systems, and the scheduler. |
agents | Agent templates and lifecycle hooks. |
environment | Typed, model-wide environment values. |
messaging | The four message specialisations. |
model | The ModelBuilder layer. Implies agents and environment. |
gpu | GPU state mirroring and compute dispatch through wgpu. |
messaging_gpu | GPU-resident message buffers. Implies messaging and gpu. |
profiling | Tracing spans and Chrome Trace output. |
all | Everything above. |
A model built with ModelBuilder requires model. Add messaging for message
passing, and gpu or messaging_gpu for GPU execution.
The feature matrix lists the supported combinations.
Verify the toolchain
cargo build
The gpu feature requires only that the wgpu crate compiles; running a GPU
system additionally requires a working graphics adapter. See CPU and GPU
state.
Your first model
This chapter builds the first_model example: a population of walkers on an
integer number line. Each tick, every walker takes one deterministic step left
or right; a reduction then reports the population's spread.
Run it with:
cargo run --example first_model --features model
The code below is included from the compiled example. The full source is
examples/first_model.rs.
The component
A component is a plain Copy struct. Each walker holds one integer position:
#![allow(unused)] fn main() { use std::sync::{Arc, RwLock}; use syren::agents::AgentTemplate; use syren::model::ModelBuilder; use syren::{advanced::EntityShards, ComponentRegistry, DetRng, FnSystem, QueryBuilder, Welford}; /// Each walker holds a single integer position on a number line. #[derive(Clone, Copy, Default)] struct Position { x: i64, } }
Building the model
Register the component, freeze the registry, then describe the population and
the system with ModelBuilder. The system's access set is derived from the
query it runs, and the model seed is set with
with_seed:
#![allow(unused)] fn main() { // Register the component up front, then freeze the registry so archetype // layouts are fixed for the life of the model. let registry = Arc::new(RwLock::new(ComponentRegistry::new())); let position_id = registry.write().unwrap().register::<Position>()?; registry.write().unwrap().freeze(); // Every walker starts at the origin. let population: Vec<Position> = vec![Position { x: 0 }; WALKERS]; // A query that writes `Position`. The system derives its access set from // this query (`from_queries`), so the declared access cannot drift from // what the system actually touches. let step_query = QueryBuilder::with_registry(Arc::clone(®istry)) .write::<Position>()? .build()?; let walk_query = step_query.clone(); let mut model = ModelBuilder::new() .with_seed(SEED) .with_component_registry(Arc::clone(®istry)) .with_shards(EntityShards::new(1)?) .with_agent_template( AgentTemplate::builder("walker") .with_component::<Position>(position_id)? .with_capacity(WALKERS) .build(), )? .with_agent_population("walker", position_id, population)? .with_system(FnSystem::from_queries( 0, "random_walk", &[&step_query], move |ecs| { // `run_context` carries the seed, tick, and system id. Salting a // per-entity `DetRng` with the entity's identity makes each // walker's step independent of the order workers visit rows in, // so the walk is identical whatever the thread count. let context = ecs.run_context(); ecs.for_each_entity_w1::<Position>(walk_query.clone(), move |entity, pos| { let mut rng = DetRng::from_context(context, u64::from(entity.index())); pos.x += if rng.next_below(2) == 0 { -1 } else { 1 }; }) }, )) .build()?; }
This build shows three points:
FnSystem::from_queriesderives the system's read/write set from the queries it runs, so the declared access matches what the system touches.DetRng::from_contextis salted with the entity's identity, so a walker's step does not depend on the order in which worker threads visit rows. The same seed produces the same walk at any thread count.with_agent_populationmaterialises the whole component column at once rather than spawning agents individually.
Running and summarising
Run a fixed number of ticks, then reduce over the population. Welford
accumulates count, mean, and variance in a single pass and combines partials
from parallel chunks:
#![allow(unused)] fn main() { model.run(TICKS)?; }
#![allow(unused)] fn main() { // A single-pass reduction over the population: `Welford` accumulates count, // mean, and variance and combines partials from parallel chunks stably. let summary_query = QueryBuilder::with_registry(registry) .read::<Position>()? .build()?; let stats = model.ecs().world_ref().reduce_read::<Position, Welford>( summary_query, Welford::default, |acc, pos| acc.push(pos.x as f64), |acc, other| acc.combine(other), )?; println!( "after {TICKS} ticks: count={} mean={:.4} variance={:.4}", stats.n, stats.mean, stats.variance() ); }
For a symmetric ±1 walk over 50 ticks, the mean is near zero and the variance is near the number of steps.
Next: running ticks and reading and recording results.
Running ticks
A built Model advances one tick at a time. A tick has no fixed meaning; the
framework defines the order in which the parts of a tick run.
Advancing the model
model.tick()?; // advance one tick
model.run(50)?; // advance 50 ticks
let done = model.tick_count();
tick runs one tick; run runs a fixed number.
Both return an ECSResult. A failure in any system stops the tick and returns
the error; the world is not left partially updated.
What happens in a tick
Each tick runs, in order:
- Sub-schedulers, in the order they were added. A sub-scheduler shares the model's world and has its own systems and stages.
- Nested models, each of which completes its own
tickand then runs its bridge to write parent-facing effects. - The root scheduler, which runs the model's own systems.
Within a scheduler, systems are grouped into stages. Systems in the same stage have non-conflicting access and run in parallel; stages run in sequence. See scheduling.
Determinism across ticks
Each system's run context carries the model seed and the tick counter. Draws
taken through DetRng::from_context depend on (seed, tick, system_id, salt),
so they do not vary with the worker-thread count. The same model and seed
produce the same trajectory on one core or many. See
reproducibility.
Reading and recording results
A model holds state in two places: the component columns (per-agent data) and the environment (model-wide values). Per-agent data is read with a reduction; model-wide data is read from the environment.
Reducing over the population
A reduction runs a query over every matching component column, folds each value into an accumulator, and combines the per-chunk accumulators. Run one against the world reference the model exposes:
let query = QueryBuilder::with_registry(registry)
.read::<Position>()?
.build()?;
let stats = model.ecs().world_ref().reduce_read::<Position, Welford>(
query,
Welford::default,
|acc, pos| acc.push(pos.x as f64),
|acc, other| acc.combine(other),
)?;
println!("count={} mean={:.3} variance={:.3}", stats.n, stats.mean, stats.variance());
The built-in accumulators are Count, Sum, MinMax, and Welford
(count, mean, and variance). Welford::combine merges partials so a reduction
returns the same result regardless of how the population is partitioned across
threads. For two-component folds, use reduce_read2.
Reading the environment
Model-wide values live in the environment, keyed by name and type. Read the
current value with the model's environment handle:
let value: MyState = model.environment().get::<MyState>("my_state")?;
The environment holds aggregates a system computes once per tick (for example, a price index) and audit records. See use environment values.
Recording a time series
To record a trajectory, read after each tick and append a row. Define each output schema's column names once, next to the code that formats the row, so the header and the values stay aligned. The macroeconomy example uses this pattern; see collect results and profiles.
Entities, components, and archetypes
Components
A component is a plain Rust type, usually a small Copy struct, that holds
one part of an agent's state. Components carry no behaviour; systems act on them.
A type becomes a component when it is registered with a ComponentRegistry,
which assigns it a ComponentID.
The registry is filled up front and then frozen. Freezing fixes the set of component types and their identifiers for the life of the world, so storage layouts are decided once.
Entities
An entity is a compact handle carrying an index and a version. The version distinguishes a live entity from a recycled slot: when an entity is despawned its slot may be reused, and the version marks earlier handles for that slot as stale.
Archetypes and chunks
An archetype is the set of entities that have exactly the same components.
Storage is organised by archetype: each archetype keeps one columnar array (an
Attribute<T>) per component, split into fixed-size chunks.
This is a struct-of-arrays layout, so iterating one component over a large population walks contiguous memory. Adding or removing a component migrates the entity to a different archetype, copying its columns to the destination.
Shards
Entities are distributed across shards (EntityShards), typically one per
worker thread, so spawns and despawns on different shards do not contend on a
single structure.
Where this lives
The registry, archetypes, chunked attributes, and shards form the core ECS. A
model reaches them indirectly, through component registration, queries, and
iteration. The lower-level types are exposed in the advanced module.
Queries and borrows
A query selects the entities that have a set of components and declares
whether each is read or written. Build one with QueryBuilder, giving the
registry it resolves component types against:
let query = QueryBuilder::with_registry(registry)
.read::<Position>()?
.write::<Velocity>()?
.build()?;
The result is a BuiltQuery: a resolved signature of component identifiers and
access modes. A built query is cheap to clone and can be reused across ticks.
Iterating
A query runs against an ECSReference, the handle to the world for the current
system. The for_each family walks every matching chunk:
for_eachiterates the declared components.- The
for_each_entity_*variants also pass theEntityfor each row, for a per-agent computation that needs the entity's identity (for example, to salt a deterministic RNG). - The
reduce_readfamily folds a read query into an accumulator; see reading results.
Iteration parallelises across chunks. Each closure sees only the rows in one chunk, and reductions combine per-chunk partials, so the work distributes across threads without changing the result.
Runtime borrow checks
The compile-time borrow checker cannot see which components a query touches, so Syren enforces the aliasing rules at runtime. A borrow tracker records the read and write borrows a system holds and rejects a conflicting one — two writes to the same component, or a read overlapping a write — with an error. Systems declare their access so the scheduler never places conflicting systems in the same parallel stage; the runtime check is the backstop.
Query shape and reductions
Some methods require a particular query shape. reduce_read expects exactly one
read component and no writes, and returns a shape error otherwise. This check
prevents a mis-built query from reading the wrong column.
Systems and access
A system is a unit of work the scheduler runs once per tick. A system declares an access set — the components it reads and writes — and the scheduler uses that declaration to decide which systems can run together.
FnSystem and query-derived access
A system is usually an FnSystem, which wraps a closure.
FnSystem::from_queries derives the access set from the queries the system runs:
let step_query = QueryBuilder::with_registry(registry).write::<Position>()?.build()?;
let run_query = step_query.clone();
let system = FnSystem::from_queries(
0, // system id
"random_walk", // name
&[&step_query], // queries whose access this system needs
move |ecs| {
ecs.for_each_entity_w1::<Position>(run_query.clone(), |entity, pos| {
// ...
})
},
);
Because the access set is derived from the queries, it matches what the system
touches, and changing the query changes the access. A system whose access is not
captured by a single query can construct an AccessSets directly.
Access sets and conflicts
Two systems conflict when one writes a component the other reads or writes. Conflicting systems do not run at the same time. Non-conflicting systems — for example, two systems that write disjoint components — run in parallel. The scheduler computes this from the declared access; see scheduling.
Channels for ordering
A system may need to run after another even when the two touch different components, when one consumes an effect the other produces. This ordering is expressed with channels: a system declares that it produces or consumes a named channel, and the scheduler orders producers before consumers. See order systems with channels.
Backends
A system runs on the CPU by default. A system can also declare a GPU backend
through the GpuSystem trait, so the scheduler dispatches it as a compute
shader; see CPU and GPU state.
Scheduling, stages, and channels
The scheduler compiles a set of systems into an ordered execution plan and runs it each tick. It packs systems that declare non-conflicting access into stages that run in parallel.
Stages
The scheduler packs systems into stages. A stage is a group of systems with no conflicting access; the systems in a stage run in parallel, and stages run in sequence. Packing is driven by:
- Access conflicts — a write to a component that another system reads or writes places the two in different stages.
- Channel ordering — a consumer of a channel runs in a later stage than the channel's producers.
The plan is deterministic: the same set of systems and constraints produces the same stages every run.
Channels
A channel is a named ordering edge. A system declares that it produces or consumes a channel, and the scheduler runs producers before consumers. A channel orders two systems that have no shared component access. Environment values and message boundaries each own a channel, so a system that reads an environment value runs after the systems that write it.
Boundaries
A boundary is a model-owned resource written during a stage and made visible at the stage edge; the environment and the message buffers are boundaries. Boundary writes are staged per worker during parallel execution and merged at the stage edge in a fixed order, so concurrent writers do not contend and the merge is deterministic.
Activation order and the seed
Within a stage, the activation order of systems is fixed and seeded, so an
order-sensitive step is reproducible. The seed set with ModelBuilder::with_seed
feeds both this activation order and each system's run context. See
reproducibility.
Inspecting the plan
A built Model prints its execution plan as text or as a Graphviz DOT graph,
which shows the stage each system is placed in:
println!("{}", model.execution_plan_text());
Agents and lifecycle hooks
Requires the agents feature (implied by model).
At the ECS level, every agent is an entity with components. The agent layer adds a naming and templating convention on top: a named kind of agent with a fixed set of components, and bulk creation and destruction of that kind.
Agent templates
An AgentTemplate names a kind of agent and lists its components. Build one
with the fluent builder and register it with the model:
AgentTemplate::builder("walker")
.with_component::<Position>(position_id)?
.with_capacity(count)
.build()
The capacity reserves storage for the expected population, so the columns are allocated once. A template is keyed by its name; populations and lifecycle operations refer to that name.
Populations
For an initial population, use the bulk path:
ModelBuilder::with_agent_population materialises a whole component column at
once rather than spawning agents individually. See initialise and mutate
populations.
At runtime, a model spawns or despawns agents in batches keyed by template name. Batch spawn and despawn are atomic: a batch either applies in full or, on error, rolls back, so a failure leaves no half-created cohort.
Lifecycle hooks
A template can carry lifecycle hooks that fire when agents of that kind are spawned or despawned. Hooks do not run inside a system; they fire at the scheduler boundary, after the stage that requested the structural change completes. Structural mutation therefore stays outside the parallel region and its timing is deterministic.
Agents versus raw entities
A model can also drop to the raw ECS and spawn entities directly. The agent layer is optional and does not change how storage or scheduling work.
Environments and space
Environments
Requires the environment feature (implied by model).
An environment holds model-wide values — state that is not per-agent, such as
a price index, a policy rate, or an audit record. Values are keyed by name and
type. Register a key up front to get a typed handle (EnvKey):
let key = builder.register_environment::<Prices>("prices", Prices::default())?;
Each environment key owns a channel, so a system that reads an environment value is automatically ordered after the systems that write it (see scheduling). Reads and writes go through the environment store by name and type:
let prices: Prices = model.environment().get::<Prices>("prices")?;
Because the environment is a boundary, writes made during a parallel stage are finalised deterministically at the stage edge. See use environment values.
Space
Requires the environment feature (implied by model).
The space layer provides spatial structure — a discrete grid and a continuous
2-D space — for models where agents have positions and interact by locality. A
SpaceHandle owns a channel, so systems that update and read space are ordered
correctly relative to one another.
Grid geometry uses saturating integer conversions, returns empty ranges for queries that do not intersect the space, and wraps explicitly on a torus where toroidal boundaries are requested. The same cell math backs the spatial message specialisation, so spatial messaging and spatial queries agree on which cell a position falls in.
See use grids and continuous space.
Messaging
Requires the messaging feature.
Agents that influence one another do so through messages. A message type is registered against a specialisation that decides how messages are indexed and delivered; producers emit messages during one stage and consumers read them in a later stage, with the message buffers acting as a boundary between them.
The four specialisations
| Specialisation | Delivery model | Use when |
|---|---|---|
| Brute-force | Every consumer can see every message. | The set is small, or every agent must consider every message. |
| Bucket | Messages are grouped into a fixed number of buckets. | Messages are addressed to one of a known set of groups (a sector, a bank). |
| Spatial | Messages are indexed by grid cell. | Delivery is by locality — neighbours within a cell or radius. |
| Targeted | Messages are addressed to a specific recipient. | Each message has a single intended recipient. |
Each specialisation is registered with the matching ModelBuilder method
(register_brute_force_message, register_bucket_message,
register_spatial_message, register_targeted_message), which returns a typed
MessageHandle. The handle is a small Copy token a system stores and uses to
emit or read that message type.
Emitting and consuming
A system emits with the message boundary's emit, or takes an emitter for a
tight loop that produces many messages without re-locking per message. Emission
during a parallel stage is staged per worker and finalised deterministically at
the stage boundary, so the delivered set does not depend on thread scheduling.
A consuming system reads the finalised messages for its handle in a later stage. Because the message boundary owns a channel, the scheduler orders emitters before consumers automatically.
GPU messaging
With messaging_gpu, message buffers can live on the GPU so a GPU system both
produces and consumes messages without a round trip to the CPU. See add a GPU
component and system.
For worked examples of each specialisation, see use each message specialisation.
Models, sub-schedulers, and nesting
Requires the model feature.
The ModelBuilder assembles a whole model — components, agent templates,
environment, messaging, systems, and scheduling — in one fluent chain and
validates it in build. A built Model owns its world and advances
with tick.
The builder
A typical build registers a component registry and shards, sets the seed,
registers environment keys and message types, adds agent templates and
populations, adds systems, and calls build:
let model = ModelBuilder::new()
.with_seed(42)
.with_component_registry(registry)
.with_shards(shards)
.with_agent_template(template)?
.with_agent_population("walker", position_id, population)?
.with_system(system)
.build()?;
build freezes the component registry, validates that sub-scheduler names are
unique and that channels are used within a consistent scope, and constructs the
world.
Sub-schedulers
A SubScheduler is a named scope with its own systems and stages that shares
the model's world and boundaries. Sub-schedulers run before the root scheduler
each tick, in the order they were added. Use one to group a phase of the tick
that should complete before the rest runs.
The model seed is applied to the root scheduler and every shared sub-scheduler,
so a system in a sub-scheduler sees the same RunContext::simulation_seed as the
root.
Nested models
A NestedModel is a fully separate child Model — its own world, environment,
agents, and seed — with an optional bridge closure. Each tick, a nested child
completes its own tick, then its bridge writes parent-facing effects, and only
then does the root scheduler run. Because a nested model is isolated, it keeps
the seed configured by its own builder rather than the parent's.
Use a sub-scheduler to phase work within one world; use a nested model to compose a separate world whose interaction with the parent is limited to what the bridge carries.
CPU and GPU state
Requires the gpu feature.
Syren can run selected systems on the GPU while the rest of the model runs on the CPU. The model stays the source of truth; the GPU holds mirrored copies of the component columns a GPU system needs.
GPU components
A component that a GPU system reads or writes must be GPU-safe: a
fixed-layout, plain-old-data type. Such a type implements GPUPod and is
registered with register_gpu_component instead of the plain registration, so
the framework knows it can be mirrored to a GPU buffer.
Mirroring and dispatch
For a GPU system, the framework mirrors the relevant component columns into GPU buffers, dispatches the compute shader over the archetypes, and reads results back when the CPU next needs them. Mirror buffers, parameter buffers, and bind groups are cached and invalidated by generation, so steady-state ticks reuse GPU resources rather than rebuilding them each tick.
GPU systems
A GPU system implements the GpuSystem trait: it names the resources it uses
and provides the shader and dispatch parameters. The scheduler treats it like any
other system for ordering — it participates in stages and channels — but
dispatches it to the device rather than running a CPU closure.
Visibility points
The CPU and GPU views of a column are synchronised at defined points: the scheduler boundary, and explicit sync or readback calls. Between those points a column may be resident on the GPU. Synchronising only at boundaries lets GPU dispatch avoid a blocking poll during the tick.
Building versus running
Enabling the gpu feature requires only that the wgpu crate compiles. Running a
GPU system requires a working graphics adapter. Where no adapter is available,
GPU execution is unavailable and the test suite reports a skip rather than
failing. A model that must run on machines without a GPU needs a CPU fallback
path.
See add a GPU component and system.
Profiling
Requires the profiling feature.
Syren can emit a timeline of where a tick spends its time as a Chrome Trace
file, viewable in chrome://tracing or Perfetto.
Spans compile away when off
The framework and model code annotate work with tracing spans. When the
profiling feature is off, those spans compile to nothing, so a build without
the feature has no span overhead and span annotations can stay in model code.
Capturing a trace
Initialise the profiler with an output path before running, and shut it down afterwards so the trace is flushed:
syren::init("profile/run.json");
// ... run the model ...
syren::shutdown();
The framework's own systems and boundaries are already instrumented, so a capture shows the stages of each tick and the time inside them. Add spans in your systems to attribute time to specific model phases.
Reading a trace
Open the JSON file in chrome://tracing or Perfetto. Each tick appears as a run
of stages, with the systems in a stage executing in parallel. A trace shows
whether time is spent in one system, in a stage that parallelises poorly, or in
structural mutation at a scheduler boundary.
See collect results and profiles and performance methodology.
Define components and agent templates
Define a component
A component is a plain type. Keep it Copy where you can — components are stored
in columnar arrays and copied during migration.
#[derive(Clone, Copy, Default)]
struct Position {
x: i64,
}
Register it and freeze the registry before building the model:
let registry = Arc::new(RwLock::new(ComponentRegistry::new()));
let position_id = registry.write().unwrap().register::<Position>()?;
registry.write().unwrap().freeze();
register returns the ComponentID you pass to templates and populations.
Freeze once, after all component types are registered.
Define an agent template
Requires the agents feature (implied by model).
A template names a kind of agent and lists its components and capacity:
let walker = AgentTemplate::builder("walker")
.with_component::<Position>(position_id)?
.with_capacity(10_000)
.build();
Register templates on the builder with with_agent_template. A template with
several components lists each with_component before build; the population is
then supplied one column per component (see initialise and mutate
populations).
Multi-component agents
Give a template every component the agent kind has:
AgentTemplate::builder("firm")
.with_component::<Firm>(ids.firm)?
.with_component::<FirmStocks>(ids.firm_stocks)?
.with_capacity(firm_count)
.build()
The capacity should be the expected population so the columns are allocated once rather than grown incrementally.
Initialise and mutate populations
Build an initial population in bulk
Build the starting population in bulk: build a Vec per component column and
pass each to with_agent_population. This materialises the columns once instead
of spawning agents individually.
let population: Vec<Position> = vec![Position { x: 0 }; 10_000];
let model = ModelBuilder::new()
.with_component_registry(Arc::clone(®istry))
.with_shards(EntityShards::new(1)?)
.with_agent_template(
AgentTemplate::builder("walker")
.with_component::<Position>(position_id)?
.with_capacity(population.len())
.build(),
)?
.with_agent_population("walker", position_id, population)?
.build()?;
For a multi-component template, call with_agent_population once per component,
each with a Vec of the same length. The builder groups columns by template name
and spawns each agent once with all of its columns.
Spawn and despawn at runtime
At runtime, add or remove agents in batches keyed by template name, through the model's batch spawn and despawn methods. Batches are atomic: on error the whole batch rolls back, so a failure never leaves a partial cohort. Structural changes take effect at the scheduler boundary, after the current stage, which is also where lifecycle hooks fire.
A batch applies as one atomic change at a single point in the tick; prefer it over many single spawns.
Choosing shard count
EntityShards::new(n) fixes the number of shards. A shard addresses a bounded
number of entities, so size the shard count for the largest population the model
will hold. The macroeconomy example derives its shard count from the population;
see its shards_for_population for a worked rule.
Derive access sets from queries
A system must declare the components it reads and writes. Let the declaration follow the queries the system runs, so the two stay in sync.
Use from_queries
Build the query, then pass it both to FnSystem::from_queries (to derive the
access) and into the closure (to iterate):
let step_query = QueryBuilder::with_registry(registry)
.write::<Position>()?
.build()?;
let run_query = step_query.clone();
let system = FnSystem::from_queries(
0,
"random_walk",
&[&step_query],
move |ecs| {
ecs.for_each_entity_w1::<Position>(run_query.clone(), |entity, pos| {
// mutate pos
})
},
);
Changing the query — for example adding read::<Velocity>() — changes the
derived access. There is no separate access list to maintain.
Multiple queries
If a system runs more than one query, list them all so the derived access covers everything it touches:
FnSystem::from_queries(id, name, &[&query_a, &query_b], move |ecs| { /* ... */ })
When to write access by hand
Occasionally a system's access is not captured by the queries it runs — for
example, it reads a boundary in a way the query shape does not express. In that
case construct an AccessSets directly. Use the derived path where a query
captures the access; the manual path is the exception, and the only place the
declaration can diverge from what the system touches.
Why it matters
The scheduler uses the declared access to place non-conflicting systems in the same parallel stage. If a declaration understates what a system touches, the runtime borrow check catches the conflict and returns an error instead of allowing a data race. An accurate, query-derived declaration lets the scheduler parallelise the system correctly.
Order systems with channels
Access conflicts order systems that touch the same components. When two systems touch different components but one must still run after the other — B consumes an effect A produced — express that ordering with a channel.
Channel IDs
A channel is identified by a ChannelID. Model resources own these; they are
not created directly. An environment key, a message handle, and a space handle
each own a channel, and a model can register dedicated phase keys used only
for ordering. Read the id from the resource, for example key.channel_id() or
handle.channel_id().
Declaring produce and consume
A system declares channels through its AccessSets: insert a channel id into
produces or consumes. Build the access set explicitly and construct the
system with FnSystem::new:
let mut access = AccessSets::default();
access.read.set(firm_id);
access.write.set(bank_id);
access.produces.insert(phases.credit_cleared.channel_id());
let system = FnSystem::new(id, "credit_market", access, move |ecs| {
// ...
Ok(())
});
A later system that must run after it consumes the same channel:
let mut access = AccessSets::default();
access.read.set(bank_id);
access.consumes.insert(phases.credit_cleared.channel_id());
The scheduler places every consumer of a channel in a stage after all of its producers. Systems that neither produce nor consume the channel are unaffected and may still parallelise with either side.
Ordering whole phases
To sequence phases of a tick — "aggregate, then set targets, then run the labour
market" — give each phase a channel: the phase's systems produce it, and the next
phase's systems consume it. The macroeconomy example orders its quarterly
schedule this way; see examples/macroeconomy/systems.rs.
Deriving component access and adding channels
FnSystem::from_queries derives component access from queries but does not add
channels. When a system needs both, build an AccessSets (setting the component
reads and writes, or deriving them), insert the channels, and use FnSystem::new.
Use environment values
Requires the environment feature (implied by model).
The environment holds model-wide state, keyed by name and type. Use it for values that are not per-agent: a price index, a policy rate, a set of parameters, or a per-tick audit record.
Register a key
Register each environment value on the builder before building the model. It
returns a typed EnvKey:
let prices_key = builder.register_environment::<Prices>("prices", Prices::default())?;
The key carries the name and a channel id. Registering also sets the default that the value holds before any system writes it.
Read and write from a system
Inside a system, read and write the environment through the world reference by name and type:
let prices: Prices = ecs.environment().get::<Prices>("prices")?;
// compute...
ecs.environment().set::<Prices>("prices", updated)?;
Read the final value after a tick through the model:
let prices: Prices = model.environment().get::<Prices>("prices")?;
Ordering around environment values
Each environment key owns a channel, so the scheduler orders a system that reads a value after the systems that write it; this ordering is not wired by hand. Because the environment is a boundary, writes made during a parallel stage are finalised deterministically at the stage edge.
Aggregates and audits
A common pattern is to compute a per-tick aggregate (say, a price index) into an environment value in one system, and read it in later systems and after the tick. The macroeconomy example keeps its whole aggregate and audit state in one environment value updated each quarter.
Use each message specialisation
Requires the messaging feature.
Register a message type against the specialisation that matches how it is
delivered, store the returned MessageHandle, emit during one stage, and read
in a later one. The four registration methods take &mut self on ModelBuilder
and a Capacity hint (for example Capacity::unbounded(64)). Each message type
implements the trait for its specialisation.
Brute-force
Every consumer can see every message. Register with register_brute_force_message:
let offers = builder.register_brute_force_message::<Offer>(Capacity::unbounded(64))?;
Use it when the message set is small or every agent must consider every message.
Bucket
Messages are grouped into a fixed number of buckets, addressed by bucket index.
Register with register_bucket_message, giving the bucket count first:
let by_sector = builder.register_bucket_message::<Order>(num_sectors, Capacity::unbounded(256))?;
Use it when messages are addressed to one of a known set of groups — a sector, a bank, a market.
Spatial
Messages are indexed by grid cell, so a consumer reads the messages near a
position. Register with register_spatial_message, passing a spatial
configuration that describes the grid first:
let signals = builder.register_spatial_message::<Signal>(spatial_config, Capacity::unbounded(128))?;
Use it when delivery is by locality. The spatial index uses the same cell math as the space layer.
Targeted
Each message names a single recipient. Register with register_targeted_message:
let payments = builder.register_targeted_message::<Payment>(Capacity::unbounded(64))?;
Use it when a message has exactly one intended recipient.
Emit and consume
Emit through the message boundary using the handle. For a loop that produces many
messages, take an emitter once rather than emitting one at a time:
// occasional emission
messages.emit(offers, Offer { /* ... */ })?;
A consuming system in a later stage reads the finalised messages for its handle. Because the message boundary owns a channel, the scheduler orders emitters before consumers automatically, and the delivered set is independent of thread scheduling.
For GPU-resident messages, see add a GPU component and system.
Use DetRng
DetRng is the random number generator for models. It keys each draw on the
run context and a salt, so the result does not depend on how work is scheduled
across threads.
Why not a thread-local RNG
A generator with mutable state — a thread-local, or one stored on the world —
produces draws in the order rows are visited. Under Rayon's work stealing that
order changes with the thread count, which changes the trajectory. DetRng
avoids this by keying each draw on coordinates that do not depend on scheduling.
Keying on the run context
Inside a system, open a stream from the run context and a salt:
let context = ecs.run_context();
let mut rng = DetRng::from_context(context, salt);
let u = rng.next_u64();
let f = rng.next_f64(); // in [0, 1)
let k = rng.next_below(6); // in [0, 6)
from_context folds (simulation_seed, tick, system_id, salt). Two draws with
the same coordinates produce the same value; different salts give independent
streams. The simulation_seed comes from ModelBuilder::with_seed.
Per-agent streams
When a loop over agents draws randomness, salt the stream with the agent's identity, so the draw depends on the agent rather than on the position at which it is visited:
ecs.for_each_entity_w1::<Position>(query, move |entity, pos| {
let mut rng = DetRng::from_context(context, u64::from(entity.index()));
pos.x += if rng.next_below(2) == 0 { -1 } else { 1 };
})?;
The first_model example uses this pattern, which keeps its walk identical at
one and eight threads.
Draw helpers
next_u64,next_f64,next_f32— raw draws.next_below(upper)— au64in[0, upper).next_index(len)— ausizein[0, len), for choosing an element.
Randomness outside a system
Population generation happens before any tick, so there is no run context. Use a
separate DetRng::from_seed(seed) there, keyed on the model's configured seed,
and keep it distinct from the per-tick streams.
Use grids and continuous space
Requires the environment feature (implied by model).
The space layer indexes agents by position so systems can find neighbours. There
are two spaces, both built from a GridGeometry (the cell dimensions and
whether boundaries wrap) and a channel id:
GridSpace2D— a discrete grid of cells.ContinuousSpace2D— a continuous 2-D plane, indexed by an underlying grid for range queries.
A SpaceHandle owns the channel that orders the systems that build and read
the space.
Discrete grid
Populate the grid by staging each agent's cell, then query neighbours:
grid.stage(entity, col, row);
// ...after the staging stage...
let here = grid.occupants(col, row); // entities in a cell
let neighbours = grid.moore_neighborhood(col, row, radius);
let adjacent = grid.von_neumann_neighborhood(col, row, radius);
For movement where several agents compete for the same destination cell, use the
claims mechanism (GridClaims): agents bid for a cell and the winner
resolves deterministically, so contested moves do not depend on visitation order.
This is how the Sugarscape example moves agents.
Continuous space
Continuous space answers radius queries with exact distances, honouring toroidal wrapping where the geometry requests it:
for (entity, x, y) in space.neighbors_within(qx, qy, radius) {
// agents within `radius` of (qx, qy)
}
Geometry and wrapping
GridGeometry uses saturating conversions from floating-point positions,
returns empty ranges for queries that do not intersect the space, and wraps
explicitly on a torus. The same geometry backs the spatial message
specialisation, so spatial messaging and spatial queries agree on cells.
See the Sugarscape example (examples/sugarscape/) for a grid-based model.
Add a GPU component and system
Requires the gpu feature.
A GPU system runs as a compute shader over mirrored component columns. Getting a component onto the GPU has two parts: making the component GPU-safe, and writing a system that declares the GPU backend.
Make the component GPU-safe
A GPU component must be plain-old-data with a fixed layout. Implement GPUPod
for it and register it with register_gpu_component instead of the plain
registration:
let velocity_id = register_gpu_component::<Velocity>()?;
register_gpu_component records that the type can be mirrored to a GPU buffer.
Keep GPU components small and #[repr(C)]-compatible; the mirror copies their
bytes directly.
Write a GPU system
A GPU system implements the GpuSystem trait: it names the resources it uses,
supplies the compute shader, and provides the dispatch parameters (workgroup
sizing). The scheduler treats it like any other system for ordering — it takes
part in stages and channels — but dispatches it to the device.
Add it to the model the same way as a CPU system. The framework mirrors the component columns the system needs, dispatches the shader over the archetypes, and reads results back when the CPU next reads those columns.
GPU messaging
With messaging_gpu, message buffers can live on the GPU, so a GPU system both
produces and consumes messages without copying to the CPU between stages.
Register GPU message resources through the builder's GPU message methods.
Fallback and testing
Building the gpu feature only needs wgpu to compile; running a GPU system needs
a real adapter. Where none is present, GPU execution is unavailable — provide a
CPU path if the model must run on such machines. The GPU execution tests report a
skip when no adapter is found, and the manual GPU tests workflow runs them on
hardware. See CPU and GPU state.
For a model that uses a GPU system with a CPU fallback, see the metabolism system in the Sugarscape example.
Collect results and profiles
Collect results
Read per-agent data with a reduction and model-wide data from the environment; both are covered in reading and recording results. To record a trajectory, read after each tick and append a row.
Define each output schema's column names once, next to the function that formats
a row, and derive the header from the same list. The macroeconomy example does
this in examples/macroeconomy/output.rs: the headline, aggregate-trace, and
firm-trace schemas each have a column list and a row builder in one place, and a
test asserts that each header is a single line, has unique names, and has the
same field count as its row.
A sketch:
const COLUMNS: &[&str] = &["tick", "mean", "variance"];
fn header() -> String {
COLUMNS.join(",")
}
fn row(tick: u64, stats: &Welford) -> String {
format!("{},{:.6},{:.6}", tick, stats.mean, stats.variance())
}
Deriving the header and the row from the same list keeps them aligned and lets a test check that the header and its row have the same number of fields.
Collect a profile
Requires the profiling feature.
Capture where a tick spends its time as a Chrome Trace:
syren::init("profile/run.json");
model.run(ticks)?;
syren::shutdown();
Open the JSON in chrome://tracing or Perfetto. The
framework's stages and boundaries are already instrumented; add spans in your
systems to attribute time to model phases. When the profiling feature is off,
the spans compile away, so instrumentation costs nothing in a normal run.
See profiling and performance methodology.
Reproducibility
A Syren model run with the same inputs produces the same outputs, bit for bit, regardless of the number of threads.
What the framework guarantees
Given the same crate version, feature set, seed, and initial state:
- Thread-count invariance. The trajectory is identical on one worker or many. Work stealing assigns rows to workers, so no result may depend on the order rows are visited.
- Deterministic scheduling. The scheduler produces the same stages and the same activation order every run.
- Deterministic randomness. Draws taken through
DetRng::from_contextdepend only on(seed, tick, system_id, salt).
The macroeconomy example's test suite checks this directly: the same seed produces an identical trajectory at one and eight threads, and distinct seeds diverge.
What the model must do
The guarantee holds only if model code follows these rules:
- Draw randomness through
DetRng, keyed on the run context. A thread-local or shared mutable generator produces draws in an order that changes with the thread count. - Salt per-agent draws with the agent's identity, not with the loop index, so a draw does not depend on the order the agent is visited in.
- Keep parallel accumulations order-independent. Sum per worker and combine in a fixed worker order; floating-point addition is not associative, so summing in completion order drifts with the thread count. Order-independent operations, such as maxima, are already safe.
- Collect order-sensitive sets by a stable key. When a system gathers rows whose order matters, sort by a model identifier rather than the order they were produced in.
Setting the seed
Set the seed once, on the builder:
let model = ModelBuilder::new().with_seed(config.seed) /* ... */ .build()?;
The seed reaches every system as RunContext::simulation_seed. A run is
described by its seed together with the version, features, and initial state; see
run provenance.
Sources of nondeterminism
Reproducibility can be lost in a few specific ways. This chapter lists them.
Seeds
Two runs diverge if their seeds differ. Conversely, if two runs with different
seeds produce the same trajectory, the seed is not reaching the draw sites —
check that the model is built with with_seed and that draws go through
DetRng::from_context.
Thread count
A correct model is thread-count invariant. If a trajectory changes with the number of workers, some step depends on visitation order. The usual culprits are a shared mutable RNG, a floating-point sum accumulated in completion order, or a collected set whose order was not stabilised. See reproducibility.
Floating-point behaviour
Floating-point results are reproducible on the same target but are not guaranteed identical across:
- different CPU architectures or compilers,
- fused-multiply-add contraction settings, or
- different math-library versions.
Report the target and toolchain alongside numerical results, and compare trajectories on the same platform. Within one platform, order-independent accumulation keeps results exact.
External input and output
Anything the model reads from outside — a data file, the clock, the environment — is part of its inputs. A run is only reproducible if those inputs are fixed. Pin input data by content, avoid reading wall-clock time into model state, and record which inputs a run used.
GPU execution
GPU results depend on the adapter, its driver, and the shader compiler. A GPU run is reproducible on the same adapter and driver, but not necessarily across different hardware, and not necessarily bit-identical to the CPU path. Where cross-platform reproducibility matters, run on the CPU or fix the GPU environment, and validate the CPU/GPU equality tests on your hardware.
Summary
| Source | Reproducible when |
|---|---|
| Seed | Same seed, reaching draws via DetRng |
| Thread count | Model is order-independent (always, if written correctly) |
| Floating point | Same architecture, compiler, and math library |
| External I/O | Inputs are pinned |
| GPU | Same adapter and driver |
Verification and validation
A model raises two separate questions. This chapter keeps them apart.
Verification: does the model do what the specification says?
Verification asks whether the implementation realises the intended model — the equations, rules, and accounting. It is answered with the code and tests, not with data. Syren supports several techniques:
- Named equation tests. Test each equation or rule against a hand-computed expected value. The macroeconomy example tests its named equations this way.
- Invariants and accounting identities. Assert conservation laws each tick — for example that stocks and flows balance, or that GDP by output and by expenditure agree within tolerance.
- Ordering checks. Assert that systems run in the intended phase order.
- Determinism checks. Assert thread-count invariance and seed divergence, so a refactor cannot silently introduce order sensitivity.
The test suite is built for verification, and the reproducibility guarantees make its checks trustworthy.
Validation: does the model match reality?
Validation asks whether the model's behaviour matches the empirical system it represents. It is answered with data and domain judgement, and is outside what the framework can establish. Syren makes model outputs reproducible and records which parameters and inputs produced them, which is a precondition for credible validation. Whether a calibration is correct, or a result is empirically meaningful, is the modeller's responsibility.
What to claim
State which question a result answers. "The accounting identities hold each tick" is a verification claim the tests can back. "The model reproduces the observed distribution of firm sizes" is a validation claim that needs data and belongs to the study, not the framework. Keep the two separate in documentation and papers.
Run provenance
A result is reproducible only if what produced it is recorded. Record enough to reconstruct the run.
Provenance checklist
For each reported run, record:
- Crate version — the Syren version (or commit hash for an unreleased build).
- Feature set — the Cargo features the model was built with.
- Toolchain and target — the Rust version and the platform the run executed on. This matters for floating-point comparability.
- Build profile — debug or release. Performance numbers are only meaningful in release.
- Seed — the model seed.
- Configuration — every parameter and option, including the config file or scenario name if one was used.
- Input data — the identity (a content hash or a versioned path) of any external data the run read.
- Command — the exact command line, including arguments.
- Thread count — if reporting timing. Results should not depend on it, but a timing number does.
Why each item
The trajectory is a function of version, features, seed, configuration, and input data. Fix those and the trajectory is fixed. The toolchain, target, build profile, and thread count do not change a correct trajectory, but they do change timing and floating-point comparability, so record them whenever numbers are compared across machines.
Making it routine
Have the model print its provenance at startup, or write it into the output alongside the results, so a stored result carries the information needed to reproduce it.
Citation
If you use Syren in academic work, please cite it.
How to cite
The repository includes a CITATION.cff
file. On GitHub, use the Cite this repository button on the repository page to
get a formatted citation (BibTeX or APA) generated from that file. The citation
records the title, author, version, license, and repository URL.
Cite the specific version you used, so the reference points at the exact code
that produced your results. The version is in CITATION.cff and in the crate
metadata.
What to include
Alongside the software citation, report the run provenance for any result you present — version, features, seed, configuration, and input data — as described in run provenance. The software citation says which tool; the provenance says which run.
DOI
A DOI is minted for public, non-candidate releases. Until then, cite the version
and repository URL. When a DOI is available it will be recorded in CITATION.cff
and can be cited in place of the repository URL.
Feature matrix
Syren has no default features. Enable the ones your model needs. Features are additive: enabling one never removes capability, and combinations compose.
Features
| Feature | Enables | Implies |
|---|---|---|
| (none) | Core ECS: components, queries, systems, scheduler. | — |
agents | Agent templates and lifecycle hooks. | — |
environment | Typed model-wide environment values. | — |
messaging | The four message specialisations. | — |
model | The ModelBuilder layer. | agents, environment |
gpu | GPU state mirroring and compute dispatch (wgpu). | — |
messaging_gpu | GPU-resident message buffers. | messaging, gpu |
profiling | Tracing spans and Chrome Trace output. | — |
gpu_profiling | Convenience aggregate. | gpu, profiling |
all | Everything above. | all |
The space module (discrete grid and continuous space) is available with the
environment feature, and therefore with model.
Combinations checked in CI
The continuous-integration pipeline checks each of these explicitly:
- no features
agentsenvironmentmessagingmodelmodel messaginggpumodel messaging_gpuall
Test runs cover the no-features build, model messaging, and the all-features
library. Integration tests are compiled with all features.
Choosing features
- A model that uses
ModelBuilder, agents, and environments:model. - Add message passing:
model messaging. - Run systems on the GPU: add
gpu(ormessaging_gpufor GPU messages). - Capture profiles: add
profiling.
Compatibility policy
Minimum supported Rust version
The library's MSRV is Rust 1.87, declared as rust-version in Cargo.toml.
The MSRV applies to the library across all features. Benchmarks and integration
tests are not bound by it and may use newer toolchains.
CI checks the library at the MSRV with no features and with all features. A change that requires a newer language or standard-library feature than 1.87 provides must either avoid it or come with a deliberate MSRV bump.
The development toolchain is pinned separately in rust-toolchain.toml and is
newer than the MSRV. Formatting, linting, and generated output are produced with
it.
Platforms
Syren is portable Rust and builds on the major desktop platforms (Linux, macOS,
Windows). The gpu feature depends on wgpu and therefore on a platform graphics
backend; building only needs wgpu to compile, while running a GPU system needs a
working adapter.
Semantic versioning
Syren is pre-1.0. Version numbers follow the pre-1.0 Cargo convention:
- Patch (
0.y.z→0.y.(z+1)): no public API breakage. - Minor (
0.y→0.(y+1)): may break the public API, with migration notes in the changelog.
See API status for which surfaces carry which stability, and the contributing guide for the release process.
What counts as a breaking change
A breaking change is one that can stop dependent code from compiling or change a documented behaviour: removing or renaming a public item, changing a signature, tightening a bound, or changing a documented guarantee. A change to a model's numerical trajectory for a fixed seed is a behavioural change and is called out in the changelog even though it does not break compilation.
API status
Not every public item carries the same stability. Syren groups its surface into tiers by how much notice a change gets.
Stable public API
The types and functions re-exported from the crate root and from the model,
agents, environment, messaging, and space modules are the intended public
API. These follow the compatibility policy: stable across
patch releases, changed only with migration notes across pre-1.0 minor releases.
The guide and the rustdoc reference treat this surface as primary.
The advanced module
The advanced module exposes lower-level building blocks — entity shards,
archetypes, chunk borrows, type-erased attributes, and the worker staging
primitive. They exist for models and extensions that need to reach beneath the
high-level API. They are more likely to change than the stable surface and are
documented as such.
GPU API
The GPU types (GpuSystem, GPUPod, register_gpu_component, and the GPU
messaging surface) are functional but lower-level, and they depend on wgpu. They
are marked separately and may change with less notice than the CPU API, partly
because they track an external dependency.
Experimental items
Anything documented as experimental may change or be removed without the usual notice. Where an item is experimental, its rustdoc says so.
Deprecation
Where practical, an item is deprecated for one minor release before removal, so dependent code gets a compiler warning and a migration path before it is removed.
Errors and failure boundaries
Syren reports failures as values, not panics, on its normal paths. Most
operations return an ECSResult (an alias for Result with the crate error
type), and a failure inside a tick stops the tick and surfaces the error rather
than leaving the world partly updated.
Error categories
The crate error type composes the errors from each subsystem. The main categories:
- Registration — a component or resource used before it was registered, or a type mismatch. Freeze the registry after registering all components.
- Query shape — a query passed to a method that expects a different shape (for
example,
reduce_readrequires exactly one read and no writes). - Borrow conflict — two systems, or two accesses, that alias a component incompatibly. This is the runtime backstop behind the scheduler's access analysis.
- Stale entity — an operation on an entity handle whose slot was recycled; the version no longer matches live storage.
- Spawn and structural — a failed batch spawn or despawn. Batches are atomic: on error the whole batch rolls back.
- Model build — a validation failure in
ModelBuilder::build, such as duplicate sub-scheduler names or a channel used out of scope. - Messaging, environment, and space — subsystem-specific failures such as an invalid message layout or a bad environment key type.
Failure boundaries
Failures are contained at well-defined boundaries:
- Within a tick, the first system error stops execution and is returned; the world is not left half-updated by continuing.
- Structural mutation (spawns, despawns, migrations) applies at the scheduler boundary and is atomic per batch, so a failure rolls the batch back rather than leaving a partial cohort.
- Build-time validation rejects an inconsistent model before it ever runs.
Handling errors
Propagate errors with ? and decide in the run loop whether a failed tick is
recoverable. The world is not left in a torn state, so it can be inspected after
a failed tick to diagnose the cause.
Performance methodology
A performance claim is only meaningful with its context. This chapter describes how to measure and how to report.
Measure in release
Always measure with an optimised build. Debug builds are for correctness, not timing. The benchmarks use Criterion, which handles warm-up, sampling, and statistics.
Run the benchmarks
The benchmark targets live in benches/. Compile them without running to check
they build:
cargo bench --no-run --all-features
Run a benchmark (or all of them) with the features it needs:
cargo bench --all-features
cargo bench --all-features --bench iterate
Some benchmarks require specific features (for example, the GPU and messaging benchmarks); their targets declare the features they need.
Report the full context
A number without its conditions is not reproducible. Report, with every performance figure:
- the hardware (CPU model and core count; GPU adapter if relevant),
- the compiler version and target,
- the features enabled,
- the build profile (release),
- the population and problem size, and
- the exact command.
This is the same provenance discipline as for results; see run provenance.
Thread scaling
Because a correct model is thread-count invariant in outcome, thread count only affects timing. When reporting scaling, hold everything else fixed and vary only the worker count, and state the population — small populations do not have enough work to scale across many cores.
Where time goes
Use the profiler to attribute time within a tick. It shows whether time is spent in one system, in a stage that parallelises poorly, or in structural mutation at a scheduler boundary.
Safety invariants
Syren uses unsafe in a small number of places to get columnar storage and
lock-free per-worker staging. Each use rests on an invariant that the surrounding
safe API upholds. This chapter records those invariants so that changes near them
are made deliberately.
Type-erased columns
Component columns are stored type-erased and cast back to their concrete type
during iteration (cast_slice / cast_slice_mut). The invariant: a column is
only ever cast to the type it was registered with. Registration records the type
for each ComponentID, queries validate the requested type against the column,
and the registry is frozen before the world runs, so the mapping cannot change
underneath a cast.
Runtime borrow checking
Because the compiler cannot see which components a query touches, aliasing is enforced at runtime by the borrow tracker. The invariant: no two live borrows alias a component incompatibly (two writes, or a read overlapping a write). The scheduler avoids conflicts through declared access; the borrow tracker is the backstop, turning a conflicting borrow into an error instead of a data race.
Per-worker staging
Values produced inside a parallel stage are written to per-worker slots
(WorkerStage) without locking. The invariant: during the parallel phase, each
slot is written only by the worker it belongs to, and the slots are drained only
under an exclusive borrow taken after the stage completes. Worker ids are stable
per thread and a thread runs one task at a time, so no two threads write one slot
concurrently.
Batch atomicity
Batch spawn and despawn apply columnar changes and roll back on error by truncating to the pre-batch length. The invariant: a batch either applies in full or leaves storage exactly as it was, so a failure never exposes a partially constructed cohort.
GPU byte copies
GPU components are copied to and from device buffers as raw bytes. The invariant:
only GPUPod types — fixed-layout, plain-old-data — are mirrored, so a byte copy
reconstructs a valid value. register_gpu_component is the gate that enforces the
bound.
When editing near unsafe
Each unsafe block in the source carries a comment stating the invariant it
relies on. A change near one must re-establish that the invariant still holds; the
surrounding safe API maintains it.
Glossary
Access set — the components a system reads and writes, plus the channels it produces and consumes. Used by the scheduler to order and parallelise systems.
Activation order — the fixed, seeded order in which systems within a stage run, so any order-sensitive step is reproducible.
Archetype — the set of entities sharing exactly the same components. Storage is organised per archetype.
Attribute — the columnar array storing one component for one archetype, split into chunks.
Boundary — a model-owned resource (the environment, message buffers) written during a stage and finalised at the stage edge.
Channel — a named ordering edge. A system produces or consumes a channel; the scheduler runs producers before consumers.
Chunk — a fixed-size block of rows within an attribute; the unit of parallel iteration.
Component — a plain data type holding one facet of an entity's state.
DetRng — the deterministic RNG, keyed on the run context and a salt so draws do not depend on thread scheduling.
Entity — a compact handle (index and version) identifying an agent.
Environment — model-wide values keyed by name and type.
Feature — a Cargo feature gating an optional layer (model, messaging,
gpu, and so on).
Migration — moving an entity between archetypes when its component set changes.
Model — a built simulation: world, environment, agents, and schedulers,
advanced with tick.
Nested model — an isolated child Model with its own world and seed, joined
to a parent through a bridge.
Reduction — a fold over a query's component column into an accumulator, with per-chunk partials combined stably.
Run context — the per-system (simulation_seed, tick, system_id) passed to
each system; the basis for deterministic draws.
Shard — one partition of entity storage, typically one per worker.
Specialisation — how a message type is indexed and delivered (brute-force, bucket, spatial, targeted).
Stage — a group of non-conflicting systems that run in parallel; stages run in sequence.
Sub-scheduler — a named scope of systems sharing the model's world, run before the root scheduler each tick.
System — a unit of per-tick work with a declared access set.
Development setup
This section documents the engine internals and the development process. It
complements the repository's
CONTRIBUTING.md,
which is the authoritative entry point for setup, checks, and the pull-request
process.
Toolchain
The development toolchain is pinned in rust-toolchain.toml to Rust 1.91.1 with
rustfmt and clippy. With rustup installed, the pinned toolchain is selected
automatically the first time you run cargo in the repository. The library's
MSRV is 1.87; see the compatibility policy.
The check loop
Before opening a pull request, run the same checks CI runs:
cargo fmt --all --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --no-default-features
cargo test --features "model messaging"
cargo test --all-features --lib
RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps
See tests and the feature matrix for the full matrix and documentation for building the guide.
Where to start reading
- Engine architecture — how the core fits together.
- Repository layout — where each part lives.
- The
first_modelexample — the smallest complete model. - The macroeconomy example — a large, fully documented model.
Engine architecture
Syren is layered. The engine is the core ECS; the optional layers (model, agents, environment, messaging, space, GPU, profiling) build on it behind features. The ownership model — who holds what, and when it is fixed — is the main thing to understand when reading the code.
Ownership
ComponentRegistrymaps component types to identifiers and is frozen before the world runs. Freezing fixes storage layouts and query resolution once.EntityShardsowns entity allocation, partitioned into shards (typically one per worker) so spawns and despawns do not contend on a single structure.- Archetypes own the columnar storage: one chunked attribute per component. An entity's components live in the archetype for its exact component set; changing that set migrates the entity.
ECSManagerties these together into the world and exposes anECSReferenceto systems.Schedulerowns the execution plan: it packs systems into stages from their declared access and channel constraints, and runs them.- Boundaries (environment, message buffers) are model-owned resources written during a stage and finalised at the stage edge.
The tick
A tick runs sub-schedulers, then nested models, then the root scheduler. Within a scheduler, stages run in sequence and the systems in a stage run in parallel over Rayon. Structural mutation (spawns, despawns, migrations) is deferred and applied at the scheduler boundary, keeping it out of the parallel region.
Determinism
The framework is designed to be deterministic. Three mechanisms provide this:
- The scheduler produces the same stages and activation order every run.
DetRngkeys draws on(seed, tick, system_id, salt)rather than a shared stream, so work stealing does not change results.- Parallel accumulation combines per-worker partials in a fixed order.
The reproducibility guarantee rests on these invariants; see reproducibility and safety invariants.
The model layer
The model layer wraps the engine: ModelBuilder registers components, agent
templates, environment keys, message types, sub-schedulers, and nested models,
validates them, and constructs a Model. It is where the seed is applied to the
root scheduler and shared sub-schedulers. The engine has no notion of agents or
environments; the model layer provides those on top of entities, components, and
boundaries.
Repository layout
Top level
| Path | Contents |
|---|---|
src/ | The library. |
examples/ | first_model, Sugarscape, and the macroeconomy model. |
tests/ | Integration tests. |
benches/ | Criterion benchmarks. |
docs/ | This mdBook guide (book.toml, src/). |
ci/ | The external-consumer smoke crate used by CI. |
.github/ | CI workflows, issue forms, and the pull-request template. |
Root files include Cargo.toml, CHANGELOG.md, CONTRIBUTING.md,
CODE_OF_CONDUCT.md, SECURITY.md, CITATION.cff, CODEOWNERS, the licence,
and rust-toolchain.toml.
Inside src/
engine/— the core ECS. Notable modules:component(registry and descriptors),storageandarchetype(columnar storage and migration),entity(handles and shards),query(query building and resolution),systems(systems and access sets),scheduler(stage packing and execution),activation(the run context),random(DetRng),reduce(the accumulators),boundary,commands(deferred structural mutation), andmanager(the world and its reference).model/—ModelBuilder,Model, sub-schedulers, and nested models.agents/— agent templates and lifecycle hooks.environment/— typed environment values and keys.messaging/— the message registry and the four specialisations.space/— the discrete grid and continuous space.gpu/— GPU context, mirroring, and dispatch.profiling/— tracing spans and Chrome Trace output.
The public surface is re-exported from lib.rs, with lower-level building blocks
under the advanced module.
Examples are self-contained
Each example lives under its own directory (or file, for first_model) with its
sources, tests, and documentation together. The macroeconomy example, in
particular, keeps its model sources, equation mapping, parameters, deviations,
and limitations under examples/macroeconomy/.
Tests and the feature matrix
Kinds of test
- Unit tests live beside the code in
src/and cover individual types and invariants. - Integration tests in
tests/exercise whole subsystems — the scheduler graph, boundary lifecycle, entity-aware iteration, memory layout, and the GPU dispatch path. - Example tests verify the examples. The macroeconomy suite, run as an integration test, covers scheduler and market ordering, named equations, parameter defaults, CSV schema shape, and determinism.
The feature matrix
Because features gate whole layers, tests must run under the relevant
combinations. CI checks each of: no features, agents, environment,
messaging, model, model messaging, gpu, model messaging_gpu, and all.
Test execution covers the no-features build, model messaging, and the
all-features library; integration tests are compiled with all features.
Run the common combinations locally:
cargo test --no-default-features
cargo test --features "model messaging"
cargo test --all-features --lib
cargo test --all-features --no-run
Determinism tests
Determinism is tested explicitly: a model runs at several thread counts and the trajectories are compared bit for bit, and distinct seeds are asserted to diverge. A change that touches iteration order, accumulation, or randomness must keep these tests green; they guard against silently introducing order sensitivity.
GPU tests
GPU execution tests need an adapter. Where none is present they report a skip,
so they do not block CI. The manual GPU tests workflow runs them on a
self-hosted runner with real hardware.
MSRV
The library is checked at the MSRV (Rust 1.87) with no features and all features. Avoid language or standard-library features newer than the MSRV in library code, or bump the MSRV deliberately.
Benchmarks
The benchmarks in benches/ use Criterion and cover the hot paths: spawning,
iteration, ticking, reduction, query matching, scheduler packing, structural
mutation, parallel scaling, and the GPU paths.
Running
Compile all benchmarks without running (this is what CI checks):
cargo bench --no-run --all-features
Run a benchmark with the features it needs:
cargo bench --all-features --bench iterate
Some benchmark targets declare required features (for example, the environment, messaging, model, and GPU benchmarks); Cargo only builds a target when its features are enabled.
Measuring a change
When a change is meant to affect performance:
- Measure the relevant benchmark before and after, on the same machine, in release.
- Report the numbers with their full context — hardware, features, build profile, population, and command — as described in performance methodology.
- Confirm the change did not alter a trajectory for a fixed seed unless that was the intent; a performance change should not be a behavioural change by accident.
Attributing time
Use the profiler to see where a tick spends its time before optimising. A benchmark shows whether a change helped; the profiler shows where the time goes.
Documentation
Syren has three documentation surfaces:
- rustdoc is the reference for the public Rust API.
- This mdBook guide (under
docs/) is the user and contributor manual. - The examples carry their own documentation next to their sources.
Building
# API reference
RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps --open
# The guide
mdbook build docs
mdbook serve docs # live preview at http://localhost:3000
Code in the guide
Code blocks in the guide come from one of three places: a compiled example
included with {{#include}} and ANCHOR markers (the getting-started chapter
does this from first_model), a doctest compiled by rustdoc, or a rust,ignore
block used for an illustrative snippet.
Releases
Releases follow the process in
CONTRIBUTING.md.
This chapter summarises it and records the reasoning.
Steps
- Version. Update the version in
Cargo.tomland keepCITATION.cffin sync with it. The citation must name the exact code a result came from. - Changelog. Finalise the entry for the version, including any breaking changes with migration notes and any trajectory changes for a fixed seed.
- Package inspection. Run
cargo package --lockedand reviewcargo package --list. The archive must exclude local tooling, build output, profiling captures, and IDE settings; CI asserts this. - Documentation. Build rustdoc (all features, warnings denied) and the mdBook guide from the release commit.
- Full CI. Run the whole pipeline from a clean checkout.
- Tag and release. Tag the version and create the GitHub release.
- Publish.
cargo publish. - DOI. For a public, non-candidate release, mint a DOI and record it in
CITATION.cff.
Release candidates
A release candidate (-rc.N) carries out everything through building the package
and documentation and creating a draft release, but stops before tagging,
publishing to crates.io, and minting a DOI. These irreversible steps require a
separate approval.
Versioning
Syren is pre-1.0. Patch releases do not break the public API; minor releases may, with migration notes. See the compatibility policy and API status.
Documentation deployment
The guide is published to GitHub Pages from the default branch; docs.rs builds the versioned API reference from the published crate. The two are independent: the Pages site tracks the latest default-branch guide, and docs.rs tracks published versions.