simul is a discrete-event simulation library for running high-level
simulations of real-world problems and for running simulated experiments.
simul is a discrete-event simulator using incremental time progression,
with a FIFO message queue per agent for interactions between agents. It also
supports deterministic typed experiments and seeded searches over
caller-defined candidate spaces.
Use-cases:
- Discrete-event simulation
- Complex adaptive systems
- Random search
- Job-shop scheduling
- Birth-death processes
- Computer experiments
- Other: simulating logistics, operations research problems, running experiments to approximate a global optimum, simulating queueing systems, distributed systems, performance engineering/analysis, and so on.
Warning
Experimental and unstable. Almost all APIs are expected to change.
- For some examples, see the
examplessubdirectory. - For ideas not yet implemented, see
examples/CANDIDATES.md. - For agents with their own state, implement
Agentfor your type and add it withAgentInitializer::reactiveorAgentInitializer::proactive; see Custom agents.
experiment is independent of the agent engine.
- Schedule explicit
TrialSpecvalues withCandidateIdandReplicationId.run_serial_boundedrejects duplicate identities and oversized batches before evaluation, and returns records ordered byTrialId. Batch independent candidates in one runner call. Pass&candidatetoFixedReplications::trialsto share large inputs without cloning their contents. - Replay uses the V1 keyed BLAKE3/ChaCha12 protocol. Retain
ReplayKey, candidate inputs, and evaluator configuration. Named common-random streams share noise across candidates;ValidationHoldoutseparates validation noise. - With
parallel,run_paralleluses a dedicated bounded Rayon pool and one mutable workspace per worker. Its factory isFnMut() -> Result<W, WE>, called on the caller thread. Explicit batch/output storage and the pool are prepared before factories, and every factory succeeds before evaluation starts. Requested workers are capped by work and available parallelism. Evaluators must use only their inputs, context, immutable configuration, and unobservable scratch for worker-count-independent results. Workers claim small contiguous chunks of the canonical batch until none remain, so trials of uneven cost do not leave workers idle. Chunks write directly into disjoint ranges of one final result allocation; initialization guards drop partial results on failure without a second buffer. An evaluator panic is reported as the lowest panicking trial, whatever the schedule. For repeated batches, constructParallelExecutor::new(config)once and callexecutor.run(seed, trials, init_worker, evaluate). Worker capacity is fixed at construction; each batch initializes fresh workspaces on its caller thread. Empty batches invoke no callbacks, and failed batches do not invalidate the executor. Prefer serial execution when individual batches are very cheap. - Setup failures are run-level errors; evaluator
Errvalues remain ordered trial records. With unwinding enabled, callback panics discard partial output and return a run-level error without retries. This does not roll back callback side effects, and cannot catch panics inpanic = "abort"builds. aggregate_scalarandaggregate_bernoulliapply explicit failure policies. Unrepresentable scalar arithmetic returnsArithmeticOverflow, never a successful non-finite summary.search::SeededRandomSearchprovides tokenized ask/tell search.CrossEntropyOptimizerandsimulated_annealing_search_with_rngremain engine-independent optimizer alternatives; aggregate noisy trials inside their objective rather than ranking a lucky single run.- To evaluate a
Simulationin a trial, build it withSimulation::builder(..).trial(context). Its seed comes from the trial's common-random stream, so candidates in one common-random group share their agents' draws replication by replication; agent streams are keyed by name, so the pairing survives candidates that add or reorder agents.
See examples/simple_experiment.rs for a complete typed experiment.
[dependencies]
simul = "0.6.0"use simul::agent::{periodic_consumer, periodic_producer};
use simul::{Halt, Simulation, SimulationError};
/// Example of a minimal, simple Simulation that can be executed.
fn main() -> Result<(), SimulationError> {
// Runs a simulation with a producer that produces work at every tick of
// discrete time (period=1), and a consumer that cannot keep up (can only
// process that work every second tick).
//
// The halt condition tells the simulation when it is finished: here,
// "when the simulation is 10 ticks old, we're done." The messages carry
// no payload, so their type is `()`.
let mut simulation = Simulation::<()>::builder(Halt::AtTime(10))
// We pass in two agents:
// `producer`: produces a message to the consumer every tick
// `consumer`: consumes w/ no side effects every second tick
// Agents are powerful, and you can pass-in custom implementations here.
.agents([
periodic_producer("producer", 1, "consumer"),
periodic_consumer("consumer", 2),
])
.build()?;
// For massive simulations, you might block on this line for a long time.
simulation.run();
// Post-simulation, you can do analytics on the stored metrics, data, etc.
simulation
.agents()
.iter()
.for_each(|agent| println!("{agent:#?}"));
Ok(())
}Implement Agent<M> for your own types, where M is the simulation's message
type. AgentInitializer::reactive(name, agent) runs an agent only when it has
messages; AgentInitializer::proactive(name, agent) also calls its on_tick
every tick it is awake. .asleep_until(t) starts an agent asleep until tick
t, and .initial_queue(messages) gives it messages before the first tick.
use simul::agent::{Agent, AgentContext, AgentInitializer};
use simul::message::Message;
use simul::{Halt, Simulation, SimulationError};
/// Everything the agents say to each other.
#[derive(Clone, Copy, Debug)]
enum Drink {
Coffee,
Tea,
}
/// Orders a drink every 3 ticks.
#[derive(Clone, Debug)]
struct Customer;
impl Agent<Drink> for Customer {
fn on_tick(&mut self, ctx: &mut AgentContext<'_, Drink>) {
// Draws come from the simulation seed, so equal seeds give equal runs.
let drink = if ctx.rng().uniform_f64() < 0.5 {
Drink::Coffee
} else {
Drink::Tea
};
ctx.send("barista", drink);
ctx.sleep_for(3);
}
}
/// Serves each order, then rests for as long as the drink takes.
#[derive(Clone, Debug, Default)]
struct Barista {
coffees: u32,
teas: u32,
}
impl Agent<Drink> for Barista {
fn on_message(&mut self, ctx: &mut AgentContext<'_, Drink>, order: &Message<Drink>) {
match order.payload {
Drink::Coffee => {
self.coffees += 1;
ctx.sleep_for(2);
}
Drink::Tea => {
self.teas += 1;
ctx.sleep_for(1);
}
}
}
}
fn main() -> Result<(), SimulationError> {
let mut simulation = Simulation::builder(Halt::AtTime(30))
.agents([
AgentInitializer::proactive("customer", Customer),
AgentInitializer::reactive("barista", Barista::default()),
])
.build()?;
simulation.run();
// Results stay in the agents; read the barista back as its own type.
let barista = simulation
.find_by_name("barista")
.and_then(|agent| agent.downcast_ref::<Barista>())
.expect("the barista is a Barista");
assert_eq!(10, barista.coffees + barista.teas);
Ok(())
}- A simulation is a collection of
Agentsthat interact with each other viaMessages. - The simulation keeps a discrete time (u64) which advances one tick at a time, jumping over ticks in which no agent can act when the halt condition allows it.
- What an
Agentdoes is provided by you inon_message()andon_tick(). Each tick, an awake agent with queued messages handles the oldest one inon_message; aProactiveagent then also getson_tick. Agentsmust have a unique name. Each also has a denseAgentId: the order in which it was added to the builder.- If an
Agentwants to interact with anotherAgent, it can send aMessagevia the&mut ctx: AgentContextpassed intoon_tickandon_message.ctx.send(name, payload)resolves a name;ctx.send_to(id, payload)skips the lookup, andctx.agent_id(name)resolves a peer once for reuse. ctx.send_after(delay, id, payload)delivers a messagedelayticks later instead of at the end of the tick. Use it for latency and, sent toctx.id(), for timeouts: an agent waiting on a timer stays idle instead of polling every tick, so quiet ticks are still skipped. Messages that have not arrived when a run halts stay inSimulation::in_flightfor the next run.- Randomness comes from
ctx.rng(), a stream derived from the simulation's seed, the agent's name, and the tick, so equal seeds give equal runs, and adding or reordering other agents does not change an agent's draws. ctx.sleep_for(ticks)puts an agent to sleep; it then wakes into the mode it was built with. An agent that has not finished a message callsctx.set_processing_status(MessageProcessingStatus::InProgress)to keep it at the front of its queue for its next visit; the default,Done, consumes it.Simulation::builder(halt)also takes.seed(..),.starting_time(..),.trial(..),.message_history(..),.queue_depth_metrics(..), and.asleep_cycles_metric(..);build()returns an error for duplicate names.- Built-in agents send
M::default()or consume with no side effects:periodic_producer,periodic_consumer, andgeometric_producer/geometric_consumer, whose gaps are memoryless with a given mean (at least one tick).
The simulation runs all the logic of calling on_tick and on_message,
distributing messages, tracking metrics, incrementing time, and when to halt.
A Simulation is finished when its Halt condition holds (Halt::AtTime(t),
Halt::Never, or Halt::when(predicate)), or after the tick in which an
Agent calls ctx.send_halt_interrupt(reason); Simulation::halt_reason()
returns that reason. If a callback panics, the simulation becomes
SimulationMode::Failed and refuses to run again; Simulation::mode() reports
whether it is constructed, running, paused, completed, or failed.
simulation.run_until(t) pauses once the time reaches tick t, without
running it, unless the run ends first; run() or another run_until then
continues exactly as one uninterrupted run would, still skipping quiet ticks.
A paused Simulation is a complete snapshot: clone it to branch. To compare
alternatives that differ only from a decision tick, run once to that tick,
clone per alternative, change each clone through
find_by_name_mut(name) and downcast_mut::<YourAgent>(), and run the clones
on. Agent draws depend only on the seed, the agent's name, and the tick, so
each branch matches a run that took its alternative from the start, while the
shared prefix runs once. Branches share nothing unless agents hold shared
handles such as Rc or Arc to outside state.
Messages identify agents by AgentId: source is the sender (absent for an
initial queue entry) and destination the addressed agent (absent when the
target matched no agent, in which case the message is counted but not
delivered; initial queue entries are addressed to their owner).
Simulation::agent(id) and Simulation::agent_id(name) translate between
identities and agents. Create initial messages with
Message::new(time, payload), not struct literals.
Every agent counts the messages it consumed and produced
(SimulationAgent::consumed_count, produced_count) and their total wait, so
the calc_*_statistics methods and halt conditions on counts need nothing
else. To keep the messages themselves, build with .message_history(true);
SimulationAgent::consumed and produced then return them in order. History
is off by default because it holds every message for the whole run, and the
history accessors panic when it is off rather than look empty.
Keep results in the agents themselves. After a run,
simulation.find_by_name("door").and_then(|agent| agent.downcast_ref::<Door>())
returns the agent as its own type, or None for any other type, so there is no
need to share Rc<Cell<_>> or locks with agents to get answers out.
Messages are typed. A simulation picks one message type M, usually an enum
of everything its agents say to each other, and every agent implements
Agent<M> and sends M values with ctx.send(target, payload), so handlers
match on message.payload instead of decoding bytes. () is the default for
simulations whose messages carry nothing; name it with
Simulation::<()>::builder(..) when only built-in agents are present, since
they work with any M (producers send M::default()). A payload moves into
the receiver's queue without copying, and shared data travels as M = Arc<T>.
Recording message history needs M: Clone; running does not.
With queue_depth_metrics(true), each agent records QueueDepthChanges:
the tick from which its queue depth changed, and the new depth.
Simulations with more than 64 agents and no asleep-cycle metric use an ordered
ready bitmap and wake heap during sparse phases, switching to dense scans as
activity rises. Smaller simulations and runs with the asleep-cycle metric keep
dense scans. Callback order, end-of-tick delivery, and halt checks match dense
scans. With Halt::AtTime or Halt::Never and no asleep-cycle metric, the
engine also jumps over ticks in which no agent can act, to the next wake-up,
delayed arrival, or the halt time; Halt::when predicates see every tick, so
they keep every tick.
Issues, bugs, features are tracked in TODO.org.