Skip to content
jmqdPublic

About

discrete event simulation library for high-level simulations of real-world problems and simulated experiments

Resources

Stars

8 stars

Watchers

1 watching

Forks

Repository files navigation

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:

Usage

Warning

Experimental and unstable. Almost all APIs are expected to change.

  • For some examples, see the examples subdirectory.
  • For ideas not yet implemented, see examples/CANDIDATES.md.
  • For agents with their own state, implement Agent for your type and add it with AgentInitializer::reactive or AgentInitializer::proactive; see Custom agents.

Typed experiments

experiment is independent of the agent engine.

  • Schedule explicit TrialSpec values with CandidateId and ReplicationId. run_serial_bounded rejects duplicate identities and oversized batches before evaluation, and returns records ordered by TrialId. Batch independent candidates in one runner call. Pass &candidate to FixedReplications::trials to 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; ValidationHoldout separates validation noise.
  • With parallel, run_parallel uses a dedicated bounded Rayon pool and one mutable workspace per worker. Its factory is FnMut() -> 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, construct ParallelExecutor::new(config) once and call executor.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 Err values 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 in panic = "abort" builds.
  • aggregate_scalar and aggregate_bernoulli apply explicit failure policies. Unrepresentable scalar arithmetic returns ArithmeticOverflow, never a successful non-finite summary.
  • search::SeededRandomSearch provides tokenized ask/tell search. CrossEntropyOptimizer and simulated_annealing_search_with_rng remain engine-independent optimizer alternatives; aggregate noisy trials inside their objective rather than ranking a lucky single run.
  • To evaluate a Simulation in a trial, build it with Simulation::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.

Basic usage

[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(())
}

Custom agents

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(())
}

Simulation Concepts / Abstraction

  • A simulation is a collection of Agents that interact with each other via Messages.
  • 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 Agent does is provided by you in on_message() and on_tick(). Each tick, an awake agent with queued messages handles the oldest one in on_message; a Proactive agent then also gets on_tick.
  • Agents must have a unique name. Each also has a dense AgentId: the order in which it was added to the builder.
  • If an Agent wants to interact with another Agent, it can send a Message via the &mut ctx: AgentContext passed into on_tick and on_message. ctx.send(name, payload) resolves a name; ctx.send_to(id, payload) skips the lookup, and ctx.agent_id(name) resolves a peer once for reuse.
  • ctx.send_after(delay, id, payload) delivers a message delay ticks later instead of at the end of the tick. Use it for latency and, sent to ctx.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 in Simulation::in_flight for 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 calls ctx.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, and geometric_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.

Contributing

Issues, bugs, features are tracked in TODO.org.

About

discrete event simulation library for high-level simulations of real-world problems and simulated experiments

Resources

Stars

8 stars

Watchers

1 watching

Forks

Used by

Contributors

Languages