# simu — Discrete Event Simulation Library Specification
## 1. Project Overview
`simu` is a Rust library for Discrete Event Simulation (DES), inspired by Python's SimPy but designed
from the ground up to be idiomatic Rust, high-performance, and scalable. The primary target use case is
complex workflow simulation (e.g., hospital operations), where thousands of independent processes
interact through shared resources and events.
---
## 2. Crate Naming
The library is imported as `simu` (`use simu::…`). The crates.io **package** name
`simu` is already taken (an unrelated iOS-simulator CLI), so the crate publishes as
**`simu-des`** while keeping `[lib] name = "simu"` — users add `simu-des = "0.1"`
and still write `use simu::…`. See `PUBLISHING.md` for the full release plan.
---
## 3. Non-Functional Requirements
| **Performance** | Must support thousands of concurrent processes with low overhead |
| **Scalability** | Monte Carlo parallelism via OS threads (`std::thread`) |
| **Determinism** | Given the same seed and configuration, a simulation must be reproducible |
| **Correctness** | Events at equal simulation time must be processed in deterministic order |
| **Idiomatic Rust** | Public API uses standard Rust patterns; no unsafe in user-facing code |
| **Error strategy** | Programming errors (wrong API use) may panic; simulation errors use `Result` |
---
## 4. Architecture
### 4.1 Core Execution Model
DES is inherently sequential within a single simulation run: the scheduler processes one event at a
time, advancing simulated time monotonically. This means real parallelism within a single run provides
no benefit — and would introduce synchronisation overhead.
**Chosen approach: single-threaded custom async executor per simulation instance.**
- Each simulation process is an `async fn` or `async` block.
- A custom executor (not tokio/async-std) drives process execution based on simulated time, not
wall-clock time.
- The executor polls futures manually; no OS threads or I/O reactors are involved per simulation.
- Monte Carlo parallelism is achieved by running independent simulation instances on separate OS threads.
This gives maximum per-simulation throughput (zero sync overhead) while enabling multi-core utilisation
across runs.
```
┌─────────────────────────────────────────────────────────┐
│ Monte Carlo Driver (monte_carlo::run / std::thread) │
│ │
│ Thread 0 Thread 1 Thread N │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ SimEnv │ │ SimEnv │ │ SimEnv │ │
│ │ EventQueue │ │ EventQueue │ │ EventQueue │ │
│ │ Processes │ │ Processes │ │ Processes │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 4.2 Simulation Environment (`SimEnv` and `EnvHandle`)
The environment is split into two types:
- **`SimEnv`** — owns all simulation state and drives the event loop. Not cloneable; lives on one thread.
- **`EnvHandle`** — a lightweight, `Clone`able handle that processes use to interact with the simulation.
Obtained via `env.handle()` and passed into spawned processes.
Both share the same underlying `SimState` and the same randomness source — a boxed,
pluggable `RandomSource` (`Rc<RefCell<Box<dyn RandomSource>>>`) defaulting to `StdRng`.
`SimEnv` is `!Send + !Sync` (via `Rc`) and must live on one thread.
```rust
pub struct SimEnv { /* opaque */ }
impl SimEnv {
/// Create a new environment seeded from OS entropy.
pub fn new() -> Self;
/// Create with a specific RNG seed for reproducibility (StdRng).
pub fn with_seed(seed: u64) -> Self;
/// Create driven by a custom, pluggable randomness source — e.g. the
/// portable `SplitMix64` feed used for exact cross-engine comparison.
pub fn with_source<R: RandomSource + 'static>(source: R) -> Self;
/// Reseed the active randomness source, restarting its stream.
pub fn set_seed(&mut self, seed: u64);
/// Return a cloneable handle for passing into processes.
pub fn handle(&self) -> EnvHandle;
/// Current simulation time.
pub fn now(&self) -> f64;
/// Spawn a new process into the simulation.
pub fn spawn<F>(&self, process: F) -> ProcessHandle<F::Output>
where F: Future + 'static, F::Output: 'static;
/// Run until the event queue is empty.
pub fn run(&mut self);
/// Run until simulated time reaches `until`.
pub fn run_until(&mut self, until: f64);
/// Create a timeout event. Convenience wrapper around `EnvHandle::timeout`.
pub fn timeout(&self, delay: f64) -> Timeout;
/// Create a paired event handle. Convenience wrapper around `EnvHandle::event`.
pub fn event(&self) -> (EventTrigger, EventAwaitable);
}
#[derive(Clone)]
pub struct EnvHandle { /* opaque */ }
impl EnvHandle {
/// Current simulation time.
pub fn now(&self) -> f64;
/// Create a `Timeout` that resolves after `delay` simulated time units.
/// The deadline is fixed at creation (`now() + delay`). Panics if `delay`
/// is negative or non-finite (zero is allowed).
pub fn timeout(&self, delay: f64) -> Timeout;
/// Create a paired `(EventTrigger, EventAwaitable)` for inter-process signalling.
pub fn event(&self) -> (EventTrigger, EventAwaitable);
/// Spawn a child process from within a running process.
pub fn spawn<F>(&self, future: F) -> ProcessHandle<F::Output>
where F: Future + 'static, F::Output: 'static;
/// Borrow the shared RNG. The returned guard implements `RngCore`.
/// Must not be held across an `.await` point.
pub fn rng(&self) -> impl RngCore + '_;
}
```
### 4.3 Process Model
A process is any `async fn` or `async` block that receives an `EnvHandle`. Processes interact with the
simulation by awaiting simulation primitives.
```rust
async fn patient_journey(env: EnvHandle, resources: HospitalResources) {
// Request a bed (blocks if none available)
let _bed = resources.beds.request().await;
// Wait 15 simulated time units for triage
env.timeout(15.0).await;
// Request a doctor
let _doctor = resources.doctors.request().await;
// Treatment duration sampled from distribution.
// RNG must be sampled before the .await — the guard cannot cross an await point.
let duration = env.rng().sample(Exp::new(1.0 / 45.0).unwrap());
env.timeout(duration).await;
// Resources released automatically when guards are dropped
}
```
Key design choices:
- Processes are spawned with `env.spawn(future)` and run lazily by the scheduler.
- `EnvHandle` is `Clone` — processes clone it rather than borrowing.
- Panicking inside a process currently unwinds the entire `run()` call: the executor polls
processes without `catch_unwind`, so a panic aborts the whole simulation rather than terminating
just the offending process. Per-process isolation (catch the panic, drop only that process, and
surface it as a simulation error) is a post-MVP item — see [§6](#6-post-mvp-roadmap). Until then,
treat a process panic as fatal to the run.
- `spawn` returns a [`ProcessHandle<T>`](#4-4-core-types) that resolves to the
process's return value. Dropping the handle detaches the process
(fire-and-forget). `ProcessHandle<T>` is not `Clone` — broadcast patterns
use [`EventTrigger`](#event) instead.
### 4.4 Event Model (MVP)
Two event types are supported in the MVP:
| `Timeout` | Resolves when sim time advances by `delay` units |
| `EventAwaitable` | Resolves when explicitly triggered via its paired `EventTrigger` |
Both implement `Future<Output = ()>` and can be directly `.await`ed inside a process.
```rust
// Timeout
env.timeout(10.0).await;
// Manual event (e.g., a signal between processes)
let (trigger, awaitable) = env.event();
env.spawn(async move {
env.timeout(5.0).await;
trigger.fire(); // consumes trigger; wakes all current and future waiters
});
awaitable.await;
```
**Multi-waiter support:** `EventAwaitable` is `Clone`. Multiple processes can await the same event;
all are woken when `trigger.fire()` is called.
**Fire-before-await latch:** If `trigger.fire()` is called before any process awaits the event, the
`fired` flag is set. Any subsequent `.await` on the awaitable resolves immediately without suspending.
**`EventTrigger::fire` consumes `self`** — a trigger can only be fired once.
**Combinators** `AnyOf` and `AllOf` compose any `Future<Output = ()>` futures:
```rust
use simu::{any_of, all_of};
// Race: resolve when the first of several events fires
any_of![h.timeout(10.0), signal.clone()].await;
// Barrier: resolve when all events have fired
all_of![phase_a, phase_b, phase_c].await;
```
Both accept one or more expressions via macro (which auto-`Box::pin` each);
`AnyOf::new(vec![])` panics, `AllOf::new(vec![])` resolves immediately.
**Post-MVP additions:** `Interrupt` (preemption); `Condition`.
#### ProcessHandle
`spawn` returns a `ProcessHandle<T>` that is itself a `Future<Output = T>`.
Awaiting the handle suspends the caller until the spawned process finishes,
and yields its return value. Handles are **not `Clone`** — single-await,
tokio-`JoinHandle`-style. Broadcast patterns should use `EventTrigger`.
```rust
pub struct ProcessHandle<T> { /* opaque, T: 'static */ }
impl<T: 'static> Future for ProcessHandle<T> {
type Output = T;
}
impl<T: 'static> ProcessHandle<T> {
/// Await and discard the value — for use with `any_of!` / `all_of!`.
pub fn discard(self) -> impl Future<Output = ()> + 'static;
}
```
Dropping the handle before awaiting **detaches** the process: it keeps
running; its return value, if any, is dropped when the process completes.
This matches `tokio::JoinHandle` semantics.
Typical patterns:
```rust
// Return a value from a process
let h = env.spawn(async { env.timeout(10.0).await; compute_result() });
let result = h.await;
// Join multiple child processes as a barrier
let a = env.spawn(phase_a());
let b = env.spawn(phase_b());
all_of![a.discard(), b.discard()].await;
// Fire-and-forget (idiomatic — just drop the returned handle)
env.spawn(background_work());
```
### 4.5 Resource Model (MVP)
A `Resource` models a pool of identical, limited-capacity units (e.g., hospital beds).
```rust
pub struct Resource { /* opaque, Clone */ }
impl Resource {
/// # Panics
/// Panics if `capacity` is zero.
pub fn new(capacity: usize) -> Self;
/// Request one unit. Suspends the calling process if none are available (FIFO).
/// Returns a guard that releases the unit when dropped.
pub fn request(&self) -> ResourceRequest;
/// Current number of units in use.
pub fn in_use(&self) -> usize;
/// Total capacity.
pub fn capacity(&self) -> usize;
/// Number of processes currently queued (excludes canceled requests).
/// `PriorityResource` and `PreemptiveResource` expose the same accessor;
/// `Container` exposes `get_queue_len()` / `put_queue_len()`.
pub fn queue_len(&self) -> usize;
}
```
Acquisition is RAII: the returned `ResourceGuard` releases the unit when dropped.
```rust
let guard = resource.request().await; // waits if at capacity
// use resource ...
drop(guard); // unit is released; next waiter is woken (FIFO)
```
**Resource ownership:** `Resource` wraps `Rc<RefCell<ResourceState>>` internally and implements
`Clone`. All clones share the same pool. There is no need for `Arc` or `Mutex` because the executor
is single-threaded. Resources are created outside `SimEnv` and shared across processes by cloning:
```rust
let machine = Resource::new(1);
for _ in 0..3 {
let m = machine.clone(); // cheap Rc clone
env.spawn(async move {
let _guard = m.request().await;
// ...
});
}
```
`Resource` is `!Send + !Sync` — consistent with `SimEnv`.
**`PriorityResource`** is also available when priority scheduling is needed:
```rust
pub struct PriorityResource { /* Clone, !Send+!Sync */ }
impl PriorityResource {
pub fn new(capacity: usize) -> Self;
/// Request one unit. Lower priority number = higher priority (0 is highest).
/// Within the same priority level, requests are served FIFO.
pub fn request(&self, priority: u32) -> PriorityResourceRequest;
pub fn in_use(&self) -> usize;
pub fn capacity(&self) -> usize;
}
```
```rust
let nurse = PriorityResource::new(1);
// critical patients (priority 0) jump ahead of standard patients (priority 1)
let _guard = nurse.request(triage_level).await;
```
**`Container`** models a reservoir of continuous quantity (e.g., blood supply, fuel):
```rust
pub struct Container { /* Clone, !Send+!Sync */ }
impl Container {
/// Create empty container. Panics if capacity <= 0.
pub fn empty(capacity: f64) -> Self;
/// Create with initial level. Panics if capacity <= 0, initial_level < 0,
/// or initial_level > capacity.
pub fn new(capacity: f64, initial_level: f64) -> Self;
pub fn level(&self) -> f64;
pub fn capacity(&self) -> f64;
/// Add `amount`. Suspends if level + amount > capacity.
/// Panics if amount <= 0 or amount > capacity (could never complete).
pub fn put(&self, amount: f64) -> ContainerPutRequest;
/// Remove `amount`. Suspends if level < amount.
/// Panics if amount <= 0 or amount > capacity (could never complete).
pub fn get(&self, amount: f64) -> ContainerGetRequest;
}
```
Both `put` and `get` suspend when they cannot immediately complete. Waiters are
served in **strict head-of-line FIFO**: a freshly-arriving request never takes
level/space ahead of an already-queued waiter, even when the current level would
let it complete immediately, so a blocked head-of-queue request holds the line
for everyone behind it (matching SimPy's `Container`). The level change is
committed eagerly by the wake cascade (not on re-poll), so processes always see
the correct level after `.await`.
**`PreemptiveResource`** is a priority pool whose *in-use* units can be evicted
by a higher-priority request:
```rust
pub struct PreemptiveResource { /* Clone, !Send+!Sync */ }
impl PreemptiveResource {
/// Create a pool of `capacity` units. Panics if `capacity == 0`.
pub fn new(capacity: usize) -> Self;
/// Request a unit at `priority` (lower = higher priority). Resolves when a
/// unit is free OR a strictly lower-priority holder can be preempted;
/// otherwise queues in priority order.
pub fn request(&self, priority: u32) -> PreemptiveRequest;
pub fn in_use(&self) -> usize;
pub fn capacity(&self) -> usize;
}
pub struct PreemptiveGuard { /* RAII; releases on drop unless preempted */ }
impl PreemptiveGuard {
/// Future that resolves when this unit is preempted — race it against work.
pub fn preempted(&self) -> EventAwaitable;
/// Synchronous check after a race.
pub fn is_preempted(&self) -> bool;
}
```
When all units are busy, a higher-priority request **evicts** the holder with
the lowest priority that is strictly worse than its own (ties broken toward the
most-recently-acquired holder, which has made the least progress); the unit
transfers immediately without passing through the queue.
Preemption is **cooperative-at-yield**, not forcible. A discrete-event executor
cannot unwind a process suspended on an unrelated future, so — exactly as with
SimPy interrupts and all Rust async cancellation — the victim observes
preemption at its next yield point and is expected to bail:
```rust
let guard = crew.request(2).await;
any_of![env.timeout(service_time), guard.preempted()].await;
if guard.is_preempted() {
return; // higher-priority work took the unit; clean up
}
// otherwise completed normally; dropping `guard` releases the unit
```
A victim that never checks its signal simply runs to completion (it has already
surrendered the unit on the books, so it can no longer block anyone). Dropping a
guard that was already preempted is a no-op — the unit is gone.
**Remaining post-MVP resource types:**
| `Store` / `FilterStore` | Post-MVP |
### 4.6 Monte Carlo Parallelism
Each simulation run is a pure function of its inputs (config + seed). Multiple runs are launched on
OS threads. The recommended pattern uses the built-in `monte_carlo::run` helper:
```rust
use simu::monte_carlo;
let results = monte_carlo::run(0..10, |seed| {
let mut env = SimEnv::with_seed(seed);
// ... build and run simulation ...
env.run();
env.now()
});
// results[i] corresponds to seed i
```
`monte_carlo::run` collects results in seed order. By default it spawns one *scoped* `std::thread`
per seed (`std::thread::scope`), so the closure may borrow from the caller's stack — no `'static`
bound and no `Arc` wrap; enabling the `monte-carlo` feature switches the backend to rayon's
bounded work-stealing pool (preferable for hundreds/thousands of seeds, where one OS thread per seed
is wasteful). The public contract — seed-ordered results and panic propagation — is identical either
way. Because `SimEnv` is created *inside* each closure, it never crosses thread boundaries and its
`!Send` nature is not a problem.
If any worker thread panics, the original panic payload is re-raised on the
calling thread via `std::panic::resume_unwind` (after all siblings have been
joined, so no threads are orphaned).
For finer control, threads can be managed manually:
```rust
use std::thread;
let handles: Vec<_> = (0..10u64)
.map(|seed| thread::spawn(move || run_simulation(seed)))
.collect();